系统设计:短链系统从 0 设计
短链是系统设计面试的经典题,小而全(读写、唯一性、重定向、缓存、扩展)。本文从需求拆解开始,讲清短链的生成(哈希/自增ID+进制)、存储、重定向(301 vs 302)、缓存抗读、分片扩展,以及面试里怎么"边设计边讲"展示思路。
本文要点
- 主题聚焦:TypeScript、DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
短链系统是系统设计面试里的"守门员题"。它小到 30 分钟能讲完,又大到能一路追问到分库分表、缓存一致性、全球加速——一道题能精准测出候选人到底是"背了八股"还是"真的想过"。可惜我面过的多数人,题目刚念完就开始画三个框加箭头,然后讲"我用 Redis 缓存一下",就没了。真正决定成败的,是愿不愿意在动手前先把"这玩意儿到底要解决什么"问清楚。
这篇文章按面试节奏走,从需求澄清一路讲到分片扩展,代码和架构图都按生产可用标准给。读完你应该能拿到这道题的"基础分 + 加分项"。
一、先别急着画图:把需求问清楚
系统设计第一步永远不是画架构图,而是问问题。我见过太多候选人,讲了 5 分钟方案才发现"哦,原来还要支持自定义短码"——前面全白讲。短链的需求至少要问清这几件:
功能需求
- 核心两个动作:给长链生成短码(写)、用短码跳回长链(读 / 重定向)。
- 是否支持自定义短码?(比如
s.cn/my-launch,而不是s.cn/x7K9aQ) - 短码是否过期?营销活动链通常要,个人永久链通常不要。
- 是否需要分析:点击量、来源、地域?
非功能需求(这才是拉开差距的地方)
- 读写量:典型 100:1,读远多于写。具体数字必须问出来,直接影响后续缓存和分片策略。
- 短码长度:7 位 base62 够 3.5 万亿条,业界默认。有些场景为了美观会要 6 位甚至更短。
- 可用性:短链挂了 = 用户所有外投的广告、邮件、推文全部失效,这是商业事故级别,可用性要按 99.99% 来。
- 延迟:重定向 P99 < 50ms,慢一点用户就觉得"链接坏了"。
容量估算(面试必讲,不讲就扣分)
假设月新增 1 亿条短链、读写比 100:1:
- 存储:1 亿条/月,每条按 500B 算(长链 + 短码 + 元数据),一年约 600GB,十年 6TB——单库扛得住,但已经该考虑分片。
- 写 QPS:1 亿 / 30 / 86400 ≈ 40,峰值按 5 倍 ≈ 200,完全不为写入焦虑。
- 读 QPS:100 倍,即峰值约 2 万 QPS——这才是真正吃设计的地方。
把这套数字甩出来,面试官心里已经给你加了分,因为你证明了自己能把抽象问题落到具体资源。
二、短码怎么生成:四种方案的取舍
短链系统的"灵魂"就在这。方案大致四种,我按推荐度倒着讲。
方案 A:哈希取段(MD5/SHA 取前 6-7 位)
import { createHash } from 'node:crypto';
function hashShorten(url: string): string {
return createHash('md5').update(url).digest('hex').slice(0, 7);
}
优点是无状态、可客户端算。但缺点致命:截断后冲突率不可忽略。MD5 是 128 位,你只取 28 位(7 个 hex),生日悖论下大约 2^14 ≈ 1.6 万条就开始撞。撞了怎么办?加随机盐重算再去查库——一查就得读盘,等于把"无状态"的好处全还回去了。生产里基本不这么干。
方案 B:自增 ID + 进制转换(强烈推荐)
这是工业界主流(Bitly、Twitter 大致都是这类思路的变体)。两步:用一个全局唯一发号器给每条长链发一个号,再把这个十进制号编码成更高进制的字符串。
const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const BASE = 62; // 0-9 + A-Z + a-z
// 自增 ID → 短码
function encode(id: number): string {
let n = id;
if (n === 0) return ALPHABET[0];
let out = '';
while (n > 0) {
out = ALPHABET[n % BASE] + out;
n = Math.floor(n / BASE);
}
return out;
}
// 短码 → 自增 ID(用于反查、排障)
function decode(code: string): number {
let n = 0;
for (const ch of code) n = n * BASE + ALPHABET.indexOf(ch);
return n;
}
encode(10000); // '2bI'
encode(10_000_000_000); // 'Aukyoa' —— 百亿级 ID 也只要 6 位
为什么好:天然唯一(发号器保证)、长度可控(7 位 base62 ≈ 3.5 万亿,够用几十年)、无需查重(号不重,码就不重)、可反解(出问题能从短码直接还原 ID 排障)。
发号器是另一个独立小系统。最简单是 MySQL 一张 counter 表 AUTO_INCREMENT,够撑到中等规模;再大可以用 Redis INCR、Snowflake 类算法、或专门发号服务。别让发号器变成单点瓶颈就行。
方案 C:随机串 + 查重
随机生成 7 位字符串,再查库看是否冲突。简单粗暴,缺点是写入路径多一次读、概率上还得重试。规模一大就暴露问题,只适合 demo。
方案 D:预生成(Keygenning)
后台批量预生成几千万个短码放队列,写入时直接 pop。写入路径极快、无冲突、发号器压力为 0;代价是多维护一个 KGS。延迟敏感、写入量大的场景值得做,面试可以作为"加分方案"提一句。
三、存储:一张表 + 两个索引就够起步
别一上来就 NoSQL。短链的存储模型非常简单——就是一张 KV 映射,关系库完全够用:
CREATE TABLE url_map (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
short_code VARCHAR(10) NOT NULL UNIQUE,
long_url TEXT NOT NULL,
user_id BIGINT,
created_at DATETIME NOT NULL,
expires_at DATETIME,
KEY idx_user (user_id)
);
short_code 上必须建唯一索引——它就是这条记录的"业务主键",所有读请求都从它进来。id 留着,既是发号器源头,也是后续分片的基础。到 6TB 再考虑分片(见第六节);在那之前,MySQL 主从 + 合理索引,一台扛下来。
四、重定向:301 还是 302,这是个产品问题
重定向用哪个状态码,不是技术细节,是产品决策。很多人没意识到:
- 301 Moved Permanently:永久重定向。浏览器会强力缓存,下次再点同一短链,直接从本地跳,根本不回源。
- 302 Found:临时重定向。浏览器每次都回源,服务器每次都能记录这次点击。
技术上看 301 更快、更省服务器,但只要产品要做点击分析(广告投放、邮件营销几乎都要),就必须用 302。否则用户回访的点击统计不到,商务对账直接崩。Bitly、短链平台类产品默认 302,就是这个原因。
app.get('/:code', async (req, res) => {
const longUrl = await lookup(req.params.code);
if (!longUrl) return res.status(404).end();
analytics.emit('click', { code: req.params.code, ...req.meta }); // 异步,不阻塞
res.redirect(302, longUrl);
});
一个细节:埋点必须异步。重定向的延迟感知极强,同步写分析库会把 P99 拖到几百毫秒,用户就以为链接坏了。
五、缓存:读多写少的天然战场
读写比 100:1,意味着每条短链被读 100 次后才写一次——这是缓存的完美场景。Redis 几乎是标配:
GET /:code
├─ 1. 查 Redis(cache-aside)
│ 命中 → 302
│ 未命中 ↓
├─ 2. 查 MySQL
│ 命中 → 回填 Redis(TTL 几小时到一天)→ 302
│ 未命中 → 404(同时缓存空值,防穿透)
实战要点:
- TTL 不必和短链有效期完全一致,设 1-7 天就够,长链改了也能容忍短时间不一致。
- 防穿透:不存在的短码也缓存空值(短 TTL),不然恶意刷随机短码能把 MySQL 打爆。
- 防雪崩:TTL 加随机抖动(
ttl + rand(0, 60s)),别让一批 key 同一秒集体过期。 - 热点 key:个别爆款短链可能占 30% 流量,在 Redis 之前再加一层本地 LRU,挡掉绝大多数回源。
六、扩展:从单机到分片
走到这一步,系统已经过了"演示"阶段,要面对真实规模。整体架构长这样:
用户
│
┌──────────┴──────────┐
▼ ▼
[CDN / 边缘] [负载均衡 Nginx]
命中即 302 │
│ miss ┌───────┴────────┐
▼ ▼ ▼
[读服务集群] [写服务集群] [分析服务]
│ │ (异步)
├──── Redis ───┤ (热缓存)
▼ ▼
[MySQL 分片 0..N] ← 发号器(独立服务)
(按 short_code hash)
分片策略:按 short_code hash 分到 N 个库。注意——别按自增 id 分,读请求只带短码、不带 id,按 id 分片后读还要先反解,白白多一次路由。永远按读路径上的 key 分片,这是新人最常见的分片错误。
高可用:MySQL 每个分片主从,Redis 哨兵或集群模式,写服务无状态可水平扩。发号器是单点风险,要么用 Snowflake 类算法(每台机器分一段 ID 空间),要么多主 MySQL 各预留步长。
CDN:短链重定向响应极小(几百字节头部),非常适合 CDN 缓存。但和第四节必须一起考虑——CDN 缓存会让 302 的点击统计丢失。常见做法:热门但不需要分析的部分走 CDN + 301,需要分析的部分回源 + 302。
七、面试里怎么"边设计边讲"
最后讲方法论。这道题面试官真正在看的不是你知不知道 base62,而是:
- 先问需求再画图:上来 30 秒把读写比、是否要分析、是否要自定义短码问出来,省掉这一步,后面方案一定偏。
- 先给基础方案,再优化:别一上来就讲"多级缓存 + 分片 + KGS",先给"一张 MySQL + base62"的最小可用版本,按瓶颈驱动一步步优化。层次感比炫技重要。
- 讲清容量估算:把 QPS、存储、带宽数字说出来,说明你能把抽象问题落地到资源。
- 主动讲取舍:301 vs 302、按 id 还是按 code 分片、要不要 KGS——每一步都说"选 A 是因为 X,代价是 Y"。没有取舍的设计是没思考过的设计。
- 承认不知道:被追问到盲区(比如发号器时钟回拨),老实说"细节我没深究,大概思路是 XXX",比硬编强。
小结
- 系统设计第一步永远是澄清需求——把读写比、是否要分析、是否要自定义短码问出来,再开始画图。
- 短码生成首推"自增 ID + base62":天然唯一、长度可控、可反解,工业界主流方案。
- 301 vs 302 是产品决策不是技术细节:要做点击分析就必须 302,即使代价是每次回源。
- 读路径上每个环节都要为延迟和穿透防御:Redis cache-aside + 空值缓存 + TTL 抖动 + 本地热点 LRU。
- 分片按读路径上的 key 分,别按数据库内部 id 分——这是新人最常见也最致命的分片错误。
一句话收尾:短链系统的难度不在任何单点,而在于把"生成、存储、重定向、缓存、扩展"五件事串成一条不漏的链路。面试官要看的,是你能不能讲清这条链路上每一步的取舍。
Related