如何保证Mysql和Redis双写一致性
2026/8/9 9:55:17 网站建设 项目流程

h5打开以查看

这个问题本质上是分布式系统中的数据一致性问题。因为 MySQL 和 Redis 是两个独立的存储系统,无法做到原子性更新,所以我们只能通过合理的更新策略来尽可能保证最终一致性,同时兼顾性能和可用性。

先明确:哪些方案是绝对不能用的 ❌

很多人一开始会踩这些坑,我先排除掉:

错误方案致命问题
先更新 Redis,再更新 MySQLRedis 更新成功,MySQL 更新失败 → 数据永久不一致
先更新 MySQL,再更新 Redis并发场景下会出现 "写覆盖" 问题,导致脏数据
双写都加分布式锁性能极差,完全失去了 Redis 缓存的意义

业界主流的 4 种正确方案对比 📊

方案 1:先更新数据库,再删除缓存(最常用)

  • 优点:实现简单,性能好,出现不一致的概率极低
  • 缺点:极端情况下仍有不一致风险(数据库更新成功,删除缓存失败)
  • 适用场景:90% 以上的业务场景都可以用这个方案
方案 2:先删除缓存,再更新数据库

  • 优点:比 "先更库再删缓存" 更安全
  • 缺点:并发读场景下会出现 "缓存击穿" 问题
  • 解决办法:采用 "延迟双删" 策略
方案 3:更新数据库 + 消息队列异步删除缓存

  • 优点:解决了 "删除缓存失败" 的问题,有重试机制
  • 缺点:引入了 MQ 的复杂度,有一定的延迟
  • 适用场景:对一致性要求较高的业务
方案 4:基于 MySQL binlog 的最终一致性方案(终极方案)

  • 优点:完全解耦,业务代码无侵入,一致性最高
  • 缺点:架构最复杂,运维成本高
  • 适用场景:大型互联网公司,高并发高一致性要求的核心业务

面试必问:极端场景分析 🔍

场景 1:为什么是 "删除缓存" 而不是 "更新缓存"?

答案

  1. 并发写场景下,更新缓存会出现 "写覆盖" 问题
  2. 很多缓存值不是简单的数据库字段映射,计算成本高
  3. 采用 "懒加载" 思想,只有当缓存被读取时才会重新计算,节省资源
场景 2:"先更库再删缓存" 的极端不一致情况

发生条件

  1. 缓存刚好失效
  2. 线程 A 查询数据库,得到旧值
  3. 线程 B 更新数据库,然后删除缓存
  4. 线程 A 将旧值写入缓存

结果:缓存中永远是旧数据,直到下一次更新或过期

解决办法

  1. 给缓存设置合理的过期时间(兜底方案)
  2. 采用 "延迟双删" 策略
  3. 使用 binlog 异步删除方案

我的生产环境最佳实践 ✨

  1. 基础方案:先更新 MySQL,再删除 Redis 缓存
  2. 兜底方案:所有缓存都设置过期时间(15 分钟 - 2 小时)
  3. 增强方案:删除缓存失败时,通过 MQ 进行重试
  4. 终极方案:核心业务使用 Canal 监听 binlog 异步更新缓存

生产级核心代码实现 🚀

基于 Spring Boot 3.x + Redis 7.x + RabbitMQ 3.x

1 基础方案:先更库再删缓存(带异常重试)

技术亮点

  • 统一异常处理,删除失败立即重试 1 次
  • 异步删除不阻塞主业务流程
  • 日志埋点便于问题排查
