缓存一致性:先删缓存还是先写库
云顶飞行
5 min read
判断:缓存与数据库的一致性是"最终一致"问题,不存在零成本的强一致方案。写路径上"先删缓存再写库"比"先写库再删缓存"更容易踩并发脏读的坑,但两者都靠"删除"而非"更新"缓存来规避旧值残留——缓存不写,只删。
缓存读写模式的背景
常见的 Cache-Aside(旁路缓存)模式:读时先查缓存,未命中查库并回填;写时操作数据库,并让缓存失效。Write-Through(写穿透)则是写同时落库和落缓存。Read-Through/Write-Through 通常由缓存中间件托管,但写穿透同样面临"并发下缓存与库不一致"的问题——这正是本文讨论的重心。无论哪种模式,"删"都比"写"安全,因为删把"缓存里该是什么"的判定推迟到下一次读,绕开了写竞争。
为什么是删不是写
写库后若直接把新值写进缓存,并发下可能出现:A 写库、B 读缓存未命中回源、B 把旧值写入缓存、A 再把新值写入缓存,顺序颠倒导致缓存最终是旧值。删缓存让下次读自然回源拿到新值,绕开了"写缓存"这个竞争点。
方案 A:先删缓存,再写库
删缓存
写数据库
缺陷是删缓存后、写库完成前,若有读请求进来:读缓存未命中 → 读库拿到旧值 → 回填缓存(旧值)→ 随后写库完成。缓存里就留下旧值,直到下次删除或过期。这个窗口在"写后立即有读"的场景(如刚改完配置就被查)最明显。
缓解:写完成后延迟一小段时间再删一次(延迟双删):
删缓存 → 写库 → sleep(几百ms) → 再删缓存
第二次删除清掉窗口期内被回填的旧值。sleep 时长需大于"读旧值回填"的耗时经验上限,过短无效、过长增加延迟。延迟双删的代价是引入了一次人为等待,且第二次删除仍可能失败,因此它只是把不一致窗口从"永久直到过期"缩小到"极小且短暂"。
方案 B:先写库,再删缓存
写数据库 → 删缓存
缓存删除失败会让缓存留下旧值。解决办法是给删除加重试(异步队列补偿)或监听数据库变更日志(binlog)来兜底删除。这个方案下,常见的异常是"读请求在写库前命中旧缓存,写库删缓存后一切正常"——因为读本来就拿到旧值符合当时状态,不构成脏数据。
业界常用的是方案 B + binlog 订阅:数据库变更通过 binlog 推送到缓存删除服务,解耦且可靠,失败可重放。binlog 方案的额外好处是缓存删除与业务代码彻底解耦,业务侧只管写库,不用关心缓存逻辑,一致性由独立的订阅组件保障。
| 维度 |
先删缓存再写库 |
先写库再删缓存 |
| 主要风险 |
写库前读回填旧值 |
删缓存失败留旧值 |
| 缓解手段 |
延迟双删 |
删除重试 / binlog |
| 适用 |
写后少立即读 |
通用,配合订阅更稳 |
边界条件
缓存过期时间必须设置:即便删除逻辑全失败,过期也能在限定时间内自愈,给一致性一个上界。
热点 key 重建风暴:缓存失效瞬间大量请求同时回源,压垮数据库。解法是对回源加单飞(singleflight):同一 key 只放一个请求去加载,其余等待结果共享。
无法接受最终一致的业务(如余额扣减)不应走"缓存 + 异步删",而应在数据库层用事务和行锁保证,缓存只服务只读场景。
监控与兜底
一致性问题往往是静默的:缓存里是旧值,业务侧没人报错,直到用户投诉"改了没生效"。应监控缓存命中率突变、缓存与库的抽样比对(如离线任务周期性校验热点 key),并在删除链路异常时告警。过期时间是兜底的安全网,绝不能省。
读写比的影响
读多写少(如商品详情)场景下,缓存一致性问题最缓和,因为写少、删除窗口罕被命中;写多读少(如库存扣减)场景下,删除策略与回填竞争被频繁触发,最终一致窗口更难忽略,这类数据往往应减少缓存层、把一致性压力留给数据库事务。选哪种删缓存策略,先看清业务的读写比,再定方案。
踩坑
把缓存当数据源:缓存丢了能回源,数据库丢了就真没了。所有写必须落库,缓存只是加速。
删除失败无补偿:依赖"恰好删除成功"过于理想。要么重试队列,要么 binlog 兜底。
设置了永不过期:一旦删除链路出 bug,脏值永久留存,排查极难。过期时间是兜底的安全网。
双写缓存又写库,还不校验顺序:写穿透模式下并发写,缓存和库互相打架。要么严格串行化(加锁),要么改用删除策略。
1 view
0 likes
0 comments