Cache-Aside模式下Redis与MySQL数据一致性问题分析

1. Cache-Aside模式概述

Cache-Aside模式(旁路缓存模式)是最常用的缓存使用策略之一,其核心思想是应用程序直接与缓存和数据库交互,缓存只作为数据的一个副本。

1.1 基本工作流程

text 复制代码
读操作流程:
1. 先查询缓存
2. 缓存命中 → 返回数据
3. 缓存未命中 → 查询数据库 → 将数据写入缓存 → 返回数据

写操作流程:
1. 更新数据库
2. 删除缓存

2. 数据一致性问题产生原因

2.1 操作非原子性 ⭐⭐⭐⭐⭐

问题本质: 缓存和数据库是两个独立的系统,无法在同一事务中保证原子性。

java 复制代码
// 伪代码
@Transactional
public void updateUser(User user) {
    // MySQL事务内
    mysql.update(user);  // ✅ 成功
    
    // Redis不在事务内
    redis.delete(key);   // ❌ 可能失败
}
// 如果Redis删除失败,MySQL事务已提交,导致不一致

2.1 并发读写冲突(经典问题)⭐⭐⭐⭐⭐

场景一:读操作在写操作之前启动但完成较晚;

text 复制代码
时间线:
T1: 线程A(读操作)发现缓存未命中(Cache Miss)
T2: 线程A开始查询数据库,读取到旧值 value_old
T3: 线程B(写操作)更新数据库为新值 value_new
T4: 线程B删除缓存成功
T5: 线程A将查询到的旧值 value_old 写入缓存(覆盖了删除)

结果:MySQL=new_value,Redis=old_value ❌
影响:后续读请求直接命中缓存,读取到过期数据,直到缓存过期或被再次删除

补充:为什么会发生?

操作 平均耗时 说明
MySQL查询 10-100ms 网络IO + 磁盘IO + 索引查找
MySQL更新 5-50ms 写日志 + 更新索引
Redis删除 <1ms 纯内存操作
Redis写入 <1ms 纯内存操作

结论: MySQL查询慢 + Redis操作快 = 时序错乱

2.3:先删缓存后更新DB的时序问题 ⭐⭐⭐⭐

text 复制代码
时间线:
T1: 线程A(写)删除Redis
T2: 线程B(读)查Redis → Miss
T3: 线程B(读)查MySQL → 获取旧值 old_value
T4: 线程B(读)写Redis → old_value
T5: 线程A(写)更新MySQL → new_value

结果:MySQL=new_value,Redis=old_value ❌

结论:先删缓存后更新DB的方案,不一致概率更高,不推荐。 ❌

2.4 缓存删除失败

数据库更新成功,但缓存删除失败

text 复制代码
时间线:
T1: 写操作A更新数据库成功
T2: 写操作A尝试删除缓存,但失败(网络问题等)

结果:缓存中仍保留旧数据,与数据库不一致

常见失败原因:

java 复制代码
1. 网络问题
   - Redis连接超时
   - 网络抖动
   - 连接池耗尽

2. Redis问题
   - Redis宕机
   - 主从切换
   - 内存满OOM

3. 代码问题
   - 异常被吞掉
   try {
       redis.delete(key);
   } catch (Exception e) {
       // ❌ 没有处理,导致不一致
       log.error("...", e);
   }
   
   - 事务回滚但缓存已操作
   @Transactional
   void update() {
       mysql.update();
       redis.delete(key); // ✅ 删除成功
       throw new Exception(); // ❌ 事务回滚,但缓存已删
   }

3. 解决方案详解

3.1 先更新数据库,后删除缓存(标准Cache-Aside)

3.1.1 原理

这是Cache-Aside模式的标准实现,其核心思想是:

  • 先确保数据库数据正确
  • 然后删除缓存,强制后续读操作重新从数据库加载最新数据

3.1.2 优点

  1. 实现简单:逻辑清晰,易于实现和维护
  2. 最终一致性:虽然可能出现短暂不一致,但最终会达到一致
  3. 性能影响小:相比更新缓存,删除操作更轻量
  4. 避免缓存数据过期问题 :不需要关心缓存中存储的数据结构与数据库是否匹配

3.1.3 缺点

  1. 存在短暂不一致窗口:在高并发场景下可能出现缓存与数据库不一致
  2. 缓存删除失败风险:如果删除缓存失败,会导致数据不一致

