系统设计 01:需求澄清——功能、SLO、数据不变量与范围
“做一个分享服务”不能直接变成组件清单。它还没有说明:分享给谁,链接可以撤销吗,读者缓存了旧内容怎么办,创建响应丢了还能不能重试。这些问题的答案会直接改变读写路径。
延续第 00 篇:系统设计题究竟交付什么的教学场景,本篇只处理一件事:把含混需求转成可验证的契约,不借课程题解推测真实产品架构。
先问会改变架构的问题
产品希望用户创建一条分享,接收方打开链接,创建者以后可以撤销。选择以下教学假设,而不是默许它们:只有登录的创建者能撤销;现在分享目标是 URL 元数据,非文件正文;读者无需登录,但链接转发即可能被别人读取;撤销确认之后才开始的新读取,不得返回目标。撤销确认之前已发出响应的读取,不承诺能在网络中撤回。
排除公开搜索、按创建者列举、链接到期、屏蔽恶意 URL 和隐私内容的接收者鉴权。这些是后续产品决策,不是“先不画出来就自动解决”。若用户问“这条链接必须仅让指定同事读取吗”,应先改变接口和访问控制模型,而不是把随机分享 ID 当成授权。若用户问“网页已被别人保存怎么办”,服务端撤销不能抹去接收方保存的副本;必须缩小承诺。
| 问题 | 本题确认 | 可观察验收 |
|---|---|---|
| 创建超时后重试 | 同一创建者同一幂等键指向同一份分享;同键换目标拒绝 | 提交后故意丢失响应,重试检查分享数与 ID |
| 撤销 | 仅创建者可撤销;确认后开始的新读取不返回目标 | 另一个账户尝试撤销,再读并检查响应 |
| 拒绝访问 | 不向未获准读者暴露目标;是否隐藏存在性须事先定 | 401/403/404 的契约测试,不借状态码代替权限检查 |
| 诊断日志 | 允许异步记录读取事件,不作为授权和撤销的事实来源 | 停掉记录器后读取不改变权限判定 |
HTTP 状态并不自行实现权限。按 RFC 9110 §15.5.4,服务若想隐藏被禁止资源的存在,可以返回 404 而非 403;这里选择对未获准读者统一返回 404,监控仍按内部拒绝原因分类。POST 请求不能因为网络超时便无条件重发,RFC 9110 §9.2.2 区分方法幂等性与应用自己增加的重试保障。
SLO 要有分母,也要有窗口
暂定读取 30 天有效请求成功率至少 99.9%,有效读取的 p99 服务端延迟不超过 250 ms;创建的 p99 不超过 500 ms。有效读取是“读取开始时分享仍有效,且读者符合本题访问约束”的请求。撤销后的访问、不合法路径和主动超出配额的请求单列,不能用海量 404 冲淡错误率。故障超时应计入可用性失败,而不能因为没有成功延迟样本就从报表里消失。SLO 是拟议目标;本篇没有真实服务监控数据。
沿用教学假设:每天创建 20 万次,每份分享平均读取 10 次,峰值/均值按 8 倍,故读取峰值约 200000 × 10 / 86400 s × 8 = 185.19 read/s。30 天按该模型总共 6000 万次有效读取,若实际可用性指标按上述口径为 99.9%,约有 6 万次失败的预算;这不是说允许任何一次撤销泄露。单条记录 800 B,索引与日志各分摊 160 B,365 天、3 份存储约 200000 × (800+160+160) B × 365 × 3 = 245.28 GB = 228.43 GiB;真实的日志保留期、额外索引、压缩及成本要另算。
若只把读写比从 10:1 改为 100:1,写入和留存容量不变,读峰值却变为 1851.85 read/s;假设单次响应有效载荷 1024 B,出口约 1851.85 × 1024 × 8 = 15.17 Mbit/s,不是 MB/s。这可能先压到授权读检查,再压到网络;哪层先饱和要靠实测。若增加读副本,必须先回答“副本延迟可否突破即时撤销”;如果不能,撤销相关读取仍需经过权威状态或建立能证明上界的机制。
把读写确认点画清楚
接口为 POST /shares(Idempotency-Key、目标 URL),GET /shares/{share_id},以及仅创建者可调的 DELETE /shares/{share_id}。主数据模型包括 shares(share_id, owner_id, target, state, version, revoked_at) 和 requests(owner_id, key, payload_fingerprint, share_id);(owner_id, key) 有唯一约束。请求映射与分享创建必须同一原子提交;相同键但目标不同不能直接回旧结果,需明确冲突响应。这是待真实数据库验证的设计契约,并非内存模型已经证实的事务语义。
flowchart LR
U[创建者或读者] -->|POST/GET/DELETE 携带身份或分享 ID| H[HTTP 服务]
H -->|授权检查 / 同步读 state 和 target| D[(权威数据存储)]
H -->|同步事务写 shares 和 requests / 撤销 state| D
D -->|提交结果 / 当前版本| H
H -->|仅在提交后确认 / 读结果| U
H -.->|读后尽力异步发送诊断事件| L[诊断记录器]
不要把虚线里的诊断记录当成审计证据:尽力而为的异步发送在进程故障时可能丢失。若合规要求“每次撤销必有审计事件”,要重新协商原子提交与可靠投递;不能把日志当成已经交付的事实。初版读取直接查权威存储,没有客户端控制的公共缓存。HTTP Cache-Control: no-store 指令要求遵守规范的缓存不存储响应,但 RFC 9111 §5.2.2.5 明言它不足以保证隐私,也不能使过去已保存的内容消失。
超时和缓存要用反例推翻
sequenceDiagram
participant C as 创建者
participant H as HTTP 服务
participant D as 权威存储
participant K as 备选读缓存
C->>H: POST 创建,幂等键 K1
H->>D: 原子保存分享与 K1 映射
D-->>H: 提交 S1
Note over H,C: 响应中途丢失,客户端超时
C->>H: 用 K1 重试
H->>D: 查询 K1 对应 S1
D-->>H: S1,不再创建
H-->>C: S1
C->>H: DELETE S1
H->>D: 验所有者并提交 revoked
D-->>H: 撤销已提交
H-->>C: 撤销确认
Note over H,K: 备选方案若缓存未清,之后新读会取到旧值
两种方案的分歧不是“哪个更高级”。直接查询权威状态,实现路径短、撤销语义清楚,但所有读取落到存储;缓存优先可以降低热读压力,却需要处理失效延迟、失效通知丢失、已在途的读取和已保存的副本。如果产品接受撤销最多延迟 60 秒可见,才可重新选择有明确 TTL 的缓存方案,并将实际失效时延列为指标;本题承诺“确认后新读不可见”,不能靠给缓存加一个 60 秒 TTL 糊过去。缓存故障时应回源或拒绝读取,不能为提升成功率直接放行旧内容。
最小模型练习用 Python 标准库实现该反例:正常路径模拟创建写入后丢失响应,同键重试仍为 S1、不同内容被拒、非所有者撤销被拒,撤销后新读为 None(退出 0);刻意打开不失效的内存缓存后,新读拿到旧目标(退出 2)。原始命令、输出和限制见仓库 examples/system-design/evidence/01/contract.md,源码在 examples/system-design/labs/01/share_contract.py。这只证明模型里的契约差异;没有并发事务、真实 HTTP 缓存或实际的 p99 测量,不能从这两个退出码推断生产组件已经达标。
面试追问可以从“撤销后继续读会怎样”“客户端丢了创建确认会怎样”“若分享仅授权特定人怎样改接口”三个方向展开。练习时间可分为需求 5 分钟、容量与接口 8 分钟、最小路径 12 分钟、缓存与失败 15 分钟、复核 5 分钟;这仅是练习安排。可迁移的决策规则:先问不可违反的数据承诺和测量口径,再考虑把读取移到哪一层;新增缓存必须同时给出可见性上界及失效后的退路。
参考资料
- RFC 9110:HTTP Semantics,§9.2.2(幂等)及 §15.5.4(403 与隐藏存在性的 404)。
- RFC 9111:HTTP Caching,§5.2.2.5(
no-store的作用与隐私限制)。



