分布式系统(28):缓存失效、填充与旧值竞态
数据库已经是 v2,缓存也删过了,系统却还能重新缓存 v1。这里没有缓存节点“回滚”,也不需要消息乱序:一个较早开始的读请求在失效之后才把旧查询结果写回,就足以让旧值复活。
分布式系统(27):FaRM 的乐观验证、RDMA 与提交路径第 27 篇的事务协议会认证读写集并保存共同决定。cache-aside 路径通常没有这样的统一协议:数据库是权威源,Memcached 是可丢失的派生状态,应用分别调用两边。本文从这条边界出发,复现旧值回填,并比较 TTL、延迟双删、lease、版本栅栏和单 key 串行化。
先写清 cache-aside 的状态机
读取流程通常是:先查缓存;miss 后查数据库;再把结果写入缓存。写入流程通常是:提交数据库,然后删除缓存。后续读者通过 miss 获取新值。
flowchart LR
R[读取请求] --> G{cache get}
G -->|hit| V[返回缓存值]
G -->|miss| D[读取数据库]
D --> S[cache set]
S --> V
W[写请求] --> U[提交数据库]
U --> X[cache delete]
这张图跨了两个独立系统。普通 Memcached set 可以覆盖现有 item,add 只在 key 不存在时存储,delete 删除 item,CAS 只对当前 item 做条件替换。它们都没有把数据库事务与缓存命令合成一次提交。[Memcached Basic Text Protocol]
缓存节点还可能驱逐 item,进程重启也会丢失内存状态。对 cache-aside 来说,这通常影响命中率而非数据库正确性;前提是 miss 始终能回到权威源,并且填充不会把已经失效的旧结果重新变成长期命中值。
本文把单 key 安全目标写成一句可检查的话:数据库版本推进到 v2,且对应的失效屏障已经生效后,任何在屏障前取得的 v1 填充资格都不得再安装为可命中值。这个目标不等于多 key 事务快照,也不承诺每次读都立刻看到最新数据库提交。
先失效再写数据库:最直接的反例
设缓存和数据库最初都是 v1。写者 W 先删缓存,读者 R 随即 miss 并从数据库拿到 v1。W 再把数据库提交为 v2。最后 R 才执行普通 set(v1)。
sequenceDiagram
participant W as 写者 W
participant C as Memcached
participant D as 数据库
participant R as 读者 R
W->>C: delete(k)
R->>C: get(k) = miss
R->>D: read(k) = v1
W->>D: commit(k=v2)
R->>C: set(k=v1)
Note over C,D: DB=v2,cache=v1
若 v1 没有 TTL,它可能一直留到下一次写、驱逐或人工清理。即使有 TTL,在过期前仍然会命中旧值。把写入顺序调整为“先数据库、后删除缓存”,可以消除上图的具体排列,但还不够形成严格保证。
先写数据库再删缓存,仍有跨越窗口
换一个读者时序:R 已经因为 miss 读到数据库 v1,但尚未 set;W 随后提交 v2,并删除此时仍为空的缓存;R 最后把 v1 写回。
sequenceDiagram
participant R as 读者 R
participant C as Memcached
participant D as 数据库
participant W as 写者 W
R->>C: get(k) = miss
R->>D: read(k) = v1,暂停
W->>D: commit(k=v2)
W->>C: delete(k),此时为空
R->>C: set(k=v1)
Note over C,D: 删除成功也没有撤销 R 的回填资格
问题不只在两条写命令的顺序。读者把“miss”当成一张长期有效的写许可证,而 delete 只删除了当时存在的 item。旧读者仍握着数据库结果,之后的无条件 set 会重建 key。
Facebook 的 NSDI 2013 论文把这种现象称为 stale set:并发更新重排后,web server 把已经不是最新的值放回 memcache。[Scaling Memcache at Facebook §3.2.1]
TTL 和延迟双删只收缩窗口
TTL 是陈旧状态的保险丝。假设旧值在逻辑时间 10 过期,时间 9 的读取仍会命中 v1,时间 10 才 miss。它给出了这个有限模型中的上界,没有阻止旧读。
expiration=0 在官方文本协议里表示不因 TTL 过期。即使设置了非零 TTL,服务端时间、重新填充、驱逐策略和故障切换仍要纳入真实上界;应用不能把“最终会过期”写成 read-after-write。
延迟双删是在写前或写后删除一次,再等待固定时间后删除第二次。若旧读者在第二次删除前完成回填,第二删能清掉 v1;若数据库查询、调度暂停或网络延迟更长,读者在第二删之后才 set,旧值仍会复活。
flowchart TB
D1[第一次 delete] --> U[数据库写 v2]
U --> T[等待固定 Δ]
T --> D2[第二次 delete]
R1[旧读者在 D2 前回填] --> D2
D2 --> OK[旧值被清掉]
D2 --> R2[更慢读者在 D2 后回填]
R2 --> BAD[旧值再次命中]
论文 §4.3 的 cold-cluster warmup 使用了相似的 hold-off:delete 后一段时间拒绝 add。作者明确承认,若 delete 延迟超过两秒,理论上的不一致仍可能发生;这是依据特定负载做的概率权衡,不是通用正确性证明。
lease 撤销的是写入资格
NSDI 2013 的 lease 机制在 cache miss 时返回一个绑定 key 的 64 位 token。客户端读完数据库,回填时必须带回 token。若期间收到该 key 的 delete,Memcached 会使旧 token 失效;旧值即使已经从数据库返回,也不能再 set。
sequenceDiagram
participant R as 读者 R
participant C as lease-aware cache
participant D as 数据库
participant W as 写者 W
R->>C: get(k) miss
C-->>R: lease token=7
R->>D: read(k)=v1
W->>D: commit(k=v2)
W->>C: delete(k),撤销 token 7
R->>C: set(k=v1, token=7)
C-->>R: reject
R->>C: 重新 miss,取得 token=8
R->>D: read(k)=v2
R->>C: set(k=v2, token=8)
同一个机制还能缓解 thundering herd。论文实现限制每个 key 发放 token 的频率,其他 miss 客户端暂时等待,让一个获胜者负责访问后端并填充。防旧值和防击穿是两个效果:前者依赖 delete 撤销旧 token,后者依赖控制同时获得填充资格的客户端数量。
这是 Facebook 论文里的定制 memcache 机制,不能假设所有上游 Memcached 客户端都能直接使用。当前官方 Meta Text Protocol 提供原子 winner、serve-stale、CAS override 等工具,但端到端数据库版本、失效源与重试仍由系统设计者组合。
普通 CAS 不自动等于版本栅栏
Memcached item 带 64 位 CAS 值。gets 取回 CAS,再用 cas 条件更新,可以阻止“基于旧缓存 item 的并发覆盖”。然而旧值回填场景里,读者从数据库拿到 v1,写者又把 cache key 删除了。若客户端随后对空 key 使用普通 set 或 add,原 item 的 CAS 上下文已经不存在。
要用业务版本阻止回退,比较所需的下界不能只藏在会被删除的 value 中。一个方案是在缓存或协调层保留 minimum_version=2 的墓碑:v1 填充被拒绝,v2 才能安装。官方 Meta 协议在 1.6.27 之后允许用 E flag 覆盖 CAS 值,版本、时钟或 HLC 可以放进 8 字节递增数;文档同时明确,多 pool 的方案并非 strict consistency。
flowchart LR
I[失效建立 min_version=2] --> O{回填版本 >= 2?}
OLD[v1 回填] --> O
O -->|否| REJECT[拒绝旧填充]
NEW[v2 回填] --> O
O -->|是| STORE[安装为可命中值]
版本还要回答来源与生命周期:谁分配单调版本,事务提交与版本发布是否同序,墓碑多久保留,cache 重启后从哪里恢复,多 key 写是否共享一个提交版本。少了这些条件,“value 里加 version”只能帮助发现旧值,未必能阻止它再次写入。
串行化和 write-through 把成本移到协调路径
如果一个 per-key 锁覆盖读者的“查缓存→读数据库→填充”以及写者的“写数据库→失效”,上述交错会被排除。写者先完成时,读者只能读 v2;读者先完成时,写者随后删除它刚填的 v1。安全性更直接,代价是热点 key 排队、锁服务故障和持有锁期间的后端延迟。
write-through 让写路径同时更新缓存,减少下一次 miss,却没有凭空获得数据库—缓存原子性。数据库成功、缓存写失败要恢复;缓存先更新、数据库失败要撤销;超时又可能隐藏已经执行的操作。除非二者共享事务或由可重放日志和版本规则连接,write-through 仍需定义失败窗口。
不同方案的保证不能混成“最终一致”:
| 策略 | 阻止旧回填 | 主要代价/前提 |
|---|---|---|
| TTL | 否,只限制存活时间 | 过期前仍旧;更短TTL增加后端负载 |
| 延迟双删/hold-off | 只覆盖小于窗口的读者 | 固定延迟没有任意慢请求上界 |
| key-bound lease | 是,delete撤销旧资格 | 需要token代际状态与协议支持 |
| 持久版本下界 | 是,拒绝低版本 | 版本单调、墓碑可靠且不能过早丢失 |
| 单 key 串行化 | 是,在锁覆盖范围内 | 热点排队、锁故障与活性成本 |
| write-through | 单独不足 | 仍需跨系统失败恢复和版本顺序 |
失效消息必须有恢复来源
应用完成数据库提交后只发送一次 delete,进程可能在两步之间崩溃,网络超时也不能证明命令没有执行。重试 delete 本身通常是幂等的,但首先要知道哪些 key 应该重试。
Facebook 的区域内方案把待失效 key 编入数据库修改语句。mcsqueal 从数据库已提交 SQL 中抽取 delete,再通过 mcrouter 广播;失效若丢失或误路由,可以从可靠提交日志重放。[NSDI 2013 §4.1]
flowchart LR
TX[数据库事务<br/>数据变更 + 待失效key] --> LOG[commit log]
LOG --> M[mcsqueal]
M --> R[mcrouter批量转发]
R --> C1[cache cluster 1]
R --> C2[cache cluster 2]
FAIL[下游失败] --> BUF[缓冲并重放]
BUF --> R
这是一套围绕 Memcached 建立的外部失效协议,不是标准 delete 命令自带持久性。即使日志可重放,传播期间仍可能旧读;安全目标、监控与超时处理必须说明是强阻断、有限窗口还是 best effort。
跨区域还要等数据库副本追上
远端区域的数据库副本可能落后于 master。master 刚提交 v2,远端缓存立即收到 delete;下一次 miss 若从尚未复制 v2 的本地副本读,就会重新缓存 v1。更快的失效反而扩大了 refill 与复制流的竞态。
论文用复制与失效顺序、remote marker 等机制降低这类概率,并明确把整体模型称为 best-effort eventual consistency。它还指出 marker 被驱逐、同 key 并发修改或组件延迟都可能增加旧读。把 marker 放进可驱逐缓存,与“删掉普通缓存项只影响命中率”具有不同的正确性后果。[NSDI 2013 §5]
本地实验:同一旧值跨过不同屏障
实验位于 examples/distributed-systems/cache28/check.py,使用 Python 标准库和逻辑时间。它没有启动 Memcached 或数据库。
1 | |
Python 3.12.3 正式运行得到:先失效再写库、先写库再失效的两条固定轨迹都以 DB=v2/cache=v1 结束;延迟双删只清掉第二删之前的回填;TTL 在逻辑时间 9 仍返回旧值,10 才 miss。旧 lease token 与低于墓碑的版本都被拒绝,新版本成功安装;单 key 串行调度最终缓存 v2。
完整输出见结构化观察,命令与修正记录见实验证据,验证范围见验证说明。这些断言没有覆盖任意调度、真实网络、时钟、驱逐或性能。
两个推演练习
为什么 add 仍不够。 写者删除空缓存后,旧读者执行 add(k,v1);此时 key 不存在。结果是什么?若另一个新读者抢先 add v2,情况又如何?
第一种情况下 add 会成功,旧值复活;第二种情况下 v1 的 add 失败,只是竞争顺序恰好保护了结果。add 解决“谁先填”,没有判断获胜者的数据版本是否新。
失效到了,为什么远端仍可能旧。 master 已提交 v2,远端缓存也删除了 k,但远端数据库副本仍是 v1。下一次 cache miss 应该做什么?
若直接读本地副本并填充,就会得到 v1。系统需要等待复制位点、转读 master、检查remote marker或携带足以拒绝v1的版本下界。选择哪一种,决定了延迟、可用性和旧读窗口。
工程结论
删除缓存只改变“当前有没有值”,没有自动撤销在途读者的写入资格。可靠方案要么让失效推进一个可验证的代际,要么用版本墓碑拒绝回退,要么把读库和填充、写库和失效纳入同一串行化范围。TTL和延迟双删有实际价值,但它们表达的是窗口与概率,不是无条件安全证明。
下一篇进入 Kafka。届时要继续拆开三个经常被混称为“提交”的位置:副本日志提交、消费者位点提交,以及输入到输出的事务边界。
参考资料
- Nishtala 等,2013,Scaling Memcache at Facebook:§2.1、§3.2.1、§4.1、§4.3、§5、§7.3。
- Memcached Basic Text Protocol:set/add/CAS/delete与expiration语义,访问2026-09-25。
- Memcached Meta Text Protocol:anti-dogpiling、serve-stale与CAS override,访问2026-09-25。
- Fitzpatrick,2004,Distributed caching with memcached:Memcached的原始架构定位。
- Gray 与 Cheriton,1989,Leases: An Efficient Fault-Tolerant Mechanism for Distributed File Cache Consistency:lease的一般权限与故障模型。详细阅读位置和反向检索见
writing-plans/distributed-systems/research/28-cache-consistency.md。
