设计一个短链接系统

深入系统设计场景题高频约 11 分钟读完

一句话回答

短链接系统把长 URL 映射成一个 6~8 位的短码,访问短链时查出长 URL 并重定向。短码推荐用发号器分配的 ID + Base62 编码:不会冲突、长度可控,编码前对 ID 做一次可逆的打散,防止被顺序遍历;哈希截断要处理冲突,预生成随机短码适合要求短码完全随机的场景。需要统计点击就用 302,301 会被浏览器缓存,之后的点击不再经过服务端。系统读远多于写,跳转走"本地缓存 → Redis → 数据库",点击统计通过消息队列异步处理,另外要做恶意链接检测、限流和过期清理。

详细解析

第一步:澄清需求

  • 功能:生成短链(可选自定义短码、有效期)、跳转、查看点击统计;同一用户重复提交同一个长链接,返回原来的短码
  • 非功能:跳转延迟低、高可用(短链发出去就收不回来,服务一挂所有链接都失效);短码不能被轻易遍历
  • 量级估算(都是假设值):
    • 每月新增 1 亿条:1 亿 ÷(30 × 86400 秒)≈ 39 次/秒,峰值按 5 倍算约 200 次/秒
    • 读写比 100:1:跳转平均约 3900 次/秒,峰值约 2 万次/秒
    • 保存 5 年:1 亿 × 12 × 5 = 60 亿条,每条按 500 字节(含索引)算约 3 TB,要分库分表
    • 短码长度:62^6 ≈ 568 亿,已经大于 60 亿;取 7 位(62^7 ≈ 3.5 万亿)留出余量,短码也更稀疏、更难猜中
    • 缓存:每天有 1000 万条不同的短链被访问,每条缓存 500 字节,约 5 GB,一组 Redis 主从就放得下

第二步:整体架构

文本
生成:客户端 / 开放 API → 网关(鉴权、按用户和 IP 限流)→ 短链服务
        1 校验 URL:格式、协议白名单、域名黑名单、安全检测
        2 这个用户生成过同一个长链接 → 直接返回原短码
        3 发号器取 ID(号段缓存在本地)→ 打散 + Base62 → 短码
        4 写 MySQL(按 ID 分片)
跳转:浏览器 GET https://s.example/CjINSnR → 跳转服务(无状态、多副本)
        1 短码解码成 ID:格式非法,或 ID 大于发号器已分配的最大值 → 直接 404
        2 本地缓存 → Redis → MySQL,查出长链接、状态、过期时间
        3 已封禁 → 警告页;已过期 → 404;正常 → 302 + Location
        4 点击事件写入本地缓冲,批量发到消息队列,不阻塞跳转
统计:消息队列 → 统计消费者(按短码、按小时聚合)→ 统计库

第三步:核心模块和数据模型

短码生成方案:

方案 做法 优点 缺点 适用场景
哈希截断 对长链接算哈希(如 MurmurHash),取一部分转成 Base62 不需要发号器;不冲突时同一链接得到同一短码 会冲突:每次都要查库确认,冲突了加盐重算 数据量不大
自增 ID + Base62 发号器分配唯一 ID,转成 Base62 不冲突、长度可控、能反解出 ID 连续 ID 会被遍历,要打散;依赖发号器 大多数场景(本文采用)
预生成短码池 离线批量生成随机短码放进"未使用"表,服务按批领取 短码完全随机,生成时不用计算和查重 要维护号码池,领取时要防止重复发放 对随机性要求高

哈希截断的冲突有多频繁:已有 60 亿条时,新链接撞上已有短码的概率约 60 亿 ÷ 3.5 万亿 ≈ 0.17%。单次不高,但每次生成都要查库确认(可以先查布隆过滤器),而且越往后越高。

发号器的实现见分布式 ID。雪花 ID 是 64 位整数,转成 Base62 要 10~11 位,太长,短链更适合号段模式这种从小到大分配的 ID。连续的 ID 编码后也是连续的,所以编码前先做一次可逆的打散(见代码示例)。这只是混淆,收集足够多的短码仍可能推出规律;要求更高时,用分组密码做变换或用随机的预生成短码。

数据模型:短码能解码回 ID,主表按主键查即可,不用单独存短码,主键也保持递增:

SQL
CREATE TABLE short_links (
  id         BIGINT PRIMARY KEY,          -- 发号器分配,编码后就是短码
  long_url   VARCHAR(2048) NOT NULL,
  user_id    BIGINT NOT NULL,
  status     TINYINT NOT NULL DEFAULT 1,  -- 1 正常,2 封禁
  expire_at  DATETIME NULL,               -- NULL 表示永久有效
  created_at DATETIME NOT NULL,
  KEY idx_expire (expire_at)
);

主表按 ID 分片,跳转只查一个库,分片方法见分库分表;按用户去重另建一张 (user_id, url_hash) → id 的映射表,按 user_id 分片。跳转只按主键查,换成 KV 存储也可以。

301 还是 302:

301 永久重定向 302 临时重定向
浏览器缓存 没有 Cache-Control 时也可以被启发式缓存,之后的点击可能直接跳走 只有显式声明了缓存时间才会被缓存,默认每次点击都经过短链服务
点击统计 统计不全 每次都能统计
服务端压力 小 大
封禁、修改目标 对已经缓存的浏览器不生效 立即生效

