你翻过线上日志没?我翻过。凌晨两点,用户明明支付成功了,状态却一直显示“未支付”,后台查订单数据库里状态明明是“已支付”,但用户页面读到的还是旧值。最后定位出来,不是支付接口的问题,是缓存和数据库没对齐。
缓存、数据库、一致性,这三个词一旦凑到一起,就意味着一定会有某天凌晨电话叫你起来看数据。这几年来我在不同项目里反复处理这个问题,从单体Tomcat到分布式集群都遇到过。这篇博文就把我踩过的坑、用过的方案、反复验证过的手段一次讲清楚,适合正在做后端开发、系统设计,或者被线上数据不一致问题折磨的朋友参考。
1. 一致性问题的根因:两条写路径没有原子绑定
1.1 缓存是什么:从边缘到核心的“速度差填补”
先说个基础概念。缓存不是一个高深技术,它就是在“速度差异”的两端之间插一层中间存储。你电脑上装系统,字体文件、图标文件会被系统缓存;你浏览器访问网站,图片和脚本会被浏览器缓存;你往移动硬盘拷大文件,操作系统会先往内存里写缓存再异步落盘——这就是“移动硬盘要不要开启写入缓存”那个选项的本质。浏览器缓存位置能设置到D盘、系统缓存可以清理重置,这些日常操作都在说明一件事:缓存用来加速,但缓存的本质是“一份可能滞后于源数据的副本”。
数据库和缓存的关系也是这样。数据库磁盘IO再快,也比不上内存操作;关系型数据库再能扛,也扛不住秒级百万次读请求。于是大家都是在数据库前面挡一层Redis或者本地缓存,把高频读请求截下来,让数据库喘口气。
但问题恰恰出在“副本”两个字上。副本在创建之后,源数据只要发生变化,副本就面临过期风险。我们平常说缓存数据库一致性,第一层意思其实是:如何让这份副本尽量跟随源数据变化,不要给用户读到过期甚至错误的数据。
1.2 数据库的“可靠”与缓存的“快”并不天然兼容
数据库设计目标是可靠和持久。一次写操作要落日志、刷脏页、保证ACID;缓存设计目标是快,读写内存,支持过期淘汰。两者在设计哲学上就不一样。
你写一个订单状态,数据库层面有事务保护,要么全成功要么全回滚。但如果你同时要更新数据库和缓存,你去哪里找这个事务?Redis没有和MySQL共享事务的能力,消息队列也没有和数据库共享事务的接口。两个系统之间的写入天然是两步操作。
这里有一个最容易被忽视的失败路径:数据库写成功了,缓存删除或更新失败了。比如你更新了订单状态,然后去删Redis键,Redis刚好主从切换、网络抖动,删除操作超时了。此时数据库里是新数据,缓存里是旧数据,一致性就破了。又或者反过来,缓存更新成功了但数据库事务回滚了,缓存里就是一条不该存在的脏数据。
我的观点是:缓存和数据库的一致性问题,本质上是两条写路径的原子性缺失。你说要保证强一致,就必须让两步操作变成一个原子操作,这在分布式系统里几乎不可能零成本做到;你说接受最终一致,就要设计好“谁先谁后、失败怎么补、怎么对账”的机制。
1.3 一致性问题的三种形态
先认识敌人,再谈解决方案。根据我的经验,线上遇到的一致性bug基本就三种形态:
第一种,读到旧值。数据库已经更新,缓存还是老数据。典型场景是删除缓存失败、或者压根没删。
第二种,读到新值又闪回旧值。用户第一次请求读到新数据,刷新一次又变回旧数据。典型场景是并发请求下,旧数据被某个线程重新写回缓存,覆盖了新值。
第三种,数据永久错乱。缓存里写入了数据库永远没有的数据,比如事务回滚但缓存更新成功,或者update操作序列顺序颠倒。
这三种形态严重程度不同,处理代价也不同。有些业务可以容忍几秒的旧值,但绝对不能容忍闪回;有些业务连几秒旧值都容忍不了。所以动手之前先搞清楚你面对的是哪种形态、业务能不能接受,这是后面所有方案选择的前提。
2. 一致性到底要什么级别:先定义需求再谈方案
2.1 强一致、最终一致与应用边界
很多人一上来就喊“我要强一致”,但实际业务根本不需要。
强一致的意思是:任何时刻任何节点读到同一份数据都是最新的。在单机数据库内部,靠事务和锁可以达到;在分布式系统里,要实现强一致,基本就是让所有读写都走同一个协调节点,牺牲可用性或者吞吐量。你做个登录状态的缓存,用户改个头像,如果严格要求全世界立马看到新头像,这个成本你根本撑不住。
最终一致的意思是:允许短暂的不一致,但保证在某个时间窗口内,所有副本最终都会收敛到最新值。绝大多数缓存场景用的都是这个级别。关键是这个时间窗口够不够短,短到用户感知不到,或者感知到了也不认为出错。
你可以拿两个业务对比感受一下。文章阅读量显示在页面上,你刷新两次看到两次不同的数字,用户根本不会在意,甚至没人知道哪个是老值;但你在电商页面看到“库存还剩5件”,点击购买却提示“库存不足”,用户就会觉得系统有bug。同样是缓存不一致,前者是最终一致就够,后者必须尽最大努力做到强一致。
2.2 读己之写与单调读:两个常用的“低成本强一致”
不是所有强一致需求都要上分布式事务。很多场景只是要求“我自己改完的数据,我自己马上能看到”。这叫“读己之写”。做法很简单:用户在同一个会话里,强制读数据库,或者请求带一个版本号,命中正在进行写操作的数据时直接穿透缓存。
还有一种是单调读要求:用户第一次读到某个版本后,后续读到的版本不能比之前更旧。这个要求比“读己之写”稍微放宽一点。实现方式是缓存里存版本号,每次读请求都带上次版本号,如果缓存里的版本号比客户端上次读到的还小,就回源数据库。
这两个手段我实际用过,能解决相当一部分“感觉像强一致”的需求,而且几乎零成本,不需要引入额外组件。你可以把它理解成“业务级别的版本校验”:数据库更新时发一个自增版本号,缓存放数据也放版本号,读的时候发现版本倒退了就穿透到底层重新拉一次。
2.3 选型判断:什么时候才值得引入分布式事务一致性
分布式事务一致性是另一个重灾区。有人一看缓存库不一致,就建议上两阶段提交、分布式事务框架,结果性能和复杂度双双爆炸。
我这些年做技术选型有一个判断清单,你可以直接拿过去用:
第一,这个数据更新频率高不高?如果一天改不了几次,比如用户昵称、商品标题,你根本不用分布式事务,给缓存设一个较短的过期时间,过期后自然回源,最终一致就够了。
第二,不一致的窗口影响面多大?影响的是单个用户自己看到旧头像,还是全局用户看到错误价格?影响面小,放宽一致性要求;影响面大,考虑强一致手段甚至直接不下缓存。
第三,能容忍放弃可用性吗?两阶段提交在协调者故障时会阻塞,你要是核心交易链路,可能一个节点抖动,整个下单流程就卡住了。对高可用要求极高的系统,宁可短暂不一致,也不能让主链路不可用。
如果这三个问题想清楚了,你会发现大部分缓存系统其实是“本地缓存+分布式缓存+数据库”三层,而真正需要分布式强一致的业务少之又少。
3. 主流落地:Cache Aside 与延迟双删
3.1 Cache Aside:为什么它是默认选择
业界用了几十年的Cache Aside模式,至今仍是默认方案。它的核心规则就两条:
读:先读缓存,缓存没有就查数据库,再写缓存并返回;写:先更新数据库,然后删除缓存(或者更新缓存)。
这套模式代码看起来简单,但为什么大家都选它而不是别的模式?因为它把“数据库”当成唯一事实来源,缓存永远可以随时丢弃重建。数据库是持久化的锚点,缓存是易失副本,这个分工非常清晰。业务开发时,你不用担心缓存数据被写错,因为最坏情况就是缓存没数据,回源拉一次。
对比下其它模式你就懂了。Write Through模式要求写操作同时更新数据库和缓存,每次写都必须两边都成功,失败处理复杂;Write Behind模式先把写操作记在缓冲里,异步批量刷库,吞吐高但一旦宕机丢数据。对于绝大多数业务系统,Cache Aside的语义最简单、最容易维护、也最容易在出问题时人工纠正。
3.2 为什么“先删缓存再更新库”也有坑
Cache Aside在写路径上有个经典顺序问题:更新数据库之后删缓存,并发期间可能会出现“旧值写回”的脏数据。
举例。缓存里存商品价格为100元。用户A发起改价,把数据库改成80元;在A还没来得及删缓存时,用户B发起读请求,缓存还没删,B直接读到缓存里的100元——这是旧值。等A删完缓存,B的请求又因为缓存缺失重新查了数据库,查到80元并写回缓存——这时缓存是新值,没毛病。
但换一种时序就全反了。A把数据库改成80元之后删除缓存;在A删除之后、数据库更新之前,B读到缓存缺失,查数据库查到旧值100元,写回缓存。然后A才把数据库改成80元。最终数据库是80元,缓存里却是100元,而且缓存没有过期时间的话,这个错误数据会一直存在。这就是“并发下旧值写回”的经典案例。
所以现在主流做法其实是“先更新数据库,再删除缓存”,同时配合较短的缓存过期时间作为兜底。这个方案的保证级别是:删除失败或延迟删除时,只是短暂旧值,但不会出现永久错乱;只要你保证“删除缓存”的动作最终会执行,数据就会收敛。
3.3 延迟双删实操:延迟时间、失败重试、幂等保障
“先更新库再删缓存”也不能完全规避上面那个时序问题,于是诞生了延迟双删:更新数据库之后,立刻删除一次缓存;稍等一段时间,再次删除缓存。
第二次删除是为了解决“在第一次删除后、第二次删除前,某个读线程把旧值写回缓存”的情况。你可能会问,延迟多久合适?我的经验是:延迟时间要大于“读请求从缓存缺失到查库再到写回缓存”的最长耗时。一般业务读耗时在10到100毫秒之间,保守一点设置500毫秒到1秒,能覆盖绝大多数场景。
延迟双删也不是银弹。它最大的短板是:第二次删除如果也失败了怎么办?我的做法是加一个删除重试机制——删除缓存前先把“本次缓存键和删除动作”写入一个本地重试表或者消息队列,删除失败后由后台任务重试。另一个兜底是给所有缓存键设置合理过期时间,比如30到60分钟,就算重试全部失败,最坏情况也就是短时间读到旧值,过期后自动回源收敛。
这里我放一段我在生产环境里压过线的伪代码,可以直接参考:
// 更新数据库 updateOrderStatus(orderId, PAID); // 第一次删除缓存 delCache("order:" + orderId); // 延迟第二次删除 asyncDelay(500ms); delCache("order:" + orderId);再用一张表带你看懂各种模式的区别和选型场景:
| 方案 | 核心动作 | 失败风险 | 适用场景 |
|---|---|---|---|
| 先删缓存,再更新库 | 删缓存 → 写库 | 并发旧值写回概率高 | 基本不推荐单独使用 |
| 先更新库,再删缓存 | 写库 → 删缓存 | 删除失败时短暂旧值 | 大多数业务推荐 |
| 延迟双删 | 写库 → 删缓存 → 延时再删 | 第二次删除可能失败 | 并发读量大、容忍度低的场景 |
| 版本号/时间戳校验 | 写库带版本 → 写回缓存校检 | 需业务配合改造 | 对数据新旧敏感的场景 |
4. Redis缓存治理与多级缓存的实际战斗
4.1 Redis缓存治理:过期、淘汰与失效代价
Redis本身只是一把武器,真正决定系统能不能扛住的是缓存治理策略。线上缓存最常见的毛病不是“没有缓存”,而是“缓存键设计混乱、过期时间随意、热点数据没治理”。
关于过期时间,我的建议是:能短则短,不要因为性能压力把TTL设置成永远。TTL是最终一致的兜底机制,没了这个兜底,一旦任何删除动作失败,你就只能靠人工清缓存才能恢复。我之前接手过一套系统,把商品详情缓存TTL设为24小时,结果运营改价格后用户看了大半天旧价格,就是因为删除动作失败又没TTL兜底。后来把TTL压到10分钟,再配合主动删除,出问题最多也就治10分钟。
淘汰策略也要想清楚。Redis默认的volatile-lru只在设置了过期时间的键里做淘汰,如果你有些键没有设置过期时间,内存满了之后反而会被LRU无差别淘汰,导致大量缓存缺失回源数据库。要梳理清楚哪些键该设TTL、哪些键允许常驻,按业务重要程度分级。
4.2 缓存穿透、击穿、雪崩:三个容易混淆的“失效问题”
缓存失效这件事,线上最常见的是三个词:穿透、击穿、雪崩。很多人混着说,但它们其实是三个完全不同的故障场景。
缓存穿透是请求的数据连数据库都不存在。攻击者拿一堆不存在的ID来刷接口,缓存永远没有,请求全部打到数据库,DB直接被打挂。解法是布隆过滤器,或者把空结果也缓存起来,缓存空值的TTL设短一点,比如30秒。
缓存击穿是某个热点Key过期瞬间,大量并发请求同时回源数据库。典型场景是秒杀商品详情页,缓存正好失效,成千上万的请求一起打进来。解法是互斥锁,只在缓存重建时允许一个线程回源,其他线程自旋等待;或者用逻辑过期策略——缓存里存一个过期标志,检测到快过期时异步重建,不回源阻塞。
缓存雪崩是大面积Key在同一时间失效,或者Redis本身宕机,导致大量请求直接压到数据库。解法是过期时间加随机偏移,避免同一时刻集体失效;同时做好降级方案,比如在Redis故障时让服务降级为本地短缓存加数据库限流。
这三个里面,击穿和一致性关系最密切。因为一旦发生击穿,大量线程同时回源,就可能出现“多个线程查到不同版本”的竞争,最终写回缓存的是旧数据。
4.3 MyBatis缓存、Spring三级缓存与全局缓存的分工
多级缓存是另一个常见的混乱来源。MyBatis缓存、Spring三级缓存、Redis缓存,它们解决的问题维度完全不一样,很多人混在一起理解——这并不对。
Spring三级缓存本质是解决单机Spring框架内部Bean创建时的循环依赖问题,它只在IoC容器初始化阶段起作用,跟线上用户请求的一致性没有关系,更不会影响数据库和Redis的一致性。你可以把它当成一个“解决对象创建顺序”的机制,别被这个名字里的“三级缓存”带偏。
MyBatis的一级缓存作用在同一个SqlSession里,同一个会话内多次查询同一SQL且数据没变化时直接命中缓存,看起来没问题。但一级缓存的生命周期和数据库事务并不完全一致,一旦你手动控制SqlSession不当,就会出现“数据库里改完了,但同一个SqlSession里读到的还是旧值”的现象。我的建议很直接:在分布式和高并发场景下,默认把MyBatis一级缓存关闭,二级缓存也要谨慎使用。让ORM层只负责把SQL翻译成结果,不做跨节点的数据缓存,一致性责任集中交给Redis和数据库之间去管理,反而更清晰。
如果你在架构里同时用了本地缓存和Redis,一定要想清楚它们各自的一致性要求:本地缓存更适合放变化频率极低、允许分钟级延迟的数据;高频变动的数据,最好还是直接走Redis,或者加消息通知主动失效本地缓存。
5. 分布式场景与同步方案:从双写到变更订阅
5.1 为什么双写不可靠:重复、乱序与失败补偿
有些人会把一致性方案做成业务代码里“同步双写”,也就是数据库写完,再同步写Redis。这个方案看着简单,实际最坑。
第一,数据库写成功了,Redis写超时怎么办?你可能需要回滚数据库,但回滚动作没法保证,数据库事务已提交,你只能在逻辑上再发一条“补偿更新”,可补偿更新也可能失败。第二,并发写同一Key时,网络延迟可能导致后发的Redis写请求先到,最后Redis里保存的反而是一份旧数据。第三,业务代码里到处散落着双写逻辑,维护成本极高,哪次漏写了一个地方,就是新的一致性隐患。
我不建议业务代码做双写,除非你的团队有严格的规范和充分的对账机制。更可靠的路径是把“数据库变更”当成唯一事件源:数据库Binlog或者变更日志里记录了每一次真实数据变更,你订阅这些变更事件,然后异步更新缓存。你不再手动管两个系统,而是让数据库的变更通知驱动缓存更新。
5.2 基于数据库变更的缓存同步:Binlog订阅的工程细节
以MySQL为例,你可以说用Canal接Binlog拿到变更数据,然后写入Redis。这个方案不是只有大厂能用,一个小团队也能做,核心在于理解事件流的一致性保证。
一个关键点:Binlog是顺序的。你的缓存更新必须保证按这个顺序执行,否则你先处理了后写的变更、再处理前写的变更,缓存里就会留下旧值。所以消费端要做分区有序,同一个数据键的变更,必须固定在一个消费者线程里串行处理。你还要做幂等,每条变更事件带一个唯一ID或时间戳,处理前先比对Redis里的版本,如果事件版本小于等于当前版本,直接跳过。
这套方案的另一大优势是“补偿天然存在”。数据库里每一条更新都会有Binlog,不需要你手动发消息通知缓存删除;就算缓存更新失败,你也可以通过消费者重试机制不断重放事件,直到缓存对上了。我个人的体会是,它比手动双写可靠得多,因为它完全不用改动业务代码,和业务解耦。
不过要注意,缓存过期时间还是要保留。Binlog订阅也可能因为消费者异常堆积、重启等原因造成较长的窗口期。TTL兜底能让系统在极端情况下自动恢复,而不是永远卡在错误数据上。
5.3 数据库同步软件与配套工具的选型细节
除了缓存和数据库的一致性问题,很多团队还会用数据库同步软件进行从库搭建、数据迁移或数据归档。比如在MySQL主从同步、Oracle数据同步、SQL Server定时同步这些场景,都会涉及同步工具的选型和环境匹配问题。
数据库同步软件和缓存一致性的关系是这样的:它们本身不是一致性方案,而是“数据搬运”工具。你在源库和目标库之间做同步,目标库数据是源库的副本,同样会存在“同步延迟”和“同步失败”的问题。选工具时重点看三点:支持哪些数据源类型、断点续传能力、变更捕获方式。以MySQL为例,有基于Binlog的增量同步;以Oracle和SQL Server为例,则可能依赖触发器、日志挖掘或定时任务,延迟往往是秒级甚至分钟级。
配套工具上,数据库客户端和驱动匹配也是一个经常踩坑的地方。我记得有同事在使用数据库管理工具时遇到过“请先安装Access数据库64位系统驱动程序”的报错,就是因为本机装了64位Office,但工具用的还是32位驱动,两边不匹配。这类问题不影响线上,但会影响你排查问题的效率。
数据库连接池参数同样值得关注。连接池里如果有太多空闲连接不释放,数据库端可能会清理它们,导致应用拿到的连接失效;而连接池容量太小,高并发下一旦缓存全部失效,回源请求直接把连接池打满,数据库也跟着雪崩。连接池的连接数和超时时间,一定要按缓存失效场景下的峰值回源流量来压测验证,而不是按平时的吞吐量来配。
导数据这类批量操作也会影响一致性。用工具把Excel导入数据库时,如果你的导入逻辑是一行一行写的,每秒几百行,数据库负载还受得住;如果是一把锁表导入,导入期间所有读请求可能被阻塞,而缓存还在提供旧数据服务,这就会造成“导入前后差异巨大”的体验。批量导入后,最佳实践是先清理相关缓存,再放开流量。
6. 线上问题排查:五个步骤与一张速查表
6.1 从“数据对不上”到“定位根因”的五步排查法
线上报告缓存和数据库不一致的时候,别急着清缓存。我有一套固定排查流程,效率很高。
第一步,确认差异状态。拉出Redis里的值和数据库里的值,对比时间和内容。如果Redis里没有这个Key,说明问题不在缓存陈旧,而在读请求绕过了缓存或者缓存被误删。
第二步,看删除动作有没有执行成功。凡是走Cache Aside方案的,写库之后必须删缓存。去Redis日志或者代码日志里查这次写操作的删除指令耗时和返回结果,确定是不是删除失败或超时。
第三步,检查TTL是否合理。如果这个Key的过期时间是24小时或者更长,而你发现缓存里的值和数据库不一致,那这个TTL本身就是帮凶。当你没有信心每次删除都成功时,TTL就是你的第二次机会,设短一点永远不会吃亏。
第四步,复现并发时序。可以写一个简单的多线程测试,模拟“写库线程加并发读线程”同时跑,看缓存最终是否被旧值覆盖。如果你能在测试环境复现,再对比生产代码的读写顺序,通常马上能看出问题出在“删完缓存又被写回”还是“压根没删”。
第五步,检查监控和重试。看看自己有没有后台任务在重试删除,有没有对账任务定时比对缓存和数据库的值。没有的话,说明你的系统连“自愈”能力都没有,这回只能人工处理,下回应该把重试和对账加上。
清缓存本身也是一门学问。上线平台一般都有缓存清理入口,如果是普通测试环境,直接用Redis客户端删掉对应Key也行。自动化测试里用Python加Selenium清浏览器缓存、清Redis缓存也都可以脚本化,但生产环境执行清缓存操作一定走审批和操作记录,避免误清导致缓存击穿。
6.2 一致性高频问题速查
| 症状 | 根因 | 处置手段 |
|---|---|---|
| 改完数据,页面一直显示旧值 | 删除缓存失败或TTL太长 | 手工清除对应Key;缩短TTL;加删除重试 |
| 刷新一次看到新值,再刷新变旧值 | 并发旧值写回缓存 | 延迟双删;版本号校验 |
| 缓存里存在数据库没有的数据 | 事务回滚但缓存更新成功 | 更新缓存前先确数据库提交成功;错误数据立即清缓存 |
| 缓存失效瞬间,数据库被并发打挂 | 缓存击穿 | 互斥锁重建;逻辑过期异步刷新 |
| 大量Key同时过期,数据库被打满 | 缓存雪崩 | TTL加随机偏移;本地降级缓存 |
| 缓存数据明明没问题,接口却总是查库 | 本地缓存/ORM缓存层级干扰 | 关闭MyBatis一级缓存,梳理缓存键分布 |
6.3 我个人的几点体会
做了这么多年,我的最大感受是:缓存和数据库一致性不是一个“解决掉就一劳永逸”的问题,而是一个“持续设计、持续治理”的问题。与其追求理论上的绝对一致,不如把目标设定为:绝大多数请求走缓存足够快,少数不一致场景能快速发现、快速修复、快速解释。
版本号是一个成本低、效果好的技巧。我比较喜欢在每个缓存Value里带上数据库更新时的版本号或时间戳,这样即使并发写回缓存,也能在校验时识别出“这个数据比当前的旧”,然后主动丢弃而不是覆盖。
重试和监控是两件不能省略的事。任何一条删除缓存动作,都应该有失败日志和重试机制,哪怕只是个简单的定时任务扫重试表。否则你根本不知道系统已经在旧值状态下跑了多久。
我还想提醒做架构设计的朋友:缓存层级不是越多越好。你每加一层缓存,就是给一致性增加一分难度,同时也给排查增加一层迷雾。能让数据直连的地方就不要硬塞缓存,能5分钟失效的就不用设置永不过期,能用Redis一处解决的就不用本地缓存层层叠加。
最后再分享一个小技巧:每次上线涉及缓存写逻辑时,加一条临时的“新旧值并存”监控日志,对比数据库变更时间与缓存更新时间。这个日志不需要长期保留,但上线初期靠它能快速定位到底是链路没打通,还是顺序错乱,能帮你省掉大量排查时间。