Appearance
短链系统设计
定位:读多写少系统的教科书场景。代码量极小,考的全是编码方案取舍、缓存架构、容量估算三件基本功。也是估算题的高发区(多少位够用多少年)。发号器部分与库内《分布式ID:从雪花算法到生产实现》直接衔接。《美团-Leaf——美团点评分布式ID生成系统》同样直接衔接。
一、需求量化
写入:日增短链 100 万条(对内部系统已是很大值)
读取:跳转 1 亿次/天,读写比 100:1,峰值读 QPS ≈ 5 万
要求:短码 ≤ 7 字符;跳转延迟 P99 < 50ms核心画像一句话:读多写少、读路径 QPS 高但逻辑极简(短码→长链一次映射)。这种系统的设计重心天然落在"跳转路径上多级缓存",写路径只要保证不重不发即可。
二、核心流程
生成:长 URL ──→ 发号器取号(id) ──→ 62 进制编码为短码
──→ 落库(short_url 表) ──→ 返回 t.cn/Ab3x9K
跳转:GET t.cn/Ab3x9K
│ 布隆过滤器 ──不存在──→ 404(挡掉爬虫/攻击的无效码)
▼ 存在
Redis 缓存 ──命中──→ 302 Location: 长URL(打点统计)
▼ 未命中
DB 回源 ──→ 写回缓存(含空值缓存短 TTL)──→ 302三、短码怎么来:发号器 vs 哈希
| 维度 | 发号器(号段/雪花) | 哈希(MD5 + 冲突处理) |
|---|---|---|
| 长度 | 可控,7 位管够 | 不可控:截断则冲突,全量则 32 字符 |
| 冲突 | 全局唯一,无冲突 | 生日悖论必然冲突,需加盐重哈希 |
| 写路径复杂度 | 取号即编码,一次成功 | 冲突时查库判重 + 重试,写入放大 |
| 可读性/安全性 | 递增 → 可被遍历爬取 | 无规律 |
| 依赖 | DB 号段或时钟 | 纯计算,无依赖 |
结论:发号器是主流。哈希路线的致命伤是截断必然冲突(见第六节估算)。冲突处理把写路径搞复杂,收益只有"无外部依赖"。
- 号段模式:DB 一次发一段(如 1 万个号)到应用内存,扛住 DB 故障,Leaf-segment 详解见库内美团 Leaf 篇。
- 雪花:趋势递增、无 DB 依赖,但要处理时钟回拨——生产细节在《分布式ID:从雪花算法到生产实现》。
- 防遍历:发号器产出的 id 单调递增,短码可被
+1枚举。做法:id 与展示码间加一层可逆混淆(如 Feistel 网络或 id 异或固定掩码再编码)。看起来随机,自己仍能解码。
四、62 进制编码
字符表 0-9 a-z A-Z 共 62 个,本质是把 BIGINT id 映射成最短字符串:
java
private static final char[] BASE62 =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();
static String encode(long id) { // id → 短码
StringBuilder sb = new StringBuilder();
while (id > 0) {
sb.append(BASE62[(int) (id % 62)]);
id /= 62;
}
return sb.reverse().toString(); // 逆序保证高位在前
}
static long decode(String code) { // 短码 → id
long id = 0;
for (char c : code.toCharArray())
id = id * 62 + indexOf(c);
return id;
}注意点:decode 时要校验非法字符(防脏数据打进缓存);同一张字符表全公司统一,换表等于数据全废。
五、301 还是 302
| 301 永久重定向 | 302 临时重定向 | |
|---|---|---|
| 浏览器缓存 | 缓存,后续不再请求短链服务 | 每次都请求 |
| 服务端压力 | 最省 | 每次跳转过一遍服务 |
| 点击统计 | 做不了(看不到请求) | 可埋点:UV/PV、来源 |
| 目标可变更 | 不可(缓存里改不掉) | 可随时改长链 |
短链服务选 302:统计是短链的商业价值底线,且运营需要改跳目标。省流量的责任交给缓存层而不是 301。这是这道题最经典的追问,答反了(无脑 301)基本暴露没做过。
六、估算题:多少位够多少年
62^4 ≈ 1.5 千万 62^5 ≈ 9.2 亿
62^6 ≈ 568 亿 62^7 ≈ 3.5 万亿按日增 100 万条计:一年 3.65 亿 < 62^5(9.2 亿)。62^6(568 亿)够 155 年,62^7(3.5 万亿)够 9600 年。所以 6~7 位对绝大多数系统是"永远够",选 7 位留混淆空间。
存储账:每条 ≈ 短码 7B + 长链均值 1KB 内 + 索引 2 倍 ≈ 300B/条。100 亿条约 3TB,单表放不下 → 按 code 哈希分 16/32 库。顺带说明哈希路线冲突的必然性:7 位空间 3.5 万亿。存 100 亿条约按生日悖论 n²/2N 期望冲突对已是天文数字。不查库判重根本防不住。
七、读路径:读多写少的缓存架构
布隆过滤器(全量短码,Redis/本地)
│ 确定不存在 → 404(防穿透:爬虫枚举的无效码根本不进缓存和 DB)
▼ 可能存在
Redis:code → long_url,TTL 长一些(短码几乎不变)
▼ 未命中
DB 回源 → 写回 Redis;查无此码 → 空值缓存 TTL 30s(穿透另一半解法)- 布隆过滤器:说"不存在"是确定的,说"存在"可能有误判——误判只是多查一次缓存/DB,方向安全。需要删除(短码可删)就升级计数布隆/布谷鸟过滤器。
- 击穿:爆款短链单 key 过期瞬间集中回源 → 互斥重建或逻辑过期;雪崩:TTL 加随机抖动。三件套系统展开见《缓存穿透-击穿-雪崩》。
- 热点:头部短链 QPS 高 → 本地缓存(Caffeine,容量 1 万,TTL 1s~10s)挡一层,miss 再走 Redis。
- 一致性:长链修改/删除 → 先改 DB 再删缓存。302 模式下每次跳转都过服务端,改动能立刻生效,这也是选 302 的隐藏好处。
表结构与写入:
sql
CREATE TABLE short_url (
id BIGINT PRIMARY KEY, -- 发号器号码,雪花/号段
code VARCHAR(16) NOT NULL,
long_url VARCHAR(2048) NOT NULL,
expire_at DATETIME,
UNIQUE KEY uk_code (code),
KEY idx_long (long_url(64)) -- 前缀索引做"同长链判重"
);八、追问链
- 布隆误判会不会把有效短链判没了? 不会。误判方向是"可能存在",最坏多一次查询;"不存在"才返回 404,而那是确定的。
- 同一长链重复提交返回同一个码吗? 看业务:营销要求一链一码(便于分渠道统计)就每次发新号。要求幂等就用
idx_long前缀判重返回旧码。主动说出来这是产品决策,加分。 - 跳转 QPS 再翻 10 倍怎么办? 跳转是无状态读 → 先加本地缓存层,再横向扩容。302 响应本身可让 CDN 边缘缓存 1s。
- 用户想自定义短码(t.cn/my-name)? 自定义码走独立校验:先查冲突,写入靠
uk_code唯一索引兜底。自定义码与发号码空间隔离。 - 短链过期/删除?
expire_at惰性删除(访问时判断)+ 离线归档任务。删除要同步删缓存 + 重建布隆(或用带删除的过滤器)。 - 短链被用于钓鱼? 跳转目标进安全黑名单,命中插风险提示页;新长链异步送风控评分。
- 发号器 DB 挂了怎么办? 号段模式的 buffer(双 buffer 预加载)扛分钟级故障。这正是选号段不选裸自增的原因。
九、面试官视角小结
面试官在短链题里打量的四件事:
- 取舍表达:能主动给出"发号器 vs 哈希"的对比表并说清为什么主流是发号器。而不是只讲自己会的那一个。
- HTTP 语义细节:301/302 的缓存行为差异答得准不准,直接暴露对协议的理解深度。
- 读路径基本功:布隆 → 缓存 → DB 的三段式信手拈来,且知道每段防的是什么问题。
- 估算能力:62^n 的量级张口就来,能把"位數、年限、存储"连成一笔账——系统设计岗的硬通货。
一句话总结:写路径发号器保唯一,读路径三层缓存保延迟。302 保住统计与可控性,估算保住设计不返工。
2026-10 新增(P0 补全)。