我给热点数据加了缓存提速,结果用户改完资料刷新还是看到旧的、甚至并发改一下缓存就和数据库长期对不上,我对着这个缓存一致性问题排查了大半天的复盘

我给热点数据加了 Redis 缓存提速,结果用户改完资料、提示保存成功了,刷新还是旧数据;并发下偶尔缓存和数据库长期对不上、怎么刷都不对,只能手动清缓存。我加了"更新数据库后也更新缓存"反而更乱。把并发读写画在时间轴上才懂:缓存和数据库是两个独立系统、更新无法原子完成,中间总有不一致窗口,并发下交错就出问题;而我犯的典型错误是选了"更新缓存"而非"删除缓存"——并发下两个写请求更新缓存的顺序可能乱(A写X、B写Y,缓存却被A的X覆盖了B的Y),导致DB是Y、缓存是X永久不一致。这篇从缓存一致性与四种更新策略讲起,到 Cache-Aside(更DB+删缓存而非更新缓存)+TTL兜底+延迟双删的正解、缓存穿透/击穿/雪崩三大经典问题、几种策略对比、该不该用缓存的判断、带防穿透防击穿的完整实现,以及那句最戳心的——软件工程几乎没有免费午餐,引入一个东西就签下了管理它全部副作用的责任契约,成熟的标志是看见代价并管理好它。

我给热点数据加了缓存提速,结果用户改完资料刷新还是看到旧的、甚至并发改一下缓存就和数据库长期对不上,我对着这个缓存一致性问题排查了大半天的复盘

这是一个让我对"缓存一致性"刻骨铭心的故事。我有一些热点数据(比如用户资料),读得很频繁,为了提速,我加了一层缓存(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 最终能保持一致,而不是上线后靠用户投诉才发现不一致。

我立下的几条规矩

这场"缓存不一致"的事故,换来了我用缓存时,刻进骨子里的几条铁律:

  1. 写操作用"更 DB + 删缓存",不要"更新缓存"。更新缓存在并发下会写入乱序、留下永久脏数据;删除是幂等的、最坏多读一次 DB。
  2. 缓存一定要设 TTL。它是最后的兜底——任何偶发不一致,过期后都会自愈,再坏也坏不过 TTL 那么久。
  3. 缓存与 DB 无法原子更新,接受最终一致。别幻想强一致;强一致场景要么别用缓存,要么上 binlog/分布式锁。
  4. TTL 加随机抖动,防雪崩。别让大量 key 在同一秒集中过期,把 DB 一次性压垮。
  5. 防穿透、击穿。查不存在的→缓存空值/布隆过滤器;热点 key 过期→互斥锁/单飞或不过期。
  6. 缓存是 DB 的保护伞,别让它破洞或合上。缓存挡不住的请求都会落到脆弱的 DB,要保护好这把伞。
  7. 加缓存前先问"值不值"。缓存用一致性复杂度换性能;能用更简单方案(优化 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我让大模型帮我查一个库的 API,它信誓旦旦地给了我一个方法名、连参数都写得有模有样,结果那个方法根本不存在,我对着这场一本正经的胡编排查了大半天的复盘

2026-6-2 3:05:48

技术教程

我在 C# 里把一个 LINQ 查询当结果反复用,先 Count 再 foreach,结果同一个查询被默默执行了好多遍、数据库往返暴增,我排查了大半天的复盘

2026-6-2 3:18:20

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索