Cache-Aside模式下Redis与MySQL数据一致性问题分析
1. Cache-Aside模式概述
Cache-Aside模式(旁路缓存模式)是最常用的缓存使用策略之一,其核心思想是应用程序直接与缓存和数据库交互,缓存只作为数据的一个副本。
1.1 基本工作流程
读操作流程:
1. 先查询缓存
2. 缓存命中 → 返回数据
3. 缓存未命中 → 查询数据库 → 将数据写入缓存 → 返回数据写操作流程:
1. 更新数据库
2. 删除缓存
2. 数据一致性问题产生原因
2.1 操作非原子性 ⭐⭐⭐⭐⭐
问题本质: 缓存和数据库是两个独立的系统,无法在同一事务中保证原子性。
// 伪代码
@Transactional
public void updateUser(User user) {// MySQL事务内mysql.update(user); // ✅ 成功// Redis不在事务内redis.delete(key); // ❌ 可能失败
}
// 如果Redis删除失败,MySQL事务已提交,导致不一致
2.1 并发读写冲突(经典问题)⭐⭐⭐⭐⭐
场景一:读操作在写操作之前启动但完成较晚;
时间线:
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的时序问题 ⭐⭐⭐⭐
时间线:
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 缓存删除失败
数据库更新成功,但缓存删除失败
时间线:
T1: 写操作A更新数据库成功
T2: 写操作A尝试删除缓存,但失败(网络问题等)结果:缓存中仍保留旧数据,与数据库不一致
常见失败原因:
1. 网络问题- Redis连接超时- 网络抖动- 连接池耗尽2. Redis问题- Redis宕机- 主从切换- 内存满OOM3. 代码问题- 异常被吞掉try {redis.delete(key);} catch (Exception e) {// ❌ 没有处理,导致不一致log.error("...", e);}- 事务回滚但缓存已操作@Transactionalvoid update() {mysql.update();redis.delete(key); // ✅ 删除成功throw new Exception(); // ❌ 事务回滚,但缓存已删}
3. 解决方案详解
3.1 先更新数据库,后删除缓存(标准Cache-Aside)
3.1.1 原理
这是Cache-Aside模式的标准实现,其核心思想是:
- 先确保数据库数据正确
- 然后删除缓存,强制后续读操作重新从数据库加载最新数据
3.1.2 优点
- 实现简单:逻辑清晰,易于实现和维护
- 最终一致性:虽然可能出现短暂不一致,但最终会达到一致
- 性能影响小:相比更新缓存,删除操作更轻量
- 避免缓存数据过期问题 :不需要关心缓存中存储的数据结构与数据库是否匹配
3.1.3 缺点
- 存在短暂不一致窗口:在高并发场景下可能出现缓存与数据库不一致
- 缓存删除失败风险:如果删除缓存失败,会导致数据不一致
3.2 延迟双删策略 ⭐⭐⭐⭐
3.2.1 原理
延迟双删策略是对标准Cache-Aside的改进,通过二次删除缓存来解决并发读写导致的不一致问题:
- 先删除缓存
- 更新数据库
- 延迟一段时间后再次删除缓存
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 优点
- 解决并发读写问题:第二次删除可以清除掉并发读操作写入的旧数据
- 增强一致性:相比标准Cache-Aside,提供更好的一致性保证
3.2.4 缺点
- 实现复杂度增加:需要引入异步任务和延迟机制
- 短暂性能影响:在两次删除之间,可能有更多请求直接查询数据库
- 延迟时间难以精确设置:设置过短可能无效,设置过长影响性能
3.3:订阅MySQL Binlog ⭐⭐⭐⭐⭐
架构设计
MySQL → Binlog → Canal/Maxwell/Debezium → MQ(Kafka/RocketMQ) → 缓存服务 → Redis
// 1. Canal客户端监听Binlog
@Component
@Slf4j
public class CanalBinlogListener {@Autowiredprivate RocketMQTemplate mq;@PostConstructpublic void start() {// 连接Canal ServerCanalConnector 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和DELETEif (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() {@Overridepublic void onSuccess(SendResult result) {log.debug("Sent cache invalidate: {}:{}", tableName, primaryKey);}@Overridepublic 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> {@Autowiredprivate RedisTemplate redis;@Autowiredprivate Cache<String, Object> localCache; // 本地缓存@Overridepublic 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:分布式锁(强一致性)⭐⭐⭐
@Service
public class LockBasedCacheService {@Autowiredprivate RedissonClient redisson;@Autowiredprivate 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 {// 获取锁超时,降级查DBlog.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:第二次删除失败的处理
// ❌ 错误:忽略异常
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:事务回滚的处理
// ❌ 错误:事务内调度异步任务
@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() {@Overridepublic void afterCommit() {// 事务提交后才执行String key = "user:" + user.getId();redis.delete(key);asyncExecutor.schedule(() -> {redis.delete(key);}, 500, TimeUnit.MILLISECONDS);}});
}
4.2 Binlog订阅的注意事项
⚠️ 注意点1:Binlog格式必须是ROW
-- 检查Binlog格式
SHOW VARIABLES LIKE 'binlog_format';-- 如果是STATEMENT或MIXED,需要改为ROW
SET GLOBAL binlog_format = 'ROW';-- 配置文件 my.cnf
[mysqld]
binlog_format = ROW
binlog_row_image = FULL # 完整行镜像
原因:
STATEMENT格式:只记录SQL语句,无法准确提取变更的行
MIXED格式:混合模式,不保证都是ROW
ROW格式:记录每行的变更,可以准确提取主键
⚠️ 注意点2:主从延迟问题
// ❌ 问题场景
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解析失败的处理
@Component
public class RobustBinlogListener {@Autowiredprivate 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 数据结构不一致问题
问题本质 :缓存中存储的数据结构可能与数据库中的结构不同
数据库:规范化的关系表结构
缓存:可能是聚合后的、优化查询的结构(如JSON、Hash等)
当直接更新缓存时,需要维护两套数据转换逻辑:
- 数据库→缓存的数据转换
- 业务逻辑→缓存的数据转换
这增加了系统复杂度和出错概率。
2.2 并发更新冲突
场景分析 :两个并发写操作可能导致数据覆盖错误
时间线:
T1: 写操作A更新数据库(X=1)
T2: 写操作B更新数据库(X=2)
T3: 写操作B更新缓存(X=2)
T4: 写操作A更新缓存(X=1)→ 错误!覆盖了更新的值结果:数据库X=2,缓存X=1,数据不一致
而使用删除策略时:
时间线:
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 高并发场景
更新缓存策略 :
- 并发写可能导致缓存数据覆盖错误
- 复杂的更新操作增加锁竞争
- 性能影响较大
删除缓存策略 :
- 删除操作简单快速
- 并发删除不会导致数据错误
- 性能更好,扩展性更强
