我给热点数据加了缓存提速,结果用户改完资料刷新还是看到旧的、甚至并发改一下缓存就和数据库长期对不上,我对着这个缓存一致性问题排查了大半天的复盘
这是一个让我对"缓存一致性"刻骨铭心的故事。我有一些热点数据(比如用户资料),读得很频繁,为了提速,我加了一层缓存(Redis):读的时候先查缓存,缓存没有再查数据库、并回填缓存。性能确实上去了。可没多久,问题就来了:用户明明改了自己的资料、也提示"保存成功"了,可刷新页面,看到的还是改之前的旧数据;更要命的是,在并发下,偶尔会出现缓存里的值,和数据库里的值,长期对不上、一直是错的,怎么刷新都不对,只能手动清缓存才能恢复。
我一开始以为是缓存没更新的简单 bug,加了"更新数据库后,也更新一下缓存"的逻辑,结果在并发下反而更乱了。直到我把并发的读写操作,在时间轴上一步步画出来,才终于揭开真相,补上了我对缓存一个最核心的认知漏洞:问题的核心,是"缓存一致性"——而它的根源,在于缓存(Redis)和数据库,是两个独立的系统,我对它们的"更新",无法原子地、同时完成。这中间,永远存在一个"数据库已经变了、但缓存还没变(或反之)"的时间窗口;而一旦有并发,这个窗口里发生的读写交错,就可能导致缓存被"错误的旧值"污染、并长期保持错误。更关键的是,我犯了一个典型错误:我选择了"更新缓存"(写完 DB 就把新值写进缓存),而不是"删除缓存"(写完 DB 就把缓存删掉、下次读再回填);而"更新缓存"在并发下,极易产生脏数据——想象一下:请求 A 写入了新值 X、请求 B 写入了新值 Y,但由于网络/调度的乱序,它们"更新缓存"的顺序反了(A 的缓存写在了 B 之后),于是数据库里是 Y、缓存里却是 X,永久不一致。我这才痛彻地明白:引入缓存,本质上是引入了"同一份数据的两个副本",而"让两个副本始终保持一致",是一个有着固有难度的经典问题;天真地以为"更新了数据库,再顺手更新下缓存"就万事大吉,会在并发的浪潮下,被各种意想不到的时序交错,冲得七零八落。缓存,是用"一致性的妥协"去换"性能的提升"的;用好它的前提,是清醒地认识到这种妥协的存在,并选择一个在并发下足够健壮的"缓存更新策略"(而那个策略,通常是"更新数据库 + 删除缓存",而非"更新缓存")。
故障现场:更新缓存策略,在并发下产生脏数据
我把这个"缓存不一致"的现场,摊开给你看:
# ✗ 灾难: "更新数据库 + 更新缓存" 策略, 并发下产生永久脏数据
# 错误策略: 写操作 = 更新DB, 然后更新缓存(写最新值进缓存)
# def update_user(id, data):
# db.update(id, data) # 1. 更新数据库
# cache.set(id, data) # ✗ 2. 更新缓存(写新值)
# 并发翻车场景(两个写请求 A、B 几乎同时):
# 时刻1: 请求A 更新 DB 为 X
# 时刻2: 请求B 更新 DB 为 Y
# 时刻3: 请求B 更新缓存为 Y ← B 先写了缓存
# 时刻4: 请求A 更新缓存为 X ← A 后写缓存(乱序!)
# 结果: DB = Y(最新), 缓存 = X(旧的) → 永久不一致! 怎么读都是错的 X。
# 另一个坑(只更DB没动缓存 / 缓存没失效):
# - 改了 DB, 但缓存里还是旧值, 且没 TTL → 用户一直看到旧数据。
# 为什么"更新缓存"比"删除缓存"差?
# - 更新缓存: 把"算好的新值"写进去 → 并发下写入顺序可能乱 → 脏数据。
# - 删除缓存: 写完 DB 直接删掉缓存 → 下次读"未命中"→ 从 DB 读最新并回填。
# 即使并发, 最坏是"多读一次 DB", 不会留下错误的值。
# 缓存与DB不一致的根源:
# - 它们是两个独立系统, 更新无法原子完成。
# - 中间总有"一个变了另一个没变"的窗口。
# - 并发 + 这个窗口 = 各种时序交错 → 不一致。
# 根因: 用"更新缓存"策略, 并发下缓存写入乱序产生永久脏数据;
# 且缓存与DB无法原子更新, 天然存在不一致窗口。
看着这个在时间轴上交错的并发场景,我才算彻底想明白了这场"缓存不一致"的根源。问题的核心,是我用了"更新数据库 + 更新缓存"的策略,而它在并发下会产生永久脏数据。翻车场景是:两个写请求 A(写 X)、B(写 Y)几乎同时——DB 的更新顺序是先 X 后 Y(最终 Y),但"更新缓存"的顺序却因乱序反了(B 先写缓存 Y、A 后写缓存 X),结果 DB=Y(最新)、缓存=X(旧的),永久不一致,怎么读都是错的 X。而为什么"更新缓存"比"删除缓存"差?更新缓存是把"算好的新值"写进去,并发下写入顺序可能乱、产生脏数据;删除缓存是写完 DB 直接删掉缓存,下次读未命中就从 DB 读最新并回填,即使并发,最坏也只是"多读一次 DB",不会留下错误的值。而缓存与 DB 不一致的根本原因是:它们是两个独立系统、更新无法原子完成,中间总有"一个变了另一个没变"的窗口;并发 + 这个窗口,就产生各种时序交错的不一致。归根结底:用"更新缓存"策略,并发下缓存写入乱序产生永久脏数据;且缓存与 DB 无法原子更新、天然存在不一致窗口——这,就是根源。
第一件事:搞懂缓存一致性与几种更新策略
定位到根源,我必须把"缓存一致性"和"该怎么更新缓存"从根上彻底搞清楚:
缓存一致性: 缓存与DB是两副本, 更新非原子, 要选健壮的更新策略
# 为什么有一致性问题?
# - 缓存是"同一份数据的第二个副本", 为了快。
# - 更新"DB + 缓存"两个系统, 无法做成一个原子操作。
# - 中间必有窗口 + 并发 → 不一致。这是"固有难题", 只能"尽量减小", 难 100% 消除。
# 四种常见更新策略(写操作时怎么处理缓存):
# 1. 先更DB, 再"更新"缓存 ✗ 并发写入乱序 → 永久脏数据(本文的坑)。
# 2. 先更DB, 再"删除"缓存 ✓ 推荐(Cache-Aside): 删掉, 下次读回填最新。
# 3. 先"删除"缓存, 再更DB △ 也有并发坑: 删完到更DB之间, 读请求回填了旧值。
# 4. 先更DB, 再删缓存 + "延迟再删一次"(延迟双删) ✓ 进一步堵住 3 的窗口。
# 为什么 "更DB + 删缓存"(Cache-Aside)最稳?
# - 写: 更新DB → 删除缓存(不是写新值)。
# - 读: 查缓存 → 命中返回; 未命中 → 查DB → 回填缓存。
# - 并发最坏情况: 多读几次DB, 不会留下错误的值(删除是幂等的)。
# 但 Cache-Aside 仍有极小不一致窗口:
# - "读未命中查DB" 和 "另一个写更新DB+删缓存" 交错, 理论上可能回填旧值。
# - 概率极低(要求读的DB查询比写的删缓存还慢且恰好交错), 工程上可接受。
# - 兜底: 给缓存设 TTL, 即使偶发不一致, 过期后也会自动纠正。
# 关键认知: 缓存一致性是"取舍", 不是"消除"。
# - 接受"最终一致 + 极小不一致窗口", 用 删除缓存 + TTL 把风险压到最低。
# - 强一致场景(钱), 要么别用缓存, 要么用更重的方案(分布式锁/订阅binlog)。
# 核心: 缓存与DB更新非原子必有不一致窗口; 写操作优先用"更DB+删缓存"(Cache-Aside)
# 而非"更缓存", 加 TTL 兜底, 接受最终一致。
原理终于清晰了。为什么有一致性问题?——缓存是"同一份数据的第二个副本"(为了快),而更新"DB + 缓存"两个系统无法做成一个原子操作,中间必有窗口 + 并发 → 不一致;这是"固有难题",只能尽量减小、难 100% 消除。四种常见更新策略:① 先更 DB 再"更新"缓存(✗ 并发写入乱序、永久脏数据,本文的坑);② 先更 DB 再"删除"缓存(✓ 推荐,Cache-Aside);③ 先删缓存再更 DB(△ 删完到更 DB 之间,读请求会回填旧值);④ 先更 DB 再删缓存 + 延迟再删一次(✓ 延迟双删,堵住 ③ 的窗口)。为什么 "更 DB + 删缓存"(Cache-Aside)最稳?写:更新 DB → 删除缓存(不是写新值);读:查缓存命中返回、未命中查 DB 并回填;并发最坏只是多读几次 DB,不会留下错误的值(删除是幂等的)。但它仍有极小不一致窗口:"读未命中查 DB"和"另一个写更 DB+删缓存"交错时,理论上可能回填旧值,但概率极低、工程可接受;兜底是给缓存设 TTL,即使偶发不一致,过期后也会自动纠正。由此,我刻下一个关键认知:缓存一致性是"取舍",不是"消除"——接受"最终一致 + 极小窗口",用"删除缓存 + TTL"把风险压到最低;强一致场景(钱)要么别用缓存、要么用更重的方案。归根结底:缓存与 DB 更新非原子、必有不一致窗口;写操作优先用"更 DB + 删缓存"(Cache-Aside)而非"更缓存",加 TTL 兜底,接受最终一致。
第二件事:正解——Cache-Aside(更 DB + 删缓存) + TTL 兜底
搞懂了原理,正解就清晰了:写操作用 Cache-Aside(更新 DB 后删除缓存),并给缓存设 TTL 兜底,必要时延迟双删。
# ✓ 正解: Cache-Aside 模式 —— 读回填, 写删缓存; 加 TTL 兜底
import time, threading
TTL = 300 # ✓ 缓存过期时间(秒): 即使偶发不一致, 过期后自动纠正
# ✓ 读: 查缓存 → 命中返回; 未命中查 DB 并回填(带 TTL)
def get_user(uid):
data = cache.get(uid)
if data is not None:
return data # 命中
data = db.query(uid) # 未命中, 查 DB
cache.set(uid, data, ex=TTL) # ✓ 回填并设过期时间
return data
# ✓ 写: 更新 DB → 删除缓存(不是更新缓存!)
def update_user(uid, new_data):
db.update(uid, new_data) # 1. 先更新数据库
cache.delete(uid) # ✓ 2. 删除缓存(下次读自然回填最新)
# 并发最坏: 多读一次 DB; 不会留下错误的值。
# ✓ 进阶: 延迟双删(堵住"读回填旧值"的极小窗口)
def update_user_double_delete(uid, new_data):
cache.delete(uid) # 先删一次(可选)
db.update(uid, new_data) # 更新 DB
cache.delete(uid) # 再删一次
# ✓ 延迟一小会儿(避开并发读回填旧值的窗口)后, 异步再删第三次:
def delayed():
time.sleep(0.5)
cache.delete(uid)
threading.Thread(target=delayed, daemon=True).start()
# 三层保障:
# 1. 用"删除"而非"更新"缓存 → 避免并发写入乱序的脏数据。
# 2. 缓存设 TTL → 任何偶发不一致, 过期后自动自愈。
# 3. (强一致需求)延迟双删 / 订阅 binlog 异步删缓存 → 进一步收紧窗口。
# 核心: 读回填(带TTL), 写"更DB+删缓存"(Cache-Aside); TTL 兜底自愈,
# 高一致要求再上延迟双删或 binlog 订阅。
修复的方案,是"删除 + 兜底"。核心是 Cache-Aside 模式:读——查缓存,命中返回、未命中查 DB 并回填(带 TTL);写——更新 DB 后,删除缓存(而不是更新缓存!),下次读自然回填最新值,并发最坏只是多读一次 DB、不会留下错误的值。三层保障:第一,用"删除"而非"更新"缓存——避免并发写入乱序的脏数据(这是治本);第二,缓存设 TTL——任何偶发不一致,过期后都会自动自愈(这是兜底,极其重要,意味着"再坏也坏不过 TTL 那么久");第三(强一致需求),延迟双删 / 订阅 binlog 异步删缓存——进一步收紧那个"读回填旧值"的极小窗口。(延迟双删的思路:更新 DB 后删一次缓存,再延迟一小会儿、异步再删一次,把那个并发读可能回填的旧值,再清掉。)归根结底:读回填(带 TTL),写"更 DB + 删缓存"(Cache-Aside);TTL 兜底自愈,高一致要求再上延迟双删或 binlog 订阅。
第三件事:缓存的另外三大经典问题
这次踩坑后,我顺势把缓存的另外几个"经典必踩"的问题,也一并梳理清楚了——它们和一致性一样,是用缓存绕不开的坎:
缓存三大经典问题: 穿透、击穿、雪崩(都会把压力打到 DB)
# 1. 缓存穿透(查"根本不存在"的数据)
# - 现象: 大量查 DB 里也没有的 key(如恶意刷不存在的 id)。
# - 后果: 缓存永远不命中(因为没有), 每次都打到 DB → DB 被压垮。
# - 解法: ① 缓存空值(查不到也缓存一个"空", 带短 TTL)。
# ② 布隆过滤器(先判断 key 可能存在吗, 不存在直接拒绝)。
# 2. 缓存击穿(某个"热点 key"恰好过期)
# - 现象: 一个超高并发的热点 key 过期的瞬间, 大量请求同时未命中。
# - 后果: 这些请求全部同时打到 DB 去回填 → DB 瞬间被打爆。
# - 解法: ① 互斥锁/单飞(singleflight): 只放一个请求去查DB回填, 其他等。
# ② 热点 key 不过期 / 逻辑过期(异步更新)。
# 3. 缓存雪崩(大量 key"同时"过期, 或缓存整个挂了)
# - 现象: 大批 key 在同一时刻集中过期, 或 Redis 宕机。
# - 后果: 海量请求同时落到 DB → DB 被洪峰冲垮 → 全线崩。
# - 解法: ① TTL 加随机抖动(别让大量 key 同一秒过期)。
# ② 缓存高可用(集群/哨兵), 加限流/降级/熔断兜底。
# 共同本质: 缓存"挡不住"的请求, 都会落到脆弱的 DB 上。
# - 缓存是 DB 的"保护伞", 要保证这把伞"别突然破洞或合上"。
# 核心: 穿透(查不存在→缓存空值/布隆)、击穿(热点key过期→互斥锁/不过期)、
# 雪崩(批量过期/挂了→TTL加抖动+高可用+限流降级); 都防"请求洪峰打垮DB"。
原来缓存的"坑",远不止一致性一个。缓存穿透:大量查"DB 里也根本没有"的 key(如恶意刷不存在的 id),缓存永远不命中、每次都打到 DB;解法是缓存空值(带短 TTL)或布隆过滤器。缓存击穿:某个超高并发的热点 key 恰好过期的瞬间,大量请求同时未命中、全部涌向 DB 回填、把 DB 瞬间打爆;解法是互斥锁/单飞(singleflight,只放一个请求去查 DB)或热点 key 不过期。缓存雪崩:大批 key 同一时刻集中过期、或 Redis 整个宕机,海量请求同时落到 DB、把它冲垮;解法是TTL 加随机抖动(别让大量 key 同秒过期)+ 缓存高可用 + 限流/降级/熔断。它们的共同本质是:缓存"挡不住"的请求,都会落到脆弱的 DB 上——缓存是 DB 的"保护伞",要保证这把伞别突然破洞(穿透/击穿)或合上(雪崩)。归根结底:穿透(查不存在→缓存空值/布隆)、击穿(热点 key 过期→互斥锁/不过期)、雪崩(批量过期/挂了→TTL 加抖动+高可用+限流降级);它们都是在防"请求洪峰打垮 DB"。
下面这张图,是这次缓存一致性问题的成因与解法:
第四件事:几种缓存更新策略的对比
这次踩坑后,我把几种缓存更新策略,按"并发安全、一致性、复杂度"横向比了一遍,按场景对号入座。
| 策略 | 做法 | 并发安全 | 评价 |
|---|---|---|---|
| 更DB + 更新缓存 | 写完DB把新值写缓存 | ✗ 写入乱序→脏数据 | ✗ 别用(本文的坑) |
| 更DB + 删除缓存 | 写完DB删缓存, 读时回填 | ✓ 最坏多读DB | ★★★ 推荐(Cache-Aside) |
| 删缓存 + 更DB | 先删缓存再写DB | △ 读会回填旧值 | ★ 有窗口, 不如上面 |
| 延迟双删 | 更DB+删缓存, 延迟再删一次 | ✓ 进一步收窗口 | ★★ 高一致场景 |
| 订阅 binlog 删缓存 | 监听DB变更, 异步删缓存 | ✓ 解耦可靠 | ★★ 复杂但稳, 大型系统 |
| 只读缓存 + TTL | 不主动删, 靠过期更新 | ✓ 简单 | ★ 容忍 TTL 内旧数据的场景 |
把它们排在一起,选择就清楚了。最该用的,是"更 DB + 删除缓存"(Cache-Aside)——它并发安全(最坏多读一次 DB,不留脏值)、实现简单、覆盖绝大多数场景,是默认首选。而那个"更 DB + 更新缓存",正是本文的坑,应该避免;"先删缓存再更 DB"有"读回填旧值"的窗口、不如前者。对更高一致性要求:延迟双删(进一步收紧窗口)、订阅 binlog 异步删缓存(把"删缓存"和业务解耦,更可靠,适合大型系统,但更复杂)。而对能容忍短暂旧数据的场景:"只读缓存 + TTL"最简单——不主动删,纯靠过期更新(比如一些不那么实时的统计数据)。它给我的最大启发是:缓存策略的选择,本质是在"一致性要求"和"实现复杂度"之间做权衡:大多数场景,Cache-Aside + TTL 就是性价比最高的"够用"之选;不要一上来就追求"最强一致"而引入 binlog 订阅这种重型方案,也不要图省事用"更新缓存"埋下脏数据的雷——按你的业务对一致性的真实要求,选那个"恰好够用"的策略。
第五件事:到底该不该用缓存?
这次踩坑也让我反思:缓存虽好,但它引入了复杂度,不是所有场景都该用。我梳理了一套"该不该上缓存"的判断。
| 维度 | 适合用缓存 | 不适合/要慎重 |
|---|---|---|
| 读写比 | 读多写少(缓存命中率高) | 写多读少(缓存频繁失效, 收益低) |
| 一致性要求 | 能容忍短暂不一致(最终一致) | 要求强一致(钱/库存关键值) |
| 数据热度 | 有明显热点(少量数据被高频访问) | 访问均匀分散(缓存命中率低) |
| 计算成本 | 查询/计算昂贵(缓存省得多) | 查询本身极快(缓存没必要) |
| 数据变化频率 | 变化不频繁 | 变化极快(缓存还没用就过期) |
| 系统复杂度承受 | 团队能 hold 住一致性/穿透等问题 | 简单系统, 加缓存得不偿失 |
这张表,让我对"缓存"有了更冷静的认识——它不是免费的银弹。缓存最适合的场景,是"读多写少 + 有明显热点 + 查询昂贵 + 能容忍最终一致":这时,缓存命中率高、省下的成本巨大、而一致性的妥协又可接受,收益远大于它引入的复杂度。而不适合/要慎重的:写多读少(缓存频繁失效、收益低)、要求强一致(钱、库存这种关键值,缓存的不一致风险不可接受)、访问均匀分散(命中率低、缓存白搭)、查询本身极快(没必要)、数据变化极快(缓存还没用上就过期)。最关键的,是系统复杂度的承受:引入缓存,就引入了一致性、穿透、击穿、雪崩这一整套要操心的问题;如果团队 hold 不住,或者系统本身很简单,强行加缓存,反而是"得不偿失"。它给我的最大启发是:每一项"优化",都是有"代价"的——缓存用"一致性的复杂度"换"性能";在引入它之前,先诚实地问自己:"我真的需要它吗?这个性能问题,有没有更简单的解法(比如优化 SQL、加索引)?我能承受它带来的复杂度吗?"。不为了"显得高级"而上缓存,只在"收益确实大于代价"时才用它——这,是一种比"会用缓存"更重要的架构判断力。
第六件事:给数据加缓存时,我现在会怎么决策
现在,每当我准备给某份数据加缓存,脑子里都会过一遍这张决策图——核心就两问:该不该用?用了怎么保证一致?
这张图的灵魂,是两个层次的判断。第一层,该不该用?——只有满足"读多写少 + 有热点 + 查询贵 + 能容忍最终一致"才适合;不满足就别盲目加,先试 SQL 优化/加索引等更简单的方案。第二层,用了怎么保证一致与稳定?:读查缓存未命中回填带 TTL、写用 Cache-Aside(更 DB + 删缓存、不要更新缓存)、TTL 加随机抖动防雪崩;有强一致要求再上延迟双删/binlog;防穿透(缓存空值/布隆)、防击穿(互斥锁)。最后,也是我以前最缺的一步:压测验证——在并发读写下,确认缓存与 DB 最终能保持一致,而不是上线后靠用户投诉才发现不一致。
我立下的几条规矩
这场"缓存不一致"的事故,换来了我用缓存时,刻进骨子里的几条铁律:
- 写操作用"更 DB + 删缓存",不要"更新缓存"。更新缓存在并发下会写入乱序、留下永久脏数据;删除是幂等的、最坏多读一次 DB。
- 缓存一定要设 TTL。它是最后的兜底——任何偶发不一致,过期后都会自愈,再坏也坏不过 TTL 那么久。
- 缓存与 DB 无法原子更新,接受最终一致。别幻想强一致;强一致场景要么别用缓存,要么上 binlog/分布式锁。
- TTL 加随机抖动,防雪崩。别让大量 key 在同一秒集中过期,把 DB 一次性压垮。
- 防穿透、击穿。查不存在的→缓存空值/布隆过滤器;热点 key 过期→互斥锁/单飞或不过期。
- 缓存是 DB 的保护伞,别让它破洞或合上。缓存挡不住的请求都会落到脆弱的 DB,要保护好这把伞。
- 加缓存前先问"值不值"。缓存用一致性复杂度换性能;能用更简单方案(优化 SQL/索引)解决就别盲目上缓存。
附:一个带防穿透/防击穿的完整缓存读取实现
把前面讲的一致性、防穿透、防击穿揉到一起,这是我现在写"缓存读取"的一个比较完整的实现,可以直接参照:
import random, threading
TTL = 300
EMPTY = "__EMPTY__" # 空值标记(防穿透)
_locks = {} # 每个 key 一把锁(防击穿)
_locks_guard = threading.Lock()
def _get_lock(key):
with _locks_guard:
return _locks.setdefault(key, threading.Lock())
def get_user(uid):
# 1. 查缓存
v = cache.get(uid)
if v == EMPTY:
return None # ✓ 防穿透: 命中"空值", 直接返回不查DB
if v is not None:
return v # 命中真实值
# 2. 未命中: 加锁, 只放一个请求去查DB回填(✓ 防击穿)
lock = _get_lock(uid)
with lock:
# 双重检查: 拿到锁后再看一次, 可能别人已回填
v = cache.get(uid)
if v == EMPTY:
return None
if v is not None:
return v
# 3. 真正查DB
data = db.query(uid)
if data is None:
# ✓ 防穿透: 查不到也缓存一个"空值", 带较短TTL
cache.set(uid, EMPTY, ex=60)
return None
# ✓ 防雪崩: TTL 加随机抖动, 避免大量key同时过期
cache.set(uid, data, ex=TTL + random.randint(0, 60))
return data
def update_user(uid, new_data):
db.update(uid, new_data) # ✓ 先更DB
cache.delete(uid) # ✓ 再删缓存(Cache-Aside)
# 这一个函数里同时防了:
# - 一致性: 写用 Cache-Aside(更DB+删缓存) + TTL 兜底。
# - 穿透: 查不到也缓存空值(带短TTL)。
# - 击穿: 每key加锁, 只放一个请求回填, 其余等待复用。
# - 雪崩: TTL 加随机抖动, 错开过期时间。
# 核心: 一个健壮的缓存读取要同时考虑一致性+穿透+击穿+雪崩;
# 空值标记防穿透、单key锁防击穿、TTL抖动防雪崩、写删缓存保最终一致。
这段代码,把缓存的几大问题,在一个函数里一次性兜住了。它同时防了四件事:一致性(写用 Cache-Aside 更 DB+删缓存 + TTL 兜底)、穿透(查不到也缓存一个"空值"标记、带短 TTL,让恶意刷不存在 key 的请求也能被缓存挡住)、击穿(每个 key 一把锁,未命中时只放一个请求去查 DB 回填、其余等待复用结果,避免热点 key 过期瞬间击穿到 DB)、雪崩(TTL 加随机抖动,错开大量 key 的过期时间)。它最值得玩味的,是那个"双重检查":拿到锁后再查一次缓存——因为在你等锁的这段时间,可能别的请求已经查好 DB 并回填了,这时就直接复用、不必再查一次 DB。这,正是我想用这段代码,留给每一个用缓存的开发者的最后一课:一个"看起来只是查个缓存"的简单操作,要做到生产级的健壮,背后其实要同时考虑一致性、穿透、击穿、雪崩这一整套问题;把这些"边界情况和异常场景"都提前想到、并在代码里一一兜住,正是"能写功能的程序员"和"能扛住高并发的工程师"之间,那道真实而关键的分水岭。魔鬼在细节,而健壮,正是由这一个个被妥善处理的细节,累积而成的。
写在最后
回头看,这场由"缓存不一致"引发的事故,真正教给我的,是一个比"用 Cache-Aside"本身更深的道理:软件工程里,几乎没有"免费的午餐";每一个为了某个好处(如性能)而引入的方案,几乎都同时引入了新的复杂度和新的问题;而一个成熟工程师的标志,不是"知道某个方案的好处",而是"清醒地知道它的代价,并有能力管理好这份代价"。缓存,给了我梦寐以求的性能,但它同时,也悄悄地、不容商量地,塞给了我一整套"一致性、穿透、击穿、雪崩"的难题;我之前的错误,是只贪图它的好处(快),却完全没意识到、更没准备好去管理它的代价(不一致)。这让我深刻地领悟到:"引入一个东西"的那一刻,你就同时签下了一份"管理它全部副作用"的责任契约;你享受它带来的好处,就必须有能力、也有准备,去应对它带来的所有麻烦。所以,做技术选型、做架构设计,最重要的能力之一,是"看见代价"的能力:面对任何一个诱人的方案(缓存、微服务、消息队列、新框架……),都要冷静地追问:"它的好处是什么?它的代价又是什么?这份代价,我承担得起、管理得了吗?";并只在"好处确实大于代价、且代价可控"时,才引入它。权衡利弊、看见代价、管理复杂度——这,是我用一次"缓存不一致"的事故,换来的、关于架构、也关于一切技术决策的、最朴素也最深刻的领悟。如果这篇复盘,能让你在下一次想"加个缓存"时,先想清楚"我能管好它的一致性吗",那我对着那个对不上的缓存熬的这大半天,就值了。
—— 别看了 · 2026