@Service @Slf4j public class UserService { @Autowired private UserMapper userMapper; @Autowired private RedisTemplate<String, Object> redisTemplate; // 自定义线程池,避免使用默认线程池导致OOM @Autowired private ThreadPoolTaskExecutor cacheExecutor; /** * 更新用户信息(基础双写方案) */ @Transactional(rollbackFor = Exception.class) public void updateUser(User user) { // 1. 先更新数据库 int rows = userMapper.updateById(user); if (rows == 0) { log.warn("更新用户信息失败,用户不存在: {}", user.getId()); return; } // 2. 异步删除缓存,不阻塞主流程 String cacheKey = "user:info:" + user.getId(); cacheExecutor.execute(() -&gt; { try { redisTemplate.delete(cacheKey); log.info("删除缓存成功: {}", cacheKey); } catch (Exception e) { // 立即重试1次,仍失败则记录告警,后续由定时任务兜底 log.error("第一次删除缓存失败,重试中: {}", cacheKey, e); try { redisTemplate.delete(cacheKey); log.info("重试删除缓存成功: {}", cacheKey); } catch (Exception ex) { log.error("重试删除缓存失败,需人工介入: {}", cacheKey, ex); // 发送告警(邮件/短信/钉钉) alertService.sendAlert("缓存删除失败", cacheKey); } } }); } }
2 增强方案:延迟双删(解决并发读写脏数据)

技术亮点

  • 使用线程池实现延迟任务,不阻塞主线程
  • 可配置延迟时间,适配不同数据库同步延迟
  • 幂等性检查,避免重复删除
@Service @Slf4j public class UserService { // 省略其他注入... @Value("${cache.delay-delete-time:500}") private long delayDeleteTime; /** * 更新用户信息(延迟双删方案) */ @Transactional(rollbackFor = Exception.class) public void updateUserWithDelayDelete(User user) { String cacheKey = "user:info:" + user.getId(); // 1. 第一次删除缓存 redisTemplate.delete(cacheKey); // 2. 更新数据库 userMapper.updateById(user); // 3. 延迟删除缓存(核心:等待读线程完成旧值写入) cacheExecutor.schedule(() -&gt; { // 幂等性检查:如果缓存不存在,无需删除 if (Boolean.TRUE.equals(redisTemplate.hasKey(cacheKey))) { redisTemplate.delete(cacheKey); log.info("延迟删除缓存成功: {}", cacheKey); } }, delayDeleteTime, TimeUnit.MILLISECONDS); } }
3 高可靠方案:MQ 异步删除(解决删除失败问题)

技术亮点

  • 消息持久化 + 重试机制,保证最终一致性
  • 幂等性设计,防止重复消费
  • 死信队列处理失败消息,避免消息丢失
// 生产者 @Service @Slf4j public class CacheDeleteProducer { @Autowired private RabbitTemplate rabbitTemplate; public void sendDeleteMessage(String cacheKey) { try { // 消息体包含唯一ID,用于幂等性 CacheDeleteMessage message = new CacheDeleteMessage( UUID.randomUUID().toString(), cacheKey ); rabbitTemplate.convertAndSend("cache-exchange", "cache.delete", message); log.info("发送删除缓存消息成功: {}", message); } catch (Exception e) { log.error("发送删除缓存消息失败: {}", cacheKey, e); throw new RuntimeException("发送缓存删除消息失败", e); } } } // 消费者 @Component @Slf4j public class CacheDeleteConsumer { @Autowired private RedisTemplate<String, Object> redisTemplate; @RabbitListener(queues = "cache-delete-queue") public void handleDeleteMessage(CacheDeleteMessage message) { String cacheKey = message.getCacheKey(); String messageId = message.getMessageId(); // 1. 幂等性检查:如果该消息已处理过,直接返回 String idempotentKey = "cache:delete:idempotent:" + messageId; if (Boolean.TRUE.equals(redisTemplate.hasKey(idempotentKey))) { log.info("消息已处理,跳过: {}", messageId); return; } try { // 2. 删除缓存 redisTemplate.delete(cacheKey); log.info("消费消息删除缓存成功: {}", cacheKey); // 3. 标记消息已处理,过期时间24小时 redisTemplate.opsForValue().set(idempotentKey, "1", 24, TimeUnit.HOURS); } catch (Exception e) { log.error("消费消息删除缓存失败: {}", message, e); // 抛出异常,触发RabbitMQ重试机制 throw new RuntimeException("处理缓存删除消息失败", e); } } } // 消息实体 @Data @AllArgsConstructor @NoArgsConstructor public class CacheDeleteMessage implements Serializable { private String messageId; private String cacheKey; }
4 终极方案:Canal 监听 binlog 异步更新(业务无侵入)

技术亮点

