ARTICLE DETAIL

资讯详情

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

Redis与数据库数据一致性:从Cache Aside到binlog订阅的实战方案

Redis与数据库数据一致性:从Cache Aside到binlog订阅的实战方案 先说一个我印象深刻的真实事故。有一回负责的订单模块上线了一个“收货地址修改”功能代码逻辑很简单更新数据库之后顺手把 Redis 里的地址缓存删掉。结果上线第二天客服那边炸了大量用户反馈“我明明改了地址为什么下单还是老地址”。排查了半天问题出在更新数据库的事务还没提交另一个线程已经把新地址写回了 Redis接着事务回滚数据库里是旧值缓存里却是新值——两边永远对不上了。从那之后我对“Redis 与数据库DB的数据一致性”这件事再也不敢掉以轻心。这个题目看起来像面试八股实际上它是线上系统里最经典的隐性炸弹之一。只要你用了缓存就绕不开“同一份数据有两个副本”这个事实而只要有两个副本就一定存在“谁先谁后、怎么补偿、失败了怎么办”的问题。这篇文章把我这些年遇到过的场景、踩过的坑、用过的方案一次性说清楚从原理到实操再到排查希望能帮你少走几条弯路。1. 缓存一致性问题的根因1.1 为什么数据会“对不上”先回到最基础的问题Redis 和数据库都存了一份数据为什么不能天然保持一致因为这两份数据的写入时机和写入路径完全不是一回事。数据库里的数据是业务系统的“真相”source of truth每次变更都依靠事务来保证持久化和完整性。Redis 里的数据是“加速副本”它存在的意义是让读请求不必每次都打到数据库上。正常情况下一个读请求先查 Redis命中就直接返回没命中再去查数据库然后把结果回填进 Redis。这套流程有个专门的名字叫 Cache Aside 旁路缓存策略也是绝大多数团队默认的选择。问题出在写操作上。一次更新操作如果只改数据库Redis 里的旧值就成了脏数据后续读请求会源源不断地读到过期内容。如果先写 Redis 再写数据库Redis 写成功、数据库写失败两边又变了样。你当然可以引入分布式事务把两次写操作绑在一起但这对性能的杀伤力比不加缓存还大。所以我们在工程上面对的不是“能不能保持一致”的问题而是“在什么时间窗口内、以多大概率接受不一致”。想清楚这一点很多方案取舍就有方向了。1.2 一致性问题最容易出现的典型场景我总结了这些年看到的案例绝大多数一致性Bug逃不出下面几种场景先更新数据库再更新缓存并发请求 A 和 B 同时修改同一条数据。A 先改了数据库B 后改了数据库但 B 先写缓存、A 后写缓存最终缓存里是 A 的旧值。这就是经典的“后写覆盖先写”。先删缓存再更新数据库请求 A 删掉缓存后还没来得及改数据库请求 B 这时读缓存未命中去数据库查到旧值并回填缓存随后 A 才把数据库更新掉。缓存里留下的旧值可能永远不被刷新。更新数据库成功但删缓存失败这是最隐蔽的一种。代码逻辑没问题顺序也没问题但 Redis 网络抖动、连接超时、Key 刚好过期导致删除操作没生效。没有重试机制的话脏数据能一直存活到缓存过期。事务回滚但缓存已变更开头那个事故就是这种。事务没提交时缓存先被改了等到事务回滚两边就错位了。这四种场景的共同点是读请求和写请求在时间上交叉了。只要两次并发操作之间存在读写交叉就一定有窗口期。我们要做的不是消灭窗口期几乎不可能而是把窗口期压到业务可接受的范围。2. 主流一致性方案Cache Aside 与更新顺序2.1 Cache Aside 为什么成为默认选择最近几年聊缓存方案大家普遍认可的是 Cache Aside。核心规则只有两条读的时候先读缓存未命中就读数据库然后回填缓存。写的时候先更新数据库再删除缓存而不是更新缓存。这里最关键的选择是“删除缓存”而不是“更新缓存”。一开始很多同学不理解我把缓存改成新值不就行了为什么要多一次删除操作原因在于更新缓存的并发风险非常大。两个线程同时写数据库最后写库的值是 B但最后写缓存的可能是 A数据库和缓存的值就此错位而删除缓存则没有这个问题。读请求发现缓存未命中会去数据库查最新值并回填天然就能对账。用个生活化的类比数据库是保险柜里的账本缓存是贴在墙上的便签。账本改了以后把便签撕掉比在便签上摹写新数字安全得多。因为不管是谁看不到便签都会去翻账本翻到的一定是准的。2.2 更新顺序究竟该“先 DB 后删缓存”还是“先删缓存后 DB”Cache Aside 说的“先更新数据库再删除缓存”也不是没有争议很多人会问先删缓存、再更新数据库不行吗答案是在绝大多数场景下不行。先删缓存会引入一个非常危险的时间窗口。比如线程 A 删了缓存还没更新数据库线程 B 读缓存未命中把数据库的旧值回填到缓存并且这个值可能长期不过期之后 A 才更新数据库。此时缓存里的旧值和数据库里的新值已经错位而且很难自然恢复。相比之下先更新数据库再删缓存即使删除操作失败也还有缓存过期时间当作兜底脏数据不会永远存在。不过有经验的团队都知道“先 DB 后删缓存”只是解决了顺序问题没有解决失败问题。删除缓存这一步如果 Redis 超时或者服务异常整个方案就塌了。我们线上曾经遇到过一个现象数据库更新成功了但删除 Redis Key 的命令超时异常被吞掉缓存数据一直维持到 TTL 自动过期。在 TTL 较短比如五分钟的业务上这个问题不大但一旦有人把 TTL 设置为 7 天事故就来了。所以删除失败必须要有补偿机制。2.3 删除缓存失败的补偿重试与消息队列拿我自己的实践来说删除缓存失败后最常见的补救手段是“重试”。我见过团队在 catch 里直接循环重试三次Redis 正常时够用Redis 抖动严重时三次也大概率失败。更稳妥的做法是在业务代码里把删除失败的 Key 丢进本地内存队列或者 RocketMQ / Kafka由另一个消费任务专门删缓存。这样相当于把“删缓存”和“业务主流程”解耦开了。不过这里有一个容易被忽略的细节重试删除的时候要把 Key 的 TTL 也考虑进去。如果这个 Key 在你重试期间已经被自然的读请求重新回填成最新值那删不删都无所谓如果回填的是旧值重试删除就能救命。还有一类做法是给缓存 Key 设置一个较短的 TTL让过期时间成为兜底。TTL 越短数据不一致的时间窗口越小但缓存命中率会下降数据库压力会上升。这个参数需要结合业务实际来调不能拍脑袋。3. 进阶方案延迟双删与 binlog 订阅3.1 延迟双删能解决什么问题“先更新数据库再删除缓存”虽然逻辑正确但在极端并发下仍有瑕疵。我们前面提到过一种情况更新数据库的事务还没提交读请求已经把旧值回填到缓存里了。这就导致删除缓存的操作反而删了“该删的值”但没过多久旧值又被回填进去。为了应对这个问题社区里流行一种叫“延迟双删”的方案更新数据库后删除一次缓存过一段时间比如 500 毫秒到 1 秒再删除一次缓存。第二次删除是为了处理“第一次删除之后又有读请求回填旧值”的情况。这个延迟时间要大于读请求把旧值回填进缓存的耗时一般取业务读接口 P99 耗时再加一点余量。听起来很扎实但实际用起来有门槛。延迟时间设短了并发高的场景照样漏设长了缓存空窗期长数据库压力骤增。而且第二次删除是异步发起的如果服务在延迟期内宕机二次删除就没了。这套方案适合并发并发级别较高但业务对短暂不一致容忍度较高的场景典型的例子是商品库存、活动配置等。3.2 基于 binlog 的可靠异步补偿比延迟双删更可控的方案是“监听数据库 binlog异步刷新缓存”。以 MySQL 为例业务更新数据库成功时并不直接操作 Redis而是让 Canal 这类中间件监听 binlog把变更事件解析出来再通过消息队列通知消费者去删除或重建缓存。这个方案的好处在哪第一它彻底脱离了业务代码的“时序陷阱”因为在 binlog 里能看到真正事务提交的结果不会出现事务回滚但缓存已变更的情况。第二它有天然的重试保障binlog 消费失败可以反复投递消息持久化也能保证不丢失。第三清理缓存这个动作不再占用业务接口的 RT主链路更干净。但 binlog 方案复杂度也高。你得额外部署 Canal、维护消息队列、处理回填数据时的类型转换如果消费者逻辑写得不好还可能引发缓存雪崩。比如商品变更事件一瞬间全部到达消费者同时对一批 Key 发起回填查询数据库连接池可能被打穿。应对办法是消费端做分组、限流、批量合并按业务维度把同一商品的多个变更事件聚合成一次缓存重建。依赖 binlog 的架构模式比较适合中大型团队能力足够而且对数据一致性要求高的场景。如果团队规模小、业务简单直接上这套方案会让运维成本和故障排查难度翻倍。3.3 异步补偿的边界条件不管是延迟双删还是 binlog 订阅本质上都在传递一个信息我们接受短暂的不一致但不接受永久的不一致。所谓短暂就是缓存最坏情况下只在窗口期内返回旧值窗口期结束后缓存最终与数据库对齐所谓永久就是旧值一直存在缓存里直到被人手动清理或 TTL 到期。异步补偿方案要有效有几个边界条件必须满足。第一所有写操作都要能落到 binlog 或消息队列里如果是绕过中间件直接改数据库的表比如 DBA 手工执行 SQL那补偿机制感知不到。第二消息队列的消费延迟要可控如果积压了几百万条消息缓存恢复一致性的时间会被拉得很长。第三补偿动作必须是幂等的删除一个不存在的 Key 再正常不过但重建缓存时如果并发执行就可能出现旧值覆盖新值。还有一点值得强调业务侧最好对“最终一致”有明确的 SLO。比如“商品信息的缓存最长不超过 30 秒偏差”有了这个数值目标你在设计重试次数、TTL、消息积压告警阈值时就有了依据。4. 强一致场景下的特殊手段4.1 分布式锁与一致性之间的真实关系热搜词里反复出现“redis分布式锁”很多人会把分布式锁和数据一致性混为一谈。实际上它们解决的是不同层级的问题分布式锁解决的是“多个实例不能同时执行某段代码”数据一致性解决的是“多个数据副本最终是否对齐”。但在一些极端的业务里分布式锁确实能间接干掉缓存不一致。举个例子如果同一个 Key 的读写请求全部先抢一把锁抢到锁的线程串行执行“更新数据库 删缓存”那么前面说的并发覆盖、读写交叉问题就自然消失了。Redis 单线程模型保证分布式锁本身的互斥只要锁可靠临界区就是串行的。然而分布式锁引入的风险更大。Redis 主从切换可能导致锁丢失网络分区可能造成死锁或锁误删锁的粒度设太粗还会拖垮性能。除非业务确实需要“同一条数据在同一时刻只允许一个线程修改”否则不要为了缓存一致性去引入分布式锁性价比非常低。在我看来用分布式锁实现一致性是“最后的手段”不是“首选方案”。4.2 Redis 事务与 Lua 脚本能帮上什么忙Redis 本身提供了 MULTI/EXEC 事务和 WATCH 乐观锁机制但它们解决的是 Redis 内部多个命令之间的原子性管不到数据库。比如你可以在一个 Redis 事务里完成“读旧值、计算、写新值”但数据库的变更发生在另外一个系统里两边依然不是一个原子操作。真正在实战中有价值的是 Lua 脚本。因为 Redis 会原子地执行整个 Lua 脚本所以你可以把“判断当前值是否匹配匹配则删除”写进一个脚本避免误删其他线程刚写入的新值。下面这个脚本我在清理分布式锁和缓存时经常用-- 比较 key 当前值是否等于传入的 expectValue -- 相等则删除否则返回 0 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end把这个脚本和“先数据库后删缓存”组合起来可以避免一种典型事故线程 A 更新数据库后准备删缓存结果线程 B 已经回填了新值A 把新值误删导致缓存击穿。虽然误删并不必然导致数据错位但会放大数据库压力。有了 Lua 脚本做值比对删除动作就更有准头了。4.3 从“多核一致性”看 Redis 与 DB 一致性热搜词里还有一条“多核数据一致性”看起来和 Redis 没关系但它背后的思想非常有意思。CPU 每个核都有自己的 L1/L2 缓存共享主内存。如果某个核修改了变量而另一个核仍在读旧缓存就会出现缓存一致性问题CPU 通过缓存一致性协议比如 MESI来协调核间数据同步。如果把 CPU 的“主内存”对应成数据库把“L1/L2 缓存”对应成 Redis你会发现它们面对的问题惊人地相似有共享数据、有多份副本、有延迟同步、有并发读写。区别在于CPU 缓存一致性协议是硬件层面的强一致协议而 Redis 与数据库之间没有这种硬件协议只能靠业务层自己实现。这是我特别愿意给年轻开发同学讲的一个类比。理解了 CPU 缓存一致性你就更容易接受“Redis 与数据库之间一定存在不一致窗口”这个结论——因为连硬件级别的强一致协议都无法做到零开销更别提软件层面了。所以我们追求的一致性全都是在“成本”和“收益”之间取平衡。5. 架构选型与排查实战5.1 不同的业务场景应该选什么方案不是所有系统都要上 binlog、都要做延迟双删。我一般会把场景分成三类分别推荐不同级别的一致性方案。场景类型典型业务推荐方案理由低敏感、允许秒级不一致用户昵称、文章浏览量、系统配置先 DB 后删缓存TTL 兜底简单可靠运维成本低中敏感、短窗口可接受商品信息、购物车数量、实时库存先 DB 后删缓存 删除重试机制把窗口期压到几秒内高敏感、需要准实时一致订单状态、支付结果、优惠券核销binlog 订阅 消息队列补偿事务提交后再刷新缓存这里要给个忠告不是一致性搞得越强越好。强一致往往意味着链路变长、代价变大、可用性变差。很多业务场景里的用户根本感知不到几百毫秒的旧值那就不值得为了追求心理上的“绝对一致”把系统复杂度拉高一个量级。5.2 常见问题与排查思路实录排查缓存不一致问题我习惯按“从下往上”的方式走先看数据库的值再看 Redis 的值最后反推代码路径。第一步把 Redis 里的值和数据库里的值直接做比对。可以用 Redis 的 scan 命令批量遍历指定前缀的 Key也可以直接用客户端按业务 ID 拼接 Key 读取。第二步确认两边不一致后查看这个 Key 最近一次写入的时间结合业务日志找出是哪个请求写的。第三步检查这个请求的代码路径是更新数据库之后删缓存失败还是根本没有删缓存还是并发场景下产生了覆盖我整理过一张速查表问题排查时可以照着对现象可能原因处理手段缓存里是旧值数据库是新值删除缓存失败补充重试或异步删除任务缓存里是新值数据库是旧值并发后写覆盖改用先 DB 后删缓存思路或加锁缓存里偶尔出现旧值读写交叉窗口期延迟双删或缩短 TTL缓存长期不更新TTL 设置过长补偿没生效检查 TTL检查异步任务积压5.3 我常用的几条实战经验最后分享几个只有实际动手才会明白的细节每一条都来自踩坑现场。第一不要在大事务里操作缓存。事务提交之前删缓存一旦事务回滚缓存状态就对不上。哪怕事务大概率成功也必须把缓存操作放到事务提交之后。第二给缓存 Key 设计规范的后缀。我见过很多团队排查一致性问题时连 Key 的拼接规则都不统一一个业务在多个服务里用了不同规则扫描都扫不全。建议把 Key 的版本号、业务前缀、业务 ID 在文档里固定下来最好由一个公共类统一生成。第三Redis 的序列化方式会影响比对。如果值是用 JDK 序列化或者 Kryo 序列化写入的你从命令行看到的是一堆二进制乱码无法直观比对。排查时建议先确认序列化器再写个小的读值工具或者临时在代码里打日志输出反序列化结果。第四不要忽略缓存击穿和雪崩的伴随风险。把缓存删掉之后如果同时有大量请求未命中缓存数据库可能瞬间接不住。删缓存之前要评估热点数据规模必要时用“逻辑过期”方案代替直接删除比如把 Key 的 value 包一层带过期时间的对象读请求发现逻辑过期后再异步刷新。第五监控比方案更值钱。一致性方案的最终效果要靠监控来证明。我建议对核心缓存 Key 增加“写后校验”的日志每次更新数据库之后异步读一次 Redis 看是否已删除或已更新对删除失败的记录要打点告警别等到客服投诉才知道出问题。6. 我目前比较推荐的落地组合看得出这篇文章篇幅不小实际操作并不需要一次上全套。如果让我给一个中等规模的业务系统做推荐我会建议按以下组合来搭业务主流程采用 Cache Aside先更新数据库事务提交后删除缓存删除动作包一层重试失败就丢到本地队列或 MQ 异步处理缓存 TTL 设置为业务可接受的最大偏差时间比如 30 分钟到 2 小时对核心写入链路增加“更新后校验”日志配合监控大盘及时发现异常。如果后续业务规模变大、一致性要求变高再考虑引入 Canal 监听 binlog把缓存刷新完全解耦出来。在真正操作过以后我的体会是数据一致性不是某一个神秘方案能一劳永逸解决的它更像是“缓存生命周期管理”的延伸。Cache Aside、延迟双删、binlog 订阅每个方案都有它的窗口期和代价你要做的是结合自己的业务容忍度、团队运维能力和故障恢复速度去做一道权衡题。把 TTL、重试、监控这三个基础动作做好你就已经解决了 80% 的缓存一致性问题剩下的 20%才需要复杂的中间件和架构改造来硬顶。最后再分享一个小技巧排查一致性问题时别急着写代码验证先用redis-cli monitor观察一段时间内的实时命令流看看某个 Key 的读、写、删操作究竟是怎么交织的。很多时候问题原因就在那几行命令的时序里看完你比翻半天代码都清醒。
返回列表