Redis缓存与MySQL一致性终极方案 🔄

55 阅读10分钟

一、开篇故事:仓库与橱窗的同步 🏪

想象商场的橱窗展示:

场景:商品价格更新

仓库(MySQL):
  商品A: 100元

橱窗(Redis缓存):
  商品A: 100元
  
顾客查询:
  → 看橱窗(Redis)→ 100元 ✅快速
  
问题来了:
  仓库降价:商品A: 80元
  但橱窗没更新:商品A: 100元 ❌
  
顾客:
  "橱窗显示100,我就按100买!"
  收银员:"仓库价格是80..."
  顾客:"那你们数据不一致啊!💢"

这就是缓存一致性问题


二、缓存一致性的挑战 ⚠️

2.1 为什么会不一致?

原因1:更新顺序问题
  → 先更新数据库,后删除缓存
  → 如果删除缓存失败 → 不一致

原因2:并发问题
  线程A:读取缓存(未命中)→ 读数据库(旧值)→ 写缓存
  线程B:更新数据库(新值)→ 删除缓存
  时间线:如果A最后写缓存 → 缓存是旧值 → 不一致

原因3:缓存过期问题
  → 缓存过期了
  → 多个线程同时查询
  → 都发现缓存不存在
  → 都去查数据库
  → 缓存击穿!

2.2 强一致 vs 最终一致

强一致性(Strong Consistency):
  → 任何时刻,缓存和数据库完全一致
  → 代价:性能差、复杂度高
  → 适用:金融交易、库存扣减

最终一致性(Eventual Consistency):
  → 允许短暂不一致
  → 但最终会一致
  → 代价:性能好、复杂度低
  → 适用:商品详情、用户信息

结论:
  → 大部分场景使用最终一致性 ✅
  → 强一致性场景直接查数据库(不用缓存)

三、四种经典方案 🎯

3.1 方案1:Cache Aside(旁路缓存)⭐⭐⭐⭐⭐

最常用的方案!

读取逻辑

public User getUser(Long userId) {
    // 1. 先查缓存
    String cacheKey = "user:" + userId;
    User user = redis.get(cacheKey);
    
    if (user != null) {
        return user;  // 缓存命中 ✅
    }
    
    // 2. 缓存未命中,查数据库
    user = userMapper.selectById(userId);
    
    if (user != null) {
        // 3. 写入缓存
        redis.setex(cacheKey, 3600, user);  // 1小时过期
    }
    
    return user;
}

更新逻辑(先更新DB,后删除缓存)

public void updateUser(User user) {
    // 1. 先更新数据库
    userMapper.updateById(user);
    
    // 2. 后删除缓存
    String cacheKey = "user:" + user.getId();
    redis.del(cacheKey);
}

为什么不是"先删缓存,后更新DB"?

时间线(先删缓存):
  T1: 线程A:删除缓存
  T2: 线程B:查询缓存(未命中)→ 查数据库(旧值)→ 写缓存(旧值)
  T3: 线程A:更新数据库(新值)
  T4: 数据库是新值,缓存是旧值  不一致!
  
时间线(先更新DB):
  T1: 线程A:更新数据库(新值)
  T2: 线程B:查询缓存(命中旧值)← 短暂不一致 ⚠️
  T3: 线程A:删除缓存
  T4: 线程B:查询缓存(未命中)→ 查数据库(新值)→ 写缓存(新值)✅
  
结论:
   "先更新DB,后删缓存"更好
   不一致窗口更小

为什么不是"更新缓存"而是"删除缓存"?

方案A:更新缓存
  1. 更新数据库
  2. 更新缓存(写入新值)
  
问题:
  → 如果是复杂计算的值(如统计数据)
  → 每次更新都要重新计算
  → 浪费CPU
  → 如果缓存很少访问,浪费更新

方案B:删除缓存 ✅
  1. 更新数据库
  2. 删除缓存
  3. 下次查询时,缓存未命中,查数据库,再写缓存
  
优点:
  → 懒加载(Lazy Loading)
  → 只有访问时才计算
  → 节省资源

删除缓存失败怎么办?

