把热门内容放进 CDN 能减少回源,但“缓存命中率很高”没有回答分享撤销之后谁还能看见旧内容。失效不只是一条删除命令:要先划清哪些读允许旧值、哪些读必须在确认撤销后立刻拒绝,再决定缓存键、TTL 和回源容量。

沿用网关与连接边界及前几篇的分享场景:GET /shares/{id} 读取目标、所有者 DELETE /shares/{id} 撤销、图片预览按版本读取。本篇只讨论分享元数据和已公开的不可变预览,不讨论私有对象的直连 CDN 方案、DRM 或 CDN 产品计费。此前约定“撤销确认后不能再公开返回目标”,这一要求高于缓存命中率。

先把可缓存和不能缓存的访问拆开

读路径分两类。分享目标在源站的权威状态为 ready/revoked,公开读取必须检查撤销;若一个共享缓存可直接返回旧目标,就算源站状态已经改变,访问者仍可能看见旧响应。受保护响应应避免进入共享缓存;对遵循 HTTP 规范的缓存,响应侧 Cache-Control: no-store 表示不应存储该响应(RFC 9111:no-store)。这不阻止读者已复制的内容,也不等于 CDN 供应商对撤销时限作了承诺。

对公开且不随权限变化的预览,用内容版本参与 URL/缓存键,允许缓存按策略过期;上传、替换或重新生成时创建新版本,不覆盖同键的旧字节。AWS 的 CloudFront 文档把按路径失效和使用版本化文件名列为两种更新选择;这里只把它们当作方案对照,没有使用 CloudFront 或验证失效传播时间。版本化 URL 不能让一个已经公开发出的旧 URL 在撤销时自动失效;若这也属于强撤销范围,必须把鉴权放到受控读取路径并重新设计可缓存边界。

flowchart LR
    C[读者] -->|GET 分享目标| G[受控 HTTP 读取]
    G -->|每次检查 revoked| D[(权威状态)]
    D -->|可读或拒绝| G
    G -->|不让共享缓存复用受保护目标| C
    C -->|GET 公开预览 v1| E[边缘缓存]
    E -->|未命中才回源| O[(预览对象源)]
    O -->|已完成的版本字节| E
    E -->|命中或填充| C

这里的“公开”是产品前提,不是“所有私有图片都能放 CDN”。一旦预览也需要撤销即刻生效,数据路径不能仍把公开版本链接直接当作授权凭证。缓存键应包括影响响应的版本、尺寸和公共变换参数;若身份/权限影响结果,要么不在共享缓存存储,要么另行证明键、鉴权和撤销边界正确,不靠添加一个 owner_id 字段猜测安全性。

命中率提升,也要给回源留余地

独立教学假设:每天上传 20 万张预览,每天读取 400 万次,读写比 20:1;预览 256 KiB,每次读峰值为日均的八倍,约 4000000 × 8 / 86400 = 370.37 read/s。峰值有效载荷 370.37 × 256 KiB ≈ 92.59 MiB/s ≈ 776.72 Mbit/s。在所有请求同一分布、命中率练习假设为 95% 时,源站平均需要接峰值约 18.52 miss/s、预览字节约 4.63 MiB/s;如果一次广域失效使峰值窗口内全部未命中,就可能变成约 370.37 miss/s、92.59 MiB/s,是这组假设下的 20 倍。不是 CDN 实测,也不保证全部边缘同时冷启动。

只计本篇已完成的单张 256 KiB 预览,保留 90 天、两份,存储量 200000 × 90 × 2 × 256 KiB = 9.437 TB。每张 800 B 元数据、200 B 索引保存 90 天、三份,另计 54 GB;读写合计每天 420 万条、每条 200 B 的诊断日志保存 7 天一份,另计 5.88 GB,三项约 9497.06 GB。以练习价格 0.02 货币单位/(GB·月),仅上述存储约 189.94 货币单位/月;不含原图、边缘缓存副本、请求数、回源传输和真实服务费。若预览大小翻四倍而请求与命中率不变,字节出口与回源字节也按四倍变;只提升命中率不能解决某个单独热点对象同时大量回源的风险。

最小可用设计先不缓存受保护的目标,只缓存可公开、完成后不可变的预览。对热点预览失效后可能出现并发回源,需要按键合并填充或设置受限并发、超时与失败响应,源站容量预算应覆盖热键与失效突发;这些措施都是待组件验证的设计,不是本篇实验已观察到的 CDN 功能。若产品要求旧预览可在失效后最多再展示 60 秒,TTL 可作为一个候选上界的组成部分;如果允许过期响应、离线缓存或其他边缘策略,还要逐项核验,不能单靠 max-age=60 保证绝对上界(RFC 9111:响应新鲜度与失效、§4.4)。

十秒内的撤销漏洞

固定输入模型 examples/system-design/labs/06/cache.py 刻意把受保护目标错误地放进一个 TTL=60 秒的缓存。t=0 填入 target-v1,t=10 源站将分享标为撤销,t=10 的边缘读取仍命中旧目标;t=20 人为执行缓存失效后才读不到。正常命令退出 0;使用 --strict-revoke 把撤销后读到旧目标判为违例,退出 2。原始输出见 examples/system-design/evidence/06/cache.md。这里没有 HTTP、CDN 或真实分布式失效,不可把“t=20 清掉了”翻译成“真实 CDN 传播只花十秒”。

1
2
python3 examples/system-design/labs/06/cache.py
python3 examples/system-design/labs/06/cache.py --strict-revoke
sequenceDiagram
    participant C as 读者
    participant E as 边缘缓存模型
    participant D as 权威状态
    C->>E: t=0 读受保护目标
    E->>D: 首次未命中,查询 ready
    D-->>E: target-v1
    E-->>C: target-v1,TTL=60
    Note over E,D: t=10 源站撤销并确认
    C->>E: t=10 再读目标
    E-->>C: 仍返回 target-v1,违反强撤销
    Note over E,D: t=20 本地显式失效(非真实 CDN 时延)
    C->>E: t=20 再读
    E->>D: 缓存未命中,重新查 revoked
    D-->>E: 拒绝
    E-->>C: 不返回旧目标

对比两条迁移路径:如果旧实现直接缓存目标响应,应先把受保护读取迁回每次权威校验,双路径抽样比较撤销后可见性,再停用旧缓存键;如果只是公开预览更新,可用版本化 URL,并在失效时对旧版本的可达性设明确产品约束。故障恢复要区别“源站读不到”与“仍有旧缓存”:受保护请求失败时应拒绝而不是盲目兜底旧目标;公开预览能否返回旧版本则按已说明的宽松契约处理。面试里先问强撤销适用于哪个对象,再算回源放大与存储成本,最后讲失效时间线,比直接说“加 CDN、TTL 一分钟”更能暴露设计边界。

参考资料