☰
Redis与数据库一致性:原理、主流方案与工程实践
2026/10/8 3:54:00 网站建设 项目流程

做后端开发的,几乎都被同一个问题折磨过:Redis缓存里的数据和数据库里的数据,到底怎么才能保持一致?明明读取的时候走Redis,写数据的时候又落在MySQL,两边各自为政,稍微并发一高就开始打架。Redis与DB的一致性保证不仅是大厂面试里的高频考点,生产环境里一旦处理不好,轻则数据错乱,重则线上事故。

这篇文章我不打算列一堆“共识式”的八股结论,而是想从底层链路出发,把“为什么缓存会不一致”“业界主流的保障思路”“踩坑点在哪里”讲透。无论你是刚接触缓存的新人,还是已经维护过Redis集群的资深工程师,里面的方案拆解和问题排查经验,应该都能直接用在项目里。

1. 先想清楚:缓存一致性到底要解决什么问题

1.1 业务链路里的缓存角色

在一个典型的服务端请求链路里,Redis承担的从来不是“存储系统”的角色,而是“高速读取层”。用户第一次请求,缓存未命中,后端去数据库捞数据,然后回填到Redis;后续大量并发请求直接打在缓存上,数据库的压力自然降下来。

这里最核心的认知是:数据的主权在数据库,Redis里的数据永远只是数据库某个时间点的“副本”。这就像你电脑里的文件,U盘里也有一份,但你不会觉得U盘是原始文件。只要承认这个前提,一致性问题的本质就变成了:当数据库里的“源文件”变了,Redis里的“副本”要怎么同步更新,以及能容忍多久的延迟。

很多开发者在双写时喜欢“先更新缓存,再更新数据库”,这种顺序会在数据库写失败时造成Redis里放了永远不回滚的新值,反而更危险。后面我会专门说操作顺序的问题,这里先明确一个关键结论:绝大多数场景下,策略选择不是“更新缓存”,而是“删除缓存”,让下一次读取时再回填。

1.2 什么时候会不一致

一致性问题最典型的触发场景就是“写操作”。假设订单状态从“待支付”变成“已支付”,后台执行了SQL更新,此时Redis里残留的键如果还是“待支付”,前端用户刷新后看到的状态就是错的。

这里有一个细节,很多人容易忽略:删除缓存这个动作本身也可能失败。比如Redis不恰好网络抖动、连接池满了、Key过期失败,删除没有执行,业务代码却以为成功了。更隐蔽的是并发竞态:一个线程更新了DB,正准备删除缓存;另一个请求正好在更新后、删除前把旧值重新读入缓存,导致你删了旧值,它又写回一个旧值。这个窗口时间短,但一旦出现,数据就会“脏”很长时间,因为缓存没有过期时间的话,旧值会一直存在。

所以,谈一致性之前,先要刻画“不一致窗口”的概念:从DB数据变更生效那一刻起,到Redis中的旧值真正被清除或更新为止,这中间的时间长度就是不一致窗口。绝大多数设计的目标,是把这个窗口压缩到用户无感知的程度,或者靠后续手段去收敛。

1.3 一致性目标分级

我们常说的“一致性”,在实际工程里不是铁板一块。强一致性意味着读到的永远是最近写入的结果,这在分布式缓存架构下几乎不可能也不必要;最终一致性才是常态——允许短暂的不一致,保证最终会收敛到一致。

在做技术选型时,建议先问自己三个问题:

  • 业务对一致性的要求到底有多高?金额、库存一类往往敏感,阅读量、标签位之类的容忍度就高。
  • 不一致窗口能接受多少?100毫秒以内,1秒以内,还是分钟级?
  • 如果出现不一致,有没有自动修复机制?比如过期时间、定时对账、删除重试。

定好目标,才能决定下面讲的方案该做到哪一步。很多人一上来就追求“强一致”,结果是代码复杂度翻了几倍,最后发现业务根本不需要这种强度,纯属给自己找麻烦。

2. 双写场景下的主流方案与取舍

2.1 Cache Aside:为什么删除缓存比更新缓存稳

业界最基础、最实用的缓存策略叫Cache Aside Pattern,也叫旁路缓存。它的套路非常清晰:

  • 读请求:先读缓存,命中则返回;未命中则读数据库,回填Redis,返回。
  • 写请求:先更新数据库,然后删除对应的缓存键。

