系统设计 12:短链服务:唯一创建与可撤销重定向
短链的读取看起来只是一次键值查询,但“随时可以撤销”会改变缓存和重定向选择。假如浏览器已经记住永久重定向,服务端再删除映射也不能让这个浏览器重新询问。编码长度决定碰撞空间,数据库约束决定是否覆盖,撤销契约决定读取路径,三者不能互相替代。
候选服务支持创建、访问、到期和撤销,产品名 TinyURL 只表示题型。它不复刻任何公司的内部架构。前面的 ID、缓存和数据模型基础在这里组合成一条能明确确认点的路径。
创建者与访问者有不同的接口
POST /links 接受目标 URL、有效期和可选自定义别名;调用者身份来自认证上下文,幂等键放在请求头。成功返回 201 和 code;同一所有者、同一幂等键、同一请求正文再次提交返回原记录;同键不同正文返回冲突。自定义别名已存在返回 409,而不是覆盖已有用户的链接。
GET /{code} 返回临时重定向或不存在结果。DELETE /links/{code} 必须校验所有者并提交撤销状态。公开读取可把不存在、私有无权限和已撤销统一为 404,避免额外泄漏;如果产品需要到期提示页,可以在不泄漏私密信息的前提下给出 410。只对合法 URL 创建短链,限制长度与允许的 scheme,不将 javascript: 等非预期 scheme 放入 Location。
数据表可定义为 links(code PRIMARY KEY NOT NULL, owner_id, target, state, expires_at, version),另有 requests(owner_id, request_key, payload_hash, code),复合唯一键为 (owner_id, request_key)。创建时同时写链接与请求映射,再返回确认;提交后响应丢失,客户端用原键查询即可。短码唯一性应由数据库决定,不能先 SELECT 不存在再 INSERT 而不设约束。
SQLite 官方文档说明 PRIMARY KEY 与 UNIQUE 的约束行为;本地实验显式加 NOT NULL,避免依赖某些表类型下文本主键对 NULL 的历史兼容行为。生产数据库的隔离与冲突重试另行验证,不由 SQLite 的通过推导多主集群也安全。
先算读路径,再决定编码长度
教学假设每天新增 100 万条,每天重定向 1 亿次,读写比 100:1,峰值系数 10。平均写约 11.57 requests/s、平均读约 1157.41 requests/s,峰值读约 11574.07 requests/s。每次重定向响应按 500 B 估算,峰值有效载荷约 5.79 MB/s;这不包括跳转目标站的内容流量,短链服务器不替用户下载那个页面。
每条元数据按 400 B,保留 365 天,三份数据为 1000000 × 400 × 365 × 3 = 438 GB,索引、日志和备份另算。点击分析若每次写 120 B,单日原始事件就是 12 GB,比映射表新增 0.4 GB 大三十倍,故统计通过独立异步路径处理,不能让点击计数写锁阻塞跳转。
Base62 七位有 62^7 = 3521614606208 个值。空间很大不等于随机创建不会碰撞:若保留 3.65 亿条,均匀随机抽样的生日近似碰撞对数约 n(n-1)/(2M) ≈ 18916。这是碰撞对的期望估算,不是会覆盖这么多条。唯一约束拒绝冲突后重新生成,就能保持“不覆盖”;空间占用率约 0.0104%,因此单次新抽样撞上既有记录的概率仍较低。
序列转 Base62 省去随机碰撞重试,但可枚举、暴露规模,并需要稳定分配号段。随机码难以批量猜中也不等于访问授权,私密链接应有认证或足够强且可撤销的访问凭证。自定义别名是另一命名空间约束,保留词和大小写规范化必须在入库前统一。
撤销契约决定是否允许缓存直接返回
最小方案是 HTTP 服务加权威数据库,读路径查状态和目标再返回。先不缓存,只有测到数据库读取成为瓶颈才缓存不可变目标。若声明“撤销完成后开始的请求不得跳转”,读取必须经过能看见已提交撤销的权威检查;使用可能落后的副本就削弱了这项承诺。
flowchart LR
C[创建者] -->|POST 幂等键| A[短链API]
A -->|同事务写映射与请求键| D[(权威库 唯一短码)]
V[访问者] --> R[重定向入口]
R -->|状态与到期检查| D
R --> T[不可变目标缓存]
R -->|临时重定向 no-store| V
R -.-> E[异步点击事件]
C -->|撤销 增加版本| A
这里选择 302 并发送 Cache-Control: no-store;重点是缓存指令,而非误以为“302 天生不能缓存”。RFC 9111 的 no-store 约束合规缓存不存储本次响应,no-cache 则允许存储但复用前必须验证。对不可撤销且目标永久稳定的链接,永久重定向可能更节省回源;一旦引入撤销承诺,就不能把客户端永久缓存当作透明优化。
对于允许“撤销五秒内生效”的公开链接,可以把权威状态缓存五秒,并把 CDN 刷新失败作为退化到 TTL 的上界。但上界还需要包含多层缓存年龄、时钟偏差和过期时是否允许返回陈旧内容。不能在 CDN 配置了 stale-if-error 后仍声称故障时五秒必撤销。严格撤销时,状态服务不可用应拒绝跳转,这会牺牲部分读取可用性。
恶意链接治理也沿同一状态机。新链接可处于 PENDING_REVIEW,扫描通过变 ACTIVE,被举报后变 BLOCKED。扫描目标 URL 时可能引入 SSRF,扫描器需要网络隔离与目标限制,不能在创建 API 的高权限网络里直接访问任意用户 URL。内容安全结果是带版本的决策,不能让旧的“安全”消息覆盖新的封禁。
并发创建与撤销负例
examples/system-design/labs/12/shortlink.py 建立真实 SQLite 文件,十二个线程各用独立连接,在屏障后同时尝试插入短码 same,但目标不同。结果必须恰好一次成功、十一次唯一键冲突、数据库一行。赢家由调度决定,实验不固定哪个线程先提交,只断言没有覆盖和多行。
随后把赢家目标预热到字典缓存,更新数据库为已撤销。安全路径先读权威状态,返回空;错误路径只看热缓存,仍返回原目标。这个反例表明唯一创建和正确撤销是两项独立的验收,前者通过不能代替后者。
sequenceDiagram
participant A as 创建请求A
participant B as 创建请求B
participant D as 唯一索引
participant R as 访问路径
A->>D: INSERT same 指向目标A
B->>D: INSERT same 指向目标B
D-->>A: 一个提交成功
D-->>B: 另一个冲突 不覆盖
R->>D: 读取并预热目标
A->>D: 提交撤销状态
R->>D: 再次校验状态
D-->>R: 已撤销 拒绝跳转
1 | |
默认退出 0,负例检测到缓存越过撤销后退出 2;命令、Python 与 SQLite 版本、输出位于 examples/system-design/evidence/12/。没有启动真实 HTTP 服务、浏览器缓存或 CDN,缓存只是本地字典,因此实验只验证数据库竞争和读取门禁逻辑。HTTP 行为依据规范设计,仍需浏览器及代理集成测试。
热点、故障恢复与追问
如果单一短链占全站 20% 请求,它的峰值约 2315 requests/s;把所有短码均匀哈希并不会分散这一个键的权威状态读取。允许短暂撤销延迟时可以复制热点状态缓存;严格撤销时需要扩展权威读取能力或改变一致性实现。把缓存命中率从 95% 降至 80%,目标查询回源从约 579 升到 2315 requests/s,但强撤销门禁的检查仍可能是全量 11574 次,不能只报目标缓存的命中率。
恢复时先确认权威库与撤销版本,再恢复缓存预热。误删缓存最多增加读取成本,丢失撤销墓碑却可能重新激活链接,所以备份恢复必须同时还原状态和版本。点击统计可以补发去重,短码映射不能从点击事件反向猜测重建。容量紧张时先关闭详细分析或降低采样,不牺牲封禁检查。
限时设计先讲明是否可撤销,再花八分钟核算读写与编码,十二分钟画创建及跳转路径,十五分钟处理冲突、缓存与热点,最后复核确认点。面试追问“随机码够长为何还要唯一索引”,对应概率与正确性的区别;“同一长 URL 是否只允许一个短码”,对应产品去重语义与多所有者生命周期,默认允许不同用户独立撤销自己的映射更清晰。
参考资料
- SQLite CREATE TABLE:主键、非空与唯一约束。
- RFC 9111:HTTP Caching:缓存存储、验证与失效语义;本文重定向策略属于候选业务设计。






