当前位置: 首页 > news >正文

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 优点

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

3.1.3 缺点

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

3.2 延迟双删策略 ⭐⭐⭐⭐

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

  1. 先删除缓存
  2. 更新数据库
  3. 延迟一段时间后再次删除缓存
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 ⭐⭐⭐⭐⭐

架构设计

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 高并发场景

更新缓存策略 :

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

删除缓存策略 :

  • 删除操作简单快速
  • 并发删除不会导致数据错误
  • 性能更好,扩展性更强
http://www.dtcms.com/a/594694.html

相关文章:

  • 做任务 网站随州网站制作
  • 2025-11-10
  • 网站艺术设计redis wordpress 设置
  • 建立外贸英文网站应该怎么做英文网站注册
  • 芝罘网站建设设计手机网站软件
  • 11.10 脚本算法 五子棋 「重要」
  • 揭阳做网站公司北京响应式网站制作公司
  • 做网站都要买出口带宽吗嘉祥网站建设哪家好
  • sql查询 笛卡尔积 子查询
  • 可做笔记的阅读网站成都市微信网站建设报价
  • 【LLIE技术专题】基于成对低光图像学习自适应先验方案代码讲解
  • 瑞金网站建设推广自助建站吧
  • 深圳大型网站开发seo与网站建设
  • 行业网站 cms最好的在线影视免费
  • Day1算法训练(数字统计,两个数组的交集,点击消除)
  • 双并网点 + 104 协议传输!Acrel1000 打造厂区储能综合自动化标杆方案
  • 网站备案要收费吗网络营销方案论文
  • 哪个网站可以帮助做数学题计算机应用网站建设与维护是做什么
  • Vue3:详解toRefs
  • 性价比高的建筑设备监控管理系统企业
  • 网站建设好吗网站建设与制作实训报告
  • 如何做好网站推广营销WordPress最强大的主题
  • 免费室内设计素材网站网站建站基本要素
  • P4198 楼房重建 题解
  • asp网站例子一套公司vi设计多少钱一
  • YOLOv5(一):目录结构 学习顺序
  • 密云建站推广电子商务网站建设考试
  • Python | 常用的控制流语句及工作原理
  • 网站建设公司有哪些方面郑州妇科
  • seo综合查询网站源码微网站建设招聘