为什么是“删除缓存”而不是“更新缓存”?因为更新缓存这个动作的副作用很大。比如一个数据对象有20个字段,某次变更只改了其中一个字段,你把整个对象序列化后覆盖到Redis,序列化成本和写缓存成本都比删除高。更关键的是,如果并发环境里有两个线程同时更新同一个key,因为网络到达顺序不一致,最后留在缓存里的可能是旧的那个请求写入的数据,产生数据反串。

而删除缓存就简单多了,它把“缓存的正确性”交回给读路径:下次读的时候发现缓存不存在,去数据库拉最新的值就行。

为了保证这个模式真正可靠,有几个细节:

  • 删除缓存不要放在数据库事务之前,必须放在事务成功提交之后。否则DB回滚了,缓存却删了,下次读又把旧值回填了,反而多一次来回。
  • 如果删除缓存失败,不能静默吞掉异常。至少要打日志,最好能做到重试。
  • 缓存键删除操作本身要尽量快,不要在删除前做耗时的业务逻辑,否则会拉长不一致窗口。

2.2 先更新DB后删缓存的竞争问题

Cache Aside并不是完美的。哪怕是“先更新DB,再删缓存”的正确顺序,也会有一个隐蔽的并发窗口。

考虑这样一个时间线:

  1. 线程A更新数据库,把某商品库存从100改成50。
  2. 线程B在这时发起一次读请求,发现缓存里还是100,直接返回了旧库存。
  3. 线程A执行删除缓存,成功。
  4. 后续读请求会把50回填到缓存。

严格说,上面的场景在B读取到旧值的那一刻已经产生了不一致,只是删除缓存后,后续请求会很快收敛。大部分业务能容忍这种毫秒级不一致,所以这个方案依然是首选。

但如果出现反向的竞态,风险就大了:

  1. 线程A更新数据库,把库存改为50。
  2. 线程B读缓存未命中,去数据库读到了50,但线程B操作极慢,还没来得及回填。
  3. 线程A删除缓存成功。
  4. 线程B此时才把“50”写回缓存,看起来没问题。
  5. 但如果线程B读到的不是最新值,而是更早的一个快照,比如“100”,那就会把旧值重新写进缓存。

这种场景里,删除动作发生在旧值回填之前,所以“删了也白删”。要规避这个问题,单靠删除一次不够,于是就有了延迟双删策略。

2.3 延迟双删策略

延迟双删的核心思路很简单:更新完数据库之后,隔一小段时间再删一次缓存。这样即使第一次删除之后有并发线程把旧值写回,第二次删除也会把这些脏值清掉。

典型流程:

  1. 更新数据库。
  2. 第一次删除缓存。
  3. 线程休眠几十到几百毫秒,给并发读请求一个窗口。
  4. 第二次删除缓存。

休眠时间怎么定?没有标准答案,要看业务特征。建议设为“最慢读请求RT的1.5~2倍”,比如你统计过读请求回填缓存最长花150毫秒,就睡300毫秒左右。这里的核心逻辑是:让所有可能把旧值写回缓存的并发读请求,都落在两次删除之间,第二次删除时旧值已经写不回来了。

这个方法的最大缺点就是“异步等待”不好看,阻塞了更新链路。实际工程里我不建议同步sleep,更实用的做法是:把第二次删除异步化,投递到一个延迟队列或MQ中,隔几百毫秒再执行。这样既不阻塞主链路,又能达到同样的效果。

延迟双删也不是银弹。它基于概率去压低不一致窗口,但没法彻底消除。如果两次删除之间正好有极端慢的请求,第二次删除后它才把旧值写回,那依然会带来脏数据。所以,如果业务一致性要求很高,不能只靠这个方案,需要配合其他兜底手段。

2.4 基于版本号与更新时序的主动校验

延迟双删只能清理旧值,不能防止旧值再次写入。真正想彻底解决“回填旧值”的问题,思路就要从状态控制转向版本控制。

简单说,就是缓存值里不仅存业务数据,还附带一个版本号或时间戳。比如Redis的键值结构设计成商品信息:100 -> {data: {...}, version: 18}。更新时先查数据库拿最新的数据版本,回填时写入这个版本。任何线程在回填前都先取一次当前版本,只有在回填版本不低于缓存版本时才写入。