3.2 延迟双删策略 ⭐⭐⭐⭐

3.2.1 原理

延迟双删策略是对标准Cache-Aside的改进,通过二次删除缓存来解决并发读写导致的不一致问题:

  1. 先删除缓存
  2. 更新数据库
  3. 延迟一段时间后再次删除缓存
java 复制代码
public void updateData(String key, Object value) {
    // 1. 先删除缓存
    redis.delete(key);
    
    // 2. 更新数据库
    mysql.update(key, value);
    
    // 3. 延迟一段时间后再次删除缓存
    executorService.schedule(() -> {
        redis.delete(key);
    }, delayTime, TimeUnit.MILLISECONDS);
}

3.2.2 关键参数:延迟时间

延迟时间的设置需要考虑:

  • 数据库主从复制延迟
  • 业务读操作的执行时间
  • 系统响应时间
    通常设置为500ms-1s,具体需要根据实际业务情况调整。

3.2.3 优点

  1. 解决并发读写问题:第二次删除可以清除掉并发读操作写入的旧数据
  2. 增强一致性:相比标准Cache-Aside,提供更好的一致性保证

3.2.4 缺点

  1. 实现复杂度增加:需要引入异步任务和延迟机制
  2. 短暂性能影响:在两次删除之间,可能有更多请求直接查询数据库
  3. 延迟时间难以精确设置:设置过短可能无效,设置过长影响性能

3.3:订阅MySQL Binlog ⭐⭐⭐⭐⭐

架构设计

text 复制代码
MySQL → Binlog → Canal/Maxwell/Debezium → MQ(Kafka/RocketMQ) → 缓存服务 → Redis
java 复制代码
// 1. Canal客户端监听Binlog
@Component
@Slf4j
public class CanalBinlogListener {
    
    @Autowired
    private RocketMQTemplate mq;
    
    @PostConstruct
    public void start() {
        // 连接Canal Server
        CanalConnector connector = CanalConnectors.newSingleConnector(
            new InetSocketAddress("canal-server", 11111),
            "example", "", ""
        );
        
        connector.connect();
        connector.subscribe("your_db\\..*"); // 订阅数据库
        connector.rollback();
        
        // 持续消费
        while (true) {
            Message message = connector.getWithoutAck(100);
            long batchId = message.getId();
            List<Entry> entries = message.getEntries();
            
            if (batchId != -1 && !entries.isEmpty()) {
                processEntries(entries);
            }
            
            connector.ack(batchId);
        }
    }
    
    private void processEntries(List<Entry> entries) {
        for (Entry entry : entries) {
            if (entry.getEntryType() != EntryType.ROWDATA) {
                continue;
            }
            
            RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());
            EventType eventType = rowChange.getEventType();
            
            // 只处理UPDATE和DELETE
            if (eventType == EventType.UPDATE || eventType == EventType.DELETE) {
                String tableName = entry.getHeader().getTableName();
                
                for (RowData rowData : rowChange.getRowDatasList()) {
                    // 提取主键
                    Long primaryKey = extractPrimaryKey(rowData);
                    
                    // 发送删除缓存消息
                    CacheInvalidateMessage msg = new CacheInvalidateMessage();
                    msg.setTable(tableName);
                    msg.setKey(primaryKey);
                    msg.setTimestamp(System.currentTimeMillis());
                    
                    mq.asyncSend("cache-invalidate-topic", msg, new SendCallback() {
                        @Override
                        public void onSuccess(SendResult result) {
                            log.debug("Sent cache invalidate: {}:{}", tableName, primaryKey);
                        }
                        
                        @Override
                        public void onException(Throwable e) {
                            log.error("Failed to send cache invalidate", e);
                            // 降级:直接删除
                            redis.delete(buildKey(tableName, primaryKey));
                        }
                    });
                }
            }
        }
    }
}

// 2. MQ消费者删除缓存
@Component
@RocketMQMessageListener(
    topic = "cache-invalidate-topic",
    consumerGroup = "cache-service-group",
    messageModel = MessageModel.BROADCASTING // 广播模式,所有实例都删除
)
public class CacheInvalidateConsumer implements RocketMQListener<CacheInvalidateMessage> {
    