要统计、要能随时封禁就用 302(307 也行,GET 请求下效果一样);链接永不修改、只想减轻服务端压力,才考虑 301。几个重定向状态码的区别见 HTTP 状态码。

第四步:关键难点

读多写少和热点:

  • 跳转是纯读路径:本地缓存 → Redis → MySQL。爆款链接是热 Key,靠只放热点、过期时间短的本地缓存分摊,见大 Key 和热 Key
  • 扫描不存在的短码会穿透到数据库:解码出的 ID 超过已分配的最大值直接拒绝,其余用缓存空值或布隆过滤器挡住,见缓存穿透
  • 缓存的过期时间不能超过链接本身的有效期;封禁时先改库再删缓存,见缓存一致性

防滥用:

  • 生成接口按用户、IP 限流,匿名用户额度更低,见用 Redis 实现限流
  • 恶意链接:生成时查域名黑名单、调用 URL 安全检测服务;目标页面的内容会变,还要定期复检、接受举报,确认后改为封禁
  • 跳转时对 404 过多的 IP 限流,防止批量猜短码;不允许短链指向短链域名自己,避免循环跳转

过期清理:

  • 跳转时检查 expire_at,过期就返回 404(或 410),保证过期的链接立即不可用
  • 后台任务按 idx_expire 分批删除或归档,每批限制条数,避免大事务和主从延迟
  • 过期的短码不回收复用:旧短链可能还印在海报上、留在聊天记录里,复用会把用户带到别人的页面

访问统计异步化:跳转服务把点击事件(短码、时间、IP、UA、Referer)写入本地缓冲,批量发到消息队列,发送失败也不影响跳转。消费者按短码、按小时聚合 PV,UV 用 HyperLogLog 估算(见 Bitmap、HyperLogLog、GEO),明细写入列式分析库(例如 ClickHouse)。统计允许少量丢失,不为它牺牲跳转的可用性。

第五步:扩展与优化

  • 高可用:跳转服务无状态、多机房部署;发号器的号段缓存在本地,数据库短暂不可用时仍能继续生成
  • 边缘加速:跳转逻辑可以下沉到 CDN 的边缘计算 + KV,离用户更近,代价是封禁、修改的生效有延迟

代码示例

短码和 ID 互相转换(Node.js,用 BigInt 避免精度问题):

JavaScript
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
const LEN = 7
const SPACE = 62n ** 7n // 7 位短码的容量,约 3.5 万亿
const A = 2176477521911n // 乘数:约为容量的 0.618 倍,必须和 SPACE 互质(不能被 2 和 31 整除)
const A_INV = 2009758120775n // A 模 SPACE 的逆元(A * A_INV % SPACE === 1n),用扩展欧几里得算法离线算好

// ID → 短码:乘 A 取模把相邻的 ID 打散到整个空间,再转成定长 Base62
function encode(id) {
  if (id < 0n || id >= SPACE) throw new RangeError('ID 超出 7 位短码的容量')
  let n = (id * A) % SPACE
  let code = ''
  for (let i = 0; i < LEN; i++) {
    code = ALPHABET[Number(n % 62n)] + code
    n /= 62n
  }
  return code
}

// 短码 → ID:跳转时按主键查询;格式不对直接返回 null,不用查库
function decode(code) {
  if (code.length !== LEN) return null
  let n = 0n
  for (const ch of code) {
    const d = ALPHABET.indexOf(ch)
    if (d < 0) return null
    n = n * 62n + BigInt(d)
  }
  return (n * A_INV) % SPACE
}

console.log(encode(1n), encode(2n), encode(3n)) // CjINSnR eDrBKLI QXapD9z:相邻 ID 的短码看不出规律
console.log(decode(encode(123456789n))) // 123456789n

面试官可能追问

同一个长链接,每次生成都返回同一个短码吗?

按用户去重比较合适:同一用户重复生成返回原短码;不同用户各自生成、各自统计、各自设置有效期。全局去重会让不同用户的点击数据混在一起,一个人的封禁或修改也会影响别人。去重用"用户 ID + 长链接哈希"的映射表,哈希相同时再比较原文,防止哈希冲突。

自定义短码怎么和自动生成的短码共存?

自动生成的短码固定 7 位,规定自定义短码的长度不能是 7 位,两者就不会冲突。自定义短码存在单独的表里,用唯一索引保证不重复,跳转时按长度决定查哪张表。还要保留 api、admin 这类系统路径,过滤敏感词。

发号器成了单点怎么办?

用号段模式(原理见分布式 ID):每个实例一次领一段 ID 在内存里分配,快用完时异步领下一段,发号用的数据库短时间不可用,手里的号段还能撑一阵。号段之间不连续、重启浪费一部分号段,对短链都没有影响。

短链被用来传播钓鱼网站,怎么办?

事前:生成时检测、限流,匿名创建的短链可以先展示一个中间页,告诉用户即将跳转到哪个域名。事中:定期复检、接受举报,命中后封禁并删除缓存。事后:封禁的短链跳到警告页,同一账号或 IP 多次违规就封号。

易错点

  • 跳转用 301,上线后发现点击统计远低于实际,因为浏览器缓存了重定向
  • 哈希截断不处理冲突,两个长链接得到同一个短码,后写的覆盖了先写的
  • 直接把连续的 ID 转成 Base62,短码可以被顺序遍历
  • 点击统计同步写数据库,统计库一慢,跳转也跟着变慢

AI 模拟面试官

用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。