2710 字
约 9 分钟
15
S12-A19 · 缓存的写模式:write-through、write-behind 与 write-around

一个用户资料接口更新后,缓存与数据库不一致了几秒,而在这几秒里用户刷新看到的还是旧昵称——因为他自己刚刚改过。选了"先更新库、再更新缓存"的方案,理论上一致,实际在并发下出现了"旧值覆盖新值":两个请求的缓存写顺序颠倒了。

缓存写模式的争论通常停在"先删还是先改",而真正决定行为的是三件事的顺序:写库、写缓存、以及"谁对最终一致性负责"。三种模式(through / behind / around)给出了三种答案。


1. 三种模式的定义

模式 行为
write-through 写库同时写缓存,二者同步
write-behind 先写缓存,异步刷库
write-around 只写库,让缓存失效

第一种最"直觉",但它有个隐含假设:两次写可以原子完成。第一种还有个"性能"副作用:写路径要等两个系统都成功,于是写延迟 = 库延迟 + 缓存延迟。在高频写场景里,这个加法很快就到了瓶颈。
而库与缓存是两个系统,做不到——于是"库成功、缓存失败"会留下不一致。

第二种的"性能最好"来自"写只碰到缓存"(内存操作),于是单次写可能快一个数量级。而它的风险是"把数据库降级成了备份"——这在故障语义上是根本变化。
第二种性能最好、风险最高:缓存是权威,库是副本。缓存挂掉会丢数据,而"异步刷库失败"会导致永久丢失。

第三种最保守:缓存不写,只失效。代价是"写后第一次读必然 miss",即"写多读少"场景下缓存几乎无用。

2. 顺序之争:先删还是先改

方案 问题
先改库,再改缓存 并发下旧值覆盖新值
先删缓存,再改库 删除与改库之间读到旧值并回填
先改库,再删缓存 通常最稳,仍有小概率
延迟双删 缓解但不根治

第三项是"事实上的主流":改库后删缓存,让下一次读回源。它的残余风险是"删缓存失败"或"读发生在删之前",而这两种概率都很低。

第二项的问题很具体:删缓存后、改库前,有请求读到旧库值并把它写回缓存第二项的"回填"有个放大器:回填通常带长 TTL(因为读路径不知道这是旧值)。于是"一条旧值"会在缓存里停留很久,比"没删"更糟。
,于是缓存里是"旧值 + 长 TTL",比不删更糟。

// 三种顺序的失败窗口对比(假设并发读写各 1 次)
1 先改库→再改缓存:并发写可能"旧覆盖新"
2 先删缓存→再改库:读请求可能"回填旧值"
3 先改库→再删缓存:失败窗口最小(毫秒级),业界主流
// 结论:不是"完美方案",而是"失败概率最小"

3. 缓存"写成功、库失败"怎么办

策略 后果
忽略 缓存有、库无(脏数据)
回滚缓存 需保证能回滚
只删不写 避免脏数据
标记待修 需后台对账

第三项(标记待修)在实践中常被做成"异步对账":定时比对缓存与库,发现不一致就修。它治标有效,但需要"能比对"(有版本号或时间戳)。
第三项是"write-around"的实质:缓存不做写操作,只做删除。于是"库失败"时缓存里什么都没有,下次读回源——不会产生脏数据。

第一项是最危险的:缓存里的值永远比库"新",而它没有任何机制被纠正(除非过期)。

// 保守但正确:只删不写
public void update(User u) {
    repo.save(u);                        // 先落库(失败则抛,缓存不动)
    cache.delete("user:" + u.getId());    // 再失效(失败只影响新鲜度)
}
// 关键:库是唯一权威;缓存只是"可能过期的副本"

4. write-behind 的适用与代价

场景 是否适合
计数器/点赞数 适合(可丢少量)
会话/临时状态 适合
账户余额 不适合
订单状态 不适合

第二项(会话)是 write-behind 的典型:会话本来就允许丢失第一项(计数)有个"业务"问题需要先答:"少 3 个赞"会不会被用户发现。多数场景不会,但在"抽奖/投票"这类场景里,少一个数就可能引发争议。
,而它写入极频繁,异步刷库收益明显。

第一项(计数)要接受"可能少几个":异步刷库失败会丢计数,而"点赞数少 3"通常可接受。

第三项与第四项的共同点是"不可丢":它们需要"库是权威、且写成功才算成功",于是只能 write-through/around。

