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

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

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

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

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

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

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

image

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

方案 2:先删除缓存,再更新数据库

image

  • 优点:比 "先更库再删缓存" 更安全
  • 缺点:并发读场景下会出现 "缓存击穿" 问题
  • 解决办法:采用 "延迟双删" 策略

方案 3:更新数据库 + 消息队列异步删除缓存

image

  • 优点:解决了 "删除缓存失败" 的问题,有重试机制
  • 缺点:引入了 MQ 的复杂度,有一定的延迟
  • 适用场景:对一致性要求较高的业务

方案 4:基于 MySQL binlog 的最终一致性方案(终极方案)

image

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

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

场景 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 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(() -> {
            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(() -> {
            // 幂等性检查:如果缓存不存在,无需删除
            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 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 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 双写一致性?先说说你的思路。”

候选人 🤔:

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

面试官 👍:

“可以,说说看。”

候选人 💣:

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

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

image

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

候选人 🔥:

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

并发写会有顺序问题:”

image

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

面试官 💡:

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

候选人 ✨:

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

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

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

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

面试官 🧐:

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

候选人 👇:

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

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

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

面试官 ⏱️:

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

候选人 🛠️:

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

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

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

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

面试官 🚀:

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

候选人 🌊:

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

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

image

“优点就是业务零侵入,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 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(() -> {
            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 {

    @Autowired
    private RedisTemplate 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

image

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


原文地址: https://www.cveoy.top/t/topic/qHgY 著作权归作者所有。请勿转载和采集!

免费AI点我,无需注册和登录