ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis缓存一致性:缓存更新策略与延迟双删避坑指南

Redis缓存一致性:缓存更新策略与延迟双删避坑指南

1. 从一个"数据明明改了却查不到"的现场说起

先说一个我这些年反复遇到的场景:某次排查一个线上问题,用户反馈"修改完资料,刷新页面还是老样子",但后台日志明明打印了更新成功的 SQL,数据库里查出来也是新值。开发和运维在群里拉扯了两个小时,最后定位到问题不在数据库,也不在业务代码,而是缓存和数据库之间的更新时序出了岔子。

这个现象在业内极其普遍,因为它不像空指针那样会当场炸给你看,它更像一个"慢性病"——平时不发作,一到并发上来、流量一大,就零零散散地冒出来,而且极难复现。很多人对缓存的认知停留在"加个 Redis 就快了"这一层,觉得缓存就是一个大号 HashMap,put 进去、get 出来,天经地义。但真正做过高并发系统的人都知道,缓存最难的从来不是"怎么读",而是"怎么保证读到的还是对的"。

这篇内容就是围绕这个核心问题展开的。我会把"缓存数据更新"这件事从底层机制到落地策略完整拆一遍:为什么四种经典的更新组合里只有一种相对靠谱、延迟双删到底在删什么、先操作数据库再删缓存为什么能躲过大部分坑、以及那些文档里不会写的脏数据残留窗口到底有多长。适合已经用过缓存但被数据一致性问题折磨过的同学,也适合正准备给系统加缓存、想提前避坑的同学。读完之后你至少能做到:看到一段缓存更新代码,能立刻判断它在什么并发场景下会出问题。

2. 缓存为什么会"说谎":先把不一致的根源挖出来

2.1 缓存本质上是数据库的一份副本,副本就必然有滞后

很多人潜意识里把缓存当成"另一份数据库",这是个危险的认知。缓存的正确定位是数据库某个时刻状态的快照副本,它天生不具备权威性,权威数据永远在数据库里。既然是副本,就存在"正本已经改了、副本还没跟上"的时间窗口,这个窗口就是我们说的一致性缺口。

打个生活化的比方:公司通讯录有一份总表(数据库),每个部门墙上贴了一份打印版(缓存)。人事在总表上把某人的分机号改了,但各部门墙上的打印版不会自动变。这时候有人照着墙上打电话,打不通——不是通讯录错了,是他看的那份是旧的。缓存不一致的本质就是这么朴素:多份数据副本之间缺少即时同步机制。

理解这一点非常关键,因为它直接决定了后面的所有策略都只能是"缩小窗口",而做不到"彻底消灭窗口"。任何声称"我的方案能保证缓存和数据库强一致"的说法,如果不付出分布式锁或者两阶段提交这种代价,基本可以判定为不严谨。

2.2 三种典型的不一致形态,先学会分辨

实际工作中遇到的不一致,粗分下来有三种形态,处理方式完全不同,混为一谈就会误诊。

不一致形态表现常见成因
短时不一致更新后几百毫秒内读到旧值,之后自动恢复主从复制延迟、删除缓存与写入的时序差
长期不一致旧值一直存在,直到缓存过期或被手动清掉更新时漏删缓存、删除失败未重试、并发覆盖
逻辑不一致值本身能对上,但业务语义错了缓存了不该缓存的中间态、计算逻辑版本不同步

第一类通常可以接受,因为业务上往往能容忍毫秒级的滞后;第二类是真正的故障,必须靠重试、补偿、过期兜底来解决;第三类最隐蔽,往往不是缓存本身的问题,而是缓存粒度设计错了——比如把一个会频繁变化的聚合结果整体缓存,却没有随依赖项一起失效。

提示:排查线上"数据没更新"的问题时,第一步永远是确认缓存里当前存的到底是什么值、这条 key 的过期时间是多少、它是被哪段逻辑写进去的。不要一上来就怀疑数据库。

2.3 读写并发才是真正的杀手

如果系统是单线程的,缓存更新根本不是问题——先删缓存再写库,或者先写库再删缓存,随便怎么搞都对。问题恰恰在于读请求和写请求是并发交错执行的,而缓存的"读"通常还包含一个"回填"动作(cache miss 时从数据库读出来再写回缓存),这个回填动作是造成绝大多数脏数据的元凶。

