“做一个分享服务”不能直接变成组件清单。它还没有说明:分享给谁,链接可以撤销吗,读者缓存了旧内容怎么办,创建响应丢了还能不能重试。这些问题的答案会直接改变读写路径。

延续第 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 分钟;这仅是练习安排。可迁移的决策规则:先问不可违反的数据承诺和测量口径,再考虑把读取移到哪一层;新增缓存必须同时给出可见性上界及失效后的退路。

参考资料