    @Autowired
    private RedisTemplate redis;
    
    @Autowired
    private Cache<String, Object> localCache; // 本地缓存
    
    @Override
    public void onMessage(CacheInvalidateMessage msg) {
        String key = buildKey(msg.getTable(), msg.getKey());
        
        // 删除本地缓存
        localCache.invalidate(key);
        
        // 删除Redis缓存
        redis.delete(key);
        
        log.info("Cache invalidated: {}", key);
    }
}

3.4:分布式锁(强一致性)⭐⭐⭐

java 复制代码
@Service
public class LockBasedCacheService {
    
    @Autowired
    private RedissonClient redisson;
    
    @Autowired
    private RedisTemplate redis;
    
    /**
     * 读操作:加锁保证不会读到中间状态
     */
    public User getUser(Long userId) {
        String key = "user:" + userId;
        String lockKey = "lock:user:" + userId;
        
        RLock lock = redisson.getLock(lockKey);
        
        try {
            // 尝试获取锁,最多等5秒,持有10秒
            if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
                
                // 双重检查
                User user = (User) redis.opsForValue().get(key);
                if (user != null) {
                    return user;
                }
                
                // 查询数据库
                user = userMapper.selectById(userId);
                
                // 写入缓存
                if (user != null) {
                    redis.opsForValue().set(key, user, 1, TimeUnit.HOURS);
                }
                
                return user;
            } else {
                // 获取锁超时,降级查DB
                log.warn("Failed to acquire lock, fallback to DB: {}", userId);
                return userMapper.selectById(userId);
            }
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("Interrupted while acquiring lock", e);
            return userMapper.selectById(userId);
        } finally {
            // 释放锁
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
    
    /**
     * 写操作:加锁保证原子性
     */
    @Transactional(rollbackFor = Exception.class)
    public void updateUser(User user) {
        String key = "user:" + user.getId();
        String lockKey = "lock:user:" + user.getId();
        
        RLock lock = redisson.getLock(lockKey);
        
        try {
            if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
                // 更新数据库
                userMapper.updateById(user);
                
                // 删除缓存
                redis.delete(key);
                
                log.info("Updated user with lock: {}", user.getId());
            } else {
                throw new RuntimeException("Failed to acquire lock for update");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Interrupted while acquiring lock", e);
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

4. 方案中的注意事项

4.1 延迟双删的注意事项

⚠️ 注意点1:延迟时间的确定

⚠️ 注意点2:第二次删除失败的处理

java 复制代码
// ❌ 错误:忽略异常
asyncExecutor.schedule(() -> {
    redis.delete(key);
}, 500, TimeUnit.MILLISECONDS);

// ✅ 正确:重试机制
asyncExecutor.schedule(() -> {
    boolean success = false;
    int retries = 3;
    
    while (!success && retries > 0) {
        try {
            Boolean deleted = redis.delete(key);
            success = Boolean.TRUE.equals(deleted);
            
            if (!success) {
                retries--;
                Thread.sleep(100);
            }
        } catch (Exception e) {
            log.error("Delayed delete failed, retries left: {}", retries, e);
            retries--;
        }
    }
    
    if (!success) {
        // 发送告警
        alertService.send("Delayed delete failed: " + key);
    }
}, 500, TimeUnit.MILLISECONDS);

⚠️ 注意点3:事务回滚的处理

java 复制代码
// ❌ 错误:事务内调度异步任务
@Transactional
public void updateUser(User user) {
    userMapper.updateById(user);
    
    // 如果后续发生异常回滚,这个任务已经提交了
    asyncExecutor.schedule(() -> redis.delete(key), 500, TimeUnit.MILLISECONDS);
    
    throw new RuntimeException(); // 事务回滚,但删除任务已提交
}

// ✅ 正确:事务提交后才调度
@Transactional
public void updateUser(User user) {
    userMapper.updateById(user);
    
    // 注册事务同步回调
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                // 事务提交后才执行
                String key = "user:" + user.getId();
                redis.delete(key);
                
                asyncExecutor.schedule(() -> {
                    redis.delete(key);
                }, 500, TimeUnit.MILLISECONDS);
            }
        }
    );
}

4.2 Binlog订阅的注意事项

⚠️ 注意点1:Binlog格式必须是ROW

sql 复制代码
-- 检查Binlog格式
SHOW VARIABLES LIKE 'binlog_format';

-- 如果是STATEMENT或MIXED,需要改为ROW
SET GLOBAL binlog_format = 'ROW';

-- 配置文件 my.cnf
[mysqld]
binlog_format = ROW
binlog_row_image = FULL  # 完整行镜像

原因:

text 复制代码
STATEMENT格式:只记录SQL语句,无法准确提取变更的行
MIXED格式:混合模式,不保证都是ROW
ROW格式:记录每行的变更,可以准确提取主键

⚠️ 注意点2:主从延迟问题

java 复制代码
// ❌ 问题场景
T1: 写主库 → 更新成功
T2: Canal监听Binlog → 删除缓存
T3: 读请求 → 查缓存Miss → 查从库 → 从库还未同步 → 读到旧值 → 写入缓存

// ✅ 解决方案1:延迟删除
@Override
public void onBinlogEvent(BinlogEvent event) {
    String key = buildKey(event);
    
    // 考虑主从延迟,延迟删除
    asyncExecutor.schedule(() -> {
        redis.delete(key);
    }, 200, TimeUnit.MILLISECONDS); // 延迟200ms
}

// ✅ 解决方案2:读主库
public User getUser(Long userId) {
    String key = "user:" + userId;
    User user = redis.get(key);
    
    if (user == null) {
        // 缓存未命中,强制读主库
        user = userMapper.selectByIdFromMaster(userId);
        redis.set(key, user);
    }
    
    return user;
}

⚠️ 注意点3:Binlog解析失败的处理

java 复制代码
@Component
public class RobustBinlogListener {
    
