[db:标题]

摘要:前言 缓存(例如:Redis)和数据库的数据一致性问题,也是一个经典的问题,无论是在面试还是在日常工作当中,遇到的概率非常大。尤其是在高并发的场景下,这个问题会变得更加严重。 业内常见的解决方案 先更新数据库,再删缓存。 延迟双删:先删缓存
前言 缓存(例如:Redis)和数据库的数据一致性问题,也是一个经典的问题,无论是在面试还是在日常工作当中,遇到的概率非常大。尤其是在高并发的场景下,这个问题会变得更加严重。 业内常见的解决方案 先更新数据库,再删缓存。 延迟双删:先删缓存,再更新数据库,延时一段时间,再删一次缓存。 Canal/Maxwell订阅 binlog,更新数据库,基于中间件监听binlog后删除缓存。 这三种方案基本上就能解决市面上大部分的业务场景中的缓存不一致的问题了。 方案 优点 缺点 适用场景 先更新数据库,再删缓存 实现简单,对业务代码侵入小 删缓存失败,存在数据不一致的问题 95%一般场景都适合,尤其是并发量不大,或者对一致性要求不太高的。 延迟双删 数据一致性保证更好 第二次删失败要重试;延迟 时长要靠压测估 对数据一致性要求高,并发量大的场景。 更新数据库,基于中间件监听binlog后删除缓存 与业务代码解耦,一致性有保障 实现复杂,需要引入中间件 适合基础建设完善,且并发高对一致性要求高的场景 先写数据库,再删缓存 先来说一下,为什么是删除缓存,而不是更新缓存? 并发写冲突风险高 在高并发场景下,多个线程/服务可能同时更新同一份数据,如果采用更新缓存的策略,容易出现竞态条件,导致缓存中的数据是旧值或错误值。 假设有两个线程 A 和 B,同时更新数据库和缓存: 时间 线程 A 线程 B T1 更新数据库为 100 T2 更新数据库为 200 T3 更新缓存为 100(A 的旧值) T4 更新缓存为 200(B 的新值) 最终缓存中是 200,看起来没问题,但如果 T3 发生在 T4 之后(由于网络延迟、线程调度等),缓存就会被A的旧值覆盖,变成 100,导致缓存和数据库不一致。 时间 线程 A 线程 B T1 更新数据库为 100 T2 更新数据库为 200 T3 更新缓存为 200(B 的新值) T4 更新缓存为 100(A 的旧值) 但是删除缓存就不存在这个问题,删除缓存操作具有幂等性,多次执行不会产生副作用,相比更新缓存更适合在高并发场景下使用。 缓存更新逻辑复杂,容易出错 更新缓存时,通常需要: 从数据库读取最新值 构造缓存对象(可能涉及序列化、字段拼接等) 写入 Redis 这个过程中,任何一个环节出错,都可能导致缓存中的数据是不完整或错误的。 删除缓存逻辑简单,只要一个 DEL 操作,出错概率低,恢复快。 延迟加载(Lazy Loading)更自然 删除缓存后,下一次读取时会触发缓存未命中,然后从数据库加载最新数据并回填缓存。 这种延迟加载机制: 保证了缓存中的数据总是从数据库加载的最新值 避免了提前写入缓存时可能用到的旧数据 删除缓存配合“先更新数据库,再删除缓存”策略更可靠 先更新数据库,再删除缓存,这个策略在大多数情况下能保证最终一致性,即使出现极端情况(如删除缓存失败),也可以通过消息队列重试或定时任务补偿。 但是有时候删除缓存还要考虑一些极端场景,例如:删除缓存后,大量请求打到数据库,造成缓存击穿。通常的解决方案就是 使用互斥锁(如 Redis 分布式锁)保证只有一个线程去加载数据 在一致性优先的场景下,删除缓存比更新缓存更稳妥、更简单、更容易兜底的选择。 先写数据库还是先删缓存? 上面通过比较,我们知道了,删缓存比更新缓存更好,所以为了降低数据不一致的情况产生,选择删缓存。那么是先更新数据库再删缓存呢,还是说,先删缓存再更新数据库呢? 先删缓存,再更新数据库 举个例子来说明: 还是有两个线程 A 和 B,线程A是写操作,线程B是读操作。 时刻 线程 A(写) 线程 B(读) T1 删除缓存成功 T2 缓存 miss,去库读旧值 100 T3 把 100 回填缓存 T4 数据库更新为 200(还没提交或同步完成) 也有可能T3发生在T4之后 时刻 线程 A(写) 线程 B(读) T1 删除缓存成功 T2 缓存 miss,去库读旧值 100 T3 数据库更新为 200(还没提交或同步完成) T4 把 100 回填缓存 如果先删缓存,就有可能导致缓存里在很长一段时间内都是 旧值 100,直到下一次失效或更新。 先更新数据库,再删缓存 如果我们先更新数据库,再删除缓存,有一个好处,那就是缓存删除失败的概率还是比较低的,除非是网络问题或者缓存服务器宕机的问题,否则大部分情况都是可以成功的。 并且这个方案还有一个好处,那就是数据库是作为持久层存储的,先更新数据库就能确保数据先写入持久层可以保证数据的可靠性和一致性,即使在删除缓存失败的情况下,数据库中已有最新数据。 还是两个线程A和B,线程A写,线程B读。 时刻 线程 A(写) 线程 B(读) T1 更新数据库为 200(事务已提交) T2 删除缓存(DEL) T3 缓存 miss,去库读 200 T4 把 200 回填缓存 延迟双删 这种先更新数据库,再删缓存,也有一定的缓存不一致的概率,例如:在更新完数据库,还未删除缓存的这段时间。还有就是更新完数据库,如果删除缓存失败了的情况。 那么怎么解决这些问题呢?可以使用更复杂的另一个方案延迟双删。 “延迟双删”是为了解决高并发场景下 缓存与数据库数据不一致 的一种折中策略,它并不能保证强一致性,但可以缩小不一致的时间窗口。之所以要执行两次删除,是为了应对并发读写过程中可能出现的脏数据回写问题。 基本流程 第一次删除缓存 在更新数据库前或后,先删除缓存,防止旧数据被读取。 更新数据库 延迟一段时间后,再次删除缓存 这是关键步骤,目的是清除在并发过程中可能被错误写入的旧数据。 为什么需要两次删除? 第一次删除:防止读取旧缓存 如果不删缓存,用户可能一直读到旧数据。 删除缓存后,新的读请求会“穿透”到数据库,理论上能拿到最新数据。 第二次删除(延迟删):防止并发写入脏数据 在第一次删除缓存后,如果有并发读请求进来,它可能: 发现缓存为空; 去数据库读数据; 但此时数据库还没更新完,或者主从同步延迟,读到了旧数据; 然后把旧数据写回缓存,造成“脏数据回写”。 👉 延迟一段时间后再次删除缓存,就是为了清除这种被误写入的旧数据,尽可能保证缓存与数据库的一致性 延迟双删需要注意事项 延迟时间设置是个经验值, 应大于主从同步最大延迟 + 一次完整读请求耗时 + 网络抖动缓冲时间,建议通过监控主从延迟动态调整。 延迟双删不能100%避免不一致,只是降低概率 。 如果第二次删除失败,仍可能导致不一致,因此通常会配合消息队列重试机制作为兜底方案。 这是一种最终一致性策略,适用于对一致性要求较高、但无法承受强一致性开销的业务场景 Cache-Aside+监听binlog删缓存 Cache-Aside + 监听 binlog(或异步消息)删除缓存,是目前主流的最终一致性方案,把“业务代码”与“缓存失效”彻底解耦,既保留 Cache-Aside 的简单性,又通过异步机制弥补“删缓存失败”或“并发脏读”带来的不一致窗口。 基本流程 这种流程的好处在于 写路径缩短到一次 DB 事务,应用不直接碰 Redis,P99 延迟低。 即使 Redis 宕机、MQ 堆积,binlog 仍在,具备天然的重放机制,可用于故障恢复。 代码示例 // 1. 业务代码——只写库 @Transactional public void updateOrder(Order order) { // 写操作不直接更新缓存,由binlog监听异步删除缓存,读操作仍通过Cache-Aside机制回填缓存。 orderMapper.updateById(order); } // 2. Canal 适配器——把 binlog 转成简单事件 @CanalEventListener public class OrderHandler { @KafkaSender(topic = "order_binlog") public void onUpdate(CanalEntry.Entry entry) { // 解析出主键 orderId String orderId = parseOrderId(entry); return new BinlogEvent("UPDATE", orderId, entry.getHeader().getExecuteTime()); } } // 3. 缓存失效服务——消费并删除 @KafkaListener(topics = "order_binlog", groupId = "cache_clean") public void cleanCache(BinlogEvent event) { try { redisTemplate.del("order:" + event.getOrderId()); } catch (Exception e) { // 失败抛异常,让 MQ 重试 sendRetryMsg(event.getOrderId()); throw new RuntimeException("DEL failed", e); } } 上面代码示例,我是分开处理的,先处理canal的消息,再处理删除缓存的消息。其实理论上也可以合并到一起,并且如果删除缓存失败,就不提交MQ的offset,只有消费端 ACK 只有 DEL 成功才提交 offset。 Cache-Aside 负责“让缓存可旁路”,binlog 监听负责“让缓存一定失效”;两者结合,写性能不衰减,一致性可兜底,架构可水平扩展,在大多数读多写少、对一致性要求较高的业务场景中,是一种均衡且可扩展的方案。。 总结 前面介绍了几种情况的具体问题和解决方案,那么实际工作中应该如何选择呢? 没 MQ、没 DBA、团队小 → 先更新库再删缓存,极限情况下脏读 1 s 能忍就忍(例如:电商商品详情、用户资料)。 读 QPS 爆表(10w+)且同一条 key 被反复热读 → 延迟双删把“刚写立刻被读脏”概率再压一个量级(例如:秒杀库存、热点配置)。 已经上了 Canal/RocketMQ,或者团队有“消息驱动”规范 → 业务代码最干净,后续谁改库都不用管缓存;同时做好“消息失败可回查 DB 修复”的兜底脚本(订单、支付、账务等“不能错钱”场景)。 任何的技术方案,都是一个权衡的过程,要权衡的问题有很多,业务的具体情况,实现的复杂度、实现的成本,团队成员的接受度、可维护性、容易理解的程度等等。 所以,没有一个"完美"的方案,只有"适合"的方案。 但是,如何能选出一个更适合的方案,这里面就需要有更多的输入参考来做支撑了。