Skip to content

短链系统设计 ​

定位:读多写少系统的教科书场景。代码量极小,考的全是编码方案取舍、缓存架构、容量估算三件基本功。也是估算题的高发区(多少位够用多少年)。发号器部分与库内《分布式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))            -- 前缀索引做"同长链判重"
);

八、追问链 ​

  1. 布隆误判会不会把有效短链判没了? 不会。误判方向是"可能存在",最坏多一次查询;"不存在"才返回 404,而那是确定的。
  2. 同一长链重复提交返回同一个码吗? 看业务:营销要求一链一码(便于分渠道统计)就每次发新号。要求幂等就用 idx_long 前缀判重返回旧码。主动说出来这是产品决策,加分。
  3. 跳转 QPS 再翻 10 倍怎么办? 跳转是无状态读 → 先加本地缓存层,再横向扩容。302 响应本身可让 CDN 边缘缓存 1s。
  4. 用户想自定义短码(t.cn/my-name)? 自定义码走独立校验:先查冲突,写入靠 uk_code 唯一索引兜底。自定义码与发号码空间隔离。
  5. 短链过期/删除? expire_at 惰性删除(访问时判断)+ 离线归档任务。删除要同步删缓存 + 重建布隆(或用带删除的过滤器)。
  6. 短链被用于钓鱼? 跳转目标进安全黑名单,命中插风险提示页;新长链异步送风控评分。
  7. 发号器 DB 挂了怎么办? 号段模式的 buffer(双 buffer 预加载)扛分钟级故障。这正是选号段不选裸自增的原因。

九、面试官视角小结 ​

面试官在短链题里打量的四件事:

  • 取舍表达:能主动给出"发号器 vs 哈希"的对比表并说清为什么主流是发号器。而不是只讲自己会的那一个。
  • HTTP 语义细节:301/302 的缓存行为差异答得准不准,直接暴露对协议的理解深度。
  • 读路径基本功:布隆 → 缓存 → DB 的三段式信手拈来,且知道每段防的是什么问题。
  • 估算能力:62^n 的量级张口就来,能把"位數、年限、存储"连成一笔账——系统设计岗的硬通货。

一句话总结:写路径发号器保唯一,读路径三层缓存保延迟。302 保住统计与可控性,估算保住设计不返工。

2026-10 新增(P0 补全)。