    @Autowired
    private RocketMQTemplate mq;
    
    public void processBinlog(Entry entry) {
        try {
            RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());
            // 处理逻辑
            
        } catch (InvalidProtocolBufferException e) {
            log.error("Failed to parse binlog entry", e);
            
            // 发送到死信队列
            DeadLetterMessage dlm = new DeadLetterMessage();
            dlm.setEntry(entry);
            dlm.setError(e.getMessage());
            mq.send("binlog-dead-letter", dlm);
            
            // 发送告警
            alertService.send("Binlog parse failed", e);
        }
    }
}

4.3 分布式锁的注意事项。

⚠️ 注意点1:锁超时时间设置(看门狗机制续期)

⚠️ 注意点2:锁重入问题(AQS思路,统计重入次数)

⚠️ 注意点3:锁未正确释放

📊 总结与最佳实践

核心要点

  • 首选方案:延迟双删 + 短TTL
  • 高级方案:Binlog订阅 + 延迟双删
  • 强一致:分布式锁(牺牲性能)
  • 监控必备:一致性检查 + 告警

常见实施检查清单

✅ 选择合适的更新策略(先更新DB后删缓存)

✅ 实施延迟双删(动态计算延迟时间)

✅ 设置合理的TTL(根据业务特性)

✅ 添加监控告警(不一致率、命中率)

✅ 实施降级方案(Redis故障时直接查DB)

✅ 防止缓存穿透(布隆过滤器 + 空值缓存)

✅ 防止缓存击穿(互斥锁)

✅ 防止缓存雪崩(随机TTL)

✅ 事务同步(只在事务提交后操作缓存)

✅ 异常处理(重试机制 + 告警)

从CAP视角分析DB与Cache的数据一致性

在DB和Cache的分布式架构中,加入分布式Cache的目的是为了获得高性能、高吞吐,就是为了获得分布式系统的AP特性。所以,如果需要数据库和缓存数据保持强一致(强CP特性),就不适合使用缓存。从CAP的理论出发,使用缓存提升性能,就是会有数据更新的延迟,就会产生数据的不一致。使用分布式Cache,可以通过一些方案优化,保证弱一致性,最终一致性的。我们只能通过不断的方案迭代,减少不一致性的时间长度。


追问: 为什么选择删除缓存而非更新缓存

1. 更新缓存的潜在问题:

1.1 数据结构不一致问题

问题本质 :缓存中存储的数据结构可能与数据库中的结构不同

text 复制代码
数据库:规范化的关系表结构
缓存:可能是聚合后的、优化查询的结构(如JSON、Hash等)

当直接更新缓存时,需要维护两套数据转换逻辑:

  • 数据库→缓存的数据转换
  • 业务逻辑→缓存的数据转换
    这增加了系统复杂度和出错概率。

