ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Redis与数据库一致性:原理、主流方案与工程实践

Redis与数据库一致性:原理、主流方案与工程实践 做后端开发的几乎都被同一个问题折磨过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再删缓存”的正确顺序也会有一个隐蔽的并发窗口。考虑这样一个时间线线程A更新数据库把某商品库存从100改成50。线程B在这时发起一次读请求发现缓存里还是100直接返回了旧库存。线程A执行删除缓存成功。后续读请求会把50回填到缓存。严格说上面的场景在B读取到旧值的那一刻已经产生了不一致只是删除缓存后后续请求会很快收敛。大部分业务能容忍这种毫秒级不一致所以这个方案依然是首选。但如果出现反向的竞态风险就大了线程A更新数据库把库存改为50。线程B读缓存未命中去数据库读到了50但线程B操作极慢还没来得及回填。线程A删除缓存成功。线程B此时才把“50”写回缓存看起来没问题。但如果线程B读到的不是最新值而是更早的一个快照比如“100”那就会把旧值重新写进缓存。这种场景里删除动作发生在旧值回填之前所以“删了也白删”。要规避这个问题单靠删除一次不够于是就有了延迟双删策略。2.3 延迟双删策略延迟双删的核心思路很简单更新完数据库之后隔一小段时间再删一次缓存。这样即使第一次删除之后有并发线程把旧值写回第二次删除也会把这些脏值清掉。典型流程更新数据库。第一次删除缓存。线程休眠几十到几百毫秒给并发读请求一个窗口。第二次删除缓存。休眠时间怎么定没有标准答案要看业务特征。建议设为“最慢读请求RT的1.5~2倍”比如你统计过读请求回填缓存最长花150毫秒就睡300毫秒左右。这里的核心逻辑是让所有可能把旧值写回缓存的并发读请求都落在两次删除之间第二次删除时旧值已经写不回来了。这个方法的最大缺点就是“异步等待”不好看阻塞了更新链路。实际工程里我不建议同步sleep更实用的做法是把第二次删除异步化投递到一个延迟队列或MQ中隔几百毫秒再执行。这样既不阻塞主链路又能达到同样的效果。延迟双删也不是银弹。它基于概率去压低不一致窗口但没法彻底消除。如果两次删除之间正好有极端慢的请求第二次删除后它才把旧值写回那依然会带来脏数据。所以如果业务一致性要求很高不能只靠这个方案需要配合其他兜底手段。2.4 基于版本号与更新时序的主动校验延迟双删只能清理旧值不能防止旧值再次写入。真正想彻底解决“回填旧值”的问题思路就要从状态控制转向版本控制。简单说就是缓存值里不仅存业务数据还附带一个版本号或时间戳。比如Redis的键值结构设计成商品信息:100 - {data: {...}, version: 18}。更新时先查数据库拿最新的数据版本回填时写入这个版本。任何线程在回填前都先取一次当前版本只有在回填版本不低于缓存版本时才写入。这种做法的实际效果是如果是更新操作版本号单调递增旧版本的数据永远不会覆盖新版本。如果是删除操作配合占位符或空值也能避免并发回填旧数据。实施时有一个前提条件数据库本身要有版本字段得保证每次更新时版本号递增。比如给业务表加一个update_version列每次更新versionversion1。这种方式下即使删除失败了缓存里过期数据也可以靠“后台任务比对版本 覆盖更新”来修复。不过给所有业务表都加版本号改动面比较大。现实中很多团队只有在核心交易链路才这么做普适性不强更常见的做法还是“删除兜底”。3. 分布式场景下的一致性兜底消息与事务怎么配合3.1 为什么本地事务管不到Redis很多新人会疑惑更新数据库和删缓存这两个动作放在同一个事务里不就行了答案是不行原因有二。一来Redis没有事务参与外部协调的能力。你在一个Spring事务里先更新MySQL再调用Redis删除如果MySQL提交成功之后Redis删除抛异常这个异常并不会让MySQL自动回滚因为数据库事务已经提交了。就算你用一些补偿逻辑那也是编程层面的事而不是数据库事务能覆盖的。二来如果在事务执行过程中调用RedisRedis操作会阻塞事务的提交提高锁等待时间和数据库连接占用时间新能不好还容易把缓存操作挂在事务上出现问题后追溯困难。所以正确做法是把DB更新和缓存删除解耦用中间件或消息机制把两个操作“最终”一致起来。这是分布式系统思维不强求同时成功而是保证最后一定成功。3.2 本地消息表与事务消息模式本地消息表Outbox Pattern是目前比较成熟的一套方案。核心思路是把“需要删除缓存”这个事件和业务数据在同一个数据库事务里写入。具体步骤业务代码开启事务更新业务数据。在同一事务里向下游需要通知的表写入一条消息比如cache_delete_msg记录目标缓存键。事务提交后后台任务或定时任务扫这张表把消息投递到MQ。MQ消费者收到消息后执行缓存删除成功则标记消息为已消费失败则继续重试。这个方案的关键是写业务数据和写消息事件是同一个数据库事务要么都成功要么都失败。只要事务提交了消息必然存在剩下的就是消息投递和消费的问题。所以哪怕Redis某次操作异常只要MQ能重试缓存最终也会被删掉。实现成本主要在于需要建表、写扫描任务、维护消息状态机。如果公司已经有MQ基础设施这套方案的成本其实不高。对应的还有RocketMQ的事务消息原理类似区别是消息的预提交和确认状态交给Broker管理业务系统少写一张表。但事务消息通常要求消息中间件版本较新而且消息系统本身也要高可用不然中间件自己挂了两边都不好收场。3.3 监听数据库Binlog的无侵入方案如果系统已经有一定规模业务代码不好大改或者你觉得用消息也太重可以考虑监听数据库Binlog。MySQL的Binlog记录了所有数据的变更细节Canal这类中间件可以把这些变更解析成结构化事件再转发给你的处理程序。一个典型的“Canal Redis删除”链路应用更新数据库产生Binlog。Canal监听Binlog解析出被更新的表、主键、变更类型。Canal把变更事件投递到MQ。消费者拿到变更事件构造缓存键执行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里否则其中一个字段变更整条缓存都得失效成本很高。这个习惯在排查一致性问题时能帮你省下很多时间。
返回列表