1. Redis缓存和数据库之间,那点说不清道不明的帐
做后端这几年,几乎每个项目都会遇到"分布式缓存一致性"这个坎。缓存能扛读流量,这谁都知道,但只要数据改了,缓存和数据库就必然有一段时间不对账。最典型的场景就是电商的购物车、订单状态、库存余量,还有配置中心的热更新,这些全是缓存一致性问题的重灾区。你问十个人,九个人会告诉你用Cache Aside,也就是旁路缓存策略,读的时候先读缓存,没命中就读库再回填;写的时候先更新数据库,再把缓存删掉。但真在线上跑起来你会发现,这个策略看起来无懈可击,实际操作里到处是漏洞。
我自己最早踩坑是在一个交易后台系统,用户下单之后,订单状态从"待支付"变成"已支付",数据库更新成功,按理说缓存删掉就完事了。但偏偏那一次Redis删除操作超时返回失败,代码里catch住异常继续往下走。结果就是缓存里还存着"待支付"的旧状态,用户刷新页面看到订单还是没支付,客服电话直接被打爆。那次之后我才认真去研究缓存一致性的底层逻辑——它本质上不是Redis或者MySQL哪一方的问题,而是"两份数据各自独立更新"带来的时间差问题。
这篇文章不打算讲教科书理论,我会把我在项目里实际用过的方案、踩过的坑、以及最后沉淀下来的取舍逻辑都拆开说。内容主要面向后端开发、架构师,以及正在设计高并发读多写少系统的朋友。你会看到为什么延迟双删在某些场景下是伪命题,为什么Binlog订阅方案更省心,以及强一致场景下到底该不该引入分布式事务。
分布式缓存一致性这个话题,网上文章不少,但大多数只讲"双删"这一个点。我想换个角度,从问题根因出发,把常用方案串起来讲清楚,顺便把我那个订单状态错误的完整排查链路也分享出来,希望能帮同行少走弯路。
2. 一致性问题到底出在哪:两个存储之间没有事务边界
先看一个具体的运行时序。假设有一个商品的库存数量,初始值是100,数据库和缓存里都是100。现在一个请求要把库存改成99,另一个请求恰好正在读库存。按照Cache Aside策略,正常的执行顺序应该是:读请求先拿到缓存的100返回给用户,写请求更新数据库为99,然后删除缓存,下一个读请求读不到缓存,回源数据库拿到99,回填缓存。这套流程只要执行得足够快,用户几乎感知不到差异。
但问题在于,写请求里"更新数据库"和"删除缓存"这两个动作并不是原子的。如果更新数据库成功了,删除缓存之前的一瞬间,读请求来了,它会直接命中缓存里的旧值100。更麻烦的是,如果程序在校验缓存删除结果时不做处理,这个旧值可能被服务很长时间。
我列一下缓存不一致出现的三个典型窗口:
- 更新数据库成功,删除Redis失败或超时,旧缓存继续存活,直到下一个写操作或TTL到期。
- 并发读写交错:读请求在"数据库更新完成"和"缓存删除"之间回源查询并回填了旧数据,导致刚删除的缓存又被旧值覆盖。
- 多副本场景下,主库更新了,但从库延迟,读请求打到从库拿到了旧数据并回填缓存。
很多人觉得第三个窗口是数据库主从的问题,跟缓存没太大关系。但在实际系统里,主从架构+缓存架构是叠加的,读请求一旦走了从库,整个链路的延时点就多了一环。这也是为什么我在后面讲方案的时候,还会专门提到"版本号"这个思路,就是为了对付这类"回填旧值"的刁钻场景。
把这三个窗口看明白,你就能理解一个结论:缓存一致性问题的本质,是两个存储系统之间没有共享事务上下文。数据库有事务,Redis也有自己的原子操作,但跨系统之后,没有任何一个事务能同时保证"数据库更新"和"缓存失效"要么都成功、要么都失败。所以所有方案本质上都在做一件事:用某种机制把"缓存失效"这个动作补成一个最终都会执行成功的操作,或者用业务手段把脏数据的影响窗口压到最小。
这个认知相当重要。我在前几个项目里反复跟团队强调:不要试图保证"缓存和数据库永远一致",这是伪命题。物理上不可能。能追求的只是"对用户的视角来说,绝大多数时候一致,或者不一致的时间窗口小到业务不可感知"。想清楚这一点,后面的方案取舍才不会拧巴。
3. 延迟双删的真实效果:它能补漏,但补不了所有漏
3.1 延迟双删是怎么运作的
既然直接删除缓存有窗口期,那很多人自然想到一个变体:先删除缓存,再更新数据库,等一小段时间之后再次删除缓存。这就是所谓的延迟双删,也常被称为Double Delete。它的逻辑是:第一次删除先把缓存清掉,让并发读请求回源数据库,即使读到旧数据(因为数据库还没更新完成),那么更新完成后,第二次删除再把这个可能被回填的旧值清掉。听起来很合理,很多文章甚至把它说成"终极方案"。
我在线上用过的延迟双删版本是这样的:
// 步骤一:删除缓存 redisTemplate.delete(key); // 步骤二:更新数据库 orderMapper.updateStatus(orderId, PAID); // 步骤三:等待一段延迟时间 Thread.sleep(500); // 步骤四:再次删除缓存 redisTemplate.delete(key);看起来没什么问题,但实际部署的时候,踩坑点一个接一个。
第一,Thread.sleep(500)这段代码会让当前线程挂起,在高并发请求里睡掉500毫秒跟"性能杀手"没什么区别。你要么把它放到异步线程里,要么用消息队列触发第二次删除,否则接口RT直接飙到500ms以上,压测根本过不了。
第二,这个500ms到底怎么定的?拍脑袋。它能覆盖多少并发交错场景,取决于"数据库更新完成"到"最晚那个读请求回填旧值"之间的时间差。如果数据库主从延迟超过500ms,读请求从从库拿到旧数据并回填缓存的时间可能发生在第二次删除之后,那这一轮双删就白做了。
第三,更隐蔽的问题是,如果你有多个线程同时更新同一个key,第二次删除可能会误删"新值对应的缓存"。比如线程A先把库存改成98,删缓存,然后线程B改成97,删缓存,线程A的第二次删除落在线程B更新完成之后,会把缓存里本来应该是97的那份数据也删掉。这倒不会造成数据错误,最坏情况是缓存被清了,流量打到数据库,但也说明第二次删除不是无副作用的。
3.2 一次线上故障:双删之后缓存里还是旧订单状态
那次订单状态不对的故障,排查链路大概是这样的。我先把所有订单状态相关逻辑梳理了一遍,确认数据库里的值是对的,Redis里的值不对。然后看日志,发现第一次删除缓存成功,数据库更新也成功,但第二次删除压根没有执行——因为代码里有个try-catch把Thread.sleep的异常吞掉了,后续的第二次删除逻辑直接跳过。
对,你没看错,就是那么低级。但这类低级故障在真实系统里特别常见,因为双删逻辑散落在业务代码里,每个接口写一份,你能保证这版不出问题,不保证半年后接手的人不在中间加一个提前return。
修复那个事故,我当时的做法是给第二次删除加了一个不可跳过的异步重试机制:把"待删除的缓存key"写到本地延迟队列,200ms之后取出并再次执行删除,删除失败就进重试队列,连续重试三次。这样业务流程本身不需要休眠,第二次删除的可靠性也提高了。
但从那次之后我基本不在新项目里用延迟双删了,原因很简单:它把一致性保障建立在"数值恰好够大"的睡眠时间上,本质上是赌概率,而不是保证。真正的工程方案,要么让缓存删除变成一个有兜底的重试任务,要么干脆从数据管道层面解决。
4. 一致性分级:先想清楚你要的是最终一致还是强一致
4.1 绝大多数业务场景,只要最终一致就够了
很多团队一上来就琢磨要不要上分布式事务,我会反问一句:你的业务真的需要强一致吗?别急着回答"需要",先看数据形态。电商的购物车、用户资料、文章详情页、商品详情快照,这类读多写少、改了之后用户能接受"下一次刷新才看到新结果"的数据,最终一致完全够用。缓存有个TTL兜底,最坏情况下过了TTL自然过期,用户看到的新旧数据差异在可接受范围内。
最终一致的实现方式也很轻量,核心是四条:
- 业务代码里,数据库事务提交成功后,发一个异步消息(MQ或者本地消息表),消息里带上key和需要清理的缓存字段。
- 单独的消费服务收到消息后,对Redis执行删除操作。
- 如果消费者删除失败,消息不要直接确认,进入重试队列,配合指数退避。
- 所有缓存设置合理的TTL,作为最后一道自愈保险。
这套方案和双删的区别是:它把"删缓存"这个动作彻底从业务主链路中剥离了。业务代码只需要保证"数据库更新成功+消息发送成功",二者放在同一个本地事务里通过消息表完成,再往后的事情全部交给异步任务。不阻塞主流程,也不用赌时间。
4.2 强一致场景的取舍:用锁还是放弃缓存
不过也总有一些地方绕不开强一致。典型的是扣减库存、下单支付金额校验、账号余额更新这类"读到的值必须是刚提交的值"的场景。在这些场景里,用Cache Aside加异步删除基本上是行不通或者风险极高的。我见过有团队在扣库存场景里把库存放到Redis里直接做原子扣减,数据库只做最终账本,这套路不是不行,但前提是你必须接受Redis为主、数据库为备的思想,也就是把一致性问题的原点从关系型数据库搬到Redis事务上。
如果你一定要数据库和缓存强一致,常见的选择有两个。
第一个是分布式锁+DB事务。在更新操作上锁,保证同一时刻只有一个请求在走"更新数据库+删缓存"链路,读请求在更新期间要么走缓存旧值(业务短时间容忍),要么强制读主库。这个方案能把窗口缩得很小,但并发度上限直接受锁粒度制约,而且锁本身垮了或者超时了,一样有隐患。
第二个是分布式事务中间件,比如Seata的AT模式、TCC模式,把"更新数据库"和"删除缓存"纳入同一个全局事务。这个方案的一致性等级最高,但代价也最高:全局锁的粒度、事务协调器的稳定性、网络开销,都会显著上升。我个人的建议是,除非业务真的因为数据不一致发生了资损或者强对账失败,否则别把分布式事务塞进高并发缓存链路里,性价比太低了。
还有一条更直白的路子,跟一致性正则化机制的理念很像——把一致性策略固化成统一的框架能力,让业务代码不必每次自己写"先删缓存还是先更新库"的判断。比如Spring Cache的@CacheEvict就是一种粗粒度工具,更粗暴的是基于AOP统一拦截Mapper更新方法,自动失效对应缓存。这种"机制化"处理最大的好处,是避免每个开发自己实现一致性逻辑,减少出错面。
5. 更省心的做法:Binlog订阅加版本号,把一致性下沉到数据管道
5.1 Canal订阅Binlog,业务代码零侵入
如果你对延迟双删感到心累,又不想在业务代码里埋一堆"删除缓存"的逻辑,那可以考虑把缓存失效这个动作彻底从业务应用里挪走,放到数据管道层。具体做法是使用Canal这类组件伪装成MySQL从库,订阅Binlog,解析出数据变更事件,然后投递到Kafka或RocketMQ,由专门的消费者去更新或者删除Redis缓存。
这个方案的链路是:
- 开启MySQL Binlog,并确认
binlog_row_image=full,保证解析出的数据包含完整行记录。 - 部署Canal服务,配置好监听的表和数据库实例,它就像MySQL的从库一样实时接收Binlog。
- Canal把变更事件发送到Kafka,Topic可以按表拆分。
- 消费端接收到事件,解析出主键和变更后的值,然后根据业务规则,决定是删除对应的Redis key,还是直接把新值写入缓存。
这套方案的优势非常明显:业务代码完全不用感知缓存的存在,甚至缓存失效规则改了,也不需要重新发版。原来散落在各个Service里的redisTemplate.delete全部收敛到一个地方,一致性的控制变得更集中。
我没有夸大它的好处。我在项目中落地过类似方案,消费者拿到Binlog事件后,只执行一个操作:按主键规则拼出Redis key,删除缓存。整个流程不碰业务逻辑,所以即使哪天"缓存要不要删"的问题出了争议,也只需要改消费者代码,风险边界清晰。
当然它也有缺陷。Binlog订阅是异步链路,从数据库提交到消费者执行删除,通常会有几十到几百毫秒的延迟,极端情况下更久。如果业务对时间窗口极其敏感,它同样满足不了强一致。另外多引入Canal和MQ,运维复杂度也跟着上来,小团队需要评估一下成本。
5.2 缓存里存版本号:防"旧值回填"最有效的杀手锏
不管用哪种方式清理缓存,都绕不开一个场景:多个线程并发写同一个key,或者一个线程更新数据库的同时,另一个线程读到了旧值并回填缓存。延迟双删解决不了这个问题,Binlog订阅也解决不了,因为回填动作可能发生在删除之后。
真正能对付这个场景的是缓存版本号机制。具体思路是:数据库的表里加一个version字段,每次更新都version = version + 1;缓存里不仅存业务数据,还存一个对应的version。读请求回源数据库后回填缓存时,把当前的version一起写进去。当缓存被回填旧值后,如果数据库已经更新到更高的版本,我们可以通过对比版本号发现不一致,再做一次矫正。
更常见的落地方式是在缓存值里嵌套版本号字段,写入时比较版本:
# 使用Redis Lua脚本原子性对比版本号 # KEYS[1]为业务缓存key,ARGV[1]为新值,ARGV[2]为数据库当前版本号 if redis.call('get', KEYS[1] .. '_version') == ARGV[2] then redis.call('set', KEYS[1], ARGV[1]) redis.call('set', KEYS[1] .. '_version', ARGV[2]) return 1 else return 0 end这套逻辑的精髓在于:缓存写入不是无条件的,它要求你手上的版本号必须和当前缓存里的版本号一致才允许覆盖。这样即便有并发线程用旧数据回填,版本号对不上,写入也会失败,数据库最新数据不会被旧缓存数据二次污染。
不过也要说清楚,版本号方案会增加缓存的存储开销和代码复杂度,而且它解决的是"缓存被旧值覆盖"的问题,并没有解决"缓存删除被跳过"的问题。两个问题最好组合着用:删除做兜底,版本做并发保护。
6. 一致性怎么验证:不要等出问题才想起对账
方案讲再多,如果线上没有监控和校准机制,出问题的时候还是只能靠客服反馈。我现在的习惯是,任何一个缓存链路,上线之前必须先回答三个问题:缓存和数据库如果出现不一致,多久能发现?能不能定位到是哪条链路产生的?能不能自动矫正?
6.1 一致性巡检:定时抽样比对数据库和缓存
最简单的巡检方式,是做一个定时任务,扫描数据库里热度较高的数据,按主键读取对应的缓存,对比关键字段。差异超过阈值就告警。这个方法能发现多数由删除失败、回填乱序引起的脏数据,缺点是扫描频率不好控制,扫太快增加数据库压力,扫太慢发现问题的时效性差。
实用的做法是,不扫全表,只扫"最近15分钟内发生过变更的数据",这些数据才有比对的必要。变更记录可以从Binlog拿,也可以业务在更新时顺手写一张变更日志表。我建议用Binlog,因为业务表变更记录不需要额外维护,数据更全。
6.2 全链路追踪和删除失败告警
巡检只能事后发现,其实更关键的是把"删缓存失败"这个信号做成实时告警。代码里所有执行Redis删除操作的地方,把返回值和异常都记下来,带上traceId,以及当前进程的机器IP、方法名。失败率达到阈值就触发告警,并且自动把失败的key投递到重试队列。
当年那个订单状态错误事故,如果能早一点做这一步,根本不会等到客服电话打过来。所以我现在写缓存清理代码,最少要包含三样东西:操作key、操作结果、失败时是否有重试标记。这三样信息拼接成一条结构化日志,哪怕工具再差,grep也能查出来。
6.3 自愈机制:重试队列和TTL是最后的保险
最后一道保险,是给所有缓存设置一个合理的TTL。加了TTL的缓存,即使删除失败、更新失败,最长也就脏一个TTL周期。很多系统不敢给缓存设TTL是怕"提前过期导致流量打到数据库",这就是另外一个优化问题了,可以用热点预热、本地缓存做二级缓存来对冲。但不管怎么说,没有TTL的缓存相当于慢性毒药,任何一个环节问题都会被无限放大。
重试队列的自愈价值也很大。消费端删除Redis失败时,不要直接丢弃消息,把它转入延迟队列,延迟几秒后再试。连续重试多次还是失败,就发告警让人介入。配合Binlog订阅场景,消费者本身就是薄弱环节,这一套尤其必要。
7. 说实话,真正关键的还是业务数据分级
做多了以后,我对"分布式缓存一致性"这个问题的理解反而趋向于简单:不要试图用一个方案解决所有缓存问题,先给数据分级,再选方案。
| 数据特征 | 一致性要求 | 推荐方案 |
|---|---|---|
| 商品详情、配置、文章内容 | 最终一致 | Cache Aside + TTL + 异步删除 |
| 订单状态、物流信息 | 最终一致,秒级可接受 | Binlog订阅 + 版本号防覆盖 |
| 库存、余额、支付金额 | 强一致 | 必要时锁/分布式事务,或直接读主库并减少缓存层 |
| 登录态、验证码 | 本身就是缓存态 | Redis TTL自动过期,无需跨系统事务 |
这张表不是标准答案,但它是我做业务时比较实用的一套分类逻辑。低一致性要求的数据用最轻量的方案,别为了追求理论完美把链路做得又长又复杂;高一致性要求的数据,宁愿接受"缓存没命中回源数据库"的性能成本,也不要冒着资损风险去赌删除时机。
另外我还想提一点,就是一致性正则化机制这个思路。很多团队每次写缓存清理逻辑都是从零开始,同一个"更新后删缓存"的规则,这个接口写一遍,那个接口写一遍,代码质量参差不齐。如果能把这类规则沉淀成统一的注解、拦截器或者消息消费模板,从机制上保证每条更新路径都会触发缓存失效,一致性问题的发生概率会大幅下降。这比任何高深的算法都管用,因为它解决的是"人总会犯错"的问题。
最后分享一个我自己的操作习惯。新接手一个系统,我会先做一次全量的缓存key盘点,把每个key对应数据库哪个表、更新链路是哪个接口、是否设置TTL、删除失败之后有没有重试,全部列成一张清单。这份清单看起来很笨,但排查问题的时候比什么监控都有用。分布式缓存一致性这个领域,其实不缺理论方案,缺的是把方案落实到每一个具体key上的耐心。