如何保证Mysql和Redis双写一致性

📅 发布时间:2026/8/9 9:55:40
如何保证Mysql和Redis双写一致性 h5打开以查看这个问题本质上是分布式系统中的数据一致性问题。因为 MySQL 和 Redis 是两个独立的存储系统无法做到原子性更新所以我们只能通过合理的更新策略来尽可能保证最终一致性同时兼顾性能和可用性。先明确哪些方案是绝对不能用的 ❌很多人一开始会踩这些坑我先排除掉错误方案致命问题先更新 Redis再更新 MySQLRedis 更新成功MySQL 更新失败 → 数据永久不一致先更新 MySQL再更新 Redis并发场景下会出现 写覆盖 问题导致脏数据双写都加分布式锁性能极差完全失去了 Redis 缓存的意义业界主流的 4 种正确方案对比 方案 1先更新数据库再删除缓存最常用优点实现简单性能好出现不一致的概率极低缺点极端情况下仍有不一致风险数据库更新成功删除缓存失败适用场景90% 以上的业务场景都可以用这个方案方案 2先删除缓存再更新数据库优点比 先更库再删缓存 更安全缺点并发读场景下会出现 缓存击穿 问题解决办法采用 延迟双删 策略方案 3更新数据库 消息队列异步删除缓存优点解决了 删除缓存失败 的问题有重试机制缺点引入了 MQ 的复杂度有一定的延迟适用场景对一致性要求较高的业务方案 4基于 MySQL binlog 的最终一致性方案终极方案优点完全解耦业务代码无侵入一致性最高缺点架构最复杂运维成本高适用场景大型互联网公司高并发高一致性要求的核心业务面试必问极端场景分析 场景 1为什么是 删除缓存 而不是 更新缓存✅答案并发写场景下更新缓存会出现 写覆盖 问题很多缓存值不是简单的数据库字段映射计算成本高采用 懒加载 思想只有当缓存被读取时才会重新计算节省资源场景 2先更库再删缓存 的极端不一致情况发生条件缓存刚好失效线程 A 查询数据库得到旧值线程 B 更新数据库然后删除缓存线程 A 将旧值写入缓存结果缓存中永远是旧数据直到下一次更新或过期解决办法给缓存设置合理的过期时间兜底方案采用 延迟双删 策略使用 binlog 异步删除方案我的生产环境最佳实践 ✨基础方案先更新 MySQL再删除 Redis 缓存兜底方案所有缓存都设置过期时间15 分钟 - 2 小时增强方案删除缓存失败时通过 MQ 进行重试终极方案核心业务使用 Canal 监听 binlog 异步更新缓存生产级核心代码实现 基于 Spring Boot 3.x Redis 7.x RabbitMQ 3.x1 基础方案先更库再删缓存带异常重试技术亮点统一异常处理删除失败立即重试 1 次异步删除不阻塞主业务流程日志埋点便于问题排查Service Slf4j public class UserService { Autowired private UserMapper userMapper; Autowired private RedisTemplateString, 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 RedisTemplateString, 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 RedisTemplateString, 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. 按表维度拆分缓存同步逻辑业务无感知支持水平扩展面试终极加分项 能说出 先更库再删缓存 的极端不一致场景发生的概率极低的原因需要同时满足 缓存刚好失效 读线程查询慢 写线程更新快 三个条件能解释为什么不推荐使用更新缓存除了写覆盖问题还有缓存利用率低、计算成本高的问题能结合自己的项目经验说明不同业务场景下的方案选型比如非核心业务用基础方案核心业务用 MQCanal 方案能提到降级策略当 Redis 不可用时直接读数据库保证业务可用性能说出监控指标缓存命中率、缓存删除失败率、MQ 消息堆积量、Canal 同步延迟真实面试模拟真实面试模拟面试官 “你在项目里肯定用过 Redis 做缓存吧那如果让你设计一个高并发的读写场景怎么保证 MySQL 和 Redis 双写一致性先说说你的思路。”候选人 “好的面试官。这个问题我们线上实际踩过坑后来沉淀了一套方案。我先说两个最典型的错误做法因为真实面试里直接说出正确方案前理解为什么错更重要。”面试官 “可以说说看。”候选人 “第一个坑是‘先删缓存再更新数据库’。我画个时序图就清楚了”“您看读请求恰好插在删除缓存和更新DB之间把旧数据刷回了缓存导致长时间脏数据。这个在高并发下很容易复现。”候选人 “第二个坑是‘先更新数据库再更新缓存’。并发写会有顺序问题”“最终缓存里是10DB是20不一致。而且更新缓存还涉及序列化开销和写竞争所以我们基本放弃了更新缓存的思路。”面试官 “那你们线上最终怎么落地”候选人 ✨“核心是Cache Aside 模式的一个变种——流程只有两步先更新数据库再删除缓存注意是删除不是更新读请求则保持查缓存 → 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 只是性能层不参与一致性的核心逻辑。”面试官 “总结一下你的设计思路”候选人 “三句话总结吧绝不更新缓存只删除缓存用删除的幂等性避免并发写覆盖。⏳所有缓存都要有过期时间即使所有同步机制都失败过期时间也能兜底。异步重试 消息补偿保证最终一致性别为了强一致牺牲可用性。方案选型看业务容忍度场景方案一致性普通读多写少先更DB 删缓存 延迟双删最终一致窗口极小跨系统/复杂更新Binlog MQ 异步刷新最终一致百毫秒延迟资金/库存核心链路短TTL缓存 依赖DB事务强一致以上就是我们生产环境验证过的整套思路。” 核心代码片段 延迟双删异步 兜底重试Service public class CacheAsideService { Autowired private RedisTemplatelt;String, Objectgt; 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 RocketMQListenerMessageExt { Autowired private RedisTemplatelt;String, Objectgt; 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 自动重试配合死信队列兜底人工介入。操作都是幂等的delete、set即使极端情况重复消费也安全。 热点 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打开以查看