1. 缓存一致性问题的核心:过期数据和脏数据是两回事
1.1 一个最普通的读流程,不一致窗口是怎么出现的
不管你在哪个公司做后端,只要画过缓存架构图,大概率都画过这样一条链路:请求先查 Redis,没命中就回源数据库,再把结果写回 Redis,下一次读就命中缓存了。这个流程本身没毛病,它保证了"读多写少"场景下绝大部分请求不会打到数据库。关键问题出在"更新"这一步:当数据库里的数据变了,Redis 里那份旧副本并不会自己跟着变,它会静静地躺在那里,继续被当成新鲜的返回给用户。
不一致窗口就是从这一刻开始的:数据库更新完成的那个瞬间,到 Redis 里旧值被清除或刷新成功之前,所有的读请求都有可能拿到旧值。窗口的最小长度取决于缓存删除动作的执行速度,最大长度则取决于你给缓存设置了多长的过期时间,或者干脆没设过期时间。
很多刚开始接触缓存的人会觉得,解决思路很简单:更新数据库的同时把 Redis 删掉不就行了?但实际操作时会发现一个很现实的问题——"同时"这两个字是做不到的。两个操作一定有一个先后顺序,先做哪个、后做哪个,直接决定了不一致窗口的长度和性质。如果顺序选错了,窗口不仅不会缩小,甚至会扩大,还可能造出比过期数据更难清理的脏数据。后面我会专门把两条经典路径掰开揉碎讲清楚。
1.2 过期数据和脏数据,处理难度完全不在一个量级
先定义好两个关键概念,后面所有方案其实都是在跟这两种东西做斗争。
- 过期数据:数据库已经是新值,缓存还保留着旧值。这种情况通常有 TTL 兜底,或者下一次写操作会覆盖它,只要时间足够,它自己会消失。
- 脏数据:某个请求把错误的值写进了缓存,比如把回源读到的旧值回填进去了,这个值可能会长期驻留在缓存里,甚至覆盖掉后来正确写入的新值。这种情况不会自动消失,必须显式清理。
我见过不少线上事故都属于后者。经典的案例就是"先删缓存、再更新数据库"在并发场景下翻车:一个请求删了缓存后还没写完数据库,另一个请求回源读到了旧值并回填到 Redis,数据库最终变成新值,缓存却永远留着旧值。这类问题已经不是"时间窗口"能解释的了,它是状态污染,只能靠补偿机制去清理。
理解了这两个概念的区别,你再看各种一致性方案,就不会觉得它们是在故弄玄虚。所有方案本质上都在做同一个博弈:尽量缩小缓存和数据库的不一致窗口,同时保证在这个窗口内出现的数据偏差可接受、可补偿、能自动收敛。
1.3 为什么"最终一致"才是这个领域的主流目标
每个团队在做技术选型时,都面临一个尴尬的现实:如果要实现缓存和数据库的强完全一致,最简单粗暴的做法是让读请求不读缓存,或者让写请求阻塞到所有副本同步完成。但这等于把缓存带来的性能优势全部丢掉,那还引入缓存干什么?
所以业界的主流做法基本不是追求强一致,而是追求最终一致,并叠加一个业务可接受的误差窗口。我观察到一个很普遍的现象:不少开发同学刚接触缓存时,执着于"不一致就是系统有 bug",总觉得凡是出现旧数据就不能接受。实际上,绝大多数用户业务场景并没有那么苛刻,用户不关心数据在某一瞬间是否完全同步,他们真正在意的是数据最终是对的,旧数据不会长时间滞留在界面上。
这个认知其实很重要。它决定了你不会把大量精力花在追求一个不存在的"绝对一致"上,而是愿意花时间去设计一个"可控的有界不一致"系统。这也是为什么所有成熟方案的讨论都绕不开 TTL、重试、异步补偿这几个关键词——它们不是附加功能,而是达成最终收敛的核心手段。
2. 两条经典写入路径的对比:先删缓存还是先更数据库
2.1 方案A:先删除缓存,再更新数据库
先删缓存这个思路非常符合直觉:把缓存里可能存在的旧值清掉,下一次读一定会回源数据库,只要数据库写成功了,新值自然会被回填。这可以避免缓存里同时出现"更新中的中间状态",理论上听上去很合理。
但在并发场景里,它会立刻翻车。假设数据库里某个商品价格的初始值是 0,现在请求 A 要把价格改成 1,于是先删掉了 Redis 里对应的 key。就在这之后、请求 A 还没更新数据库之前,请求 B 来读这个商品价格,Redis 未命中,B 回源数据库,读到旧值 0,然后 B 把 0 写回 Redis。紧接着 A 才执行数据库更新,把价格从 0 改成 1。
数据库现在是 1,Redis 里却是 B 回填的 0。A 已经执行过"删除缓存"这一步,不会再删第二次了,于是这条脏数据就在 Redis 里安家落户,直到下次有人主动清理它,或者 TTL 到期。回源的时机只要落在"缓存已删除、数据库未更新"这个缺口里,读到的必然是旧值,而这个旧值会被当成最新鲜的结果回填到缓存里。
生产环境中,这种现象不只存在于纸面推演。应用拆成多实例以后,请求之间的处理时间各不相同,这种交叉很容易发生。尤其当中间隔了微服务调用,回源操作的完成时间更不可控。所以方案 A 在单请求下没问题,在并发条件下就是一颗定时炸弹。
2.2 方案B:先更新数据库,再删除缓存
先更新数据库再删除缓存,是当前应用最广泛的主流方案。它的基本逻辑是:数据库是唯一事实来源,先把新值写进事实源,提交成功后再把缓存里的旧值清掉,下一次读自然就会回源拿到新值。
这个方案的不一致窗口在于:数据库更新成功之后、Redis 删除操作生效之前,可能有一个读请求正好命中缓存,读到旧值。这个窗口通常短到只有一次网络往返的时间,在大多数业务场景下几乎感知不到。
但方案 B 也有一个致命弱点:如果 Redis 删除操作失败了怎么办?比如网络瞬时超时、缓存服务重启、连接池打满等等,旧值会一直留在 Redis 里,不会再有人触发删除动作,后续所有读请求都会持续命中旧值。只要没有 TTL,这个不一致就是永久性的。所以在这个方案里,删除动作的可靠性是整个设计的关键。它不是一次网络调用那么简单,而是必须能重试、能补偿、能监控的一条完整链路。
两个方案放在一起对比会更清楚:
| 方案 | 主要不一致窗口 | 风险特性 | 推荐程度 |
|---|---|---|---|
| 先删缓存再更新数据库 | 缓存删除与DB更新之间的间隙,可能被并发读回填旧值 | 容易产生脏数据,且难以察觉 | 不推荐作为主方案 |
| 先更新数据库再删除缓存 | DB更新完成与缓存删除生效之间的瞬间 | 窗口短,但删除失败会导致长时间不一致 | 推荐,需配套可靠删除 |
这里还得补充一个很多新手容易忽略的细节:这两个方案都没有考虑"数据库更新失败"的情况。如果数据库没写成功,方案 A 缓存被删了,下一次读回源还是旧值,缓存和数据库其实还是一致的;方案 B 缓存是旧值,数据库也没变成新值,双方也保持旧值的一致。真正要命的只有两种——数据库更新成功但缓存删除失败,以及数据库还没更新完成、旧值就已经被回填进了缓存。
2.3 为什么不考虑"先更新缓存,再更新数据库"
选择策略时经常有新人会问:既然最终都会一致,那能不能先把 Redis 更新成新值,再回头改数据库?如果数据库更新成功了,两边不都是新值了吗?
这个思路在逻辑上是错的。因为缓存只是数据库的副本,它没有资格当数据的所有者。一旦数据库更新失败,缓存已经向用户返回了新值,持久层却还是旧值,这是全局性的数据灾难。之后的每一次读都是基于错误新值做业务判断,影响面完全不可控。就算数据库更新成功了,这两步之间也会出现一个很长的不一致窗口,比方案 A、B 的窗口都大。
更麻烦的是,很多业务写入需要依赖数据库的返回值。比如你插入一条记录,需要用数据库生成的自增主键去拼缓存 key 或者缓存内容,那你根本没有办法在写数据库之前拿到最终结果,自然也就无法先把缓存更新成正确的新值。所以"先写缓存"这条路基本走不通,工程上几乎没有正经方案会把它当作主路径,最多是在非常特定、边界条件很严格的情况下,作为"写后更新缓存"的优化手段出现。
3. 把不一致窗口压缩到毫秒级的组合招:延迟双删与重试队列
3.1 延迟双删到底在防什么
延迟双删是我在不少团队里见过的一类经典方案,专门用来对付前面说的"并发读回填旧值"问题。所谓延迟双删,就是执行两次删除操作:
- 请求 A 先删除 Redis 缓存。
- 请求 A 更新数据库,把值从 0 改成 1。
- 等待一段时间,比如 1 秒。
- 请求 A 再次删除 Redis 缓存。
第二次删除为什么要延迟?因为第一次删除和数据库更新之间有一个缺口,在这个缺口里可能已经有并发读请求回源并回填了旧值。给这个缺口留出时间,等回填操作完成,然后第二次删除正好把这些旧值清掉。如果第二次删除太快,旧值可能还没来得及回填,删除动作根本起不到清理作用。
这个方案的优点是简单直接,不需要额外引入中间件,在当前请求的线程里就能完成。但它的缺陷也很明显:等待时长不好选择。如果延迟太短,旧值还没回填就被删了;如果延迟太长,一个写接口要白白等待一秒甚至更久,在高并发场景下性能损失非常大。延迟长短通常需要参考慢查询时间、应用调用链路的平均耗时就慢时间,再留一点余量,常见取值在 500ms 到 1s 之间,但每个系统的最优值都不一样。
有一个细节值得注意:延迟双删解决的是"并发读回填旧值"的脏数据问题,但它没有办法保证第二次删除一定成功。如果第二次删除也失败了,整个方案就会退回到"先删缓存再更新数据库"的不安全状态,只能依赖 TTL 兜底。所以在成熟的架构里,延迟双删通常不是孤立存在的,它往往跟下面的异步补偿队列一起用。
3.2 一个可落地的异步补偿队列设计
在真实生产系统中,删除 Redis 的操作经常会因为各种各样的原因失败:Redis 连接超时、服务端 OOM、网络分区、客户端异常退出等等。你不可能保证每一次 DEL 都成功,所以必须把"删除失败后怎么办"提前设计好。
我在几个项目里实践过一套相对可靠的补偿方案:把删除操作做成一条消息,扔进一个持久化的队列里,由专门的消费者去执行删除,执行失败的按指数退避策略重试,重试达到上限的消息进入告警池。具体流程如下:
- 业务更新数据库成功后,不直接在代码里删 Redis,而是写入一条"缓存失效任务",记录 key 和失效时间。
- 一个定时任务或者消息队列消费者按照频率读取任务,执行 Redis 删除。
- 删除成功就从任务表中移除或标记完成,删除失败则重新投递,按 1s、5s、30s 的间隔递增重试。
- 超过最大重试次数后,任务转入一个异常监控表,同时给值班同学发送告警。
这套方案的收益很直接:业务线程不需要关心 Redis 删没删,只负责把"该删"这个事实记录下来。Redis 短暂抖动不会把业务链路拖垮,删除动作的可靠性从"一次网络调用"提升到"消息系统的消费链路",任何一个环节出问题都有日志可以回溯。
代价则是消息队列本身有延迟。从任务写入到消费者真正拿到任务并发起删除,中间隔了少则几十毫秒、多则几百毫秒。这个间隔同样构成了不一致窗口,对大多数站内业务来说可以接受,但如果你的业务对延迟极其敏感,还是要在代码层做一个"立即删除+失败异步重试"的组合。让正常的删除走最快的路径,失败场景才退到重试表。
3.3 我在一个订单服务上的一次真实调整经历
之前我经手过一个订单服务,商品列表页的数据走 Redis 缓存,更新任务发生在管理后台。后台编辑商品后,需要让列表页缓存失效。当时团队上线的就是"先更新数据库再删除缓存"方案,看起来没什么问题。
上线后出现了很典型的症状:在后台改完商品标题,用户打开商品详情页偶尔还是能看到旧标题,刷新一两次才变过来。这个现象不是每次都出现,频率也不高,但足够让人头疼。排查了很久,我们发现根因恰好出在异步工作流上。管理后台的编辑操作不是同步更新数据库,而是先投递一个更新任务,任务在后台执行。数据库实际提交更新的时刻,往往比业务代码里触发"删除缓存"的动作晚了几百毫秒。在这几百毫秒里,已经有不少读请求回源数据库读到了旧值,并把旧值回填进了 Redis。
后来我们把缓存失效动作从业务代码里抽了出来,改成走一张本地补偿表:数据库事务提交之后,写入一条"待删除 key"的记录,定时任务以 500ms 的间隔轮询,每条记录最多重试 5 次,全部失败就告警。同时给列表缓存设置了一个 10 分钟的 TTL 作为终极兜底。这样一来,数据库提交和缓存删除之间的时序错乱问题就自然解决了,旧数据滞留的投诉基本消失。
4. 把"删缓存"从业务代码里摘出去:基于日志变更的订阅方案
4.1 核心思路:数据库日志才是真正的事件源
在微服务架构里,"到底是谁改了这张表"这个问题经常变得很复杂。用户前端改一次、运维脚本改一次、数据补偿任务再改一次,每个数据修改入口都可能绕过业务代码里的缓存删除逻辑。如果你只在业务代码里维护缓存失效,那漏删除几乎是必然的。
在这种情况下,基于数据库变更日志的订阅方案就是更好的解。核心思想是:不依赖任何业务代码主动删除缓存,而是让一个独立组件订阅数据库的变更日志。数据库发生插入、更新、删除时,日志会产生对应事件,订阅方拿到事件后解析出表名和主键,拼出对应的 Redis key,再主动发送删除指令。
这套方案落地之后,业务代码只需要负责写数据库这一件事。缓存一致性完全交给日志同步链路来处理。不管是谁修改了数据,改了多少次,只要数据库真实发生了变化,日志事件就会产生,缓存失效就不会漏。数据持久层成为唯一可信赖的事实来源,所有下游副本都是它的跟随者。
这也是为什么中大型团队会放弃代码里的显式删除,宁愿多养一套日志订阅基础设施。当"谁改数据"这个问题没有明确答案时,日志订阅几乎是唯一可靠的办法。
4.2 落地时要注意的几个坑
日志订阅看着优雅,落地的坑非常多,挑几个最容易踩的说:
- 消费延迟。从数据库提交到订阅方拿到事件,中间隔着日志生成、网络传输、消费解析几个环节,通常有几十到几百毫秒的延迟。缓存的不一致窗口会跟着这个延迟走,所以它不适合对实时性要求极其严格的功能。
- 重复消费。日志同步经常因为网络抖动或位点回退导致同一条事件被消费两次,如果消费方做了删除操作,第二次删除没命中 key,不影响正确性;但如果消费逻辑里还改了其他状态,就必须做幂等设计。
- 主从切换。主库切到新节点之后,日志位点可能会对应不同的记录,订阅方如果没能重新对齐进度,事件就会丢失。这点需要在架构上特别注意。
- 不是所有表都适合订阅。有的业务表变更极其频繁,比如埋点日志、操作流水,如果它们也被当成缓存失效源,消费速度会跟不上生成速度,反而把 Redis 连接和内网带宽打满。
这里得控制好订阅范围,只订阅确实需要缓存失效的核心业务表,其他表一律过滤掉。
4.3 这套方案的边界在哪里
日志订阅方案最适合"读多写少、数据条目多、缓存命中率要求高"的场景,比如商品详情、用户资料、配置项查询。这类数据更新频率不高,但每次更新都必须让下游尽快感知,日志订阅把"感知动作"从代码中移除,稳定性和覆盖率都很理想。
它不擅长库存这类热点数据的实时同步。库存的更新频率极高,日志事件每秒可能成千上万条,消费速度跟不上,缓存被删了又立刻被下一轮读请求回填,反而出现缓存抖动。对这类场景,直接在业务代码里做"更新数据库后主动删缓存"反而更可控。
所以判断要不要上日志订阅,可以先问自己一个问题:这张表的修改者究竟有哪些?如果答案只有一个明确的服务,正常方案就够用;如果答案是"很多服务、脚本、运维都可能改",那日志订阅几乎是唯一靠谱的选择。
5. 多节点、多级缓存下的放大挑战
5.1 Redis 集群里删一个 Key 没那么简单
很多一致性方案分析默认系统只有一个 Redis 实例、一个键空间,真实生产完全不是这样。Redis Cluster 环境下,key 会根据槽位分布到不同节点,删除某个 key 必须路由到正确的数据分片。如果客户端分片逻辑跟缓存 key 规则不一致,或者代理层把请求路由到了错误的节点,删除命令就会漏掉真正的旧值节点。
集群模式下还经常出现部分节点成功、部分节点失败的情况。比如节点正在做主从切换或者重新分片,DEL 命令可能在某个节点上被丢弃;网络分区导致删除命令没有到达所有副本。这种局部失败很隐蔽,因为从客户端接口返回值看,删除命令总体是成功的。
我的建议是,在多节点环境下,给缓存删除统一配上"确认加补偿"机制。删除前确认 key 在哪个节点,删除后检查返回值,失败项进入重试队列并纳入监控。缓存已经分散到多台机器上,任何单点删除的"我已经发出去"都不能被当作"已完成"。
5.2 本地缓存(进程内缓存)其实是另一个坑
有些应用为了追求极致性能,会在业务进程里再放一层本地缓存,查询时先查本地,再查 Redis。引入这一层之后,一致性问题的复杂程度立刻上升一个数量级。
本地缓存没有中心化管理,应用 A 删除的是它自己进程里的 key,应用 B 进程里的本地缓存可能还残留旧值。如果通过消息广播去通知所有节点清缓存,广播量跟节点数成正比,节点一多性能就会崩溃。不少团队试过这种方式,最后基本都放弃了。
我在实际系统里见过比较务实的做法:本地缓存一律不设置"无限期常驻",把 TTL 设成几秒到几十秒。就算广播删除没生效,本地缓存也会在极短时间内自动过期,下一次读写就会回源到 Redis 或数据库。牺牲一部分命中率,换来最坏情况下的最终一致性,在多实例部署中非常划算。
5.3 多级缓存整体的一致窗口是叠加的
更扎心的现实是,如果系统同时存在"本地缓存 + Redis + 数据库查询结果缓存",处理一致性不能只看一层。每一层都有自己的回源时间和过期机制,如果上游服务返回结果时又套了一层自己的缓存,全链路的不一致窗口就是每一层窗口的叠加。
举个例子。服务 A 从库里取数据后缓存到 Redis,服务 B 调用 A 时又把结果缓存了一份,服务 C 再调 B 时又缓存了一次。这种情况下,即使每一层都严格执行"更新后删除缓存",全链路的不一致窗口依然是每层窗口的总和,并且很难精确估算边界。
做多级缓存的关键原则是:每一层都要有短 TTL 兜底,并且明确每一层的更新线路,不能指望一条链路全部同步到最新值。我自己的习惯是:越靠用户侧的缓存,TTL 设得越短,因为用户直接感知旧数据;越靠数据库侧的缓存,TTL 可以适当放长一些,因为它的数据准确性会影响下游所有层,但回源成本也高。
6. 工程上的务实取舍:一致性等级与兜底 TTL
6.1 强一致需求本来就不该交给缓存层
很多技术和面试里都会质疑一个问题:"怎么保证 Redis 和数据库强一致?"其实这个问题的前提就有问题。强一致意味着任意时刻读取到的值必须是最新的,那读取路径上就不能存在任何带延迟的副本。直接查数据库主库才是最简单可靠的方案。
如果业务确实需要强一致,正确做法是:不要把缓存当作数据语义的一部分,只能把它当成可选的性能加速器。更新时加锁,读取时也配合版本号机制,保证读取到的缓存副本一定来自当前版本。这已经不是"保证缓存与数据库一致"的范畴了,而是在设计一个分布式事务协议,成本和复杂度都非常高。
所以在做架构选型时,我第一步永远是确认业务到底接不接受最终一致。大部分查询类业务是接受的,不能接受的是"长时间不一致";少部分强约束业务才需要硬性强一致,而这些场景通常不应该依赖普通缓存返回业务数据,要么走专门的强一致存储,要么直接读主库。
6.2 TTL 不是偷懒,是收敛兜底
很多团队给 Redis 设置 TTL 时有个根深蒂固的误区:担心 TTL 太短会拉低命中率,于是干脆不设置 TTL,让 key 永久存活。如果删除动作一直可靠,这样确实能保证高命中率。问题在于,删除动作根本不可能做到百分之百可靠。一次网络抖动、一个代码 bug、一次运维失误,都可能导致一次删除悄悄失败。
这种场景下,TTL 是唯一不需要人工介入的兜底机制。哪怕前面提到的补偿队列、消息订阅全部失效,只要 key 带了 TTL,它最终会过期,读请求就会重新回源数据库,拿到最新值,实现最终收敛。站在这个角度看,TTL 不是可有可无的锦上添花,而是整个一致性体系的最后一道防线。
我设置 TTL 时有个简单原则:TTL 的时长应该等于"业务能容忍的最大不一致时长",而不是"希望缓存多存一会儿"。比如商品价格可以接受 1 分钟内的显示偏差,TTL 就设 1 到 2 分钟;用户昵称可以接受更久,就设 5 到 10 分钟。这样哪怕真出现了删除失败,旧数据最多保留 TTL 这么长,不会变成永久污点。
6.3 我做方案取舍时的几个判断准则
总结了多年实战经验,我遇到"缓存与数据库一致性问题"时会按下面这套思路问下去:
- 读写比例是多少?如果读多写少,缓存收益很大,值得投入方案成本;如果写多读少,缓存的收益本来就有限,一致性成本反而会拖累整体性能。
- 业务能容忍的不一致时间是多少?几毫秒、几秒、还是几分钟?容忍度越低,方案越重,成本越高。明确容忍度可以帮你快速排除掉绝大多数过度设计。
- 谁在改数据源?如果只有一个明确服务能改,业务代码里主动删缓存就够了;如果存在多个入口,脚本、补偿任务、后台都可能改,那就要上日志订阅或者集中补偿。
- 删除失败后会发生什么?有没有兜底 TTL?有没有监控告警?如果都没有,无论你选哪种方案,都可能在下一次缓存服务抖动时埋下雷。
这套判断标准覆盖了我在大多数项目里遇到的核心问题。技术方案没有唯一正确答案,只有"在特定约束下最合适的选择"。
7. 最后聊聊实战体会
写了这么多技术路径,最后分享几点一线实操下来的体会。不追求面面俱到,但都是踩坑之后沉淀下来的。
第一,不要把重担全压在"删除"这一条路径上。删除命令再可靠也只是整条链路里的一环,更好的做法是给系统多留几个兜底:TTL 兜底、补偿表兜底、监控告警兜底。删除失败不可怕,可怕的是删失败了没人知道,也没有自动恢复机制。
第二,日志订阅方案虽然优雅,但引入成本不低,运维复杂度也不小。如果团队规模不大、数据量也就一般,先用"先更新数据库加可靠删除加短 TTL"足够撑住绝大多数场景。等到真出现了"数据源被多个入口修改"这种信号,再考虑日志订阅,别为了追求最前沿的方案做过度设计。
第三,一致性问题是排查难度极高的脏活,你看到的"偶尔旧数据"往往不是简单的时序错误,而是缓存、数据库、异步任务、多实例同时作用的结果。排查这类问题时,最好给每次删除操作加上日志和 trace 关联,把"数据库变更时刻、缓存删除时刻、读请求回源时刻"完整串成时间线。只要能把时间线拉出来,定位是哪个环节慢了一步就很快了。
最后,始终记住这句话:数据库是事实源,缓存是加速器,两者之间天然存在时间差。我们做的所有方案,都是在有限成本内把这个时间差压缩到业务可接受,并且保证它最终收敛。脑子想清楚这一点,再复杂的问题都能拆成一个个普通的小决策。