public void updateUser(User user) {
    // 1. 更新数据库
    userMapper.updateById(user);
    
    // 2. 删除缓存
    String cacheKey = "user:" + user.getId();
    try {
        redis.del(cacheKey);
    } catch (Exception e) {
        // 删除失败,记录日志
        log.error("删除缓存失败:{}", cacheKey, e);
        
        // 方案1:重试(3次)
        for (int i = 0; i < 3; i++) {
            try {
                Thread.sleep(100);
                redis.del(cacheKey);
                return;  // 成功
            } catch (Exception retry) {
                // 继续重试
            }
        }
        
        // 方案2:发送到MQ,异步重试
        mqProducer.send("cache_delete_topic", cacheKey);
    }
}

// MQ消费者
@RabbitListener(queues = "cache_delete_queue")
public void deleteCacheRetry(String cacheKey) {
    try {
        redis.del(cacheKey);
    } catch (Exception e) {
        // 重试失败,记录到数据库,人工处理
        failedTaskService.save(cacheKey, "cache_delete", e.getMessage());
    }
}

3.2 方案2:Read/Write Through(读写穿透)⭐⭐⭐

缓存作为主存储,由缓存负责同步数据库

// 伪代码(实际需要缓存系统支持)
public User getUser(Long userId) {
    // 直接查缓存,缓存自动加载数据库数据
    return cache.get(userId, key -> {
        // 缓存未命中时,自动回调此方法加载数据
        return userMapper.selectById(userId);
    });
}

public void updateUser(User user) {
    // 直接更新缓存,缓存自动同步到数据库
    cache.put(user.getId(), user, (key, value) -> {
        // 缓存自动回调此方法,同步到数据库
        userMapper.updateById(value);
    });
}

特点:

✅ 应用无需关心缓存和数据库同步
✅ 逻辑简单

❌ 缓存系统需要支持(如Guava Cache、Caffeine)
❌ 不适合分布式缓存(Redis不直接支持)
❌ 写操作慢(同步写入)

3.3 方案3:Write Behind(异步写入)⭐⭐⭐

先写缓存,异步批量写入数据库

public void updateUser(User user) {
    // 1. 立即更新缓存
    redis.set("user:" + user.getId(), user);
    
    // 2. 记录到写队列(异步)
    writeQueue.offer(user);
}

// 后台线程,批量写入数据库
@Scheduled(fixedDelay = 1000)  // 每秒执行一次
public void flushToDatabase() {
    List<User> batch = new ArrayList<>();
    writeQueue.drainTo(batch, 100);  // 取出最多100条
    
    if (!batch.isEmpty()) {
        // 批量写入数据库
        userMapper.batchUpdate(batch);
    }
}

特点:

✅ 写入性能极高(只写缓存)
✅ 批量写入数据库(减少IO)

❌ 可能丢数据(缓存写入后,数据库未写入,宕机)
❌ 复杂度高
❌ 适合对一致性要求不高的场景(如浏览次数、点赞数)

3.4 方案4:双写一致性(两阶段提交)⭐⭐

同时写入缓存和数据库,使用分布式事务保证一致性

@GlobalTransactional  // Seata分布式事务
public void updateUser(User user) {
    // 1. 更新数据库
    userMapper.updateById(user);
    
    // 2. 更新缓存
    redis.set("user:" + user.getId(), user);
    
    // 如果任一失败,都会回滚
}

特点:

✅ 强一致性

❌ 性能差(分布式事务开销大)
❌ 复杂度高
❌ 不推荐(大部分场景不需要强一致性)

四、进阶方案:延迟双删 ⭐⭐⭐⭐

解决并发问题的方案

4.1 问题场景

时间线:
  T1: 线程A:更新数据库(新值100)
  T2: 线程B:查询缓存(未命中)
  T3: 线程B:查询数据库(因为更新还没提交,读到旧值80)
  T4: 线程A:删除缓存
  T5: 线程A:提交事务
  T6: 线程B:写入缓存(旧值80)❌
  
结果:
   数据库是新值100
   缓存是旧值80
   不一致!

4.2 延迟双删方案

public void updateUser(User user) {
    String cacheKey = "user:" + user.getId();
    
    // 1. 先删除缓存(第一次删除)
    redis.del(cacheKey);
    
    // 2. 更新数据库
    userMapper.updateById(user);
    
    // 3. 延迟后再删除一次缓存(第二次删除)
    CompletableFuture.runAsync(() -> {
        try {
            // 延迟500ms(根据业务响应时间调整)
            Thread.sleep(500);
            redis.del(cacheKey);
        } catch (Exception e) {
            log.error("延迟删除缓存失败", e);
        }
    });
}

