无论是在系统设计面试中,还是在设计文档里,图都是让思路变得可见的地方。画一个标着“缓存”的方框很容易;让图真正值得一读的,是说明它为什么在那里、能吸收多少流量,以及缓存是冷的时候会发生什么。
本文以一道经典练习——短链接服务——为例,从需求一路做到完整的系统设计图。重点不在短链接服务本身,而在方法:先需求和数字,再 API 和数据,最后才画方框,并把每个决策写在大家都能看到的地方。
第 1 步:需求
功能需求:
- 为长 URL 创建短链接,可选自定义别名和过期时间。
- 把短链接重定向到对应的长 URL。
- 统计每个链接的点击次数。
非功能需求:
- 重定向必须快速且高可用;创建链接可以容忍更高的延迟。
- 系统以读为主:链接只创建一次,却会被点击很多次。
- 短码不应容易被枚举。
第 2 步:粗略估算
假设每月新增 1 亿条链接,每条新建链接带来 100 次重定向:
| 指标 | 估算 |
|---|---|
| 写入 | 100M / 每月 2.6M 秒 ≈ 平均每秒 40 次 |
| 读取 | 100 × 40 ≈ 每秒 4,000 次重定向;按 10× 峰值规划,即每秒 40,000 次 |
| 5 年后的链接数 | 100M × 12 × 5 = 60 亿 |
| 存储 | 60 亿 × ~500 字节 ≈ 3 TB,不含副本 |
| 短码长度 | 7 个 base62 字符可得 62⁷ ≈ 3.5 万亿个短码 |
这些数字已经在塑造设计:读请求占绝大多数,所以读取路径要加缓存;3 TB 不会永远待在一台小数据库上,所以数据模型必须能干净地分片;而 7 个字符留有充足的余量。
第 3 步:API
POST /api/links
{ "url": "https://example.com/a/very/long/path", "alias": "launch", "expiresAt": "2027-01-01" }
→ 201 { "code": "launch", "shortUrl": "https://s.example/launch" }
GET /{code}
→ 302 Location: https://example.com/a/very/long/path
有一个决策值得在图上写一行:301 还是 302? 301 是永久重定向,浏览器会缓存它,重复访问根本到不了你这里。这样能节省流量,却会丢掉点击统计。302 会让每次点击都经过重定向服务,这正是分析所需要的。本设计使用 302。
第 4 步:数据模型与 ID
核心表很简单:code(主键)、long_url、owner_id、created_at、expires_at。每次读取都是按 code 查找,所以键值存储或带主键的关系型数据库都能胜任;按 code 的哈希分片,可以把负载均匀分散。
生成短码才是有意思的部分:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 计数器 + base62 | 没有冲突 | 连续的短码容易被枚举;需要协调 |
| 随机 7 个字符 | 难以猜测,无需协调 | 插入时必须检查冲突 |
| URL 的哈希 | 相同 URL 得到相同短码 | 截断后会冲突;别名需要特殊处理 |
这里,写入 API 从 ID 区间分配器 租用 ID 区间,例如每次 100 万个,并在 base62 编码前用可逆置换把每个 ID 打乱。这样短码无需冲突检查就是唯一的,在外人看来也不连续。
第 5 步:画图
把需求不同的路径分开画。写入路径量小,必须正确;读取路径量大,必须快;分析可以滞后。
图表内容
- Users
- Load balancer
- ID range allocator
- Write API
- Redirect service
- Cache: code → URL
- Links DB (sharded by code)
- click events
- Analytics workers
- Analytics store
- HTTPS
- POST · 40/s
- GET · 4k/s, peak 40k/s
- lease ID range
- INSERT link
- GET code
- on cache miss
- click event
- consume
- counts
这张图的读法如下:
- 写入路径(上方)。 负载均衡器把
POST /api/links路由到写入 API,写入 API 从已租用的区间中取出一个 ID,编码后把链接写入数据库。 - 读取路径(中间)。
GET /{code}到达重定向服务,它先查 Redis。只要热点数据放得进内存,大多数重定向根本不会访问数据库。缓存未命中时,它读取数据库并回填缓存。 - 分析(下方)。 每次重定向都会向 Kafka 发出一个点击事件,分析 worker 把计数聚合到分析存储中。重定向从不等待分析。
画出能看见思路的图
- 在连线上标数字。 写入连线上的
40/s和读取连线上的4k/s, peak 40k/s,比任何标签都更能说明为什么需要缓存。 - 把假设写在方框旁边。 “缓存命中率目标 90%”“302 用于分析”“ID 按 1M 为一段租用”。
- 区分同步与异步。 请求路径用实线,点击事件用虚线。
- 说明出故障时会怎样。 如果 Redis 宕机,重定向会回退到数据库,延迟随之升高;把这一点写在图上。
- 把估算留在画布上。 在图旁边放一张小小的数字表,图就成了一份设计文档。
值得提前准备的深入话题
- 缓存。 热门链接呈长尾分布,所以一个 TTL 适中的 LRU 缓存就能覆盖大部分流量。在删除和过期时让缓存失效。
- 数据库扩展。 在这里,只读副本的作用不如缓存;按短码分片能让写入和存储均匀增长。
- 过期。 在读取时检查
expires_at,并在后台清理,而不是跑一个巨大的删除任务。 - 滥用。 按用户对创建链接限流,并用恶意软件列表检查目标 URL。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 添加网络节点包中的 互联网 节点表示用户,再添加云节点包中的 云负载均衡器 作为负载均衡器。
- 把写入 API、重定向服务、ID 分配器和分析 worker 作为 C4 模型节点包中的 容器 节点添加进来,并在“技术”字段中填写编程语言。
- 从数据节点包中添加 Redis 作为缓存、Postgres 作为链接数据库、一个 主题 承载点击事件,以及一个 数据仓库 用于分析。
- 给连线标注操作和速率,把点击事件设为虚线,并把估算写进基础节点包中的 Markdown 块。
这个示例共用 11 个图表节点(包括写有估算的 Markdown 块),在 Free 套餐的限制之内。适用于任何架构图的通用方法,请参阅如何绘制软件架构图。
常见问题
面试时,系统设计图应该画多细? 能展示读写路径、数据存储、缓存和一个异步组件就够了。只在面试官追问的地方深入。
链接表用 SQL 还是 NoSQL? 按主键查找,两者都可以。根据你打算如何分片、团队擅长运维什么来选,并把理由写在图上。
要画 CDN 吗? 只有在边缘缓存重定向时才需要。用了 302 又要统计点击,边缘缓存的作用有限,所以很多设计都省略它。
为什么不全部用 301? 301 能节省流量,但浏览器不会再来问你,于是你会丢失点击数据,也无法为回访用户更改目标地址。
数字放在哪里? 放在画布上、图的旁边,这样任何评审设计的人都能核对方框背后的假设。