这种做法的实际效果是:

  • 如果是更新操作,版本号单调递增,旧版本的数据永远不会覆盖新版本。
  • 如果是删除操作,配合占位符或空值,也能避免并发回填旧数据。

实施时有一个前提条件:数据库本身要有版本字段,得保证每次更新时版本号递增。比如给业务表加一个update_version列,每次更新version=version+1。这种方式下,即使删除失败了,缓存里过期数据也可以靠“后台任务比对版本 + 覆盖更新”来修复。

不过,给所有业务表都加版本号,改动面比较大。现实中很多团队只有在核心交易链路才这么做,普适性不强,更常见的做法还是“删除+兜底”。

3. 分布式场景下的一致性兜底:消息与事务怎么配合

3.1 为什么本地事务管不到Redis

很多新人会疑惑:更新数据库和删缓存这两个动作,放在同一个事务里不就行了?答案是不行,原因有二。

一来,Redis没有事务参与外部协调的能力。你在一个Spring事务里先更新MySQL,再调用Redis删除,如果MySQL提交成功之后Redis删除抛异常,这个异常并不会让MySQL自动回滚,因为数据库事务已经提交了。就算你用一些补偿逻辑,那也是编程层面的事,而不是数据库事务能覆盖的。二来,如果在事务执行过程中调用Redis,Redis操作会阻塞事务的提交,提高锁等待时间和数据库连接占用时间;新能不好,还容易把缓存操作挂在事务上,出现问题后追溯困难。

所以,正确做法是把DB更新和缓存删除解耦,用中间件或消息机制把两个操作“最终”一致起来。这是分布式系统思维:不强求同时成功,而是保证最后一定成功。

3.2 本地消息表与事务消息模式

本地消息表(Outbox Pattern)是目前比较成熟的一套方案。核心思路是把“需要删除缓存”这个事件,和业务数据在同一个数据库事务里写入。

具体步骤:

  1. 业务代码开启事务,更新业务数据。
  2. 在同一事务里向下游需要通知的表写入一条消息,比如cache_delete_msg,记录目标缓存键。
  3. 事务提交后,后台任务或定时任务扫这张表,把消息投递到MQ。
  4. MQ消费者收到消息后,执行缓存删除;成功则标记消息为已消费,失败则继续重试。

这个方案的关键是:写业务数据和写消息事件是同一个数据库事务,要么都成功,要么都失败。只要事务提交了,消息必然存在,剩下的就是消息投递和消费的问题。所以哪怕Redis某次操作异常,只要MQ能重试,缓存最终也会被删掉。

实现成本主要在于:需要建表、写扫描任务、维护消息状态机。如果公司已经有MQ基础设施,这套方案的成本其实不高。

对应的还有RocketMQ的事务消息,原理类似,区别是消息的预提交和确认状态交给Broker管理,业务系统少写一张表。但事务消息通常要求消息中间件版本较新,而且消息系统本身也要高可用,不然中间件自己挂了,两边都不好收场。

3.3 监听数据库Binlog的无侵入方案

如果系统已经有一定规模,业务代码不好大改,或者你觉得用消息也太重,可以考虑监听数据库Binlog。MySQL的Binlog记录了所有数据的变更细节,Canal这类中间件可以把这些变更解析成结构化事件,再转发给你的处理程序。

一个典型的“Canal + Redis删除”链路:

  1. 应用更新数据库,产生Binlog。
  2. Canal监听Binlog,解析出被更新的表、主键、变更类型。
  3. Canal把变更事件投递到MQ。
  4. 消费者拿到变更事件,构造缓存键,执行Redis删除。

这个方案的优点是业务代码完全无侵入,不需要在你写数据的代码里加任何额外逻辑,非常适合老项目改造。缺点是链路变长,实时性上会有毫秒级延迟,而且引入Canal本身就是一套新的基础设施,部署和运维成本需要计入。

还有一点容易被忽略:Binlog监听收到的是所有变更,对于缓存删除事件来说,很多变更可能根本对应不到热点键,因此会产生大量无效消息。实践里要做好过滤,比如只监听核心业务表,或者在消费者端做批量处理。

3.4 Redis分布式锁在一致性里的真正位置

搜索热词里“Redis分布式锁”和一致性经常一起出现,这里单独说一下。很多人会试图用分布式锁来保证“更新DB + 删除缓存”的原子性,比如:

RedisLock lock = redisLockUtil.getLock("product:100:lock"); try { updateDb(...); deleteCache(...); } finally { lock.unlock(); }

表面上看,加了锁之后同一时刻只有一个线程能执行更新+删除,好像就不会有并发覆盖了。但这里有个陷阱:分布式锁只锁住了你的应用线程,锁不住数据库层面其他来源的修改。比如后台任务、定时调度、数据补偿脚本、另一个服务实例没走这把锁,照样会造成同步问题。所以锁不是“一致性”的核心,它是“防击穿”“防重复初始化”的工具。

在缓存场景下,分布式锁更适合用在“缓存失效瞬间,多个线程同时去数据库查旧值并回填”的场景。你可以让同一时间只有一个线程去重建缓存,其他线程先等待,这样既降低了数据库压力,也降低了“并发回填旧值”的概率。但要说它保证Redis与DB强一致,那就夸大了。

4. 缓存治理:过期时间、多级缓存与可观测性的收口作用

4.1 过期时间是最终一致性的最后底线

我始终坚持一个观点:任何缓存键都必须设置过期时间,这是最后一道防线。就算你的删除策略全部失效,Redis里也不会永远留脏数据,TTL到了自动清掉,下次读会拉回新值。

TTL的设置要注意两个细节。第一,时间不能太长,建议按业务容忍的不一致窗口来定。比如一致性要求分钟级,TTL可以设15分钟;如果要求秒级,TTL最好压到5分钟以内。第二,TTL一定要加随机扰动,否则大量缓存同时过期,数据库就会瞬间被打穿,这就是缓存雪崩。

比如你希望默认缓存10分钟,那么实际TTL可以是600 + random(0,300)秒,让过期时间在一个区间内均匀分布。这个随机值不用太大,但能很有效地打散热点数据的过期集中度。

请注意,TTL这里更多是“兜底收口水”,而不是“主方案”。你不能指望所有缓存都在几秒内靠TTL自愈,那样极端情况下用户还是能感知到旧数据。主方案仍然应该是失效删除,TTL只是防止“删除失败”的保险。

4.2 多级缓存带来的新一致性难题

很多高并发系统不会只放一层Redis,而是再加一层本地进程缓存,也就是JVM Heap或进程内Map。读链路变成:本地缓存 -> Redis -> DB。这种方案的收益很明显,热点的QPS可以到几十万级别。但一致性难度也跟着上来了——因为本地缓存是每个服务实例各持一份,你根本没有一个统一的“删除入口”。

本地缓存常见的同步手段有三种:

  • 设置极短的TTL,比如10秒,能容忍秒级不一致就用它,简单粗暴。
  • 通过Redis的Pub/Sub广播删除消息,本地实例订阅到后删除自己的缓存。但当服务实例很多时,广播消息放大效应明显。
  • 通过一致性哈希把相同key固定路由到同一实例,这样该实例更新后只影响自己,但这个方案对负载均衡策略要求很高,水平扩容后路由表会乱。

实践里,我见到的落地方式绝大多数是“本地缓存TTL设为5~15秒 + Redis做下一层兜底”。核心数据不放本地缓存,放Redis;只有对实时性不敏感的热点数据才上本地缓存。多一层缓存就是多一分复杂度,不要为了性能把一致性成本无限推高。

4.3 Redis主从复制延迟带来的“内部不一致”

还有一种很容易被忽略的不一致,来自Redis集群自身:主从复制是异步的。当你向主节点写入或删除一个缓存键,从节点可能还保留着旧值。如果此时读请求走从节点,就会读到旧数据。

这种跟DB的关系不大,但同样会造成业务层面的“缓存不一致”。解决思路有三类:

  • 对一致性要求高的数据,只允许读主节点,牺牲一部分Redis的读扩展性和负载能力。
  • 对一致性要求低的数据,读从节点,但要接受短暂的不一致窗口。
  • 通过脚本定期对比主从数据,发现差异后做一次主节点覆盖。

有不少团队用Lettuce或Redisson客户端时遇到过RedisCommandTimeoutException,其实就是在负载高峰期,主从切换或异步复制跟不上,导致从节点数据旧,客户端等待超时。这种问题不能只靠加大超时时间来解决,还得从架构上规避读到过旧的数据。

4.4 可观测性:没有数据,一切方案都是盲的

