设计一个短链接系统
一句话回答
短链接系统把长 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,主表按主键查即可,不用单独存短码,主键也保持递增:
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 避免精度问题):
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 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。