原理:

时间线(延迟双删):
  T1: 线程A:删除缓存(第一次)
  T2: 线程B:查询缓存(未命中)
  T3: 线程B:查询数据库(旧值80,因为A还没提交)
  T4: 线程A:更新数据库(新值100T5: 线程A:提交事务
  T6: 线程B:写入缓存(旧值80)❌
  T7: 线程A:延迟500ms后,删除缓存(第二次)✅
  T8: 下次查询会读到新值100

优点:

✅ 解决并发问题
✅ 实现简单

缺点:

❌ 延迟时间不好确定
❌ 仍有短暂不一致窗口

五、终极方案:订阅Binlog ⭐⭐⭐⭐⭐

最可靠的方案!

5.1 原理

MySQL Binlog(二进制日志):
  → 记录所有数据变更
  → INSERTUPDATEDELETE

Canal(阿里开源):
  → 伪装成MySQL从库
  → 订阅Binlog
  → 解析变更事件
  → 发送到MQ或直接处理

5.2 实现

架构:
  MySQL → Binlog → Canal → MQ → 缓存同步服务 → Redis

流程:
  1. MySQL执行更新:UPDATE users SET name='Alice' WHERE id=1
  2. 写入Binlog
  3. Canal订阅到Binlog事件
  4. Canal解析:表名=users, 操作=UPDATE, id=1, name=Alice
  5. Canal发送到MQ(RocketMQ/Kafka)
  6. 缓存同步服务消费MQ
  7. 缓存同步服务:redis.del("user:1")
  8. 完成

5.3 代码示例

// Canal客户端
@Component
public class CanalClient {
    
    @Autowired
    private RedisTemplate redisTemplate;
    
    public void start() {
        CanalConnector connector = CanalConnectors.newSingleConnector(
            new InetSocketAddress("127.0.0.1", 11111),
            "example",  // destination
            "",         // username
            ""          // password
        );
        
        connector.connect();
        connector.subscribe("mydb\.users");  // 订阅users表
        connector.rollback();
        
        while (true) {
            // 获取数据
            Message message = connector.getWithoutAck(100);
            long batchId = message.getId();
            
            if (batchId != -1) {
                List<Entry> entries = message.getEntries();
                
                for (Entry entry : entries) {
                    if (entry.getEntryType() == EntryType.ROWDATA) {
                        RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());
                        EventType eventType = rowChange.getEventType();
                        
                        for (RowData rowData : rowChange.getRowDatasList()) {
                            if (eventType == EventType.UPDATE || 
                                eventType == EventType.DELETE) {
                                
                                // 解析id
                                Long userId = getColumnValue(rowData, "id");
                                
                                // 删除缓存
                                String cacheKey = "user:" + userId;
                                redisTemplate.delete(cacheKey);
                                
                                log.info("删除缓存:{}", cacheKey);
                            }
                        }
                    }
                }
                
                connector.ack(batchId);  // 确认消费
            }
        }
    }
}

优点:

✅ 最可靠(基于Binlog,不会丢失)
✅ 解耦(应用无需关心缓存更新)
✅ 实时性好
✅ 支持多种消费者(缓存、ES、数据仓库等)

缺点:

❌ 复杂度高(需要部署Canal)
❌ 运维成本高
❌ 需要开启Binlog(有性能开销)

六、不同场景的方案选择 📊

6.1 场景对比

场景一致性要求推荐方案说明
商品详情最终一致Cache Aside允许短暂不一致
用户信息最终一致Cache Aside允许短暂不一致
库存扣减强一致不用缓存直接查数据库+分布式锁
文章浏览数弱一致Write Behind异步写入,性能好
金融账户强一致不用缓存直接查数据库+事务
大型电商最终一致订阅Binlog最可靠

6.2 实战案例:电商商品详情

@Service
public class ProductService {
    
    @Autowired
    private RedisTemplate<String, Product> redisTemplate;
    
    @Autowired
    private ProductMapper productMapper;
    
