一个"先更新数据库再删缓存"的常规缓存写法,在一次并发读写恰好交错时,把旧数据又写回了缓存、脏了好久:一次缓存一致性的深度复盘
那个数据不一致的 bug 偶发又烦人:我们用 Redis 缓存商品信息,更新商品时"先更新数据库、再删除缓存"(很标准的 cache-aside 写法)。可偶尔会出现:商品明明改了价格、数据库里也是新价,可缓存里还是旧价,而且一直是旧价,直到缓存过期或下次更新才好。一开始我以为是"删缓存失败了",可日志显示删缓存成功了啊。我对着"更新 DB、删缓存"这两步反复推演各种并发时序,才终于揪出那个极其刁钻的交错,后背发凉:问题出在"读请求"和"写请求"的并发交错上。设想这样一个时序:缓存恰好刚过期(或被删)、缓存里没有这个商品;此时一个读请求来了,它发现缓存没有,就去查数据库——查到的是旧值(因为此刻写请求还没更新 DB),然后它准备把这个旧值写回缓存;就在它"查到旧值"和"写回缓存"这两个动作之间,一个写请求插了进来,它更新了 DB(写入新值)、并删除了缓存;然后那个读请求慢悠悠地把它之前查到的旧值,写回了缓存——于是缓存里又是旧值了,而且盖过了写请求刚删除缓存的效果,缓存就这么脏在了那里,直到过期。问题的根,是 cache-aside 在"读未命中回填缓存"和"写更新DB删缓存"并发交错时,存在一个时间窗口,会让"读到的旧值"在"写删缓存之后"被写回,造成缓存与数据库长期不一致。这篇就把这次"缓存一致性、脏数据"的坑,从头到尾复盘一遍。
故障现场:读回填与写删缓存的并发交错
问题在 cache-aside 的读写在特定并发时序下的交错:
cache-aside 标准写法:
读: 先查缓存; 命中返回; 未命中 → 查DB → 把结果写回缓存 → 返回。
写: 先更新DB → 再删除缓存。(下次读未命中时会用新值重建缓存)
# 这套写法【绝大多数情况】是对的、也是业界主流; 但在一种【并发交错】下会出问题:
灾难时序(读请求回填了旧值, 盖过了写请求的删除):
T1: 缓存里没有商品X(刚过期);
T2: 【读请求R】来, 查缓存未命中 → 查DB, 读到【旧值v1】(此刻还没人改);
T3: 【写请求W】来, 更新DB为【新值v2】, 然后删除缓存(缓存本来就没有, 删了也没事);
T4: 【读请求R】慢一步, 现在才把它T2读到的【旧值v1】写回缓存!
→ 结果: 缓存 = 旧值v1, 而DB = 新值v2 → 缓存脏了, 且会一直脏到下次过期/更新!
# 为什么偶发: 要"读请求查到旧值后、写回缓存前"这个极短的窗口里, 恰好插入一个写请求, 才会发生;
# 概率低, 但高并发下会撞上, 且一旦发生, 脏数据会持续较久(到缓存过期), 影响明显。
# 几种写法的对比(都不完美, 各有取舍):
# - 先更新DB, 再删缓存(本文, cache-aside推荐): 上面这种交错会脏(但概率低、且有兜底);
# - 先删缓存, 再更新DB: 删缓存后、更新DB前, 读请求会把旧值回填 → 也会脏(且窗口更大、更易中);
# - 先更新DB, 再更新缓存(不是删): 并发写时两个写的缓存更新顺序可能乱 → 脏;
# - → 没有一种"双写"能在不加协调的情况下做到强一致; 都要靠"过期兜底+接受最终一致"。
# 关键: 缓存和数据库是两个独立的存储, "双写"它们无法天然保证强一致; cache-aside在读回填与
# 写删缓存并发交错时会留脏数据; 缓存一致性本质是个"两个数据源如何保持同步"的难题。
第一次画出这个交错时序时,我又佩服又无奈:"这窗口也太刁钻了——要恰好在读请求'查到旧值'和'写回缓存'之间插入一个写,才会脏。"这个坑最本质的难点在于:缓存和数据库是两个独立的存储系统,你对它们的"双写"(更新一个、再操作另一个)不是一个原子操作——这两步之间存在时间窗口,而并发请求可以在这个窗口里插入,造成不一致。更让人无奈的是:无论你怎么调整这两步的顺序(先更新 DB 再删缓存、先删缓存再更新 DB、更新缓存而非删…),都存在某种并发交错会导致不一致——没有一种纯"双写"的写法能在不加额外协调的情况下做到强一致。下面就来拆解,缓存一致性为什么这么难、怎么务实地应对。
第一件事:搞懂缓存一致性为什么难,以及各种写法的取舍
我认真梳理了缓存一致性问题,才理解它的本质和务实的解法。
缓存一致性为什么难? 各种写法怎么取舍?
【核心: 缓存和DB是两个独立存储, "双写"非原子、有时间窗口; 任何纯双写都有并发交错会不一致; 务实做法是"接受最终一致+多重兜底"】
1. 难的根源: 缓存和DB是【两个独立的、各自更新的存储】
- 你要让它们"内容一致", 就得"两边都写/操作", 而这【不是一个原子操作】;
- 两步操作之间有时间窗口, 并发请求能在窗口里插入 → 造成不一致;
- → 这本质是"分布式系统里两个数据副本如何保持一致"的问题, 天生困难。
2. 几种双写写法都有问题(都存在某种交错会脏):
- 先更新DB再删缓存(cache-aside推荐): 读回填旧值盖过写删除(本文), 但概率低;
- 先删缓存再更新DB: 删后更新前, 读请求回填旧值 → 窗口更大、更易脏;
- 更新缓存(而非删): 并发写的缓存更新顺序可能乱、且缓存计算浪费 → 一般用"删"不用"更新";
- → 结论: 纯双写做不到强一致; cache-aside(更新DB+删缓存)是综合最优的折中, 但仍有小概率脏。
3. 务实的应对(不是追求"绝对强一致", 而是"足够好的最终一致"):
- ① 给缓存设【过期时间(TTL)】: 这是【最重要的兜底】——即使某次脏了, 过期后会用新值重建,
脏数据【最多脏一个TTL】, 不会永久脏; 几乎所有缓存方案都靠它兜底。
- ② 延迟双删: 写时"删缓存→更新DB→延迟一会再删一次缓存", 删掉那个可能被回填的旧值。
- ③ 接受短暂不一致(最终一致): 多数业务能容忍"缓存短时间(TTL内)有旧值";
→ 关键是想清楚"这个数据, 短暂不一致能接受吗?"(商品描述能, 账户余额可能不能)。
- ④ 强一致需求: 别用"缓存+DB双写", 改用其他方案(读写都走DB、或用binlog异步更新缓存(如canal)、
或对强一致数据干脆不缓存)。
4. 核心认知: "缓存"的本质是"用一份可能略旧的副本换性能";
- 用缓存, 就【隐含地接受了"它可能和DB短暂不一致"】; 想要它和DB绝对实时一致, 就违背了缓存的初衷;
- → 该问的不是"怎么让缓存绝对一致", 而是"这个场景能容忍多大/多久的不一致, 如何兜底"。
一句话: 缓存和DB是两个独立存储、双写非原子, 任何纯双写都有并发交错会不一致; cache-aside(更新DB+删缓存)
是折中最优但仍小概率脏; 务实做法是缓存设TTL兜底+延迟双删+接受最终一致, 强一致需求别用缓存双写。
这套认知,是整个坑的根。难的根源:缓存和 DB 是两个独立的存储,让它们一致就得两边操作、而这不是原子操作,两步之间有时间窗口、并发能插入造成不一致——本质是"分布式两个副本如何一致"的难题。几种双写写法都有问题:先更新 DB 再删缓存(读回填旧值盖过删除,本文,概率低)、先删缓存再更新 DB(窗口更大更易脏)、更新缓存而非删(顺序乱)——纯双写做不到强一致,cache-aside 是综合最优的折中但仍小概率脏。务实的应对(追求"足够好的最终一致"):①缓存设 TTL(最重要的兜底,脏最多脏一个 TTL、不会永久脏);②延迟双删;③接受短暂不一致(想清楚这数据能否容忍 TTL 内有旧值);④强一致需求别用缓存双写(走 DB/用 binlog 异步更新缓存/不缓存)。核心认知:"缓存"的本质是"用一份可能略旧的副本换性能",用缓存就隐含接受了"它可能和 DB 短暂不一致";该问的不是"怎么绝对一致",而是"能容忍多大/多久的不一致、如何兜底"。一句话:缓存和 DB 是两个独立存储、双写非原子,任何纯双写都有并发交错会不一致;cache-aside 是折中最优但仍小概率脏;务实做法是缓存设 TTL 兜底+延迟双删+接受最终一致,强一致需求别用缓存双写。
第二件事:正解——cache-aside(更新DB后删缓存)+ TTL 兜底 + 延迟双删
搞懂了原理,正解就清晰了:用 cache-aside(更新 DB 后删缓存)、给缓存设合理 TTL 兜底(最关键)、必要时延迟双删、按业务对一致性的要求选方案,强一致数据用 binlog 异步更新或不缓存。
// ====== 正解一(主流): cache-aside, 更新DB后删缓存 + 缓存设TTL ======
// 读:
func getProduct(id string) (Product, error) {
if v, ok := cache.Get(key(id)); ok {
return v, nil // 命中
}
p := db.Get(id) // 未命中查DB
cache.Set(key(id), p, 10*time.Minute) // ★ 回填缓存, 并设TTL(10分钟)——关键兜底!
return p, nil
}
// 写:
func updateProduct(p Product) error {
db.Update(p) // ① 先更新DB
cache.Del(key(p.ID)) // ② 再删缓存(下次读未命中会用新值重建)
return nil
}
// → 设TTL是【最重要的兜底】: 即使某次并发交错让缓存脏了, 最多脏10分钟(TTL), 之后自动用新值重建;
// 不会永久脏。绝大多数业务这就够了(接受"最多脏一个TTL"的最终一致)。
// ====== 正解二: 延迟双删(降低本文那种交错导致脏的概率) ======
func updateProductV2(p Product) error {
cache.Del(key(p.ID)) // 先删一次
db.Update(p) // 更新DB
time.AfterFunc(500*time.Millisecond, func() {
cache.Del(key(p.ID)) // ★ 延迟一会再删一次: 删掉"可能在这期间被读请求回填的旧值"
})
return nil
}
// → 延迟的第二次删除, 能清掉"那个慢读请求回填的旧值"; 进一步降低脏数据概率(但不是100%, 仍靠TTL兜底)。
# ====== 正解三: 按"对一致性的要求"选方案 ======
# 1. 能容忍短暂不一致(TTL内)的数据(商品描述、文章、配置...):
# → cache-aside + TTL 就够了, 简单实用; 接受最终一致。
# 2. 对一致性要求高、又想用缓存的:
# → 用 binlog/CDC(如canal)监听DB变更, 异步、可靠地更新/删除缓存;
# → 把"删缓存"从"应用双写"变成"由DB变更驱动", 更可靠(不会因应用删缓存失败而漏)。
# 3. 强一致(绝不能读到旧值)的数据(账户余额、库存扣减...):
# → 别用"缓存+DB双写"这种最终一致方案; 关键读写直接走DB(或用分布式锁/版本号保证);
# → 或者干脆不缓存这类数据。
# ====== 几条原则 ======
# - 缓存【一定设TTL】: 这是兜底, 保证脏数据不会永久存在(最重要的一条);
# - 用cache-aside(更新DB后删缓存): 主流折中, 配TTL足够应对多数场景;
# - 想更稳: 延迟双删 / binlog异步更新缓存;
# - 先想清楚"这个数据能容忍多大/多久的不一致", 再选方案——别一律追求强一致(代价高且违背缓存初衷)。
# 核心: cache-aside(更新DB后删缓存)+缓存设TTL兜底(最关键)是主流; 想更稳用延迟双删或binlog异步更新缓存;
# 按数据对一致性的要求选方案, 强一致数据别用缓存双写; 接受"缓存换性能=接受短暂不一致"。
修复的核心,是"用 cache-aside + TTL 兜底,并按一致性要求选方案"。正解一(主流):cache-aside(更新 DB 后删缓存)+ 缓存设 TTL——读未命中查 DB 回填并设 TTL(最重要的兜底),写时更新 DB 后删缓存;即使某次交错让缓存脏了,最多脏一个 TTL、之后自动用新值重建、不会永久脏。正解二:延迟双删——更新后延迟一会再删一次缓存,清掉"那个慢读请求回填的旧值",进一步降低脏数据概率。正解三:按一致性要求选方案——能容忍短暂不一致的用 cache-aside+TTL;要求高的用 binlog/CDC 异步更新缓存(更可靠);强一致的(余额/库存)别用缓存双写、直接走 DB 或不缓存。原则:缓存一定设 TTL(兜底)、用 cache-aside、想更稳用延迟双删/binlog、先想清楚能容忍多大不一致再选方案。归根结底:cache-aside(更新 DB 后删缓存)+缓存设 TTL 兜底(最关键)是主流;想更稳用延迟双删或 binlog 异步更新缓存;按数据对一致性的要求选方案,强一致数据别用缓存双写;接受"缓存换性能=接受短暂不一致"。
第三件事:缓存一致性相关的其他常见坑
排查后我把缓存一致性相关的其他常见坑也系统梳理了一遍。
缓存一致性的其他常见坑
# 1. 双写交错致脏(本文): 读回填旧值盖过写删除。→ cache-aside+TTL兜底+延迟双删。
# 2. 缓存没设TTL: 一旦脏了就永久脏。→ 缓存一律设合理TTL(最重要的兜底)。
# 3. 删缓存失败没处理: 更新DB后删缓存失败, 缓存留旧值。→ 重试删除/binlog兜底/靠TTL。
# 4. 用"更新缓存"而非"删缓存": 并发写缓存顺序乱、且浪费(可能写了没人读)。→ 用删, 不用更新。
# 5. 缓存和DB事务不一致: 更新DB的事务回滚了, 但缓存已删/已改。→ 删缓存放事务提交后。
# 6. 多级缓存不一致: 本地缓存+分布式缓存, 本地的没及时失效。→ 本地缓存设短TTL/广播失效。
# 7. 缓存强一致的误区: 想让缓存和DB绝对实时一致, 代价极高且违背缓存初衷。→ 接受最终一致。
# 8. 强一致数据也缓存: 余额/库存这种缓存了读到旧值出大事。→ 强一致数据慎缓存/走DB。
# 共同根源: 缓存是DB数据的一份"副本", 两个独立存储之间的同步天然有延迟和不一致窗口;
# 想用缓存又想"绝对实时一致", 是矛盾的——缓存的价值正建立在"容忍一点点不一致"上。
# 核心: 缓存设TTL(永久脏的兜底)、用cache-aside删缓存、想更稳用延迟双删/binlog; 按一致性要求选方案、
# 强一致数据别用缓存双写; 接受"缓存=用短暂不一致换性能", 想清楚能容忍多少不一致。
排查让我把缓存一致性的其他坑也梳理清了。一、双写交错致脏(本文)。二、缓存没设 TTL(永久脏)。三、删缓存失败没处理。四、用更新缓存而非删。五、缓存和 DB 事务不一致(删缓存放事务提交后)。六、多级缓存不一致。七、缓存强一致的误区。八、强一致数据也缓存。它们的共同根源是:缓存是 DB 数据的一份"副本",两个独立存储之间的同步天然有延迟和不一致窗口;想用缓存又想"绝对实时一致"是矛盾的——缓存的价值正建立在"容忍一点点不一致"上。核心是:缓存设 TTL(永久脏的兜底)、用 cache-aside 删缓存、想更稳用延迟双删/binlog;按一致性要求选方案、强一致数据别用缓存双写;接受"缓存=用短暂不一致换性能"。下面这张图,是这次缓存一致性坑的成因与解法:
第四件事:缓存写策略对比表
这次踩坑后,我把几种缓存写策略对比成一张表,按一致性要求选。
| 策略 | 做法 | 特点 |
|---|---|---|
| cache-aside(旁路, 推荐) | 更新DB后删缓存, 读未命中回填 | 主流折中, 配TTL足够, 小概率脏 |
| 延迟双删 | 删→更新DB→延迟再删 | 降低脏概率, 仍靠TTL兜底 |
| binlog异步更新 | 监听DB变更更新缓存 | 更可靠, 不漏(架构复杂些) |
| write-through | 写时同步写缓存和DB | 一致性好但写慢, 实现复杂 |
| 强一致数据不缓存 | 余额/库存直接走DB | 最简单可靠(放弃缓存收益) |
这张表把缓存写策略钉清了。核心是:缓存写策略没有"最好",只有"最适合你对一致性的要求"——能容忍最终一致用 cache-aside+TTL(主流、简单);要更可靠用 binlog 异步更新;要强一致干脆别缓存(走 DB);关键是先确定"这个数据需要多强的一致性",再选对应代价的方案。它给我的最大启发是:"一致性"和"性能/可用性"之间,存在一个需要根据业务来权衡的"谱系"——越追求强一致,往往越牺牲性能/可用性/简单性(强一致就别缓存、就走 DB);越追求性能,就越要接受一定的不一致(用缓存就接受最终一致);这正是分布式领域 CAP/BASE 等理论的核心:在一致性、可用性、性能之间,你必须根据场景做取舍,不可能全都要。这给了我一种务实的架构观:做缓存(以及任何涉及一致性的)设计时,第一步不是"追求完美的一致",而是"问清楚业务:这个数据,到底需要多强的一致性?能容忍多久/多大的不一致?"——把"一致性需求"想清楚,才能选对"代价匹配"的方案(强需求上重方案、弱需求用轻方案),而不是对所有数据都用同一种(要么一律强一致代价过高、要么一律弱一致出事故);"按一致性需求分级、对症下药",是缓存乃至分布式数据设计的务实之道。按一致性需求选缓存写策略、理解一致性与性能的权衡谱系——是这个坑带给我的架构认知。
第五件事:这个坑暴露的"分布式下的一致性是相对的"
这次让我接受了一个分布式系统的现实:绝对的实时一致往往是奢望。我把不同一致性级别整理成表。
| 一致性级别 | 含义 | 代价 |
|---|---|---|
| 强一致 | 任何时刻读到的都是最新的 | 高(牺牲性能/可用性) |
| 最终一致 | 经过一段时间后会一致 | 低(性能好, 接受短暂不一致) |
| 读己之写 | 自己改的自己能立刻读到 | 中 |
| 缓存+DB(cache-aside) | 最终一致(TTL内可能旧) | 低, 高性能 |
| 追求缓存强一致 | 想让缓存实时等于DB | 极高且违背缓存初衷 |
这张表道出了分布式一致性的现实。核心是:在分布式系统(缓存+DB 就是个小型分布式系统)里,"一致性"不是非黑即白的"一致/不一致",而是有强弱级别(强一致、最终一致、读己之写…)的;越强的一致性代价越高;而缓存这种为性能而生的东西,天然就处在"最终一致"这一档——想把它拔高到"强一致",代价极高且违背了它存在的意义。它给我的深刻启发是:从单机时代来的我们,习惯了"数据就是实时一致的"(单机里改了立刻就是新的);可一旦进入分布式(多副本、缓存、多节点),"绝对的、实时的、全局一致"就变成了一种昂贵甚至不可得的奢望;分布式系统逼着我们接受一个现实:很多时候,我们能要的、该要的,是"最终一致"(过一会儿就一致),而非"实时强一致"。这给了我一种分布式时代的务实心态:设计分布式数据时,要从"追求实时强一致"的执念中走出来,学会"评估业务真正需要的一致性级别、并接受为性能/可用性而做的最终一致妥协"——"这个场景真的需要强一致吗?还是最终一致就够了?";"恰当地放低对一致性的不必要要求",换来的是巨大的性能和可用性收益——这是分布式架构里一种重要的成熟。接受分布式下一致性是分级的、绝对实时一致是奢望、按需选最终一致——是这个缓存坑带给我的更深认知。
第六件事:给一个数据加缓存时,我现在的判断习惯
现在每当我要给一个数据加缓存,我都会按这张图先想清楚:
这张图的精髓,是"先定一致性需求,强一致别缓存、最终一致用 cache-aside+TTL"。先问需要多强一致:强一致(余额/库存)别用缓存双写;能容忍最终一致用 cache-aside(更新 DB 后删缓存)+ 一定设 TTL 兜底;要求较高再加 延迟双删/binlog。这套习惯,让我从"加缓存就 cache-aside 一把梭"变成了"先想这数据的一致性需求"——核心始终是:按一致性需求选方案,缓存一定设 TTL 兜底,强一致数据别用缓存双写。
我立下的几条规矩
这场"缓存一致性、脏数据"的事故,换来了我做缓存时,刻进骨子里的几条铁律:
- 缓存和 DB 是两个独立存储,双写非原子。任何纯双写都有交错会不一致。
- 缓存一定设 TTL。这是最重要的兜底——脏最多脏一个 TTL,不会永久脏。
- 用 cache-aside:更新 DB 后删缓存。主流折中,配 TTL 足够应对多数场景。
- 要更稳用延迟双删,或 binlog 异步更新缓存。
- 强一致数据(余额/库存)别用缓存双写。走 DB 或不缓存。
- 先想清楚这数据能容忍多大/多久的不一致,再选方案。
- 接受分布式下一致性是分级的、绝对实时一致是奢望。按需选最终一致。
写在最后
回头看,这场由"缓存与数据库双写不一致"引发的脏数据事故,真正教给我的,远不止"cache-aside、设 TTL、延迟双删"这些技巧。它让我对"当一份数据有了'多个副本',让它们'时时刻刻完全一致'就成了一件代价高昂、甚至不可能的事",有了一次刻骨的体会。我栽跟头,根源在于我对"缓存"抱着一个过于理想的期待:我以为"缓存就是数据库的一面镜子,数据库变了,缓存也该立刻、精确地跟着变"。可现实是:缓存和数据库,是同一份数据的两个独立副本;一旦数据有了"多个副本",维持它们"完全同步"就需要在"更新一个"和"更新另一个"之间做协调,而这两个动作之间的任何间隙,都是不一致的温床;我追求的"缓存和 DB 时刻精确一致",本质上是在追求"多个副本的实时强一致"——而这,正是分布式系统里最难、代价最高的目标之一。这让我领悟到一个关于"数据副本"的深刻认知:一旦你为了某种好处(性能、可用性、就近访问)而给数据制造了"副本"(缓存、从库、多地多活、CDN),你就不可避免地引入了"副本之间如何保持一致"这个难题——副本带来的好处(快),和副本带来的代价(不一致),是一体两面、无法分割的;"有副本,就有一致性问题"——这是一条贯穿缓存、主从、分布式存储的铁律。这给了我一种面对"副本"的清醒:每当我为了性能/可用性而引入数据副本(缓存、从库...)时,都要清醒地意识到"我同时引入了一致性问题",并主动地想清楚"我能接受多强的一致性、用什么策略兜底"——"享受副本带来的快,就要承担并管理好副本带来的不一致";"不天真地以为副本会自动和源保持完美一致",而是主动设计副本的同步策略和一致性级别,是用好一切"副本型"优化的关键。认清有副本就有一致性问题、主动管理副本的同步与一致性级别——这,是我用一次缓存一致性的事故,换来的、关于架构、也关于如何对待一切数据副本的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次给数据加缓存时,先想一句"它和 DB 短暂不一致能接受吗、TTL 设多少兜底",那我对着那份脏了好久的缓存排查的这段时间,就值了。
—— 别看了 · 2026