  • 完全解耦业务代码,无需在业务层处理缓存
  • 基于数据库 binlog,保证数据变更不丢失
  • 支持分库分表场景下的缓存同步
// Canal客户端核心代码 @Component @Slf4j public class CanalBinlogListener { @Autowired private RedisTemplate<String, Object> redisTemplate; @PostConstruct public void startCanalClient() { // 创建Canal连接 CanalConnector connector = CanalConnectors.newSingleConnector( new InetSocketAddress("127.0.0.1", 11111), "example", "", "" ); try { connector.connect(); // 订阅所有库所有表 connector.subscribe(".*\\..*"); connector.rollback(); while (true) { // 获取binlog数据 Message message = connector.getWithoutAck(100); long batchId = message.getId(); int size = message.getEntries().size(); if (batchId == -1 || size == 0) { Thread.sleep(1000); continue; } // 解析binlog for (CanalEntry.Entry entry : message.getEntries()) { if (entry.getEntryType() == CanalEntry.EntryType.ROWDATA) { CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue()); CanalEntry.EventType eventType = rowChange.getEventType(); // 只处理更新和删除操作 if (eventType == CanalEntry.EventType.UPDATE || eventType == CanalEntry.EventType.DELETE) { String tableName = entry.getHeader().getTableName(); for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) { // 获取主键ID String id = getPrimaryKey(rowData, tableName); String cacheKey = tableName + ":info:" + id; // 删除缓存 redisTemplate.delete(cacheKey); log.info("Canal删除缓存成功: {}", cacheKey); } } } } // 确认消息 connector.ack(batchId); } } catch (Exception e) { log.error("Canal客户端异常", e); } finally { connector.disconnect(); } } // 获取表主键 private String getPrimaryKey(CanalEntry.RowData rowData, String tableName) { // 根据不同表获取主键,这里以user表为例 for (CanalEntry.Column column : rowData.getBeforeColumnsList()) { if (column.getName().equals("id")) { return column.getValue(); } } throw new RuntimeException("未找到主键: " + tableName); } }

核心技术难点与解决方案汇总 🧩

技术难点问题描述解决方案技术亮点
删除缓存失败网络波动、Redis 宕机导致删除操作失败,数据永久不一致1. 立即重试 1 次2. MQ 异步重试(最多 3 次)3. 定时任务兜底扫描过期缓存4. 死信队列告警人工介入多级重试机制,最终一致性兜底
并发读写脏数据缓存失效时,读线程拿到旧值,写线程更新后删除缓存,读线程再把旧值写入缓存1. 给缓存设置过期时间(兜底)2. 延迟双删策略3. 读写锁(读多写少场景)延迟双删成本最低,效果最好
延迟时间难以确定延迟双删的延迟时间太短,读线程还没写完;太长影响一致性1. 压测数据库平均查询耗时,设置为其 2-3 倍2. 配置中心动态调整,无需重启服务3. 核心业务使用 Canal 方案动态配置 + 压测数据支撑,灵活调整
MQ 消息丢失 / 重复消费MQ 宕机、网络分区导致消息丢失;重试机制导致重复消费1. 消息持久化 + 生产者确认机制2. 消费者手动 ACK3. 基于消息 ID 的幂等性设计4. 死信队列处理失败消息幂等性是解决重复消费的根本
大 key 删除阻塞 Redis缓存大 key(如列表、哈希)删除时会阻塞 Redis 主线程1. Redis 4.0 + 使用 UNLINK 命令异步删除2. 大 key 拆分,分批删除3. 避免缓存大对象异步删除不阻塞主线程,保证 Redis 可用性
缓存穿透 / 击穿 / 雪崩双写一致性问题常伴随这些缓存问题,加剧数据不一致1. 布隆过滤器防穿透2. 互斥锁防击穿3. 缓存过期时间加随机值防雪崩组合使用多种策略,全面防护
分布式事务问题数据库更新和缓存删除无法原子性执行1. 放弃强一致性,追求最终一致性2. 基于 Seata 的 TCC 模式(不推荐,性能差)3. 基于 binlog 的 CDC 方案(推荐)CDC 方案完全解耦,性能最好
分库分表场景分库分表后,数据变更分散在多个库,缓存同步复杂1. Canal 监听所有分库的 binlog2. 统一缓存 key 命名规范3. 按表维度拆分缓存同步逻辑业务无感知,支持水平扩展

面试终极加分项 💎