// write-behind 的三个前提(缺一不可)
1 数据可丢(业务可接受少量丢失)
2 有"刷库失败"的补偿(重试 + 死信)
3 缓存有持久化(否则缓存重启即丢)
// 缺第 3 项时,"缓存挂了"等于"数据没了"

还有一个"跨服务"的形态:A 服务写库、B 服务读缓存。此时"删缓存"由谁做?如果 A 删、B 读,那么"删与读之间"的窗口就跨了服务,靠 TTL 兜。更可靠的做法是"用变更事件驱动删除"(binlog → 删缓存),把它变成基础设施的职责。

// 三种"谁来删"的分工
1 写服务自己删(简单,但跨服务时可能漏)
2 变更事件驱动删(可靠,多一套 CDC 与消费者)
3 读服务自己校验(版本号对比,成本在读路径)
// 判据:跨服务数量与一致性要求

5. 一致性的"窗口"该怎么定

目标 手段
秒级最终一致 先改库后删缓存
强一致(读自己的写) 写后短窗口读主/读源
分钟级 TTL 兜底
可接受旧值 不处理

第三项(TTL 兜底)的价值在于"兜底而非方案":它保证"最坏情况下多久能恢复一致",而不是"保证一致"。所以 TTL 应当与"业务能容忍多久不一致"对齐。
第二项是"用户体验"的关键:"我刚改完,为什么还是旧的" 是最刺眼的体验问题,而它只需要"该用户短窗口内读源"就能解决。

第四项在"报表/统计"类数据上常见:它们本来就不需要实时,于是"一致性"是个伪需求。

"读自己的写"还有一个更省的解法:把写接口的返回值直接用于渲染。用户刚提交的昵称已经在响应里,前端不需要再读一次缓存——于是"旧值"根本不会出现。

// 三种解法按成本排序
1 写接口返回新值,前端直接用(零额外成本)
2 该用户短窗口内读源(需路由或标记)
3 全局"写后同步刷新缓存"(成本最高,收益最小)
// 结论:优先第 1 种,因为它绕过了"读"而不是"修读"

6. 排错:五种"缓存写不一致"

现象 成因 处理方向
改完刷新还是旧值 读到了未删的缓存 写后短窗口读源
旧值覆盖新值 两个并发写缓存 改为只删不写
缓存有库无 库失败但缓存写了 改 write-around
长时间不一致 删除失败无重试 加删除重试 + 对账
计数器丢数 write-behind 刷库失败 补偿 + 可丢性评估

第五项在"点赞/浏览量"上很常见,而它往往被当成"bug"——实际是 write-behind 的固有代价第四项(删除失败无重试)的排查是"看删除接口的成功率":若它有千分之几的失败且没有重试,那不一致就是必然的,只是概率低到"看起来偶发"。
,应当先决定"可不可丢"。

最后一个"观测"手段:给缓存命中与不一致各打一个指标。因为"不一致"如果不可观测,就永远只能靠用户投诉发现——而它恰好是"低频、难复现"的一类。

// 两个必须有的指标
cache.stale_detected_total        ← 对账发现的不一致次数
cache.delete_failed_total         ← 删除失败次数(不一致的直接来源)
// 用法:delete_failed 连续非零 → 先修删除,而不是改模式

7. 边界:什么情况不必设计写模式

场景 为什么 做法
读多写极少(配置、字典) 写不构成问题 写后删缓存即可
写后不读(日志、事件) 无"读到旧值"问题 不缓存
缓存只是"加速"(可丢) 不要求一致 任意模式
已有框架统一处理 平台负责 跟随

最后一条与"演进"有关的边界:写模式会随业务变化而失配。早期"只写不读"的表后来变成"写完立刻展示",而写模式没人改——于是"偶发旧值"开始出现,而它看起来像"缓存 bug"。
判断口径一句话:写完之后,谁会在多短时间内读它。几乎没人读(日志),写模式不重要;同一个用户马上读(资料、设置),就必须处理"读自己的写"。


8. 自测

  1. 三种写模式的核心差别是什么?为什么 write-through 有个"做不到"的隐含假设?
  2. "先删缓存再改库"为什么可能比不删更糟?
  3. 为什么"先改库后删缓存"是主流?它的残余风险是什么?
  4. write-around 为什么"库失败时不会产生脏数据"?
  5. write-behind 的三个前提是什么?缺"缓存持久化"会怎样?
  6. 判断"是否需要设计写模式"的那个问题是什么?
S12-A19 · 缓存的写模式:write-through、write-behind 与 write-around
https://www.clxhxhhr.top/posts/6544/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。