8 对象存储、缓存与 CDN
为什么对象存储说「强一致」,业务还是读到旧头像、旧价格?为什么删了缓存还是脏,TTL 到底能保证什么?为什么一次批量过期会把源站打挂,重试又把它推得更深?上传超时重试,为什么会在对象存储里留下两个对象、在账单里留下两笔扣费?
能用 ETag、If-None-Match、If-Match 写条件 PUT 并处理 412;能画出四种缓存模式的读写路径与失败窗口;能用 singleflight、空值缓存、TTL 抖动和版本号处理击穿、穿透、雪崩与热点;能设计 CDN 的 cache key、purge 与 stale-while-revalidate;能读懂 curl -I 的 Age/X-Cache;能把上传完成回调做成幂等。
第 7 章把数据从「一次请求」搬到了「一条流」:生产者写日志,消费者按 offset 读,投递语义是至少一次,重复只能靠消费端去重。到了本章,数据终于静下来了——图片在对象存储里,价格在数据库里,页面在 CDN 上。但「静下来」不等于「读得到最新的那一份」。用户点开商品页,看到的往往是某一层缓存里的副本,而不是源站刚提交的值。
先看一个很普通的事故。运营在后台把商品主图换成新图,上传接口返回 200,数据库里的 object_key 也更新了。十分钟后,客服收到投诉:用户看到的还是旧图。排查发现三件事同时成立:CDN 的 TTL 是 1 小时,服务本地缓存是 10 分钟,而对象存储的 key 没变、只是内容被覆盖了。源站是对的,链路上每一层缓存也都「按配置工作」,但用户看到的仍然是旧的。缓存不是数据库的透明加速器,它是一份可能旧、可能缺、可能被驱逐的物化视图;视图和源站之间隔着 TTL、失效消息、回源 race 和网络分区。
这一章要解决的就是这三层之间的「时间差」:对象存储这一层能提供什么保证(8.1、8.2),应用缓存怎么读写、什么时候会脏(8.3、8.4),miss 的形状如何把源站打挂(8.5、8.7),CDN 的边缘缓存和失效又多了哪些维度(8.6),以及出问题时看什么(8.8)。最后用 Cloudflare 2019-07-02 的边缘故障说明「全球配置 + 边缘计算」的爆炸半径(8.9),用一次上传重试说明对象存储的幂等只覆盖字节、不覆盖业务副作用(8.10)。
一句话先放在这里:强一致描述的是「读到的当前版本」,不是「你以为是最后一次写」;缓存描述的是「最多能旧多久」,不是「永远一致」。
8.1 对象存储的语义:PUT/GET、ETag、条件写、多段上传
把对象存储当文件系统,是第一个错误。PUT /avatars/u-1024.png 不是「打开文件、写一段、关闭」,而是「用一整个新对象替换这个 key 当前指向的对象」。没有 open/seek/append,没有原地修改,没有原子 rename,也没有真正的目录:list prefix 是遍历 key 空间,不是读目录项。理解这一点,才能理解为什么「只改一个对象的元数据」也常常是一次整体复制。
| 操作 | 实际语义 | 常见误解 |
|---|---|---|
PUT | 整对象替换;单 key 原子;后完成者成为当前版本 | 以为可以局部更新、追加、改一行 |
GET / HEAD | 读当前版本;HEAD 返回元数据,不收 body | 以为 HEAD 不产生请求成本 |
DELETE | 删除当前版本;版本化桶里是加 delete marker | 以为字节立刻消失、旧版本也没了 |
COPY | 服务端复制出一个新对象 | 以为 rename 是原子元数据操作 |
LIST | 按 prefix 分页扫描 key | 以为一次 LIST 是时间点快照 |
| Multipart | initiate / upload part / complete | 以为 ETag 是整对象的 MD5 |
| Presigned URL | 带签名的临时 bearer URL | 以为可以按 URL 单独撤销 |
8.1.1 ETag 是不透明的版本令牌,不是校验和
ETag 看起来像 MD5,因为单段 PUT 的 ETag 经常就是内容的 MD5。但这是实现细节,不是契约。多段上传的 ETag 是「各分片 MD5 再 MD5」再加一个 -N 后缀;启用 SSE-KMS/SSE-C 之后,ETag 不再是内容 MD5;不同厂商、不同加密方式、不同分片大小都会让 ETag 变化。把 ETag 当内容哈希去校验下载,会在换加密方式或换分片大小时突然失效。要做完整性校验,用对象存储提供的 checksum(SHA-256、CRC32C 等),或者自己算,不要用 ETag。
Last-Modified 也不是版本令牌。它只有秒级精度,两次写入可能落在同一秒;用它做条件写,等于用「秒」当 CAS 的版本号。要并发控制,用 ETag、version id,或者自己维护的单调版本号。
8.1.2 条件写:把「读-改-写」变成一次 CAS
没有条件写时,两个 writer 的「读出来、改一改、写回去」会丢失更新:两个人都读到 v1,各自写回 v2,后完成的覆盖先完成的,先完成的人还以为自己成功了。条件头把「我读到的是哪个版本」带进写请求:
If-None-Match: *:仅当 key 不存在时创建,已存在返回 412。适合「抢注」、幂等创建、防止覆盖。If-Match: "<etag>":仅当当前 ETag 匹配时写入,不匹配返回 412。适合 CAS、乐观锁。- 并发条件写:S3 文档的说法是「第一个完成的写成功」,其余返回 412;不要把 412 当网络错误盲目重试,要重新读、合并、再写。
- 版本化桶里,如果当前版本是 delete marker,
If-None-Match: *可能允许写入;这个语义要按具体实现的文档确认,不要凭直觉。
PUT /avatars/u-1024.png HTTP/1.1
Host: media-example.s3.us-east-1.amazonaws.com
Content-Type: image/png
Content-Length: 184320
If-Match: "9f2c1d7e5b1a4c3d8e6f0a2b4c6d8e0f"
HTTP/1.1 412 Precondition Failed
x-amz-request-id: 7Q3F9Y2K4M8P1R5T
同一个请求,如果 ETag 仍然是当前版本,会得到 200 OK 和新的 ETag;如果中间有人改过,才会得到 412。412 是正常的并发结果,不是故障;它告诉你「你基于的版本已经过期」,正确的动作是重读、合并、再带新 ETag 写一次,而不是换一个随机 ETag 硬写。
# create-only:第一次 200,第二次 412,不会覆盖已有对象
curl -sS -o /dev/null -w '%{http_code}\n' -X PUT \
--data-binary @avatar.png \
-H 'Content-Type: image/png' \
-H 'If-None-Match: *' \
https://media-example.s3.us-east-1.amazonaws.com/avatars/u-1024.png
这条命令的两次输出分别是 200 和 412。第二次的 412 说明对象已存在,调用方应该走「读取已有对象」或「换一个 key」的分支,而不是重试。写侧条件头不是一开始就有的:S3 在 2024 年才给 PutObject 补齐 If-Match/If-None-Match;在此之前,跨对象做 CAS 只能靠外部锁(DynamoDB、etcd)或版本化 key。即便现在有了条件写,它也只管一个 key,不管跨 key 的业务不变量。
8.1.3 多段上传:重试的单位是分片,不是对象
大对象走多段上传:CreateMultipartUpload 拿到 UploadId,按 part number(1–10000)并行上传分片,每片除最后一片外至少 5 MiB,最后 CompleteMultipartUpload 提交 part 列表和各自的 ETag。这个设计的目的就是让重试便宜:某个分片失败,只重传那一片;重传同一个 part number 会覆盖该分片,是安全的。
但 CompleteMultipartUpload 本身不是「可以无限重复」的。完成成功后 UploadId 就失效了,再发一次 complete 会得到 NoSuchUpload(404),而不是 200。「complete 超时」不能直接当失败重试:先 HEAD 目标对象,确认 size/checksum/ETag 是否符合预期;如果已经存在且匹配,就当作成功;只有确认没完成,才重建上传。这个细节在 8.10 会变成一次真实的重复扣费。
还有两个工程点。第一,未完成的分片会占存储、会计费,配一条 lifecycle 规则 AbortIncompleteMultipartUpload 自动清理。第二,分片大小是「重试成本」和「请求数」的折中:8–16 MiB 适合百 MB 级对象,分片太小请求多、连接复用差,太大则一次重传要重发很多字节。多段对象的 ETag 不是整对象 MD5,校验要用 composite checksum 或自己算。
8.1.4 预签名 URL:把写权限借出去,但别把 key 也借出去
预签名 URL 是把「某个方法 + 某个 key + 过期时间 + 部分 header」签名后交给客户端,客户端在有效期内可以直接读写,不必持有长期凭证。它本质上是 bearer token:URL 泄露,等于权限泄露;签发后无法按 URL 单独撤销,只能等过期、轮换凭证,或者走自己的代理。SigV4 的预签名最长 7 天;客户端时钟偏差会让签名提前失效。
上传场景最常见的错误,是让客户端决定最终 key。正确做法是服务端生成 key(例如 uploads/{upload_id}/source.mp4),签一个短 TTL 的 PUT,客户端直传;完成后服务端 HEAD 校验大小、content-type、checksum,再 COPY 到最终 key。这样客户端不能覆盖别人的对象,服务端也保留了「这次上传属于谁」的记录。直接把最终 key 的写权限签给客户端,等于把「覆盖任意对象」的能力也签了出去。
8.1.5 一次预签名直传的完整时序
把 8.1.4 的建议展开成一条可执行的链路:
客户端 业务 API 对象存储
│ POST /uploads │ │
├──────────────────────────▶│ 生成 upload_id │
│ {upload_id, put_url} │ 签 staging key 的 PUT │
│◀──────────────────────────┤ │
│ PUT staging (短 TTL) │ │
├───────────────────────────────────────────────────────▶│
│ 200 + ETag │ │
│◀───────────────────────────────────────────────────────┤
│ POST /uploads/{id}/complete │
├──────────────────────────▶│ HEAD staging:size/type/checksum
│ ├───────────────────────────▶│
│ │ COPY staging → final key │
│ ├───────────────────────────▶│
│ 201 {object_key, etag} │ 写元数据(幂等键 upload_id)│
│◀──────────────────────────┤ │
这条链路里有三个校验点:complete 时 HEAD staging,确认客户端真的传了、大小/类型符合预期;COPY 到 final key 时用服务端生成的目标 key,避免客户端覆盖;写元数据时用 upload_id 做唯一键,避免重试插入两行。staging key 的 TTL 要短(比如 1 小时),配 lifecycle 清理未完成的上传。
| 检查点 | 要验证什么 | 失败时 |
|---|---|---|
| HEAD staging | 对象存在、size 在范围内、content-type 白名单 | 400/422,要求重传 |
| checksum | 客户端声明的 SHA-256 与服务端算的一致 | 422,拒绝进入转码 |
| COPY final | 目标 key 由服务端生成,源 key 属于本次 upload_id | 403/409,记录审计 |
| 元数据写入 | upload_id 唯一约束 | 冲突时返回已有记录 |
对象是不可变的字节序列加元数据,由一个 key 指向当前版本。ETag 是当前版本的不透明校验器(opaque validator):它通常随内容变化,但不保证是 MD5,也不保证跨实现可比。条件写把 If-None-Match/If-Match 带进写请求,让「读某个版本 → 基于它修改 → 写回」变成一次 compare-and-swap;失败返回 412,而不是静默覆盖。
单次 PUT 对同一个 key 是原子的:读者要么看到旧对象,要么看到新对象,不会看到半个对象。但「对象 A 更新 + 对象 B 更新 + 数据库记录更新」没有原子性;跨 key 的原子更新必须由应用自己设计(版本号、manifest、事务表),对象存储不提供。
8.2 S3 强一致之后还剩什么坑
2020 年 12 月 1 日,S3 宣布对所有 GET、PUT、LIST 提供强读后写一致性:新对象、覆盖写、删除都算,所有 Region,不需要改配置,也不额外收费。在这之前,新对象的 PUT 是读后写一致的,但覆盖写和 DELETE 是最终一致,LIST 也是最终一致。这个变化很大,但它只解决了「单 key 的数据面」这一层。
| 操作 | 2020-12 之前 | 2020-12 之后 |
|---|---|---|
| 新对象 PUT → GET | 读后写一致 | 读后写一致 |
| 覆盖 PUT → GET | 最终一致,可能读到旧值 | 强一致,读到新值或更新值 |
| DELETE → GET | 最终一致,可能还读得到 | 强一致,404 或 delete marker |
| LIST | 最终一致,可能看不到新对象 | 强一致,反映已提交变更 |
| 跨 key 原子 | 不提供 | 不提供 |
| 跨区域复制 | 异步 | 异步 |
| 桶配置/IAM | 最终一致 | 最终一致 |
「强一致」在这里的准确含义是:对单个 key 的数据面操作,读到的顺序与成功写的顺序一致;它不意味着「最后一次用户意图获胜」,也不意味着「所有副本、所有区域、所有缓存同时更新」。下面这些坑,在 2020-12 之后仍然存在。
8.2.1 并发覆盖:两个 200,一个赢家
强一致不能阻止丢失更新。假设两个 writer 都往同一个 key 写:
t0 A 开始上传 v2(200 MB,慢) t1 B 开始上传 v3(小文件,快) t2 B 完成,PUT 200;当前版本 = v3 t3 A 完成,PUT 200;当前版本 = v2 ← A 的内容更旧,但它后完成
两个 PUT 都返回 200,A 和 B 都认为自己成功了。强一致保证的是:t3 之后所有读者都看到 v2;它不保证 B 的 v3 会被保留。这不是一致性 bug,是丢失更新(lost update)。修复只能靠条件写(If-Match)或版本化 key + 指针 CAS,不能靠「S3 已经强一致了」这句话。
8.2.2 跨 key 没有事务:对象 + 数据库的双写
上传完成后,应用通常要做两件事:把对象写到 media/{id},把元数据写到数据库。如果对象写成功、数据库提交失败,会留下孤儿对象;如果数据库先提交、对象写失败,会留下「指向不存在对象」的记录。对象存储的强一致只保证「这个对象写完之后读得到」,它不参与数据库的事务,也不能和数据库一起提交。
常见修法有三种:先写对象、再写数据库,数据库用幂等键,失败可重试;先写数据库(status=pending)、再写对象、最后把状态改成 ready,读路径过滤 pending;或者用 outbox 把「对象已就绪」变成一个可重放的事件。三种都需要对账:按前缀列出对象、按表列出记录,找孤儿和悬空引用。
8.2.3 跨区域复制是异步的
跨区域复制(CRR)把对象异步复制到另一个 Region。源 Region 的强一致不覆盖目标 Region:复制有延迟,目标 Region 的读可能读到旧版本;DELETE 的 delete marker 是否复制通常要单独开启;故障切换到目标 Region 时,最后一段写可能还没过去。「S3 强一致」是单 Region 桶内的事,不是全球强一致。如果业务要求跨区域读己之写,要么读源 Region,要么在应用层记录版本并等待复制完成,要么换一个提供全球一致性的存储系统。
8.2.4 版本化与 delete marker:删除不是删除
开启版本化后,DELETE 默认不是删字节,而是给当前版本加一个 delete marker;GET 返回 404,但旧版本还在,存储和费用也还在。要真正删除,得删掉具体 version id;要恢复,删掉 delete marker。合规场景还会开 Object Lock,让删除直接失败。「强一致地返回 404」不等于「数据已经不存在」:审计、隐私删除、成本清理都要按版本语义来做,不能只看 GET 的结果。
8.2.5 列表分页不是快照
LIST 现在是强一致的:它反映已提交的变更。但一次跨多页的 LIST 不是时间点快照:你读第 1 页和第 10 页之间,可能有新对象插入、旧对象删除;每一页都「当时正确」,合起来却不是同一个时刻的集合。做库存盘点、账单对账、迁移校验时,不能假设「LIST 一遍 = 一个一致快照」;要用版本 id、时间戳 cutoff、manifest,或者接受「可能多算/少算,再做第二轮对账」。
8.2.6 控制面仍然是最终一致
数据面强一致,不等于控制面强一致。桶配置、IAM/桶策略、版本化开关这些控制面变更仍然是最终一致的;S3 文档甚至建议开启版本化后等 15 分钟再发写请求。刚改完策略立刻 403、刚开版本化看不到 version id,都可能是传播窗口,不一定是代码 bug。数据面和控制面要分开观测、分开重试。
| 强一致保证了 | 没有保证 |
|---|---|
| 单 key 的读后写、覆盖后读、删除后读 | 跨 key 的原子更新 |
| LIST 反映已提交变更 | 多页 LIST 的时间点快照 |
| HEAD、metadata、tags、ACL 的读一致 | 并发写的「用户意图顺序」 |
| 单 Region 桶内的数据面 | 跨区域复制、目标 Region 的读 |
| 成功写之后的当前版本 | CDN、服务缓存、浏览器里的副本 |
| 数据面 | 桶配置、IAM、版本化开关的传播 |
8.2.7 一个可复现的强一致实验
不要只看文档,自己验证一次「覆盖写之后读到什么」。准备两个终端,A 反复 PUT 同一个 key,B 反复 GET 并记录 ETag:
# 终端 A:每 200ms 覆盖一次,ETag 随内容变化
for i in $(seq 1 50); do
printf 'version=%s\n' "$i" > /tmp/obj.txt
curl -sS -X PUT --data-binary @/tmp/obj.txt \
-H 'Content-Type: text/plain' \
https://media-example.s3.us-east-1.amazonaws.com/lab/race.txt >/dev/null
sleep 0.2
done
# 终端 B:记录每次读到的 ETag 和内容
for i in $(seq 1 100); do
curl -sS -D /tmp/h.txt \
https://media-example.s3.us-east-1.amazonaws.com/lab/race.txt
grep -i '^etag:' /tmp/h.txt
sleep 0.1
done
你会看到:每次成功 PUT 之后的读,都能读到这次写的内容(或更新的版本),不会读到这次写之前的旧值——这是单 key 强一致。但这不能证明「先发起的写获胜」:如果 A 先发起、B 后发起但 B 先完成,最终版本是 A 的;两个 200 都真实。ETag 也不按内容新旧单调,后完成的写可能让内容回到更早的版本。要验证丢失更新,用两个不同内容的 writer 并发跑,最后看内容是不是「后完成者」,而不是「后发起者」。实验请在测试桶做,别在生产桶上刷请求。
强一致(这里指单 key 读后写)回答的是「我读到的版本,是不是不早于某次成功写」。原子性回答的是「多个 key 的修改是不是全有或全无」。新鲜度回答的是「当前版本是不是最后一次用户意图」。S3 的 2020-12 变化只补了第一个;第二个要靠事务/manifest,第三个要靠条件写、版本号或业务排序。
A 和 B 同时编辑同一张海报。A 先读 ETag v1,在本地改 3 分钟;B 读 v1,改 10 秒就上传,得到 v2;A 随后上传,没带 If-Match,得到 200,当前版本变成 v3(内容基于 v1)。B 的修改丢了。强一致让所有读者看到 v3,但没有任何机制告诉 A「你覆盖了 B」。如果 A 带 If-Match: v1,A 会收到 412,被迫重读 v2 并合并。
8.3 缓存模式:cache-aside/read-through/write-through/write-behind
先看整条读路径。客户端先到 CDN 边缘,未命中再到服务缓存(Redis 或进程内),仍未命中才回源到数据库或对象存储;回填时沿着原路写回。缓存层数越多,命中率越高,失效路径也越长。一次 purge 只清边缘,服务缓存仍然可能返回旧值;TTL 是兜底,不是一致性方案。
四种缓存模式的区别,不是「谁更先进」,而是「谁负责回填」和「写什么时候算完成」。把写路径画出来,失败窗口就出来了。
| 模式 | 读未命中 | 写完成的条件 | 主要失败窗口 | 适合 |
|---|---|---|---|---|
| cache-aside | 应用读 DB,回填缓存 | DB 提交 + 缓存删除/失效 | 删除失败、回填 race、miss 风暴 | 读多写少,能容忍短旧 |
| read-through | 缓存自己读 DB 并回填 | 取决于写路径,通常仍是 DB + 失效 | 缓存成为读的硬依赖、loader 风暴 | 访问层统一,托管缓存 |
| write-through | 缓存同步写 DB,再返回 | 缓存和 DB 都成功 | 部分成功、写延迟叠加、写放大 | 写后立刻读、写量中等 |
| write-behind | 只写缓存,异步刷 DB | 缓存成功即返回 | 缓存丢数据、乱序、重复刷 | 指标/计数等可丢场景 |
8.3.1 cache-aside:应用自己管,缓存挂了还能退化
cache-aside(旁路缓存)是最常见的模式。读:先查缓存,命中返回;未命中查数据库,把结果写回缓存,设 TTL。写:先写数据库,提交后删除缓存(注意是删除,不是更新)。它的好处是缓存不是硬依赖:Redis 挂了,读路径可以退化为直接查库(前提是数据库扛得住);缓存里也只放被访问过的数据,不会把全量数据塞进去。
失败窗口有三个。第一,删除缓存失败:数据库已经是新值,缓存还是旧值,直到 TTL 过期;要靠 outbox/重试/CDC 补删。第二,读回填 race:读者在写者提交前读到旧值,写者删除缓存后读者又把旧值写回;8.4 会画这条时间线。第三,miss 风暴:热点 key 过期时,所有并发请求一起查库;8.5 用 singleflight 解决。
8.3.2 read-through:把回填逻辑收进缓存层
read-through 把「未命中时读数据库并回填」交给缓存库或缓存服务,应用只和缓存打交道。好处是回填逻辑统一,不会每个服务各写一套;坏处是缓存从「可选加速」变成了「读路径的硬依赖」:缓存不可用时,应用可能没有直连数据库的 fallback,或者所有请求同时 fallback,把数据库打挂。
read-through 通常还要配一个写策略:写入数据库后失效缓存(本质仍是 cache-aside 的写路径),或者 write-through。它不自动解决一致性;只是把「谁来加载」这件事集中了。选它之前先问:缓存挂了,读路径还能不能活?loader 有没有并发合并和超时?
8.3.3 write-through:写返回时,两边都该是新的
write-through 的写请求要等缓存和数据库都成功才返回。实现有两种:应用写缓存,缓存同步写数据库;或者应用在同一段代码里写数据库和缓存,都成功才返回。它的目标是「写完之后,缓存里也是新值」,适合写后立刻读的场景。
失败窗口在「两边」之间:缓存写成功、数据库失败,缓存里就是脏值,必须回滚缓存或失效;数据库成功、缓存失败,写请求返回失败但数据已经落库,客户端重试可能重复副作用。写延迟也会叠加:原来只等数据库,现在要等两个系统。写量大的时候,缓存会被频繁写穿,反而不如「写库 + 删缓存」。
8.3.4 write-behind:先答应,再慢慢写
write-behind(write-back)只写缓存就返回,后台异步把变更刷到数据库。它最快,也最危险:缓存节点挂掉,未刷盘的写就丢了;同一个 key 的多次写可能乱序;刷盘失败要重试,重试可能重复。它适合指标、计数、可以丢的遥测数据,不适合余额、库存、订单状态。
如果非要用,至少要有持久化的写队列、按 key 的顺序保证、幂等刷盘、积压监控和降级开关。「缓存是临时的」这句话只在 cache-aside/read-through 里成立;到了 write-behind,缓存就是主存储的一部分,它的持久性决定了数据的持久性。
8.3.5 四种模式的失败注入
把「缓存挂了 / DB 挂了 / 写一半」分别注入,观察四种模式:
| 注入 | cache-aside | read-through | write-through | write-behind |
|---|---|---|---|---|
| 缓存全挂 | 读退化到 DB,可能压垮 DB | 读路径直接失败 | 读写都失败 | 写仍成功,但数据在缓存里丢 |
| DB 全挂 | miss 失败;命中仍可读 | loader 失败;命中仍可读 | 写失败;缓存可能已更新 | 写成功,积压到 DB 恢复 |
| 写缓存失败 | 写库成功,旧值留到 TTL | 取决于写路径 | 写请求失败,DB 可能已提交 | 写请求失败 |
| 进程崩溃 | 无未持久化数据 | 无未持久化数据 | 无未持久化数据 | 未刷盘写丢失 |
这张表说明:「缓存挂了怎么办」不是一句话,要按模式分别回答。cache-aside 要限流 DB;read-through 要有 fallback;write-through 要处理部分成功;write-behind 要接受丢数据或做持久化队列。
cache-aside:应用负责读缓存、回源、回填;写数据库后失效缓存。read-through:应用只读缓存,缓存层负责未命中时回源和回填。write-through:写请求同步更新缓存与数据库,两边都成功才返回。write-behind:写请求只更新缓存,异步刷数据库。前两者的缓存是「副本」,write-behind 的缓存是「未持久化的主数据」。
把缓存当成数据库的物化视图,会自然接受三件事:它可能旧(TTL/失效窗口)、可能缺(未命中/驱逐)、可能被重建(重启/扩容)。这三件事都不是 bug,除非业务要求「读到的必须是最新」。真正要设计的是:旧多久可以接受、缺了从哪里补、重建时源站扛不扛得住。
8.4 缓存一致性:TTL、失效、双写、延迟双删
缓存一致性不是「缓存和数据库强一致」。大多数系统做不到,也不需要;需要的是两句话:业务能容忍多旧的读,以及旧值最长能活多久。前者是产品决策,后者是工程实现。把这两句话写下来,比争论「要不要强一致」有用得多。
失效窗口(invalidation window)是从「源站提交新值」到「所有可能返回旧值的缓存层都不再返回旧值」之间的时间。它由 TTL、删除操作的传播与重试、读者回填 race、CDN purge 的 fan-out 共同决定。对外该承诺的是「最大陈旧时间」,而不是「缓存一致」。
8.4.1 TTL:兜底,不是一致性
TTL 是最简单的失效机制:到点就过期,下一次读回源。它有三个容易被忽略的细节。第一,TTL 只是「最长能活多久」的上界,前提是缓存真的执行过期;Redis 的过期是惰性删除加后台抽样,逻辑上过期后 GET 不会返回它,但内存回收可能滞后。第二,缓存可能因为内存压力被提前驱逐,TTL 没到也会没;evicted_keys 会告诉你这件事。第三,所有 key 用同一个 TTL,会在同一时刻一起过期,把「缓存问题」变成「源站问题」。
TTL 应该按数据类别设,并且加抖动:价格 30–60 秒,商品详情 5–10 分钟,图片元数据 1 小时,静态资源按内容哈希走一年。抖动不是装饰,是把「同时过期」打散成「均匀回源」。但 TTL 不能替代失效:写完之后等 TTL 过期,等于把陈旧时间拉满。
8.4.2 失效:删缓存还是更新缓存
写路径的常见选择是「更新缓存」或「删除缓存」。优先删除,原因有两个:删除是幂等的,重复删不会错;更新需要你算出「新值」,而并发写时你算出的新值可能基于旧读,写进去反而制造脏值。删除之后,下一次读会自然回源,拿到的是数据库当前值。
删除的时机必须是「数据库提交之后」。如果先删缓存再写库,读者会在删完、库还没提交的窗口里读到旧值并回填,而写者提交后不会再删一次,旧值就留到 TTL。「先写库、后删缓存」不是强一致,只是把窗口缩到「读者回填 race」这一段;它仍然需要重试和兜底。
删除失败怎么办?不能让数据库回滚——缓存删除失败不是数据库写的失败。正确做法是把「失效」记到 outbox/消息队列,由 relay 重试;或者用 CDC 订阅 binlog,在提交后异步删。删除失败要告警,不能只依赖 TTL。
8.4.3 双写:两个系统,没有原子提交
「双写」指应用同时写数据库和缓存。它的问题不是顺序,而是没有原子提交:两个系统,任何一个都可能失败,失败之后你不知道另一个是否生效。先写缓存后写库,缓存可能领先;先写库后写缓存,缓存可能落后。重试其中一边,又可能把旧值写回去。
如果必须双写,至少要做到:写数据库是唯一的事实来源;缓存写失败不影响数据库事务;失败进重试队列;每次写带版本号,缓存只接受更大的版本。更好的做法是根本不做双写:写库 + 失效,或者写库 + outbox/CDC 驱动缓存更新。
8.4.4 延迟双删与版本号
「先写库、后删缓存」之后还剩一个 race:读者在写者提交前 miss,读到旧值;写者提交并删除缓存;读者把旧值回填。时间线如下:
读者 R 写者 W
│ cache miss │
│ SELECT → v1 │
│ │ UPDATE → v2(提交)
│ │ DEL cache
│ SET cache = v1 │ ← 旧值回填,TTL 300s
▼ ▼
读者之后的所有请求都读到 v1,直到 TTL 或下一次删除
「延迟双删」是对这个 race 的补丁:写库、删缓存之后,等 Δ(比如 500 ms–1 s)再删一次,把「读者在这段时间里回填的旧值」清掉。Δ 要覆盖「读数据库 + 写缓存」的 p99.9,而不是平均值;第二删要异步做,不能阻塞写请求;删失败要重试。它的局限也很清楚:如果读者 pause 了 2 秒(GC、调度),在第二删之后才回填,旧值仍然会留到 TTL。延迟双删是缩小窗口,不是消除窗口。
更稳的做法是版本化。缓存里存 (value, version),回填时用 Lua 做「只有版本更大才写」的 CAS:
def update_price(sku: str, cents: int) -> None:
with db.tx() as tx: # 本地事务:价格 + outbox 一起提交
row = tx.query_one(
"UPDATE prices SET cents=%s, version=version+1 "
"WHERE sku=%s RETURNING version", cents, sku)
if row is None:
raise NotFound(sku)
tx.execute(
"INSERT INTO outbox (topic, payload) VALUES (%s, %s)",
"cache.invalidate",
json.dumps({"key": f"price:{sku}", "version": row["version"]}),
)
# 提交后由 relay 删除缓存;删除失败会重试,数据库不回滚。
# 读者回填时带 version,缓存只接受更大的 version(Lua CAS)。
这段代码的关键不是 outbox 的写法,而是「版本」贯穿了数据库、消息和缓存。写者提交 v2,读者即使回填 v1,CAS 也会拒绝。版本可以从数据库行号、单调序列、内容哈希来;对静态资源,内容哈希直接进 URL,根本不需要失效。
| 方案 | 旧值窗口 | 失败时 | 代价 |
|---|---|---|---|
| 只靠 TTL | 0–TTL | 无法感知写,窗口拉满 | 最简单,最旧 |
| 写库 + 删缓存 | 读者回填 race,通常毫秒–秒 | 删除失败 → TTL | 常用,需重试/告警 |
| 延迟双删 | 缩小到 Δ 之后的极端停顿 | 第二删失败 → TTL | 定时任务、额外删除 |
| 版本/CAS | 理论上不覆盖新值 | 版本源出错则退化 | 改造成本、Lua/原子操作 |
| CDC 失效 | 传播 lag | 消费失败 → 重试 | 依赖 binlog、运维复杂 |
8.4.5 用 Lua 做缓存 CAS
版本化回填需要「比较版本 + 写入 + 设置 TTL」原子完成,Redis 里用一段 Lua:
-- KEYS[1] = cache key
-- ARGV[1] = version, ARGV[2] = value, ARGV[3] = ttl seconds
local cur = redis.call('HGET', KEYS[1], 'version')
if (not cur) or tonumber(cur) < tonumber(ARGV[1]) then
redis.call('HSET', KEYS[1], 'version', ARGV[1], 'value', ARGV[2])
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
return 1
end
return 0
调用方把 version 从数据库行或消息里带过来;返回 0 表示「缓存里已经是更新的版本」,这次回填被丢弃。要点:Lua 脚本里不要用 KEYS 之外的 key(集群模式要同 slot);TTL 要设,否则版本 key 永不过期;tonumber 失败要按「版本不可比」处理,不能默认覆盖。
| 操作 | 原子性 | 适用 |
|---|---|---|
DEL + 回源 | 删除本身原子,回填不是 | 常用;接受 race |
SET 覆盖 | 单命令原子,但会覆盖新值 | 不适合并发写 |
| Lua CAS | 比较+写+TTL 原子 | 版本化回填 |
| 版本化 key | 写新 key,旧 key 自然过期 | 静态资源/内容哈希 |
DEL 之后、数据库提交之前,读者 miss 并读到旧值,回填;写者提交后没有第二次删除。旧值会一直留到 TTL。这个顺序把「写者提交后删」这个唯一的机会也弄丢了,所以通常比「先写库、后删缓存」更差。
8.5 击穿、穿透、雪崩与热点 key
这四个词经常被混着用,但触发条件不同,药也不同。先把它们分开:
| 名称 | 触发 | 现象 | 主要药 | 残余风险 |
|---|---|---|---|---|
| 击穿 | 单个热点 key 过期 | 并发 miss 全部打到源站 | singleflight、逻辑过期、SWR | 单飞 leader 失败会放大给等待者 |
| 穿透 | 查询不存在的 key | 缓存不命中,源站也没有 | 空值缓存、Bloom filter、参数校验 | 空值占内存、Bloom 误判 |
| 雪崩 | 大量 key 同时过期/缓存集群挂 | 源站 QPS 阶跃上升 | TTL 抖动、多级缓存、高可用、限流 | 抖动后仍可能重叠 |
| 热点 | 单 key QPS 超过单分片 | 单分片 CPU/网络打满 | 本地缓存、key 复制、拆分、合并 | 内存成本、复制的一致性 |
8.5.1 击穿:一个热点 key 过期
一个 10 万 QPS 的热点 key,TTL 到期的瞬间,所有并发请求同时 miss。它们都去查数据库,数据库连接池只有 100 个连接,请求排队,超时,客户端重试,队列更长。击穿的本质不是「缓存没命中」,而是「未命中的请求没有合并」。
单飞(singleflight)把同一个 key 的并发回源合并成一次:第一个请求当 leader 去加载,其余请求等它的结果。它是进程内的,50 个 pod 最多 50 次回源,通常可以接受;如果源站连 50 次都扛不住,就需要跨进程的锁、逻辑过期或 CDN 层的 request collapsing。单飞也有代价:leader 慢,所有等待者都慢;leader 失败,错误会传播给所有等待者;等待超时后,调用方可能重试,反而增加负载。
8.5.2 穿透:查一个不存在的东西
穿透是「查一个不存在的 key」:缓存没有,数据库也没有,于是每次请求都穿透到数据库。它可能是用户输错了 ID,也可能是攻击者在扫描。空值缓存是最简单的药:数据库返回空时,缓存一个短 TTL 的 sentinel(20–60 秒),后续请求直接命中空值。TTL 要短,否则对象后来被创建了,用户还会看到「不存在」。
Bloom filter 可以在访问数据库之前挡掉大部分不存在的 key,但有假阳性(不存在的 key 被放过去,仍要查一次源站)和内存成本;它适合 key 空间大、查询随机的场景。参数校验(ID 格式、长度、字符集)和按用户/IP 限流是更便宜的第一道防线。注意:空值缓存和 Bloom 都不能替代「源站返回空」的正确处理,它们只是减少无效查询。
8.5.3 雪崩:同一时刻一起过期
雪崩是「大量 key 在同一时刻失效」:批量导入时统一设 TTL、缓存集群重启、某个分片挂掉、或者一次 purge 清了全站。源站 QPS 从「缓存扛住 95%」变成「全部打过来」,然后超时、重试、更糟。
TTL 抖动是最便宜的药:ttl = base + random(0, base * 0.2),把过期时间打散到 20% 的窗口。多级缓存(进程内 L1 + Redis L2 + CDN)能在 Redis 挂掉时挡住一部分;但 L1 的 TTL 要更短,否则一致性更差。缓存集群要高可用,重启要预热;源站要有并发上限和熔断,不能指望「缓存永远在」。最后,不要在事故中 flush 缓存:那会把「陈旧」变成「雪崩」。
8.5.4 热点:一个 key 超过一个分片
热点的 QPS 可能高到一个 Redis 分片扛不住,或者一个 key 的网络流量超过单机网卡。解决办法有四种:进程内 L1 缓存(最近最少使用,TTL 很短,比如 1–5 秒);key 复制(hot:{id}:0..N,读随机、写全部,用一点一致性换吞吐);按字段拆分(把大 value 拆成多个 key,读需要的部分);请求合并(单飞/队列)。监控上,redis-cli --hotkeys(需要 LFU 策略)、OBJECT FREQ、SLOWLOG 能找到热点;如果只有主从,考虑读写分离和本地缓存。
import json, random, threading
class SingleFlight:
"""同一进程内,把同一个 key 的并发回源合并成一次。"""
def __init__(self) -> None:
self._mu = threading.Lock()
self._inflight: dict[str, dict] = {}
def do(self, key: str, fn, wait_s: float = 2.0):
with self._mu:
call = self._inflight.get(key)
if call is None:
call = {"done": threading.Event(), "value": None, "error": None}
self._inflight[key] = call
leader = True
else:
leader = False
if leader:
try:
call["value"] = fn()
except BaseException as exc:
call["error"] = exc
finally:
call["done"].set()
with self._mu:
self._inflight.pop(key, None)
elif not call["done"].wait(timeout=wait_s):
raise TimeoutError(f"singleflight wait timeout: {key}")
if call["error"] is not None:
raise call["error"]
return call["value"]
sf = SingleFlight()
MISSING = b'{"missing":true}'
def get_price(sku: str) -> dict:
key = f"price:{sku}"
raw = redis.get(key)
if raw == MISSING:
raise NotFound(sku)
if raw is not None:
metrics.inc("cache_hit")
return json.loads(raw)
metrics.inc("cache_miss")
def load() -> dict:
# 双重检查:等锁期间可能已被别的请求回填
again = redis.get(key)
if again == MISSING:
raise NotFound(sku)
if again is not None:
return json.loads(again)
row = db.query_one("SELECT cents, version FROM prices WHERE sku=%s", sku)
if row is None:
redis.set(key, MISSING, ex=random.randint(20, 40))
raise NotFound(sku)
ttl = 300 + random.randint(0, 60) # 抖动,打散过期
redis.set(key, json.dumps(row).encode(), ex=ttl)
return row
return sf.do(key, load)
这段代码有三个要点。第一,单飞只包住「回源 + 回填」,缓存命中不走单飞;第二,leader 在加载前做双重检查,避免等在锁上的请求重复查库;第三,wait_s 必须小于调用方的 deadline,否则等待者先超时、再重试,单飞反而变成放大器。跨进程时,单飞管不到别的 pod;可以用 Redis 锁、逻辑过期或 CDN 的 request collapsing,但要接受锁的 TTL 和失效问题(第 9 章)。
8.5.5 逻辑过期:永远命中,后台刷新
逻辑过期不在 Redis 上设 TTL,而是在 value 里放 expire_at;读到时如果过了 expire_at,先返回旧值,再异步刷新。这样热点 key 永远不会「同时 miss」,代价是刷新失败时旧值会一直用下去,必须有硬上界和告警。
import json, threading, time
def get_price_logical(sku: str) -> dict:
key = f"price:{sku}"
raw = redis.get(key)
if raw is None:
return refresh_sync(sku) # 冷启动,走单飞
item = json.loads(raw)
if time.time() < item["expire_at"]:
return item["value"]
# 过期:先返回旧值,最多一个请求去刷新
if redis.set(f"lock:{key}", "1", nx=True, ex=10):
threading.Thread(target=refresh_sync, args=(sku,), daemon=True).start()
return item["value"]
refresh_sync 就是上一节 cache-aside 的加载函数,这里省略实现。对比三种方案:
| 方案 | 过期瞬间 | 陈旧上界 | 适用 |
|---|---|---|---|
| TTL + singleflight | 等待 leader 回源 | leader 的加载时间 | 普通热点 |
| 逻辑过期 | 立即返回旧值 | 刷新间隔 + 硬上界 | 极热点、可容忍旧 |
| CDN SWR | 边缘返回 stale | SWR 窗口 | HTTP 静态/半静态 |
| Redis 锁 | 跨进程串行回源 | 锁 TTL | 源站极敏感 |
击穿:单个热点 key 过期,瞬间并发 miss。穿透:查询不存在的 key,缓存和源站都没有。雪崩:大量 key 同时失效,或缓存层整体不可用,源站被瞬时打满。热点:单个 key 的请求量超过一个缓存分片/节点的承载能力。四者的共同点是「miss 的分布」不同:一个点、一片不存在的点、同一时刻的一片、一个过热的点。
缓存把「读放大」从源站挪到了内存;击穿、穿透、雪崩、热点,就是这笔债在四种不同形状下同时到期。
8.6 CDN:anycast、边缘缓存、cache key、purge
CDN 不是「更快的 S3」,而是一层有很多副本的 HTTP 缓存。每个 PoP 有自己的缓存、自己的 TTL、自己的 cache key;一次内容更新要 fan-out 到所有副本,而浏览器那一层根本不接受你的 purge。理解 CDN,关键是理解「同一个 URL 在不同 PoP 上可能是不同版本」。
8.6.1 anycast:同一个 IP,很多个缓存
anycast 是把同一个 IP 前缀从很多 PoP 同时宣告出去,BGP 把用户路由到「网络意义上最近」的那个 PoP。好处是延迟低、DDoS 流量分散;对缓存的含义是:缓存是 per-PoP 的,不是全局的。同一个 URL 在法兰克福和新加坡可能是两次独立回源、两个不同的 Age;purge 必须到达所有 PoP,传播需要时间。路由变化还可能把一条连接换到另一个 PoP,造成一次 miss 或连接重置。anycast 不保证「同一个用户永远命中同一个 PoP」。
8.6.2 cache key:命中率的第一因
cache key 决定「哪些请求算同一个对象」。默认通常是 scheme + host + path + query;很多 CDN 还会把 Accept-Encoding、Accept-Language 或配置的 header 算进去。key 太粗,会把不同内容混在一起(比如把中文页面缓存给英文用户);key 太细,会让命中率归零(每个用户一个 key)。
| 维度 | 是否进 key | 说明 |
|---|---|---|
| path | 是 | 资源身份 |
| query | 是,但要规范化 | 排序、去 tracking 参数、只保留业务参数 |
Accept-Encoding | 是 | gzip/br 的字节不同,通常由 CDN 自动处理 |
Accept-Language | 看源站 | 只有服务端按语言返回不同 HTML 时才需要 |
| Cookie | 通常否 | 一旦进 key,命中率会被会话打碎;登录态走 private/no-store |
Authorization | 否 | 共享缓存不应缓存带授权的响应,除非明确 public |
| User-Agent/设备 | 看源站 | 自适应图片用独立 URL 或 Vary,不要把所有 UA 都塞进 key |
| 随机/tracking 参数 | 否 | utm_*、fbclid 要在边缘或源站剥掉 |
Vary 是标准里的「把请求头加进 key」的机制,但它很容易把基数放大:Vary: User-Agent 几乎等于「每个浏览器版本一个副本」,Vary: Cookie 几乎等于「每个会话一个副本」。能用 URL 区分的 variant,尽量用 URL;必须用 header 的,限制取值集合。还要小心 cache poisoning:如果源站会根据某个未进 key 的 header 改变响应,攻击者可以污染缓存;边缘要剥离或校验这类 header。
8.6.3 TTL、SWR 与 stale-if-error
HTTP/2 200
cache-control: public, max-age=60, s-maxage=600, stale-while-revalidate=30, stale-if-error=86400
age: 420
x-cache: HIT
这段响应头说明:浏览器可以存 60 秒;共享缓存(CDN)可以存 600 秒;过期后还有 30 秒可以「先返回旧的、同时后台刷新」;源站 5xx 时最多可以继续用旧值 1 天。Age: 420 表示这份响应已经在缓存里放了 420 秒,仍在 600 秒的 freshness 内,所以 X-Cache: HIT 是正常的。
| 指令 | 作用 | 常见误解 |
|---|---|---|
max-age | 浏览器/私有缓存的 freshness | 以为它也管 CDN |
s-maxage | 共享缓存(CDN)的 freshness,覆盖 max-age | 以为 CDN 一定看 max-age |
public / private | 是否允许共享缓存存储 | 以为 private 只是「不共享给别的用户」 |
no-cache | 可以存,但每次复用前要 revalidate | 当成 no-store |
no-store | 不存储 | 以为 CDN 一定遵守(有些会配置覆盖) |
must-revalidate | 过期后必须验证,不能直接给 stale | 和 SWR 混用,语义冲突 |
immutable | 有效期内不 revalidate(即使刷新) | 用在会变的 URL 上 |
stale-while-revalidate | 过期后 N 秒内先给 stale,同时刷新 | 以为它会保证刷新成功 |
stale-if-error | 源站 5xx/超时时给 stale | 用来掩盖源站故障,旧值可能很旧 |
stale-while-revalidate 是 CDN 上最划算的指令之一:它把「过期瞬间的并发回源」变成「一个请求刷新、其他人拿旧值」,既降尾延迟又降源站峰值,代价是「最多旧 N 秒」。stale-if-error 则把可用性放在新鲜度前面;它能扛住源站短暂 5xx,但也会让一个已经发布错了的内容多活很久,所以要配合监控和手动 purge。
8.6.4 purge:失效是 fan-out,不是一次 DELETE
purge 是命令式的失效:你告诉 CDN「这个 URL/tag 不要了」,它去各个 PoP 标记/删除。它和 TTL 是两种机制:TTL 是声明式的「最多活多久」,purge 是「现在就别用了」。purge 的传播是异步的,通常秒级到分钟级;有速率限制;有些 CDN 支持按 tag/surrogate key 批量 purge,有些只支持单 URL 或前缀;wildcard purge 很危险,可能把不该清的也清了。
┌──────────────────┐
用户 ──anycast──▶│ 边缘 PoP │──HIT──▶ 返回(Age: 420)
│ cache key │
└────────┬─────────┘
│ MISS
▼
┌──────────────────┐
│ Shield / 区域缓存 │──HIT──▶ 回填边缘
└────────┬─────────┘
│ MISS
▼
┌──────────────────┐
│ 源站 / 对象存储 │
└──────────────────┘
发布新版本:
上传对象 ──▶ 更新指针/版本号 ──▶ purge URL 或 tag
│
┌───────────────┼───────────────┐
▼ ▼ ▼
PoP A PoP B PoP C (异步,秒级)
浏览器缓存:purge 管不到 ──▶ 静态资源用内容哈希 URL,HTML 用短 TTL
这张图有两个关键点。第一,purge 只清 CDN,不清浏览器;静态资源要靠「内容哈希 URL + immutable」让浏览器自然换新,HTML 要靠短 TTL 或版本化。第二,purge 之后如果源站还没更新完,回源会把旧值又填回来;所以顺序必须是「先提交源站,再 purge」,并且 purge 要能重试/验证。
| purge 范围 | 传播 | 风险 |
|---|---|---|
| 单 URL | 秒–分钟 | URL 多时请求量大、易漏 |
| tag/surrogate key | 秒–分钟 | 需要源站/边缘打 tag,覆盖要准 |
| 前缀/wildcard | 秒–分钟 | 范围过大,可能清掉无关内容 |
| 全站 | 分钟 | 回源风暴,最后手段 |
| 浏览器 | 无法 purge | 只能版本化 URL 或短 TTL |
8.6.5 条件请求与 304:revalidate 不等于重新下载
CDN 的缓存过期后,不一定要重新下载整个对象。它可以用 If-None-Match/If-Modified-Since 向源站 revalidate;源站返回 304 Not Modified,CDN 更新头部、重置 Age,body 继续用本地的。这样省带宽,但不省一次 RTT;对 latency 敏感的路径,stale-while-revalidate 比 304 更好,因为用户不用等这次 RTT。
GET /static/app.js HTTP/1.1
Host: cdn.example.com
If-None-Match: "9f2c1d7e"
HTTP/1.1 304 Not Modified
ETag: "9f2c1d7e"
Cache-Control: public, max-age=600
注意:304 也会更新 Age 的基准,所以「Age 很小」不代表「刚回源拿了 body」;它可能只是刚 revalidate 过。排查「内容没更新」时,要同时看 ETag 和 body 的 checksum,不能只看 Age。
URL 规范化示例:
原始: /p?id=42&utm_source=wx&utm_campaign=spring&b=2&a=1 规范化后(排序、去 tracking、只留业务参数): /p?a=1&b=2&id=42 不要做的: /p?id=42&ts=1712345678 ← 每个请求一个 key,命中率 0 /p?id=42&user=u-1024 ← 每个用户一个副本,既浪费又可能串数据
cache key 是缓存用来判断「两个请求是否同一个对象」的字段组合;它决定命中率,也决定会不会把 A 的内容给 B。purge 是命令式失效,把指定 URL/tag 在边缘标记为不可用,传播是异步 fan-out。stale-while-revalidate 允许在 freshness 过期后的 N 秒内先返回旧值,同时由一个请求去源站刷新;它用「有界陈旧」换「回源峰值和尾延迟」。
对象存储的强一致不会自动 purge CDN;数据库的提交也不会让边缘的 Age 归零。CDN 有自己的 TTL、自己的 key、自己的传播延迟。要判断用户看到什么,必须把「源站版本、CDN 缓存、浏览器缓存」三层分开看。
8.7 失败模式:缓存陈旧与回源超时风暴
缓存陈旧和回源风暴经常一起出现,但不是同一个故障。陈旧是「写完了,读还是旧」;回源风暴是「读太多了,源站扛不住」。它们会互相喂养:回源失败,缓存就填不上,陈旧时间变长;用户看到旧值又刷新、重试,回源更多,源站更慢。
先看一条完整的时间线。某价格服务有 10 万个 SKU,凌晨批量导入时把价格写进 Redis,TTL 统一 3600 秒,没有抖动;源站平时 600 QPS,数据库连接池 200,p99 40 ms。
09:00 批量导入 100k SKU,warmup 写入价格缓存,TTL=3600,无抖动 10:00 这批 key 在同一分钟内过期 10:00 源站 QPS 600 → 4,000;连接池 200 打满;p99 40ms → 3s 10:01 客户端 1s 超时,重试 2 次;回源 QPS ×3 10:02 回填因源站超时失败;key 仍未缓存;更多请求穿透 10:04 源站 503;CDN 没有 stale-if-error,502 透传 10:07 限流 + 预热 + 扩容;QPS 回落 10:12 部分 key 回填了只读副本的旧快照,陈旧继续
算一下第一波。100,000 个 key 集中在 60 秒内过期,平均每秒 1,667 次 miss,加上基线 600,源站要接 2,267 QPS;客户端重试 3 倍后是 6,800 QPS,而源站容量大约 1,500 QPS,过载 4.5 倍。如果 TTL 抖动 ±10%(54–66 分钟),同一批 key 摊到 12 分钟,平均只多 139 QPS,源站几乎无感。抖动不是优化,是把「阶跃」变成「斜坡」;阶跃会把排队系统推进非线性区,斜坡不会。
8.7.1 陈旧链:写成功了,读还是旧
陈旧链通常从一次「看起来成功」的写开始:
- 数据库提交新值;
- 删除缓存失败,或者只删了 CDN、没删服务缓存;
- 读者在删除前 miss、读到旧值,删除后把旧值回填;
- 旧值留到 TTL,或者留到下一次有人手动删;
- 用户刷新、客服清缓存,可能触发一次全站 purge,把陈旧问题升级成回源风暴。
每一步单看都合理:写数据库成功了;删除失败被 catch 住、记了日志;读者只是按 cache-aside 的逻辑回填;TTL 还没到。合起来就是「数据库里是新价格,用户看到旧价格」。这类问题的排查难点是「每一层都显示成功」:CDN 显示 HIT(它确实有内容),Redis 显示有 key(它确实没到期),源站显示 200(它确实返回了当前版本)。
8.7.2 回源链:miss 自己会放大
回源链是一个正反馈:
- 大量 key 同时失效(统一 TTL、缓存重启、分片故障、全站 purge);
- 并发 miss 打到源站;
- 源站排队,p99 上升;
- 客户端超时,重试;
- 回填请求也超时,缓存仍然没有值;
- 回到第 2 步,而且这一轮请求更多。
这条链的可怕之处是:缓存失效本来是一次性的,但回填失败让它变成持续性的。只要源站还在超时,缓存就填不上;缓存填不上,源站就不会恢复。止损的关键不是「再扩容一点」,而是打断反馈:限流回源、让部分请求拿 stale、合并 miss、给源站留出恢复的余量。
| 症状 | 先查 | 常见根因 | 止血 |
|---|---|---|---|
| 用户看到旧值 | CDN 的 Age/X-Cache、Redis key、数据库 version | 删除失败、只清一层、回填 race、CDC lag | purge + 删 key + 版本号 |
| 回源 QPS 高 | 回源监控、miss ratio、--hotkeys | TTL 同步、缓存重启、热点、穿透 | 限流、SWR、单飞、空值缓存 |
| 源站 5xx | 连接池、p99、慢查询、下游 | 过载、慢查询、依赖故障 | 降级、stale-if-error、扩容 |
| 命中率骤降 | evicted_keys、key 版本、发布记录 | 内存驱逐、key 变更、分片迁移 | 扩容、回滚 key、预热 |
| 412 增多 | 条件写冲突率、热点 key | 并发写同一对象 | 退避、合并写、分片 |
8.7.3 止损顺序:先止血,再找根因
事故中最重要的不是「找到根因」,而是「别让根因继续放大」。建议的顺序是:
- 止血:给回源加并发上限/排队上限;打开
stale-while-revalidate/stale-if-error;扩容只读副本;暂停非关键写和批量任务。 - 定位:是哪个 key/前缀、哪一层、从什么时候开始;把 deploy、purge、TTL 过期、缓存重启的时间线对齐。
- 修复:TTL 抖动、singleflight、空值缓存、预热、CDC 失效、版本号;一次只改一个变量。
- 复盘:把「最大陈旧时间」写成 SLI,把「回源 QPS/源站容量」写成告警,压测一次「缓存全挂」的场景。
有一条反直觉的规则:事故中不要 flush 缓存。flush 会把「部分 key 陈旧」变成「所有 key 同时 miss」,直接把回源链推到第 1 步。正确的动作是「精确删掉出问题的 key/前缀」+「让其他 key 继续命中」,而不是「清空重来」。
8.7.4 一张事故复盘表
缓存事故的复盘不要只写「Redis 挂了」。按层填表,才能找到「为什么一层的问题会传到用户」:
| 时间 | 层 | 证据 | 当时应该做什么 |
|---|---|---|---|
| 10:00 | 缓存 | expired_keys 尖峰、TTL 分布集中 | 提前加抖动/预热 |
| 10:01 | 源站 | 回源 QPS 4,000、连接池满 | 限流回源、开 SWR |
| 10:02 | 客户端 | 超时率上升、重试 ×3 | 重试预算、退避 |
| 10:04 | CDN | X-Cache: MISS、502 透传 | stale-if-error、降级页 |
| 10:12 | 数据 | 回填值来自只读副本 | 读主库/版本校验 |
这张表的每一行都对应一个可加的护栏;只写「加强监控」等于没复盘。
运营改了价格,CDN purge 成功,服务 Redis 没删。用户请求命中 CDN 回源,源站返回 Redis 里的旧价格;CDN 把这个旧价格当成新内容缓存 10 分钟。运营看到 CDN 控制台显示「已刷新」,以为问题解决了;实际上 Redis 的旧值又被复制到了边缘。修复不是再 purge 一次,而是先删 Redis,再 purge CDN,并确认源站返回的 version 已经变化。
回源风暴(origin storm / thundering herd)是缓存 miss 率上升导致源站请求量超过其容量,延迟上升引发超时与重试,重试进一步放大请求量的正反馈。触发条件通常不是「流量涨了」,而是「miss 的形状变了」:大量 key 同时过期、缓存层重启、热点 key 失效、穿透攻击、或一次全站 purge。
缓存把「读放大」从源站挪到了内存;回源风暴就是这笔债在某一刻集中到期,而重试是利息。
8.8 观测:Age、X-Cache、命中率、S3 request id
缓存问题最难的地方是「你看不到缓存」:数据库有慢查询日志,缓存只有一个「命中/不命中」;CDN 在别人的机房里,你只能看响应头。观测要分三层做:边缘(CDN 响应头)、服务缓存(Redis stats)、源站(对象存储响应头/数据库)。先看一条最常用的命令。
curl -sSI https://cdn.example.com/static/app.9f2c1d7e.js
HTTP/2 200
date: Mon, 19 Oct 2026 02:15:31 GMT
cache-control: public, max-age=31536000, immutable
age: 8421
x-cache: HIT
x-cache-hits: 37
content-length: 84213
怎么读:
Age: 8421表示这份响应在缓存里已经放了 8,421 秒(约 2 小时 20 分)。max-age=31536000是一年,所以它仍然 fresh,X-Cache: HIT正常。X-Cache不是标准头,不同厂商字段不同:Cloudflare 用cf-cache-status,Fastly 用X-Cache/X-Cache-Hits,Akamai 用X-Cache: TCP_HIT之类。没有X-Cache就看server-timing、Via或厂商自己的头。- 如果
Age > max-age但仍是 HIT,说明缓存正在给 stale:可能开了stale-while-revalidate/stale-if-error,也可能是缓存没遵守 TTL。看Warning: 110(部分实现会加)、x-cache: STALE、以及源站是否 5xx。 Age: 0+HIT不矛盾:可能刚刚回源并写入,也可能是 revalidate 后重置了 Age;判断 miss 要看X-Cache,不要只看 Age。- 连续两次
MISS就要查 cache key:Cookie/Vary/Authorization是否进了 key,响应是否no-store/private,边缘是否在 purge 循环。
第二层是源站对象。对象存储的响应头能告诉你「这是哪个版本、哪个请求」:
curl -sSI https://media-example.s3.us-east-1.amazonaws.com/avatars/u-1024.png
HTTP/1.1 200 OK
ETag: "9f2c1d7e5b1a4c3d8e6f0a2b4c6d8e0f"
Last-Modified: Mon, 19 Oct 2026 01:40:02 GMT
x-amz-version-id: 3HL4kqtJlcpXroDTDmJrmspXd3dIbs1
x-amz-request-id: 7Q3F9Y2K4M8P1R5T
x-amz-server-side-encryption: AES256
Content-Length: 184320
怎么读:
x-amz-request-id要打进日志。对象存储的支持工单基本都问这个 ID;没有它,只能靠时间范围猜。x-amz-version-id存在,说明桶开了版本化;排查「删了还在」时,先看当前版本是不是 delete marker。没有这个头,说明版本化没开。ETag是不透明令牌,不要拿它和本地 MD5 比较;多段上传、SSE-KMS 都会让它不等于内容 MD5。Last-Modified只有秒级精度,不能当 CAS 的版本;两个写在同一秒完成时,它可能一样。- 条件写返回 412 时,不要只看「失败率」;412 是并发冲突的正常信号,突增才说明热点 key 或重试策略有问题。
第三层是服务缓存。Redis 的 INFO stats 给的是累计值,适合看趋势和比例:
redis-cli INFO stats | grep -E 'keyspace_(hits|misses)|expired_keys|evicted_keys'
keyspace_hits:8421337
keyspace_misses:441902
expired_keys:120334
evicted_keys:0
怎么读:
- 命中率 =
hits / (hits + misses)= 8,421,337 / 8,863,239 ≈ 95.0%。但命中率本身不是目标:回源 QPS = 总 QPS × (1 - 命中率)。总 QPS 40,000、命中率 95% 时,源站要接 2,000 QPS;命中率掉到 92%,源站就要接 3,200 QPS,可能直接超过容量。 evicted_keys > 0说明内存压力已经在驱逐 key;命中率会跟着掉,TTL 还没到也会没。先扩容/调maxmemory,再谈优化。expired_keys的增速突然上升,通常对应「大量 key 同时过期」;把它和 TTL 分布、发布记录对齐。keyspace_misses包含「查不存在的 key」,不全是穿透;要配合应用侧的空值缓存命中计数、INFO commandstats里的cmdstat_get一起看。- 单 key 诊断用
redis-cli --hotkeys(需要 LFU 策略)、OBJECT FREQ key、SLOWLOG GET 10、LATENCY DOCTOR;大 key 用--bigkeys。
延迟和内存也要看两条:
redis-cli --latency -h cache.internal
redis-cli MEMORY USAGE price:sku-1024
--latency 的平均/最大延迟突然上升,可能是慢命令、网络或 fork;MEMORY USAGE 帮你判断大 key 是否值得拆分。
| 指标 | 正常 | 异常 | 动作 |
|---|---|---|---|
Age | 小于 max-age | 大于 max-age 且 HIT | 查 SWR/SIE、purge 是否生效 |
X-Cache | 稳态以 HIT 为主 | 持续 MISS / STALE | 查 cache key、Cookie/Vary、源站 |
| 回源 QPS | ≤ 源站容量 60% | > 80% 或阶跃 | 限流、预热、单飞、扩容 |
| 命中率 | 按「回源 QPS」算,不设万能值 | 1 个百分点换 5% 源站容量 | 查驱逐、key 变更、热点 |
evicted_keys | 0 或偶发 | 持续增长 | 扩容、调 maxmemory、降 TTL |
expired_keys 增速 | 平滑 | 尖峰 | TTL 抖动、预热、SWR |
| S3 5xx | 偶发 | 突增 | 退避、request id、跨区 |
| 412 比例 | 低且平稳 | 突增 | 热点写、合并写、退避 |
正常/异常的分界不该拍脑袋。回源 QPS 用「源站容量的 60% 绿、80% 黄、100% 红」;命中率没有通用目标,只有「当前命中率下源站还剩多少 headroom」;Age > max-age 只有在配置了 SWR/SIE 且未超出窗口时才算正常。把这些写成告警表达式,比在事故中争论「95% 是不是正常」有用。
curl -I看边缘:X-Cache、Age、Cache-Control,判断是 HIT/MISS/STALE。- 绕过 CDN 打源站(改 Host 或直连源站),对比两边的内容和版本。
redis-cli INFO stats看命中率、驱逐、过期;--hotkeys/SLOWLOG找热点和慢命令。HEAD对象存储:ETag、version-id、request-id,确认源站版本和删除语义。- 对齐时间线:deploy、purge、TTL 过期、缓存重启,看哪个动作和故障起点重合。
8.8.1 把缓存命中写进结构化日志
指标告诉你「命中率掉了」,日志告诉你「哪个 key、哪个 PoP、哪一层」。每次回源打一条结构化日志,字段至少包括:
| 字段 | 例子 | 用途 |
|---|---|---|
cache_key | price:sku-1024 | 定位热点/失效范围 |
cache_layer | cdn / redis / local | 判断哪层 miss |
cache_status | HIT / MISS / STALE | 命中类型 |
age_s | 421 | 边缘已缓存多久 |
origin_status | 200 / 503 | 回源结果 |
origin_ms | 182 | 回源延迟 |
pop | fra | 边缘位置 |
request_id | 7Q3F9Y2K... | 串起源站日志 |
{"ts":"2026-10-19T02:15:31Z","cache_key":"price:sku-1024","cache_layer":"redis","cache_status":"MISS","origin_status":200,"origin_ms":182,"pop":"fra","request_id":"7Q3F9Y2K4M8P1R5T"}
采样要按「miss + 慢请求」加权,不能只采 1% 的随机请求:正常命中占 95%,随机采样里几乎全是 HIT,看不到问题。对回源失败、Age 超限、412 突增的请求,应该 100% 记录。
8.9 真实案例:2019-07-02 Cloudflare 边缘故障
2019 年 7 月 2 日,Cloudflare 的边缘网络发生了一次全球性故障,持续约 27 分钟。它不是因为缓存被击穿,也不是因为源站挂了;起因是一条 WAF 规则里的正则表达式,在边缘的 HTTP/HTTPS 处理进程里发生灾难性回溯,把所有处理 HTTP/HTTPS 的 CPU 核打满。对用户来说,现象是访问任何 Cloudflare 域名都返回 502——和「缓存全部 miss、源站不可达」看起来一样,但根因在边缘的计算层。
官方复盘给出的时间线大致如下(时间为复盘中的时间):
| 时间 | 事件 |
|---|---|
| 13:31 | 工程师合并了一条 XSS 检测规则的 PR |
| 13:37 | CI 构建并跑完测试,通过 |
| 13:42 | 规则通过 Quicksilver 全球下发,进入 simulate 模式 |
| 13:45 | 第一条 PagerDuty 告警;随后全球流量下跌、502 增多 |
| 14:00 | 定位到 WAF;排除攻击 |
| 14:07 | 执行 global terminate,全球禁用 WAF |
| 14:09 | 流量和 CPU 恢复 |
| 14:52 | 完成 negative/positive 验证后,WAF 重新启用 |
这条规则的核心正则可以化简成 .*.*=.*。在 PCRE 这类回溯引擎里,输入越长、匹配失败的位置越多,回溯次数会超线性增长;边缘每秒要处理大量请求,一条请求多花几毫秒,乘以并发就把 CPU 吃满。更关键的是,这条规则是以 simulate 模式下发的:它不拦截请求,但仍然要执行匹配。「只观察、不生效」不等于「没有成本」。
失败链可以画成这样:
规则变更(XSS 检测)
│
▼
Quicksilver KV 秒级全球下发(180+ 城市)
│
▼
每个 PoP 的 Lua WAF 用 PCRE 执行正则
│
▼
.*.*=.* 灾难性回溯 → 单请求 CPU 时间暴涨
│
▼
所有 HTTP/HTTPS 处理核 ~100% → 处理进程不可达
│
▼
前端还有 CPU 的机器返回 502
│
▼
Access/Jira/构建系统也不可达 → global terminate 难以执行
复盘里最值得 caching/CDN 工程师带走的,不是「正则写错了」,而是四个系统性条件同时成立:
- 全球配置面 = 全球爆炸半径。WAF 规则为了快速响应威胁,绕过了正常软件的 DOG/PIG/Canary 流程,直接全球下发;Quicksilver 让变更在秒级到达所有 PoP。速度是特性,也是风险。
- 功能测试不等于资源上界。CI 验证了「该拦的拦住、不该拦的放过」,但没有测「这条规则在最坏输入上花多少 CPU」。正则要有输入长度上限、匹配超时/预算,或者换线性时间的引擎(RE2/Thompson NFA)。
- simulate 也会执行。规则即使不拦截,也要跑匹配;「灰度/观察模式」不能假设零成本。
- 失效开关不能依赖被失效的系统。Cloudflare 用自家的 Access 做内部鉴权,Access 也在这条链路上;工程师一度无法登录控制台,只能走不常用的 bypass。kill switch 要独立、要演练。
还有一条工程教训:恢复不是「把开关拨回去」就完了。Cloudflare 在重新启用 WAF 前做了 negative test(确认问题确实是这条规则)和 positive test(确认回滚有效),并先在一个城市、去掉付费客户流量做验证。回滚也要有验证步骤,否则可能第二次把系统打挂。
把这些条件翻译成护栏,就是下面这张表:
| 条件 | 当时的护栏 | 可以补的护栏 |
|---|---|---|
| 全球秒级下发 | 无 canary,WAF 规则按设计全球直推 | 配置也走 canary/分批,设爆炸半径上限 |
| 正则 CPU 无界 | CI 只测功能 | 输入长度上限、匹配预算、RE2 类引擎 |
| simulate 仍执行 | 以为观察模式无成本 | 观察模式也做 CPU 压测/限流 |
| kill switch 依赖 Access | 用自家 Access 鉴权 | 独立 out-of-band 通道、定期演练 |
| 回滚未验证 | 直接 re-enable | negative/positive test、分批恢复 |
本节只写 Cloudflare 官方复盘公开的时间线、组件和结论,不推断未公开的客户影响数字。原文与完整正则分析见 Details of the Cloudflare outage on July 2, 2019。
8.10 重试导致重复副作用:上传重试与重复扣费
上传看起来是「幂等」的:同一个 key PUT 两次,内容一样,结果也一样。但只要上传完成会触发业务副作用,重试就不再幂等。更隐蔽的是:CompleteMultipartUpload 成功但响应丢失,重试会得到 NoSuchUpload;客户端把这个 404 当失败,重新走一遍上传和完成回调,于是对象存储里多一个对象、账单上多一笔扣费。
先看一个具体的算例。视频平台按分钟计费,客户端分片上传 200 MB 的视频,完成后调用 POST /v1/uploads/{id}/complete,服务端校验对象、触发转码、写一条计费记录。客户端超时 30 秒;服务端在第 31 秒完成。客户端重试 complete,服务端又记了一次「完成」,计费两次。
客户端 上传 API 对象存储 计费 │ PUT part 1..N │ │ │ ├───────────────────────▶│ │ │ │ ├─ Complete ─────────────▶│ │ │ 200(31s,响应丢了) │◀────────────────────────┤ │ │ 30s 超时,重试 │ │ │ ├─ complete U1 ─────────▶│ Complete 再试 ──────────▶│ NoSuchUpload (404) │ │ ├─ HEAD object ──────────▶│ 200 + ETag/size │ │ ├─ 判定已成功 ───────────────────────────────────▶│ charge key=upload:U1 │ 200 replayed │◀───────────────────────────────────────────────┤
这里有两层幂等,必须分开看。
第一层是对象层。 同一个 key、同样的字节,PUT 重试是覆盖:关闭版本化时只留一个对象;开启版本化时每次 PUT 会产生一个新版本,存储和事件都会增加。更常见的是 key 里带了随机后缀:客户端每次重试生成新的 uploads/{uuid}.mp4,于是留下两个对象、两条 ObjectCreated 事件。对象层的「幂等」只在「key 稳定」的前提下成立。
第二层是副作用层。 上传完成会触发计费、转码、发通知、写数据库。这些动作是至少一次的:对象存储的事件通知可能重复,客户端重试 complete 也会重复调用。对象存储保证不了「这次上传只计费一次」,它只知道对象写没写成功。要把「对象已就绪」和「业务副作用已发生」分成两个状态,各自有幂等键。
CompleteMultipartUpload 的 404 是一个典型的陷阱。它返回 NoSuchUpload 有两种可能:第一次 complete 其实成功了,UploadId 已失效;或者 UploadId 从来不存在/已过期。两者都返回 404,不能靠错误码区分。正确做法是:先 HEAD 目标 key,比较 size、checksum、content-type;如果和这次上传的预期一致,就当作成功,继续走幂等 complete;只有确认对象不存在或不匹配,才重新 initiate。「重试返回 404」不等于「第一次失败」。
设计上,至少要有四样东西:
- 客户端生成的
upload_id(ULID/UUID),所有重试复用它;对象 key 由它决定,例如uploads/{upload_id}/source.mp4,不要每次重试换 key。 - 一张
uploads表,upload_id是主键,状态机是initiated → uploaded → completed → charged,completed只能从uploaded迁移一次。 - 条件更新,用
WHERE status='uploaded'保证只有一个请求能把状态推到completed;0 行时读当前状态,completed就返回原结果。 - 计费幂等键,用
upload:{upload_id}调用账单;账单侧保存 key → charge_id,重放返回原 charge,不重复扣。
状态迁移可以写成一条 SQL:
UPDATE uploads
SET status = 'completed', completed_at = now()
WHERE upload_id = '01J8Z3K5'
AND status = 'uploaded'
RETURNING object_key, etag, size_bytes;
如果返回 1 行,说明这次请求赢得了状态迁移,可以继续触发计费/转码;如果返回 0 行,再查一次当前状态:completed 就返回原结果(replayed=true),initiated/uploading 返回 409(还没传完),不存在返回 404。计费调用放在数据库提交之后,带 Idempotency-Key: upload:01J8Z3K5;即使服务在「扣费成功、写 charge_id 之前」崩溃,重试也会命中账单的幂等记录,拿到同一个 charge。
POST /v1/uploads/01J8Z3K5/complete HTTP/1.1
Host: api.example.com
Idempotency-Key: upload:01J8Z3K5
HTTP/1.1 201 Created
Idempotent-Replayed: false
HTTP/1.1 200 OK
Idempotent-Replayed: true
Idempotent-Replayed 是示例接口的约定(Stripe 风格);关键不是这个头名,而是「重放要返回原结果,并且让调用方知道这是重放」。如果重放返回 500 或「已存在」,客户端会以为失败,继续重试,反而放大问题。
把上面的状态机写成代码,注意「崩溃在扣费之后、写 charge_id 之前」这个窗口:
def complete_upload(upload_id: str) -> dict:
row = db.query_one(
"SELECT status, object_key, size_bytes, charge_id "
"FROM uploads WHERE upload_id=%s", upload_id)
if row is None:
raise NotFound(upload_id)
if row["status"] == "uploaded":
won = db.query_one(
"UPDATE uploads SET status='completed', completed_at=now() "
"WHERE upload_id=%s AND status='uploaded' "
"RETURNING status, object_key, size_bytes", upload_id)
if won is not None:
row = won
else:
# 输掉 CAS:重读当前状态,completed 就走重放/对账
row = db.query_one(
"SELECT status, object_key, size_bytes, charge_id "
"FROM uploads WHERE upload_id=%s", upload_id)
if row["status"] not in ("completed", "charged"):
raise Conflict(f"upload is {row['status']}")
if row.get("charge_id"):
return {"object_key": row["object_key"], "replayed": True}
head = storage.head(row["object_key"])
if head is None or head.size != row["size_bytes"]:
raise Conflict("object missing or size mismatch")
charge = billing.charge(
amount_cents=head.size * PRICE_PER_BYTE,
idempotency_key=f"upload:{upload_id}",
)
db.execute(
"UPDATE uploads SET status='charged', charge_id=%s "
"WHERE upload_id=%s AND charge_id IS NULL",
charge.id, upload_id)
return {"object_key": row["object_key"], "replayed": False}
三个检查点各挡一类重复:状态迁移挡住「两个 complete 同时到」;HEAD 挡住「complete 404 但其实没成功」;账单幂等键挡住「扣费成功、写 charge_id 前崩溃」。如果进程在最后一条 SQL 之前崩溃,重试会看到 completed 且 charge_id 为空,带着同一个 upload:{upload_id} 再调一次账单,拿到同一笔 charge。幂等键要覆盖「重试的整个生命周期」,不能只覆盖 HTTP 请求。
把这条链路上的失败点列出来,就能看出每个检查点挡的是哪一类:
| 失败点 | 客户端看到什么 | 服务端实际状态 | 正确处理 |
|---|---|---|---|
| PUT 分片超时 | 连接错误 | 分片可能已写入 | 用同一 part number 重传 |
| Complete 超时 | 30s timeout | 可能已完成,UploadId 失效 | HEAD 对账,不重建 |
| Complete 404 | NoSuchUpload | 可能已完成,也可能从未存在 | HEAD + checksum 判断 |
| 扣费超时 | 账单接口 timeout | charge 可能已创建 | 同一 Idempotency-Key 重试 |
| 写 charge_id 前崩溃 | 无响应 | 已扣费,charge_id 为空 | 重试同一账单幂等键 |
算一笔账:每天 100 万次上传,0.3% 超时重试,就是 3,000 次重试;其中 10% 因为换 key 或重复 complete 造成重复计费,每天 300 笔;每笔 5 元,一天 1,500 元。金额不大,但退款、客服、对账的成本远高于 1,500 元;如果计费单位是「每分钟转码」或「每 GB 存储」,重复会持续累积。重复副作用不是一次性的错账,而是一条会自己长大的尾巴。
对象幂等:同一个 key、同样的字节重复 PUT,最终对象内容相同;开启版本化时,版本数会增加。副作用幂等:同一个逻辑上传(同一个 upload_id)重复触发完成回调,计费/转码/通知只生效一次。前者由对象存储的覆盖语义提供,后者必须由 upload_id + 状态机 + 计费幂等键自己保证。
如果桶开了版本化,同 key 重试 PUT 不会产生两个「当前对象」,但会产生两个版本;按版本计费、按事件触发的系统仍然会看到两次变更。排查「为什么多了一个对象」时,先确认是「key 不同」还是「版本不同」:前者是 key 设计问题,后者是版本化 + 事件去重问题。
8.11 缓存与 CDN 设计清单
把前面的内容压缩成一张表。设计评审时逐项过,比事后查事故便宜。
| # | 检查项 | 达标线 | 不达标会怎样 |
|---|---|---|---|
| 1 | 数据分级 | 每类数据写明「可容忍最大陈旧时间」 | 用「缓存一致」这种无法验收的词 |
| 2 | 事实来源 | 缓存可丢、可重建,DB/对象存储是唯一事实 | 把 write-behind 缓存当账本 |
| 3 | key 设计 | 稳定、可版本化、不含用户 id/随机数 | 命中率归零或串用户 |
| 4 | TTL | base + 抖动,有硬上界 | 同一时刻集体过期 |
| 5 | 失效 | 提交后删除,outbox/CDC 重试,失败告警 | 删失败后靠 TTL 兜底 |
| 6 | 回填 race | 版本/CAS,或延迟双删 + 接受残余窗口 | 旧值回填后留满 TTL |
| 7 | 穿透 | 空值缓存 + 参数校验 + 限流 | 不存在的 ID 打穿源站 |
| 8 | 击穿 | singleflight / 逻辑过期 / SWR | 热点 key 过期即雪崩 |
| 9 | 雪崩 | 多级缓存、HA、预热、源站限流 | 缓存重启 = 全站故障 |
| 10 | 热点 | L1 缓存 / key 复制 / 拆分 / 合并 | 单分片被打满 |
| 11 | CDN key | 规范化 query,Vary 最小化,剥 tracking | 命中率低或缓存污染 |
| 12 | purge | URL/tag 可重试,浏览器靠版本化 URL | purge 了 CDN,用户还是旧 |
| 13 | 观测 | Age/X-Cache、回源 QPS、evicted、request id | 出事只能猜 |
| 14 | 上传 | upload_id + 条件 complete + 计费幂等键 | 重试导致重复对象/扣费 |
不同数据类的策略不应该一样:
| 数据 | 例子 | 可容忍陈旧 | 策略 |
|---|---|---|---|
| 静态资源 | JS/CSS/字体 | 无限(URL 变则内容变) | 内容哈希 URL + immutable + 一年 TTL |
| 图片/视频 | 商品图、头像 | 分钟–小时 | 版本化 key + purge;不要覆盖同 key |
| 价格/库存 | SKU 价格、库存数 | 秒级 | 短 TTL + 版本号;写后读走源站 |
| 权限/会话 | 角色、token 黑名单 | 秒级或不可旧 | 不做共享缓存,或短 TTL + 主动撤销 |
| HTML 壳 | 首页、详情页 | 秒–分钟 | s-maxage 30–60 + SWR |
| 搜索结果 | 列表页 | 分钟 | TTL 1–5 分钟 + 抖动 |
这份清单里最容易被跳过的是第 1 项。团队往往直接讨论「用 Redis 还是本地缓存」,但没人回答「旧 5 分钟能不能接受」。没有这个答案,TTL 只能拍脑袋,事故后也无法判断「这次陈旧是不是 bug」。先把「最大陈旧时间」写成一句可验收的话,再选技术。
- 这类数据最多能旧多久?谁批准这个数字?
- 写完到所有缓存失效,最长路径是什么?失败会重试吗?
- 缓存全挂时,源站能接住多少 QPS?降级是什么?
- CDN 的 cache key 包含哪些维度?哪些请求会永远 MISS?
- 出问题时,用哪三条命令区分「源站旧」和「缓存旧」?
8.12 本章陷阱清单
- 把 ETag 当 MD5:多段上传、SSE-KMS 的 ETag 不是内容 MD5;校验完整性要用 checksum。
- 以为 S3 强一致 = 跨 key 事务:对象 + 数据库的双写仍然没有原子性,需要状态机/outbox/对账。
- 两个 writer 都 200 就以为没丢更新:强一致只保证读者看到后完成者;丢失更新要靠
If-Match或版本号。 - 先删缓存再写库:读者会在删完、库未提交的窗口回填旧值,而且写者提交后不会再删。
- 把双写当原子:数据库和缓存没有共同提交点;失败一边后重试另一边可能写回旧值。
- 把 TTL 当一致性方案:TTL 只是陈旧上界,且缓存可能被提前驱逐;写完等 TTL 等于把窗口拉满。
no-cache当no-store:no-cache可以存,只是每次复用前要 revalidate;no-store才是别存。- purge 只清 CDN:浏览器和服务本地缓存不受 CDN purge 影响;静态资源要靠版本化 URL。
- 所有 key 同一个 TTL:同一时刻集体过期,把缓存问题变成源站问题;TTL 要加抖动。
- 不缓存空值:不存在的 ID 每次都穿透到数据库;空值缓存 TTL 要短,创建后要能失效。
- singleflight 当全局锁:它只在一个进程内合并;50 个 pod 仍有 50 次回源,且 leader 失败会放大给等待者。
- 命中率当目标:真正要管的是「回源 QPS = 总 QPS × (1 - 命中率)」和源站 headroom。
- 上传重试换新 key / complete 404 当失败:换 key 会留下两个对象;complete 404 要先 HEAD 对账,再决定是否重建。
8.13 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 写出条件 PUT | 能写 If-None-Match: * 与 If-Match,并解释 412 的含义 | 8.1 |
| 解释 ETag | 能说出为什么它不是内容 MD5、什么时候会变 | 8.1 |
| 说出 S3 强一致的边界 | 至少 3 个仍存在的坑:跨 key、并发覆盖、CRR、delete marker、控制面 | 8.2 |
| 画出四种缓存模式 | 能标出写路径和失败窗口,不把 write-behind 当普通缓存 | 8.3 |
| 画出失效 race | 能画出「读者回填旧值」的时序,并说出两种修复 | 8.4 |
| 区分四种 miss | 击穿/穿透/雪崩/热点各给一个药和一个残余风险 | 8.5 |
| 写 singleflight | 能写出 per-key 合并、双重检查、等待超时 | 8.5 |
| 设计 CDN cache key | 能说出哪些进 key、哪些不进,Vary 的风险 | 8.6 |
读 curl -I | 能从 Age/X-Cache 判断 HIT/MISS/STALE,并知道下一步查什么 | 8.8 |
| 复述 Cloudflare 案例 | 能说出正则回溯、全球下发、simulate、kill switch 四个条件 | 8.9 |
| 设计上传幂等 | 能说出对象层与副作用层的区别,complete 404 怎么对账 | 8.10 |
| 过一遍设计清单 | 14 项里至少能答出 12 项 | 8.11 |
Q1 总 QPS 40,000,缓存命中率 96%,源站容量 2,000 QPS。现在源站接多少?命中率掉到 94% 呢?如果 20 万个 key 在 60 秒内同时过期,额外多少 QPS?你会先做哪三件事?
Q2 读者 miss 后读到 v1;写者提交 v2 并删除缓存;读者把 v1 写回,TTL 300 秒。旧值会留多久?延迟双删能完全解决吗?版本 CAS 为什么更稳?
Q3 curl -I 返回 Age: 7200、Cache-Control: max-age=600、X-Cache: HIT。这正常吗?你会怎么确认?
Q4 客户端 complete 超时后重试,收到 NoSuchUpload(404)。它应该重建上传吗?怎么判断第一次是否已经成功?
Q5 两个 writer 都带 If-Match: "v1" 写同一个 key,一个 200、一个 412。412 的一方应该怎么做?为什么不能直接换一个新 ETag 重试?
Q6 Cloudflare 那条 WAF 规则在 simulate 模式下为什么还能打挂边缘?如果让你加一条护栏,你加什么?
答案
A1 源站现在是 40,000 × 4% = 1,600 QPS,已经是容量的 80%;命中率掉到 94% 就是 2,400 QPS,超过容量 20%。20 万 key 在 60 秒内过期,额外约 3,333 QPS,叠加后源站要接约 4,933 QPS,是容量的 2.5 倍。先做三件事:给 TTL 加抖动、打开 stale-while-revalidate、给回源加并发上限/单飞;扩容和预热放在后面。
A2 旧值会留到 TTL 到期(300 秒)或下一次删除,因为回填发生在写者删除之后。延迟双删能清掉「在 Δ 内回填」的旧值,但如果读者 GC 停顿超过 Δ,第二删之后仍可能回填,所以不能完全解决。版本 CAS 更稳:缓存只接受更大的 version,读者回填 v1 会被拒绝,不依赖时间窗口。
A3 不正常:Age 已经 7,200 秒,超过 max-age=600,但 CDN 还在 HIT,说明它在给 stale。先看是否配置了 stale-while-revalidate/stale-if-error 且仍在窗口内;如果不是,查 purge 是否传播、源站是否已更新、缓存是否没遵守 TTL。绕过 CDN 直连源站对比内容,再决定精确 purge,不要直接全站 flush。
A4 不应该立刻重建。先 HEAD 目标 key,比较 size、checksum、content-type 与这次上传是否一致;一致就当作第一次已成功,继续走幂等的 complete/计费;不一致或对象不存在,才重新 initiate。根因是 complete 成功后 UploadId 失效,404 不能区分「已成功」和「从未成功」。
A5 412 表示「你基于的版本已经过期」:当前 ETag 已被别人改成 v2。正确动作是重新读 v2、合并自己的修改、带 If-Match: "v2" 重试,并加退避;如果合并有业务冲突,返回 409 让人处理。直接换一个新 ETag 硬写等于放弃 CAS,会把别人的更新覆盖掉。
A6 simulate 只表示不拦截,不表示不执行;正则仍要匹配,.*.*=.* 的回溯会耗尽 CPU。护栏优先级:给正则加输入长度上限和匹配 CPU 预算/超时,或换线性时间引擎;把配置下发也做 canary/分批;kill switch 走独立通道并定期演练。
母本对应
| 母本 | 章节 | 本书落点 | 程度 | 说明 |
|---|---|---|---|---|
| 谢希仁《计算机网络》第 8 版 | 第 6 章 应用层 | 8.1、8.6 | 背景 | 只借用 HTTP/DNS 的应用层视角;不重写协议细节 |
| 张彦飞《深入理解 Linux 网络》 | 第 4–7 章(TCP、socket、连接) | 8.6、8.8 | 背景 | anycast/连接只引用,不展开内核路径 |
| 《Python 网络编程》 | HTTP 客户端、并发与缓存 | 8.5、8.10 | 精选 | 借用工程风格;singleflight/幂等代码为本书重写 |
| 《Linux 系统与内核》第 4 篇 | 4.7(HTTP、DNS、TLS) | 8.6 | 交叉引用 | 明确不重复 TCP/HTTP/TLS 基础 |
| 本书自编 + S3/HTTP 规范 | S3 一致性模型、条件写、RFC 9111/5861 | 8.1、8.2、8.6 | 完整 | 只引用语义与公开事实,不复制文档长段 |
| 本书自编 + Cloudflare 复盘 | 2019-07-02 边缘故障 | 8.9 | 完整 | 只写官方公开时间线与结论,附原文链接 |
谢希仁和张彦飞都不讲对象存储、缓存失效和 CDN;这一章的自编部分集中在「语义 → 失败窗口 → 观测命令」这条链上。S3 的一致性模型、条件写、HTTP 缓存指令来自官方文档和 RFC,事故时间线来自 Cloudflare 官方复盘;代码和算例是按工程实践重写的,不复制厂商控制台手册,也不把某一家的实现当成行业标准。
下一站:缓存和 CDN 让读变快,但也让「谁有资格写」变得模糊:purge、双写、上传完成回调,最后都要回到「同一时刻只有一个写者」。进入 9 分布式锁、租约与选主,看锁为什么会过期、租约为什么依赖时钟、fencing 如何把旧持有者挡在资源外。