  1. 能说出 "先更库再删缓存" 的极端不一致场景发生的概率极低的原因:需要同时满足 "缓存刚好失效 + 读线程查询慢 + 写线程更新快" 三个条件
  2. 能解释为什么不推荐使用更新缓存:除了写覆盖问题,还有缓存利用率低、计算成本高的问题
  3. 能结合自己的项目经验,说明不同业务场景下的方案选型:比如非核心业务用基础方案,核心业务用 MQ+Canal 方案
  4. 能提到降级策略:当 Redis 不可用时,直接读数据库,保证业务可用性
  5. 能说出监控指标:缓存命中率、缓存删除失败率、MQ 消息堆积量、Canal 同步延迟

真实面试模拟

真实面试模拟

面试官 😊:

“你在项目里肯定用过 Redis 做缓存吧,那如果让你设计一个高并发的读写场景,怎么保证 MySQL 和 Redis 双写一致性?先说说你的思路。”

候选人 🤔:

“好的面试官。这个问题我们线上实际踩过坑,后来沉淀了一套方案。我先说两个最典型的错误做法,因为真实面试里直接说出正确方案前,理解为什么错更重要。”

面试官 👍:

“可以,说说看。”

候选人 💣:

“第一个坑是‘先删缓存,再更新数据库’

我画个时序图就清楚了:”

“您看,读请求恰好插在删除缓存和更新DB之间,把旧数据刷回了缓存,导致长时间脏数据。这个在高并发下很容易复现。”

候选人 🔥:

“第二个坑是‘先更新数据库,再更新缓存’

并发写会有顺序问题:”

“最终缓存里是10,DB是20,不一致。而且更新缓存还涉及序列化开销和写竞争,所以我们基本放弃了更新缓存的思路。”

面试官 💡:

“那你们线上最终怎么落地?”

候选人 ✨:

“核心是Cache Aside 模式的一个变种——流程只有两步:

  1. 先更新数据库
  2. 再删除缓存(注意是删除,不是更新)

读请求则保持查缓存 → miss → 查DB → 写回缓存,并给所有缓存设置过期时间兜底。

为什么删除而不是更新?因为删除是幂等的,避免并发写覆盖,而且让数据惰性加载,不读就不占内存。”

面试官 🧐:

“先更DB再删缓存,有没有极端情况仍然不一致?”

候选人 👇:

“有,不过概率极低。需要读写并发且时序恰好错位:

  • 读请求缓存miss,读到DB旧数据(此时写请求还没删缓存)
  • 写请求更新DB,删除缓存
  • 读请求把刚才拿到的旧数据写回缓存

这个窗口非常窄,但我们还是做了保护——延迟双删。”

面试官 ⏱️:

“延迟双删怎么实现?同步 sleep 吗?”

候选人 🛠️:

“绝对不能同步 sleep,会阻塞主线程。我们用的是线程池异步,流程是:

更新DB → 删缓存 → 提交异步任务(休眠300ms) → 再次删除缓存

如果第二次删缓存失败,投递到消息队列做重试,保证最终一定删掉。

300ms 是根据接口响应时间 P99 定的,基本能覆盖读请求写回缓存的时间。”

面试官 🚀:

“如果整个系统缓存更新逻辑很复杂,或者跨多个服务,光靠删缓存不够怎么办?”

候选人 🌊:

“我们上了基于 MySQL Binlog 的异步更新方案

业务代码只写数据库,完全不管缓存。Canal 订阅 binlog,投递到 RocketMQ,由专门的缓存更新服务消费,架构图是这样的:”

“优点就是业务零侵入,MQ 重试 + 死信队列保证了最终一致。缺点是有百毫秒级延迟,只适合最终一致性场景。”

面试官 ⚖️:

“那如果涉及资金、库存这种,完全不能忍受短暂不一致的呢?”

候选人 🎯:

“这种场景我们就不追求缓存强一致了。

做法是:

  • 把 Redis 当作只读缓存,TTL 设得很短(比如1秒),权威数据永远在 MySQL。
  • 写操作直接走 DB,读操作在缓存 miss 时查 DB,用分布式锁避免热点 key 的缓存击穿。
  • 真正的一致性靠数据库事务保证,Redis 只是性能层,不参与一致性的核心逻辑。”
面试官 📝:

“总结一下你的设计思路?”

候选人 🌟:

“三句话总结吧:

  1. 🚫绝不更新缓存,只删除缓存,用删除的幂等性避免并发写覆盖。
  2. 所有缓存都要有过期时间,即使所有同步机制都失败,过期时间也能兜底。
  3. 📭异步重试 + 消息补偿,保证最终一致性,别为了强一致牺牲可用性。

方案选型看业务容忍度:

场景方案一致性
普通读多写少先更DB + 删缓存 + 延迟双删最终一致(窗口极小)
跨系统/复杂更新Binlog + MQ 异步刷新最终一致(百毫秒延迟)
资金/库存核心链路短TTL缓存 + 依赖DB事务强一致

以上就是我们生产环境验证过的整套思路。”

📌 核心代码片段

🥇 延迟双删(异步 + 兜底重试)

@Service public class CacheAsideService { @Autowired private RedisTemplate&lt;String, Object&gt; redisTemplate; @Autowired private ThreadPoolTaskExecutor delayDeleteExecutor; @Autowired private RocketMQTemplate rocketMQTemplate; /** * 先更新DB,再删除缓存,并异步延迟双删 */ @Transactional(rollbackFor = Exception.class) public void updateDataAndDeleteCache(String key, Object newData) { // 1. 更新 MySQL(事务保证) updateDatabase(newData); // 2. 第一次删除缓存 redisTemplate.delete(key); // 3. 提交异步延迟二次删除 delayDeleteExecutor.execute(() -&gt; { try { Thread.sleep(300); // 休眠时间依据接口P99延迟设定 redisTemplate.delete(key); } catch (Exception e) { // 4. 二次删除失败,投递MQ做最终补偿 rocketMQTemplate.convertAndSend("cache-delete-retry-topic", key); } }); } }

💡亮点

