缓存与数据库的一致性问题,几乎是每个后端团队迟早要正面撞上的一堵墙。你可能已经听说了无数种“标准答案”——先更新数据库再删缓存、延迟双删、订阅Binlog……但真到了线上,你会发现每种方案都有自己的脾气。
这篇文章我从头到尾梳理一遍,从问题根源、经典方案、进阶方案,到最终落地的技术选型,都会结合我自己的踩坑经历来讲。希望能帮你在设计方案的时候少走几步弯路。
1. 这个问题到底出在哪:两条读写路径的天然时差
先建立基本共识:缓存层和数据库层是两个独立的存储系统,各自拥有独立的读写接口。业务代码在两者之间做数据搬运时,一旦涉及写操作,必然存在“谁先谁后”的编排问题。这个编排只要有时间差,就可能出现一个瞬间,让某个请求读到新旧不一致的数据。
1.1 所有一致性问题的源头只有一个
假设存在一份数据,数据库里是值A,缓存里是值B,A不等于B,那么对上层应用来说,读到的就是脏数据。脏数据窗口从哪里来?三个来源。
第一,写请求与写请求之间的竞争。两个线程同时对同一条数据执行更新,一个先改了数据库,另一个后改了数据库,但缓存里的值却可能是先改的那个、后改的那个、甚至干脆是旧值,完全取决于两个线程在缓存操作上的先后顺序,而这个顺序和数据库操作顺序很难严格绑定。
第二,写请求与读请求之间的竞争。一个线程刚更新完数据库,还没删缓存;另一个线程此时发起了读请求,发现缓存未命中,于是去数据库把旧值读了出来,回填到缓存。等第一个线程执行完删缓存动作,缓存里已经是旧值了,这就是经典的“脏读”窗口。
第三,写操作的中间状态被外部可见。比如你打算“先删缓存、再更新数据库”,数据库事务还没提交时,并发读请求已经把旧值重新写回了缓存,同样会制造出长期存在的脏数据。
本质来看,只要缓存不参与数据库事务的原子性、隔离性语义,就永远存在这种瞬时不一致。所以我们要讨论的,其实不是“如何彻底消灭不一致”,而是“如何把不一致窗口压缩到业务可接受的范围”。
1.2 强一致在分布式场景下是高成本选项
很多人一开始都追求“缓存里的数据必须和数据库一模一样”。但只要你退一步想,就会意识到,为了达到这个目标,要么让缓存完全丧失独立缓存的意义(每次读写都同步等待数据库事务),要么引入复杂的分布式事务协议。到了那时候,你可能会发现性能问题比一致性问题更难解决。
所以做技术决策前,必须和业务方对齐一个认知:缓存与数据库的场景,一般都是最终一致性场景,允许在毫秒级或秒级范围内短暂读到旧值,但需要保证很快追上最新状态。真正连最终一致性都无法接受的业务,比如账户余额强校验、订单库存扣减的超卖控制,压根就不该把强校验逻辑放在缓存预读链路上,而是应该直接查库、用数据库锁或事务保证。
1.3 不同业务模块的矛盾程度完全不同
同样是“缓存不一致”,影响可以天差地别。
- 用户头像昵称:几秒甚至几分钟的不一致,用户基本无感知,甚至可能觉得是网络延迟,完全可以接受。
- 商品详情页:几分钟内旧值问题不大,但如果是价格这种核心字段,就必须在秒级内收敛,否则会引发纠纷。
- 库存、余额、优惠券:这类数据一旦不一致,轻则资损,重则出现超发、套利,必须走强校验链路,缓存只承担读加速的职责,并且要接受“缓存里可能短暂是旧数据,但业务规则会兜底”。
拿到业务需求时,第一件事不是选方案,而是和产品和运营对齐:这个模块,允许脏数据存活多长时间?允许什么范围的数据不一致?这两个问题回答完,方案基本就浮出水面了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 最主流的Cache Aside模式到底解决了什么
业界用得最多的,就是大名鼎鼎的Cache Aside Pattern,也被称为旁路缓存。它的规则非常简单:读请求先读缓存,缓存没有则读数据库,然后回填;写请求更新数据库,然后把对应的缓存Key删掉。
2.1 为什么主流的写操作选择“删缓存”而不是“更新缓存”
这是很多人第一个卡住的地方。我来拆解一下。
如果你选择更新缓存,也就是把新值直接set进Redis,那么必然面临两个问题。
第一个问题是写放大。一次数据库更新,如果同时有多个读请求需要这个Key,每个读请求都可能触发一次业务逻辑,但这些逻辑本质上只是把同一份新值复制一份到缓存。如果并发高,这些写请求全部打在Redis上,完全是浪费。而删缓存的做法,下一次真实读请求到来时,只会有一个线程去数据库查询并回填,其他并发请求等待这个回填完成,成本低得多。
第二个问题是并发窗口不可控。假设线程A和线程B同时更新同一条数据,A先更新数据库,B后更新数据库。如果A在更新缓存时比B慢了一步,那缓存里最终存的是A的旧值,和数据库最新的B值不一致。这种场景下你没有任何办法优雅解决,只能靠“删缓存”来让缓存彻底失效,迫使下一次读强制回源数据库。
所以“更新缓存”在并发场景下几乎不可取,唯一适合的场景是低频写、高频读且写操作本身能够串行的场景,比如后台管理员手工改一条公告,这类场景可以更新缓存,但绝大多数在线业务做不到。
2.2 为什么“先删缓存再更新数据库”是经典大坑
我见过不少团队初版设计就是“先删缓存,再更新数据库”,理由是:删缓存成本低,就算删了之后数据库还没更新完,也最多是缓存暂时没有数据,顶多多查一次库。
实际运行起来就露馅了。最典型的时序是这样的:线程A删除缓存,还没更新数据库;线程B发起读请求,缓存未命中,于是去数据库读取旧值,回填缓存;然后线程A完成数据库更新。这时候缓存里的值永远是旧值,直到缓存过期或被下一次写操作删除,才会被纠正。
这个方案的坑在于,它把不一致窗口从“删除与更新之间”延长到了“下一次缓存失效/删除之前”,窗口大小完全不可控,极容易形成长期脏数据。
2.3 为什么“先更新数据库再删缓存”更稳妥
这一步的推理很简单:先让数据库完成更新,此时缓存里可能还有旧值,但旧值最多存活到删除动作完成的那一刻。删除动作执行前这个短暂时间窗口内,读请求如果命中缓存,读到的是旧值,如果未命中,读到的可能是新值。删除动作完成后,缓存彻底失效,下一次读强制回源,必然拿到新值。
这个方案的不一致窗口,理论上只有“数据库更新完成到缓存删除完成”之间的那几十毫秒。在实际场景中,这个窗口非常小,绝大多数业务可以接受。
但是注意,这个方案有一个臭名昭著的并发漏洞:线程A更新数据库完成后准备删缓存,此时线程B发起读请求,缓存未命中(可能因为刚刚过期或被其他操作删掉),线程B去数据库查询,拿到的是数据库更新后的新值,正常;但如果线程B是在线程A更新数据库之前发起的查询,拿到的是旧值,并在线程A删除缓存之前完成了回填,那缓存里又是旧值了。不过这个窗口同样非常小,而且需要“缓存刚好失效/被删”和“读请求刚好卡在写请求之前”同时发生,概率极低。后面我会讲延迟双删对这个漏洞的兜底。
2.4 删除缓存失败怎么办
哪怕选对了顺序,还有一个无法绕开的现实问题:更新数据库成功,但删缓存失败怎么办。如果只是偶发失败,Redis的一次网络抖动、一个连接超时,缓存里就留下了旧值。这时必须要有补偿机制。
我常用的补偿策略有三层:
- 第一层,给缓存Key设置合理的过期时间。即便删除失败,缓存旧值也最多存活到过期那一刻,不会永远脏下去。
- 第二层,删除操作失败后,立即重试,重试仍然失败,记录日志并投递到延迟队列,由后台任务进行异步重删。
- 第三层,对于核心数据,可考虑开启数据库Binlog订阅,当检测到数据变更事件时,主动清理对应Key。这个后面会展开说。
3. Redis缓存治理:从Key设计到穿透击穿雪崩
解决了读写顺序,接下来才是真正考验工程能力的地方——如何让缓存系统在线上稳定运行。这里说的不只是“删对Key”,还包括Key的规范、过期策略、以及缓存层自身的高可用防护。
3.1 Key设计:从源头杜绝脏数据和冲突
缓存Key的命名是最容易被忽略、又最容易出事的环节。如果Key设计不规范,比如直接用用户ID、商品ID裸奔,一旦业务里有两个模块共用一个Redis实例,Key极容易互相覆盖,产生诡异的不一致。
我个人的规范是这样的:业务域前缀+实体类型+业务主键。比如订单域的订单状态缓存:order:status:{orderId}。商品域的价格缓存:product:price:{productId}。这样即使多个微服务共享一个Redis实例,也不会互相污染。
另外一个必须注意的细节是,缓存Key必须和数据库的变更维度严格对齐。如果你在数据库层面按“用户ID”维度更新,但缓存Key包含了“小维度的筛选条件”,比如用户未读消息数,那么在删除时一定要把所有相关维度的Key都清掉,否则就会出现“数据变了,但某个维度Key还在用旧值”的问题。
3.2 过期时间分布:别给雪崩留口子
业务上线前,大部分人都会设置一个固定过期时间,比如商品详情缓存600秒。如果全量缓存Key都在同一时刻写入,又都设置为600秒,那么每600秒就会迎来一次全量回源的峰值。这个峰值如果压垮数据库,就会引发雪崩,而雪崩之后缓存重建的压力又会再次压垮数据库,形成恶性循环。
正确做法是给过期时间加一个随机抖动,比如基准值600秒,叠加0-120秒的随机值,把回源压力打散。这是我在一次线上促销活动中被教会的,那次活动开始后每10分钟数据库就会出一次毛刺,最终定位到就是固定过期时间导致的阶梯式回源。
3.3 缓存穿透、击穿、雪崩的工程化解法
这几个问题虽然常被当成Redis八股问,但真遇到才知道有多痛。
缓存穿透是指请求了缓存和数据库中都不存在的数据,比如用一个不存在的商品ID反复查询,每次都会打到数据库。最实用的解法是空值缓存,也就是把空结果也缓存一段时间,比如60秒,同时可以用布隆过滤器前置拦截明显不存在的ID。
缓存击穿是指某个热点Key在过期瞬间,大量请求同时涌入,全部绕过缓存直击数据库。解法有三个:互斥锁,让第一个回源的线程持有锁,其他线程等待锁释放后直接从缓存读取;逻辑过期,不等缓存真正过期,而是把过期时间存在value里,线程发现逻辑过期后主动去数据库刷新;不过互斥锁实现简单,首推。
缓存雪崩则是大量Key同时过期,或Redis实例整体挂掉,导致流量全部打到数据库。解法对应两条路:过期时间随机化,避免集中过期;Redis高可用,可以用主从、哨兵、Cluster模式,保证单点故障时读请求还能继续命中缓存。
3.4 缓存一致性操作要避开集群模式的坑
如果你用的是Redis Cluster,删除缓存时需要注意Key的CRC16 sharding逻辑。代码里如果对Key做了某种预处理(比如加了环境前缀、模块前缀),删除时必须用完全相同的Key去删,否则会出现删了A节点的Key,但请求走的却是B节点,最终缓存残留。
这个坑我踩过一次。当时同一个缓存Key在不同服务里用了不同的序列化前缀,结果一个服务删的是user:{id},另一个服务读的是user_2:{id},两个Key都映射到了相同的数据,却因为hash不一致导致删除了无用的Key,脏数据一直没被清掉。排查了好久才醒悟,后来统一收口到公共缓存工具类里才解决。
4. 进阶方案:延迟双删、Binlog订阅与异步补偿
如果纯靠Cache Aside,偶尔还是会有百万分之一的不一致窗口让业务方不满意。这时候就要上进阶手段了。
4.1 延迟双删的执行逻辑与参数考量
延迟双删的完整流程是这样的:更新数据库,删除缓存,然后等一小段时间,再次删除缓存。
这么做的目的是兜住“并发读回填旧值”的窗口。第一次删除后,业务等待的这段延迟时间,就是为了让那些“在数据库更新前发起、在缓存删除前回填”的读请求完成回填动作,然后再用第二次删除,清掉这批旧值。
延迟时间怎么定?这是一门学问。太短了兜不住慢请求,太长了又会让整体写入链路变慢。我的经验是,延迟时间应该大于“读请求从缓存未命中到完成数据库查询并回填缓存”的最长耗时。一般业务里,这个耗时受数据库连接池、网络抖动影响,可以取一个合理的最长值,比如500毫秒。如果你不确定,可以用日志统计出P99的读回源耗时,然后乘以2。
但延迟双删有个致命缺陷:它是在业务线程里sleep等待,会阻塞写请求的线程。对于高并发写入场景,这并不友好。我认为延迟双删更适合那种“写请求量不大,但一致性要求较高”的业务,比如后台管理系统的配置变更、运营位的上下线,反而不适合核心交易链路。
4.2 用订阅数据库变更日志来解耦业务代码
另一个更优雅的方案,是订阅数据库的Binlog,解析出数据变更事件,异步地清理或重建对应缓存。
具体实现上,社区里常见的中间件是阿里巴巴的Canal,它可以伪装成MySQL的从库去拉取Binlog,然后把解析出的变更事件推给消费者。消费者拿到事件后,按照约定的规则拼接出对应的缓存Key,然后执行删除或更新。
这套方案的好处是显而易见的。首先是业务代码和缓存操作完全解耦,不用在每个写接口里手动调用缓存删除逻辑;其次是顺序天然一致,因为Binlog严格反映了数据库的提交顺序;再次是天然支持重试,消费者消费失败后可以投递到死信队列,由后台任务定时补偿。
但依赖额外组件也意味着额外的运维成本和架构复杂度。Canal本身要单独部署,Binlog的拉取延迟取决于MySQL节点的压力和网络,还有数据量大时的批处理设计。这些成本在小团队或低流量场景下可能并不值得,但在读多写多、体量大的系统里,几乎成了标配。
4.3 本地消息表与延迟任务兜底
如果不想引入Canal这种重量级组件,还有一个更轻量的方案:本地消息表加上延迟任务。
具体做法是:在业务代码里,更新数据库的同时向本地消息表插入一条“待删缓存Key”的记录,二者在同一个数据库事务里完成。然后一个后台调度器定期扫描消息表,把未处理的Key挑出来,执行缓存删除动作。删除成功就更新状态,失败就继续等待下次扫描。
这种方案的好处是可靠性极高,因为只要数据库事务提交成功,消息记录就一定存在,不会因为进程崩溃而丢失。坏处是每次写操作都多一次本地消息表的插入和查询,对数据库会有一定额外压力,且最终一致性存在秒级到分钟级的延迟。如果业务能接受秒级延迟,这块性价比很高。
5. 那些“看着相关实则完全不同”的框架缓存
很多团队明明在应用层用了MyBatis、Spring Cache等框架提供的缓存能力,却忽略了它们与Redis缓存之间可能存在的一致性冲突。
5.1 MyBatis一级缓存、二级缓存的实际影响
MyBatis的一级缓存是SqlSession级别的。同一个SqlSession内,如果执行了两次完全相同的查询,第二次会直接命中一级缓存,不会发SQL到数据库。如果你在同一个会话内先查询了一次数据,然后通过另一个会话改了数据库,再回到原会话查询,你拿到的可能是旧值。好在一级缓存的存活周期很短,通常随着SqlSession关闭而失效,影响有限。
二级缓存是namespace级别的,也就是跨SqlSession共享。一旦开启,MyBatis会自己维护缓存数据,这时如果Redis缓存和应用内缓存同时存在,双份缓存的数据不一定同步,这种不一致排查起来极其痛苦。我的建议是:要么只用Redis缓存,要么彻底关掉MyBatis二级缓存。大多数规模不大的项目,完全没必要开二级缓存,性能提升有限,坑却不少。
5.2 Spring Cache的Evict执行时机与失效场景
Spring Cache提供的@Cacheable、@CacheEvict、@CachePut注解确实方便,但很多人忽略了它的执行原理。它基于Spring AOP,在方法执行前或执行后切面里去操作缓存,所以缓存操作和业务逻辑之间隔着代理层。这意味着:如果方法内部有自调用,也就是在同一个Bean内部调用另一个带缓存注解的方法,Spring AOP切面根本不会生效,缓存自然也不会被PoJie;如果方法抛异常,@CacheEvict在默认配置下会正常执行删除,但@CachePut的更新动作不会执行。
另外,Spring Cache默认的缓存Key生成规则比较简单,会直接使用参数对象生成哈希值作为Key,这就容易出现两个方法参数相同但语义不同的缓存互相覆盖。如果你要用Spring Cache,强烈建议显式指定Key生成器,比如基于SpEL表达式:#userId + '_' + #bizType,而不是依赖默认机制。
5.3 Spring三级缓存的概念辨析
提到“缓存”,很多人会联想到Spring的“三级缓存”,认为它和缓存一致性有关。这里先做个辨析:Spring三级缓存是Spring IoC容器为了处理循环依赖而设计的内部数据结构,级别分别是singletonObjects(一级)、earlySingletonObjects(二级)、singletonFactories(三级),解决的是“单例Bean初始化过程中的循环依赖”问题,跟业务数据的缓存一毛钱关系都没有。
之所以经常被混在一起,是因为名字都叫“缓存”。实际上它既不会缓存业务查询结果,也不参与任何数据一致性逻辑,只是在Bean创建早期把未完全初始化的Bean提前暴露出来。如果有人在面试里拿它当成业务缓存去答,基本就凉了。
6. 分布式事务与缓存一致性:别把两个问题混为一谈
还有一批团队,提到一致性就想着引入Seata、TCC、2PC这类分布式事务方案,试图通过事务机制让缓存和数据库保持同步。这里必须泼一盆冷水:分布式事务解决的是“多个数据源(比如订单库+库存库)之间的事务一致性”,它根本就不适合用来做缓存操作。
原因很简单:缓存的本质是高速临时存储,它支持的操作是Set、Del这类幂等逻辑,不具备事务性。就算你成功地把“更新数据库”和“更新Redis”放进同一个全局事务里,一旦Redis操作超时或失败,整个事务回滚,业务写入也被中断,代价太高。
何况分布式事务框架本身的性能开销极大,复杂度极高,引入一套Seata带来的运维成本,大概率远超它在缓存一致性上能带来的收益。所以我的结论是:缓存一致性问题的正解,永远是“在缓存层做柔性处理”,通过最终一致性来解决,而不是强拉数据库事务进场。
7. 从实战出发:一致性要求分级与技术选型清单
前面讲了原理和方案,最后落到真正的问题:手上接了一个模块,应该怎么选。我习惯先把业务分四个级别来对待。
7.1 四个一致性等级,对应四套方案
第一级:读多写少、容忍秒级延迟的展示型数据,比如文章列表、公告、配置信息。最优选就是Cache Aside + 过期时间兜底,连延迟双删都不需要。因为这类数据本身允许短时间脏读,缓存过期自然收敛。
第二级:需要很快收敛但又不能阻塞写入的数据,比如商品详情页的价格、库存余量。可以选“先更新数据库再删缓存 + 删除失败重试队列 + 较短过期时间兜底”。这里重试队列的延迟设计在几十毫秒到几百毫秒之间,业务一般感受不到。
第三级:并发读很高、又希望尽量降低不一致概率的数据,比如热点商品的促销价格。可以在第二级基础上加延迟双删,同时把延迟时间压到200-500毫秒,并配合布隆过滤器防穿透。
第四级:严格意义的强一致场景。别碰缓存,直接查库;如果确实需要缓存加速,那就把缓存只作为“读加速”,一致性校验一律靠数据库规则和幂等机制来兜底,不允许把业务正确性寄托在缓存上。
7.2 我踩过的一个地方政府项目
早年在做一个政务类项目时,为了追求“看起来高大上”,团队给一个权限配置模块加了Redis缓存。结果运营在后台修改部门权限后,前端用户的权限列表经常半小时都刷新不过来,运营投诉了无数次。后来排查发现,问题根源是:权限变更走的是不同的服务,而读权限的服务只从缓存取数据,压根没有在变更链路里删除缓存,旧值全靠过期时间兜底,过期时间又设成了1800秒。
这个案例的教训很直接:凡是跨服务修改的共享数据,必须在修改方和服务消费方之间建立显式的缓存失效协议,否则你永远不知道自己改了一个自认为“无关”的数据,却在下游某个服务里留下了一个半小时的脏窗口。这也是为什么我强烈建议核心共享数据要么走Binlog订阅,要么走消息队列通知的方式去删缓存。
另外还有个细节:缓存过期兜底不等于可以随便调大过期时间。过期时间越长,脏数据存活越久,风险越高。一般展示型数据建议不超过5分钟,核心数据建议不超过30秒。你要是设一个小时的过期时间,就要做好接受一小时脏数据的心理准备。
8. 最后一件事:把兜底和监控当作方案的一部分
方案敲定之后,千万别觉得上线就结束了。缓存一致性问题是分布式系统里最难排查的问题之一,一旦出现脏数据,没人能告诉你它是哪一步操作导致的。所以必须保证事后可以观测、可以定位、可以快速止血。
我通常会在缓存操作的下游日志里记录三类信息:具体执行了哪些缓存Key的删除或更新、操作结果成功还是失败、对应的业务请求ID和数据库操作流水ID。一旦出现脏数据,就能通过业务流水反查出它最后一次落到缓存的时间点,快速定位是哪条写链路漏删了Key。
如果团队人力允许,我还会建议加一个定时巡检任务:对核心数据,定期比对数据库CAS值或版本号和缓存值是否一致,发现不一致就主动回源数据库、刷新缓存。这个巡检任务往往能拯救你于水火,尤其是在跨服务、多链路并存的项目里。
最后想分享一个经验:缓存一致性没有银弹,越是复杂的方案,引入的额外故障点就越多。能靠“过期时间+删除重试+兜底巡检”解决的,就尽量不要引Canal、不要上分布式事务。把最终一致性作为一种默认预期,在核心业务上做强校验兜底,这才是大多数团队真正需要的平衡点。