想象这样一个时序:读请求 A 发现缓存没有数据,去数据库读到旧值 v1;此时写请求 B 把数据库更新成 v2 并删除了缓存;然后 A 才慢悠悠地把手里的 v1 写进缓存。结果就是——数据库是 v2,缓存是 v1,而且这个 v1 会一直待在那里直到过期。整个过程没有任何一步是"错"的,每一步单独看都合理,但组合起来就产生了脏数据。这就是并发带来的复杂性,也是后面所有策略设计的出发点。

3. 四种更新组合的逐一拆解:为什么三种都有坑

业界讨论缓存更新,绕不开"操作数据库"和"操作缓存"这两步的先后顺序,以及每一步是"更新"还是"删除"。组合起来主要有四种,我逐个说清楚它们各自会死在哪里。

3.1 先更新缓存再更新数据库:并发下极易互相覆盖

这个方案看起来最直观——数据变了就把缓存和库都改掉。它的致命伤在于两个写请求并发时,缓存和库的更新顺序可能错位。

设请求 A 把值改成 1,请求 B 把值改成 2。如果执行顺序是:A 更新缓存为 1 → B 更新缓存为 2 → B 更新数据库为 2 → A 更新数据库为 1。最终数据库是 1,缓存是 2,两边彻底分家。而且因为缓存里是 2、库里是 1,谁也不知道该以谁为准,只能靠人工介入。

除了并发覆盖,这个方案还有个现实问题:很多缓存值不是简单地从数据库字段直接搬过来的,而是经过计算、聚合、拼接得到的。你更新数据库时,未必能立刻算出新的缓存值,反而要额外查一次库再算一遍,成本比直接删掉缓存更高。所以这个方案在实际生产里基本被淘汰了。

3.2 先更新数据库再更新缓存:问题同上,只是换了个姿势

把顺序调过来并不能解决问题。并发下依然可能出现"A 更新库为 1 → B 更新库为 2 → B 更新缓存为 2 → A 更新缓存为 1",最终库里是 2、缓存是 1。

更麻烦的是更新缓存的时机问题。假设短时间内同一个 key 被改了 10 次,那么这个 key 的缓存就要被写 10 次,而其中可能只有最后一次是有人真正要读的。这在写多读少的场景下纯属浪费,甚至会给缓存集群带来不必要的压力。相比之下,删除操作是幂等的、廉价的,改 10 次删 10 次和删 1 次的效果完全一样。

3.3 先删缓存再更新数据库:经典的"回填覆盖"陷阱

这个方案是很多教程推荐的"标准答案",但它只解决了写写并发,没解决读写并发。前面 2.3 节描述的那个时序——读请求在缓存失效后读到旧值、又在新值写入后把旧值回填——正好击穿了这个方案。

完整的失败路径是这样的:

  1. 写请求 B 删除缓存(此时缓存为空)
  2. 读请求 A 到来,缓存未命中,去数据库读到旧值 v1
  3. 写请求 B 把数据库更新为 v2,写库完成
  4. 读请求 A 把读到的 v1 写入缓存

结果是缓存 v1、数据库 v2。这个窗口看起来很短,但它和"读请求恰好卡在中间"的概率相关,在 QPS 高的系统里,一天触发几次甚至几十次都很正常。而且这个方案还有一个变种风险:如果删除缓存后、写库前,有大量读请求涌入,它们会集体打到数据库上,形成缓存击穿,数据库可能瞬间被打挂。

3.4 先更新数据库再删缓存:相对最优,但仍有残留窗口

这是我实际项目里用得最多的方案,也是业界公认在"不引入额外机制"前提下最稳的一种。它的核心优势在于:删除动作发生在数据已经变正确之后,所以即便有读请求在中间回填了旧值,这个旧值也有很大概率被随后的删除动作清掉。

但注意,我说的是"很大概率",不是"一定"。它的失败路径是这样的:

  1. 读请求 A 缓存未命中,去数据库读到旧值 v1
  2. 写请求 B 更新数据库为 v2
  3. 写请求 B 删除缓存
  4. 读请求 A 把 v1 写入缓存

可以看到,只有当"读请求读库"这一步发生在"写请求更新库"之前,且"读请求回填"发生在"写请求删缓存"之后,才会出问题。这个条件比 3.3 的方案苛刻得多——3.3 里只要读请求在删缓存之后、写库之前发起就会中招,而这里要求读请求的回填动作恰好慢于写请求的删除动作。在真实系统里,写请求的"更新库+删缓存"两步间隔通常远小于读请求"查库+回填"的两步间隔,所以中招概率被压得很低。

下面这张表把四种方案的核心问题对齐一下,方便你直观比较:

方案写写并发读写并发实现成本我的实践建议
先更缓存再更库会互相覆盖会互相覆盖低不要用
先更库再更缓存会互相覆盖会互相覆盖低不要用
先删缓存再更库基本安全回填覆盖风险高低仅在写极少的场景用
先更库再删缓存基本安全残留窗口小低首选基础方案

4. 延迟双删到底在删什么:一个容易被抄错的方案

4.1 双删的设计意图与时间参数怎么定

既然"先更库再删缓存"还有残留窗口,业界的补丁就是延迟双删:在更新数据库并删除缓存之后,隔一小段时间再删一次缓存。第二次删除的目的很明确——把那个可能在窗口期内被读请求回填进去的旧值清掉。

关键在于"隔多久"。这个时间不能拍脑袋,它必须大于"一次读请求从查库到回填完成"的最大耗时。假设你的读链路是查库 20ms、序列化 5ms、写缓存 5ms,再加上网络抖动和 GC 停顿,那么单次回填的最坏耗时可能是 100ms 甚至更多。所以延迟时间通常取读链路最大耗时的 1.5 到 2 倍,我一般会给到 300ms 到 1s 这个量级。

更要紧的一点是:延迟任务不能放在业务线程里 sleep。我见过有人直接在更新逻辑后面写Thread.sleep(500)再删一次,这在低并发下看起来能用,但一旦 QPS 上来,线程全被睡住,接口响应时间直接爆掉。正确做法是把第二次删除丢到延迟队列、定时任务或者消息队列的延迟消息里异步执行。

4.2 双删也救不了的情况:删除本身失败了

这是我特别想强调的一个点,很多人抄了双删就以为万事大吉,却忽略了删除操作也是可能失败的。缓存集群抖动、网络超时、连接池耗尽,都会让 del 命令返回失败或者干脆抛异常。而如果删除失败,你的第二次延迟删除也同样可能失败,脏数据就永久留下了。

所以一个完整的方案必须包含失败重试机制。我的常规做法是这样:

  1. 删除缓存失败时,不阻塞主流程,把这条删除任务投递到消息队列
  2. 由消费者做有限次重试(比如 3 次,间隔递增)
  3. 重试仍失败则落库记录,由定时任务扫描补偿
  4. 同时给所有缓存 key 设置兜底过期时间,作为最后一道保险

关于过期时间,我的经验是不要设太长。热点数据的过期时间可以按"业务能容忍的最大不一致时长"来定,比如商品详情页能容忍 5 分钟,那就设 5 分钟加一点随机抖动。加随机抖动的目的是避免大量 key 在同一秒集体过期,引发缓存雪崩。这个细节很小,但线上出过一次事故之后你就会记住它。

4.3 一个必须警惕的坑:删除和更新不是原子操作

即便你用了先更库再删缓存加延迟双删,还有一个隐患:数据库更新成功、但删缓存那一步因为进程被 kill、机器重启、异常未捕获而没执行。这种情况在发布变更、容器滚动重启时特别容易发生。

应对手段有两个方向。一是把删除动作前置到事务提交之后并加保护,也就是用事务同步回调、或者监听数据库变更日志(binlog)来触发缓存删除,让删除动作和数据变更绑定,不依赖业务代码是否记得调用。二是缩短兜底过期时间,让脏数据的存在时长有上限。前者更彻底,后者更省事,我一般建议核心链路用前者、非核心链路用后者。

注意:用数据库变更日志驱动缓存失效是个很有效的思路,但它会引入额外组件和延迟,链路变长之后排查问题也更麻烦。中小规模系统老老实实用业务代码删除加过期兜底,性价比更高。

5. 把策略落到代码:可复用的读改写骨架

5.1 读路径的防击穿写法

光讲更新策略不够,读路径写得不严谨,再好的更新方案也白搭。读路径的核心要求是:缓存未命中时,只允许极少量请求打到数据库。

最基础的是加分布式锁做互斥回填:第一个发现 miss 的请求去拿锁,拿到锁的再查库回填,没拿到锁的短暂等待后重试读缓存。这个方案能防击穿,但引入了锁的开销和死锁风险,需要给锁设置合理的超时和续期。

更轻量的做法是逻辑过期:缓存里不存裸值,而是存一个带过期时间戳的结构体,值本身永不过期。读到数据后发现逻辑上已过期,就返回旧值同时异步触发一个刷新任务。这样读请求永远不阻塞,代价是可能返回稍旧的数据。这个方案适合对实时性要求不高、但对响应时间敏感的场景,比如资讯列表。