  • 删除动作脱离事务边界,但通过@Transactional保证 DB 更新成功后再删缓存。
  • 异步线程池隔离,不阻塞业务线程。
  • MQ 补偿保证“最终一定会删掉”,避免遗漏。

🥈 Binlog 消费者幂等处理

@Component @RocketMQMessageListener(topic = "binlog-cache-sync", consumerGroup = "cache-sync-group") public class BinlogCacheConsumer implements RocketMQListener<MessageExt> { @Autowired private RedisTemplate&lt;String, Object&gt; redisTemplate; @Autowired private BloomFilterManager bloomFilterManager; // 自定义布隆过滤器 @Override public void onMessage(MessageExt msg) { BinlogEvent event = JSON.parseObject(msg.getBody(), BinlogEvent.class); String cacheKey = event.getCacheKey(); // 幂等设计:用唯一消息ID+布隆过滤器防重 String msgId = msg.getMsgId(); if (bloomFilterManager.mightContain("consumed_msg", msgId)) { // 可能已消费,去Redis精确校验 if (redisTemplate.opsForValue().get("msg_consumed:" + msgId) != null) { return; } } // 处理缓存更新/删除 if (event.getType() == EventType.DELETE) { redisTemplate.delete(cacheKey); } else { redisTemplate.opsForValue().set(cacheKey, event.getData(), 30, TimeUnit.MINUTES); } // 标记消费完成(布隆过滤器+Redis双重记录) bloomFilterManager.put("consumed_msg", msgId); redisTemplate.opsForValue().set("msg_consumed:" + msgId, "1", 2, TimeUnit.HOURS); } }

💡亮点

  • 布隆过滤器 + Redis 双重保障幂等,极小内存开销下保证重复消费不会造成脏数据。
  • 消费失败由 RocketMQ 自动重试,配合死信队列兜底人工介入。
  • 操作都是幂等的(deleteset),即使极端情况重复消费也安全。

🥉 热点 key 防击穿(分布式锁 + 缓存空对象)

public Object getDataWithCache(String key) { Object value = redisTemplate.opsForValue().get(key); if (value != null) return value; // 分布式锁,防止热点key击穿数据库 String lockKey = "lock:" + key; boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS); if (locked) { try { // 双重检查 value = redisTemplate.opsForValue().get(key); if (value != null) return value; // 查DB value = queryFromDatabase(key); // 即使值为null也缓存空对象,防止缓存穿透 redisTemplate.opsForValue().set(key, value == null ? "null" : value, value == null ? 1 : 30, TimeUnit.MINUTES); return value; } finally { redisTemplate.delete(lockKey); } } else { // 没拿到锁,短暂等待后重试 Thread.sleep(50); return getDataWithCache(key); } }

💡亮点

  • SETNX轻量分布式锁,避免缓存击穿。
  • 缓存空值防穿透,TTL 短(1分钟)减少内存占用。
  • 锁过期时间 5s 兜底,避免死锁。

⚔️ 技术难点与解决方案

难点问题描述我们的解法
并发写覆盖先更DB再更缓存,乱序导致旧值覆盖新值🚫 绝不更新缓存,只删除,用删除的幂等性规避
读写并发脏数据先更DB后删缓存间隙,读请求把旧数据写回🎯 延迟双删 + 异步补偿MQ
删除缓存失败网络抖动或Redis故障导致删除失败,脏数据永存📬 异步重试 + MQ死信队列,保证“最终必删”
Binlog消费顺序同一个key的更新顺序可能被多分区打乱🔒 按key哈希分区,保证同一key的消息由同一个消费者顺序处理
Binlog消费幂等RocketMQ至少一次投递导致重复消费🌸 布隆过滤器 + Redis标记,双重幂等
缓存穿透恶意查询不存在的数据,直接打到DB🛡️ 缓存空对象 + 布隆过滤器前置拦截
缓存击穿热点key过期瞬间大量请求涌向DB🔑 分布式锁(SETNX) + 双重检查
缓存雪崩大量key同时过期,DB瞬间压力过大⏱️ TTL加随机偏移量,不设相同过期时间
短暂不一致窗口最终一致方案下,用户可能读到旧数据🧘 业务可接受 + 短TTL兜底,核心链路直接读DB

面试中我会特别强调:“设计的一致性方案,本质是用空间/复杂度,换业务可接受的最终一致”。没有绝对完美的强一致,只有与场景匹配的权衡。我们代码里每一个Thread.sleep(300)、每一个 MQ 补偿,都是在和分布式的不确定性作斗争 💪。

h5打开以查看

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

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

立即咨询