一致性治理做到一定程度,就必须上线“可观测性”手段。我们至少要看几个指标:

  • 缓存删除失败次数:这是最常见的一致性隐患,必须统计并告警。
  • 缓存命中率:命中率骤降,往往意味着大量键被误删,或者过期时间设置不合理。
  • 缓存回填延迟:回填太慢会拉长第一次读的RT,也会扩大不一致窗口。
  • 消息积压情况:如果你的删除事件走MQ,消费者积压直接等于缓存迟迟不更新,这是要立即暴露的问题。

实践中我会在每次删除缓存时打一条包含“业务键、耗时、是否成功”的日志。排查“用户反馈数据不对,但查DB是对的”这种问题时,快速定位是哪一步删除失败,会节省大量时间。

5. 常见问题与排查技巧实录

5.1 缓存穿透、击穿、雪崩

这几个问题虽然一些人觉得它们是“性能问题”,而不是“一致性问题”,但它们在排查缓存故障时几乎总会一起出现,所以快速过一遍。

  • 缓存穿透:查询一个完全不存在的key,缓存存不住,请求直达数据库。最直接的办法是缓存空值,顺便设置短TTL,比如60秒;也可以用布隆过滤器先做一次不存在判断。
  • 缓存击穿:某个热点key在缓存过期的瞬间,大量请求同时打到数据库。用分布式锁或互斥锁只放一个线程去重建缓存,其他线程等待。
  • 缓存雪崩:大量key同时过期,或者Redis节点宕机,导致请求全部落到数据库。除了TTL随机化,弄好多级缓存和容量保护也很有用。

排查时,先从监控看缓存命中率曲线,如果断崖式下跌,优先怀疑过期键集中或删除误伤。再看Redis实例的慢查询日志和网络指标,排除主从同步延迟的影响。

5.2 一个真实的删除失败补偿案例

我之前维护过一个商品中心服务,大促期间运营频繁改价格。上线初期用的就是最普通的先更新DB再删除缓存,结果出现过大面积的价格延迟。定位发现,有两类原因:一是某个Redis节点连接池被打满,删除操作排队超时;二是删除回调里做了繁重的序列化和日志格式化,拖慢了整个流程。

后来改成“DB事务内写本地消息表 + 异步消息删除”的方案后,问题基本消失。关键改动就两点:删除缓存从同步流程里摘出去;消息表扫描任务保证无限重试直到成功。改造后的不一致窗口基本控制在几百毫秒内,运营端很少再收到价格延迟的投诉。

这里有一个实战建议:消息重试一定要设计成幂等,删除缓存本身就是幂等操作,多删几次不会出错,所以非常适合做这种兜底。如果是更新缓存就不行了,因为更新后值可能被旧请求覆盖,尽量用删除而不是覆盖。

5.3 面试里被问烂的“一致性八股”速览

如果你正好在准备面试,这里有一份“Redis与DB一致性”相关的核心提纲:

  • Redis与DB不一致的本质是什么?本质是缓存中的副本数据没能及时跟随源数据的变更。
  • 为什么更新DB后要删除缓存而不是更新缓存?删除成本低,且避免并发覆盖造成错值。
  • 延迟双删的时间怎么设置?基于业务读请求的回填耗时估算,异步化改造更优。
  • 先更新缓存还是先更新数据库?正常顺序是“先更新DB,再删除缓存”,反序有更大风险。
  • 分布式事务怎么保证最终一致?本地消息表、事务消息、Binlog监听。
  • Redis分布式锁能解决一致性问题吗?不能,它主要解决并发下缓存重建和防击穿问题。

能够把这些问题从“背答案”升级到“解释取舍”,面试官通常会高看一眼。

写在最后的一点个人体会

踩过几次缓存的坑之后,我最大的体会是:一致性不是靠某一个“完美方案”实现的,而是靠一层一层的兜底堆出来的。主链路用Cache Aside把不规范操作挡掉,中间加延迟双删处理极端竞态,再往上是消息机制保证删除失败能重试,最后用TTL做全局兜底。每一层都不能保证百分之百,但叠在一起,基本能把不一致窗口压到可接受范围内。

最后再分享一个小技巧:给缓存键设计时,尽量用业务维度做后缀,比如stock:product:100,删除时也只需要删这一把锁。不要把多个业务字段塞进一个key里,否则其中一个字段变更,整条缓存都得失效,成本很高。这个习惯在排查一致性问题时能帮你省下很多时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询