# 逻辑过期的读路径示意 def get_with_logical_expire(key): raw = cache.get(key) if raw is None: # 真空未命中,走互斥回填 return load_with_lock(key) data = deserialize(raw) if data.expire_at < now(): # 逻辑过期,异步刷新,本次仍返回旧值 async_submit(refresh_task, key) return data.value

5.2 写路径的完整骨架

把前面几节的内容拼起来,写路径大致长这样。注意这里的顺序、异常处理和延迟删除的位置:

  1. 先更新数据库,并在事务提交后再执行缓存删除(不要在事务内删,否则事务回滚会导致缓存被误删)
  2. 删除缓存失败时投递补偿消息,不阻塞主流程
  3. 投递一条延迟消息,在延迟时间到达后执行第二次删除
  4. 所有缓存写入必须带过期时间,且过期时间加随机抖动
// 写路径骨架示意 public void update(Item item) { db.update(item); // 1. 更新数据库 try { cache.delete(key(item)); // 2. 事务提交后删除缓存 } catch (Exception e) { mq.send(new DeleteTask(key(item))); // 3. 失败转异步补偿 } mq.sendDelay(new DeleteTask(key(item)), 500); // 4. 延迟二次删除 }

有一个细节值得单独拎出来说:第 2 步的删除必须放在事务提交之后。如果放在事务内部,事务后续回滚了,缓存已经被删掉,虽然不会产生脏数据(下次读会重新加载),但会造成一次无谓的缓存穿透。更糟的是如果在事务内删除后、提交前有其他请求回填了旧值,回滚之后这个旧值反而变成了"合法"的缓存内容。所以这个顺序不能乱。

5.3 强一致诉求场景怎么办

有些业务确实不能容忍任何窗口,比如账户余额、库存扣减。这类场景我的建议是根本不要用缓存做权威数据源,缓存只用来做展示或者预校验,真正的扣减和判断全部走数据库的行锁或乐观锁。把强一致的要求压给缓存,是在用错误的工具解决错误的问题,最后一定会在某个并发点上翻车。

如果非要在缓存层做原子扣减,那就得依赖缓存自身提供的原子命令(比如基于 Lua 脚本的原子操作),并且要考虑缓存宕机导致的数据丢失风险——这时候缓存实际上已经变成了主存储,架构性质就变了,需要按主存储的标准来做持久化和容灾。

6. 那些文档里不会写的实测经验

6.1 高频写入场景下,删缓存比更缓存省太多

我做过一个计数器类的功能,单个 key 每秒被更新几十次。最开始图省事用了"更新库同时更新缓存",结果缓存集群的写入 QPS 直接翻了几倍,而且因为并发覆盖,计数还经常不准。改成"更新库 + 删缓存 + 让读请求按需回填"之后,写入压力降下来了,计数也准了。这个案例说明,删除是幂等的、可合并的,更新不是,在写密集场景里这个差别会被放大到非常明显。

6.2 缓存 key 的设计失误,比更新策略失误更常见

排查过很多"数据不更新"的案例,最后发现压根不是更新逻辑的问题,而是缓存 key 的维度设计错了。比如把"用户维度的配置"和"全局维度的配置"用同一个 key 缓存,更新全局配置时只清了全局 key,用户维度的旧值还在。这类问题靠任何删除策略都救不了,只能在设计阶段把 key 的维度、失效的触发源想清楚。我的习惯是给每个缓存 key 写一行注释,标明"它由哪些数据源派生、哪些操作会导致它失效",这个习惯帮我省了很多次排查。

6.3 监控比策略更重要

再严谨的方案也会有漏网之鱼,所以必须能发现不一致。我的做法是在关键链路上做抽样校验:写入后延迟一小段时间,随机抽几个 key 比对缓存值和数据库值,不一致就上报。这个校验本身有成本,所以只对核心 key 开、只做低频抽样。它能帮你在用户投诉之前发现问题,价值很高。

另外几个必须盯的指标:缓存命中率(突然下降可能是大量 key 被误删或过期)、删除操作的失败率、缓存与库的值差异率。这三个指标一抖,基本就说明有问题在酝酿了。

最后说个我自己的体会:缓存一致性这件事,与其追求"绝对正确",不如先想清楚"业务能容忍多久的脏数据"。把容忍度量化出来,再倒推需要多严格的策略,比一上来就堆砌各种机制要务实得多。很多团队把链路做得极其复杂,结果因为一个删除失败没重试,照样出问题——简单方案加上可靠兜底,往往比复杂方案没有兜底更靠谱。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进