    // 查询商品(Cache Aside)
    public Product getProduct(Long productId) {
        String cacheKey = "product:" + productId;
        
        // 1. 查缓存
        Product product = redisTemplate.opsForValue().get(cacheKey);
        
        if (product != null) {
            return product;  // 缓存命中
        }
        
        // 2. 缓存未命中,加分布式锁(防止缓存击穿)
        String lockKey = "product:lock:" + productId;
        boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
        
        if (locked) {
            try {
                // 再次检查缓存(double-check)
                product = redisTemplate.opsForValue().get(cacheKey);
                if (product != null) {
                    return product;
                }
                
                // 查数据库
                product = productMapper.selectById(productId);
                
                if (product != null) {
                    // 写缓存(1小时过期)
                    redisTemplate.opsForValue().set(
                        cacheKey, 
                        product, 
                        1, 
                        TimeUnit.HOURS
                    );
                } else {
                    // 防止缓存穿透:缓存空对象(5分钟)
                    redisTemplate.opsForValue().set(
                        cacheKey, 
                        new Product(),  // 空对象
                        5, 
                        TimeUnit.MINUTES
                    );
                }
                
                return product;
                
            } finally {
                // 释放锁
                redisTemplate.delete(lockKey);
            }
        } else {
            // 没拿到锁,等待后重试
            try {
                Thread.sleep(100);
                return getProduct(productId);  // 递归重试
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
    
    // 更新商品(先更新DB,后删除缓存)
    @Transactional
    public void updateProduct(Product product) {
        // 1. 更新数据库
        productMapper.updateById(product);
        
        // 2. 删除缓存
        String cacheKey = "product:" + product.getId();
        redisTemplate.delete(cacheKey);
        
        // 3. 延迟双删(可选)
        CompletableFuture.runAsync(() -> {
            try {
                Thread.sleep(500);
                redisTemplate.delete(cacheKey);
            } catch (Exception e) {
                log.error("延迟删除缓存失败", e);
            }
        });
    }
}

七、面试高频问题 🎤

Q1: 如何保证Redis缓存和MySQL数据的一致性?

答: 推荐方案:Cache Aside + 延迟双删 + 订阅Binlog

  1. Cache Aside:先更新数据库,后删除缓存
  2. 延迟双删:解决并发问题
  3. 订阅Binlog:终极保障(可选)

大部分场景使用Cache Aside即可,高要求场景使用订阅Binlog。

Q2: 为什么是"先更新DB,后删缓存"而不是"先删缓存,后更新DB"?

答: 因为"先删缓存"更容易出现不一致:

  • 线程A删缓存 → 线程B查缓存未命中 → 线程B查DB(旧值)→ 线程B写缓存(旧值)→ 线程A更新DB(新值)→ 数据库新值,缓存旧值

而"先更新DB"不一致窗口更小,且可通过延迟双删解决。

Q3: 为什么不更新缓存,而是删除缓存?

答: 因为懒加载更高效:

  1. 如果缓存很少访问,更新浪费CPU
  2. 如果是计算型缓存,每次更新都要重新计算
  3. 删除后,下次查询时再加载,只有访问时才计算

Q4: 缓存和数据库双写,如何保证原子性?

答: 无法完全保证原子性(没有分布式事务)。只能:

  1. 先更新DB,后删缓存(Cache Aside)
  2. 删除失败时重试(MQ异步重试)
  3. 订阅Binlog作为兜底方案
  4. 设置缓存过期时间(最终一致性)

Q5: 如何解决缓存击穿、穿透、雪崩?

答:

  • 缓存击穿(热点key过期):分布式锁 + double-check
  • 缓存穿透(查询不存在的数据):缓存空对象 + 布隆过滤器
  • 缓存雪崩(大量key同时过期):过期时间加随机值 + 多级缓存

八、总结口诀 📝

缓存一致不简单,
方案选择要慎重。
Cache Aside最常用,
先更DB后删缓。

删除缓存比更新好,
懒加载省资源。
延迟双删解并发,
Binlog订阅更可靠。

强一致别用缓存,
最终一致是常态。
监控重试要做好,
一致性能都要保!

参考资料 📚


下期预告: 155-MySQL的全文索引和空间索引的使用场景 🗺️


编写时间:2025年
作者:技术文档小助手 ✍️
版本:v1.0

愿你的缓存和数据库永远同步! 🔄✨