2.2 并发更新冲突

场景分析 :两个并发写操作可能导致数据覆盖错误

text 复制代码
时间线:
T1: 写操作A更新数据库(X=1)
T2: 写操作B更新数据库(X=2)
T3: 写操作B更新缓存(X=2)
T4: 写操作A更新缓存(X=1)→ 错误!覆盖了更新的值

结果:数据库X=2,缓存X=1,数据不一致

而使用删除策略时:

text 复制代码
时间线:
T1: 写操作A更新数据库(X=1)
T2: 写操作B更新数据库(X=2)
T3: 写操作A删除缓存
T4: 写操作B删除缓存

结果:数据库X=2,缓存已删除,后续读取会加载正确数据

2.3 缓存更新失败的风险

更新操作的问题 :更新缓存需要更多的业务逻辑和数据转换,失败概率更高

如果更新缓存失败,会导致:

  • 缓存中保留旧数据
  • 数据库已有新数据
  • 系统出现不一致状态
    而删除操作更为简单直接,失败风险更低。

3. 删除缓存的优势分析

3.1 简单性与可靠性

核心优势 :删除操作比更新操作简单可靠

  • 操作原子性 :删除是一个简单的键操作,成功或失败明确
  • 逻辑解耦 :不需要在写路径维护复杂的数据转换逻辑
  • 减少错误 :避免了缓存数据结构与业务逻辑的同步问题

3.2 懒加载模式的优势

性能优化 :采用"按需加载"策略,减少不必要的计算

  • 缓存利用率更高 :只缓存实际被请求的数据
  • 资源利用更合理 :避免为很少访问的数据更新缓存
  • 计算成本分摊 :将数据转换和计算成本分摊到读操作中

3.3 自动处理缓存过期

一致性保障 :删除缓存天然与过期策略协同工作

  • 即使删除失败,缓存过期机制也能最终保证一致性
  • 避免了更新过期或即将过期的缓存造成的资源浪费

4. 实际场景对比分析

4.1 读多写少场景

更新缓存策略 :

  • 写操作时执行复杂的缓存更新
  • 但大部分更新的缓存可能很少被读取
  • 导致计算资源浪费

删除缓存策略 :

  • 写操作简单高效
  • 只有实际被读取的数据才会重新加载到缓存
  • 资源利用更高效

4.2 缓存数据结构复杂场景

更新缓存策略 :

  • 需要在写操作中维护复杂的数据转换逻辑
  • 业务逻辑变更时需要同时更新多处代码
  • 容易出错,维护成本高

删除缓存策略 :

  • 写操作只关注数据库更新
  • 数据转换逻辑集中在读取路径
  • 维护成本低,一致性更容易保证

4.3 高并发场景

更新缓存策略 :

  • 并发写可能导致缓存数据覆盖错误
  • 复杂的更新操作增加锁竞争
  • 性能影响较大

删除缓存策略 :

  • 删除操作简单快速
  • 并发删除不会导致数据错误
  • 性能更好,扩展性更强
相关推荐
Vaclee2 小时前
Redis进阶
数据库·redis·缓存
L.EscaRC2 小时前
Redis 底层运行机制与原理浅析
数据库·redis·缓存
爱吃烤鸡翅的酸菜鱼2 小时前
Java【缓存设计】定时任务+分布式锁实战:Redis vs Redisson实现状态自动扭转以及全量刷新预热机制
java·redis·分布式·缓存·rabbitmq
我科绝伦(Huanhuan Zhou)2 小时前
Redis 生产环境安全基线配置指南:从风险分析到实操加固
数据库·redis·安全
彩旗工作室2 小时前
如何在自己的服务器上部署 n8n
开发语言·数据库·nodejs·n8n
小白起 v2 小时前
自动更新工期触发器(MYSQL)
数据库·sql·oracle
JIngJaneIL2 小时前
数码商城系统|电子|基于SprinBoot+vue的商城推荐系统(源码+数据库+文档)
java·前端·数据库·vue.js·论文·毕设·数码商城系统
~我爱敲代码~5 小时前
使用XSHELL远程操作数据库
数据库·adb
一般社员5 小时前
Windows导入大型sql文件到mysql
windows·sql·mysql