短链接服务看起来像一个周末项目:收下一个长 URL,返回一个短码,有人点击时再跳转。正因如此,它出现在许多系统设计面试中。小小的表面之下,藏着关于 ID 生成、缓存、HTTP 语义和统计分析的真实决策,每一个都会改变架构。
本文从头到尾设计一个短链接服务。先从数字开始,再讲如何无冲突地生成短码,然后回答 301 还是 302 的问题,最后给出一张可以直接复制和改造的架构图。
需求与数字
功能需求:
- 为长 URL 创建短链接,可选自定义别名。
- 把短链接跳转到原来的长 URL。
- 统计每个链接的点击数。
- 按要求让链接过期。
非功能需求:跳转必须快速且高可用;如果链接可能是私密的,短码就不能按顺序被猜到;系统也不能沦为钓鱼页面的免费跳转器。
粗略估算一下,整体形态就清楚了:
| 假设 | 数值 | 每秒 |
|---|---|---|
| 每月新增链接 | 1 亿 | 约 40 次写入/秒 |
| 每月跳转次数 | 100 亿 | 约 3,900 次读取/秒 |
| 读写比 | 100:1 | |
| 单条记录大小 | 约 500 字节 | |
| 5 年存储量 | 60 亿个链接 | 约 3 TB |
读请求比写请求多两个数量级,所以缓存、副本和注意力都应该放在读路径上。写路径很小,保持简单即可。
API
两个接口撑起整个产品:
POST /links
Content-Type: application/json
{"url": "https://example.com/very/long/path", "alias": "launch", "expiresAt": "2027-01-01T00:00:00Z"}
201 Created
{"code": "launch", "shortUrl": "https://sho.rt/launch"}
GET /launch
302 Found
Location: https://example.com/very/long/path
把它们放在不同的服务里。跳转服务无状态且只读,所以你可以单独扩容它,也很少需要重新部署。
生成短码
短码是用 base62(a–z、A–Z、0–9)表示的数字。7 个字符有 62^7,约 3.5 万亿个短码;6 个字符约有 568 亿个。按每月 1 亿个链接计算,7 个字符可以用上几千年。
真正的问题是这个数字从哪里来:
| 方案 | 原理 | 注意事项 |
|---|---|---|
| 计数器 + base62 | 一个全局序列,编码后使用 | 单点争用;短码连续,容易被猜到 |
| ID 区间 | 分配器给每个 API 实例分配一段 ID(比如 100,000 个) | 实例宕机会留下空洞;同一段内仍然连续 |
| 哈希 + 截断 | 对 URL 做哈希,保留前 7 个 base62 字符 | 会冲突;需要检查并重试 |
| Snowflake 式 ID | 64 位中包含时间戳 + 机器 ID + 序列号 | 短码更长(base62 下 11 个字符) |
哈希很诱人,因为同一个 URL 总能得到同一个短码。问题在于生日悖论:即使有 3.5 万亿个可能的短码,只要大约 220 万个链接,至少发生一次冲突的概率就会超过 50%。此后每次插入都需要做唯一性检查,并加盐重试,一次写入就变成了一次读取加一次写入。
ID 区间是务实的默认方案。每个 API 实例用一个小事务从分配器预留一段 ID,然后直接从内存发放短码,无需任何协调。如果不想要连续的短码,可以在编码前让 ID 经过一个可逆的置换(例如在 ID 空间上做分组加密)。Twitter 的 Snowflake 展示了另一条路:41 位时间戳、10 位机器 ID 和 12 位序列号,无需中心分配器就能生成唯一的 64 位 ID,代价是短码更长。
自定义别名不经过生成器。把它们存在同一张表里,依靠短码列上的唯一约束拒绝重复。
301 还是 302?
RFC 9110 定义了各个重定向状态码。这里关键的区别在于缓存:301(Moved Permanently)响应可以被启发式缓存,浏览器会记住它,下次点击时直接绕过你的服务器。302(Found)是临时的,除非你明确声明,否则不会被缓存。
- 需要统计每一次点击,或者以后想修改目标地址时,用 302。
- 链接永远不变,而且你更在意减少请求而不是完整统计时,用 301。
307 和 308 分别是保留请求方法的 302 和 301。普通的浏览器点击都是 GET 请求,选哪一个通常无关紧要。
存储与缓存
每次跳转都是按短码做一次单键查找,所以任何键查找快的存储都可以:DynamoDB 或 Cassandra 这样的键值存储,或者以短码为主键的 Postgres。单个节点不够用时按短码分片;由于设计上没有哪个短码比其他短码更热,短码的哈希能把负载均匀分开。例外是热门链接,它们由缓存吸收。
在存储前面放一个 Redis 缓存,采用 cache-aside 读取:先在 Redis 中查找短码,未命中时回源数据库,再带 TTL 写回缓存。链接的热度呈长尾分布,所以只缓存一小部分链接,就能承接大部分点击。缓存策略一文 详细讲了 TTL、缓存击穿和失效。只读副本可以分担回源路径;数据库复制一文 解释了随之而来、你必须容忍的延迟。
如果发送 Cache-Control 头,CDN 或边缘缓存可以直接返回最热门的跳转,但这样一来,边缘会响应你的服务器从未见过的点击。要么在边缘记录日志,要么不缓存跳转。
不拖慢跳转的点击统计
绝不要在跳转路径上同步写入点击计数。跳转服务把一个小小的点击事件(短码、时间戳、来源页、国家)发布到 Kafka 这样的日志中,然后立即返回。消费者按链接和日期汇总点击数,写入统计存储。统计端即使落后,跳转也照常工作;这与 事件驱动架构一文 中描述的解耦是一回事。
架构图
图表内容
- User
- Edge / CDN
- Load balancer
- Redirect service
- Shorten API
- Redis: code to URL
- Links store
- ID allocator
- URL reputation API
- Click events
- Click aggregator
- Analytics store
- GET /{code}
- POST /links
- 1. lookup
- 2. on miss
- insert code and URL
- ID range
- check the target
- click event
- consume
- daily counts
这张图的读法如下:
- 读路径(上方)。
GET /{code}请求经过边缘和负载均衡器到达跳转服务,服务先查 Redis,只有未命中时才读链接存储。 - 写路径(下方)。
POST /links进入短链 API。它通过 URL 信誉服务检查目标地址,从向分配器预留的区间中取 ID,编码成 base62,再写入链接。 - 统计(右侧)。 点击事件是异步的,所以画成虚线。聚合器把它们变成每日计数。
- Markdown 块 把容量数字放在设计旁边,方便评审者核对。
滥用、过期与自定义别名
公开的短链接服务,如果不加阻止,就是钓鱼工具。用 Google Safe Browsing 这样的信誉服务检查新的目标地址,按账号和 IP 限制创建链接的速率,并保留一个能快速禁用短码的手段。还要定期复查旧链接,因为一个干净的域名以后也可能变成恶意域名。
过期方面,把 expiresAt 和链接一起存储,在跳转时检查,再用后台任务删除过期的行。对已过期的短码返回 404 或 410,而不是跳转。
图上应该画什么
- 把读路径和写路径分开。 它们的扩展方式不同,图上应该体现出来。
- 在跳转服务上标注跳转状态码(301 或 302)。统计是否完整由它决定。
- 标明 ID 从哪里来。 方框里写一句“生成短码”,会把这个设计中最受争议的决策藏起来。
- 异步流程画成虚线。 读者应该一眼看出统计不会拖慢跳转。
- 把数字写下来。 没有每秒写入量和存储规模的设计无法评审。
系统设计图示例 在一个更大的系统上展示了同样的约定。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 添加 C4 模型节点包中的一个 人员 表示用户,再添加云节点包中的一个 CDN 和一个 云负载均衡器。
- 添加 C4 模型节点包中的四个 容器 节点:跳转服务、短链 API、ID 分配器和点击聚合器。在每个节点上写明技术和一行说明。
- 添加数据节点包中的 Redis、Kafka 和一个 数据仓库,再添加 C4 模型节点包中的一个 数据库 作为链接存储,以及一个 外部系统 表示 URL 信誉 API。
- 把读路径连在上方、写路径连在下方,把点击事件画成虚线连线,再添加一个基础节点包中的 Markdown 块,写上容量数字。
这个示例共用 13 个图表节点,在 Free 套餐的限制之内。
常见问题
短码应该多长? 7 个 base62 字符约有 3.5 万亿个短码,按每月 1 亿个链接计算,足够用几十年。先用 7 个字符,以后再允许增长到 8 个;设计中没有任何部分依赖固定长度。
应该对长 URL 做哈希来得到短码吗? 只有在去重比简单写入更重要时才这么做。截断后的哈希很早就会冲突(7 个字符时,超过 220 万个链接后,至少发生一次冲突的可能性就大于不发生),所以每次插入都需要检查和重试。
短链接服务用 301 好还是 302 好? 需要统计每一次点击,或者目标地址可能变化时,用 302。301 让浏览器缓存跳转,能节省请求,但会让重复点击从你的统计中消失。
链接应该存在哪种数据库里? 任何按主键查找快的存储都可以。在这个量级下,Postgres 能撑很久;当你需要把写入分片到很多节点时,键值存储就更有吸引力了。
如何防止别人猜到链接? 不要暴露连续的 ID。在编码前对 ID 做置换,或者使用带唯一性检查的随机短码,并且永远不要把短链接当作私密内容的密钥。