一个用户资料接口更新后,缓存与数据库不一致了几秒,而在这几秒里用户刷新看到的还是旧昵称——因为他自己刚刚改过。选了"先更新库、再更新缓存"的方案,理论上一致,实际在并发下出现了"旧值覆盖新值":两个请求的缓存写顺序颠倒了。
缓存写模式的争论通常停在"先删还是先改",而真正决定行为的是三件事的顺序:写库、写缓存、以及"谁对最终一致性负责"。三种模式(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. 自测
- 三种写模式的核心差别是什么?为什么 write-through 有个"做不到"的隐含假设?
- "先删缓存再改库"为什么可能比不删更糟?
- 为什么"先改库后删缓存"是主流?它的残余风险是什么?
- write-around 为什么"库失败时不会产生脏数据"?
- write-behind 的三个前提是什么?缺"缓存持久化"会怎样?
- 判断"是否需要设计写模式"的那个问题是什么?