h5打开以查看
这个问题本质上是分布式系统中的数据一致性问题。因为 MySQL 和 Redis 是两个独立的存储系统,无法做到原子性更新,所以我们只能通过合理的更新策略来尽可能保证最终一致性,同时兼顾性能和可用性。
先明确:哪些方案是绝对不能用的 ❌
很多人一开始会踩这些坑,我先排除掉:
| 错误方案 | 致命问题 |
|---|---|
| 先更新 Redis,再更新 MySQL | Redis 更新成功,MySQL 更新失败 → 数据永久不一致 |
| 先更新 MySQL,再更新 Redis | 并发场景下会出现 "写覆盖" 问题,导致脏数据 |
| 双写都加分布式锁 | 性能极差,完全失去了 Redis 缓存的意义 |
业界主流的 4 种正确方案对比 📊
方案 1:先更新数据库,再删除缓存(最常用)
- 优点:实现简单,性能好,出现不一致的概率极低
- 缺点:极端情况下仍有不一致风险(数据库更新成功,删除缓存失败)
- 适用场景:90% 以上的业务场景都可以用这个方案
方案 2:先删除缓存,再更新数据库
- 优点:比 "先更库再删缓存" 更安全
- 缺点:并发读场景下会出现 "缓存击穿" 问题
- 解决办法:采用 "延迟双删" 策略
方案 3:更新数据库 + 消息队列异步删除缓存
- 优点:解决了 "删除缓存失败" 的问题,有重试机制
- 缺点:引入了 MQ 的复杂度,有一定的延迟
- 适用场景:对一致性要求较高的业务
方案 4:基于 MySQL binlog 的最终一致性方案(终极方案)
- 优点:完全解耦,业务代码无侵入,一致性最高
- 缺点:架构最复杂,运维成本高
- 适用场景:大型互联网公司,高并发高一致性要求的核心业务
面试必问:极端场景分析 🔍
场景 1:为什么是 "删除缓存" 而不是 "更新缓存"?
✅答案:
- 并发写场景下,更新缓存会出现 "写覆盖" 问题
- 很多缓存值不是简单的数据库字段映射,计算成本高
- 采用 "懒加载" 思想,只有当缓存被读取时才会重新计算,节省资源
场景 2:"先更库再删缓存" 的极端不一致情况
发生条件:
- 缓存刚好失效
- 线程 A 查询数据库,得到旧值
- 线程 B 更新数据库,然后删除缓存
- 线程 A 将旧值写入缓存
结果:缓存中永远是旧数据,直到下一次更新或过期
解决办法:
- 给缓存设置合理的过期时间(兜底方案)
- 采用 "延迟双删" 策略
- 使用 binlog 异步删除方案
我的生产环境最佳实践 ✨
- 基础方案:先更新 MySQL,再删除 Redis 缓存
- 兜底方案:所有缓存都设置过期时间(15 分钟 - 2 小时)
- 增强方案:删除缓存失败时,通过 MQ 进行重试
- 终极方案:核心业务使用 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<String, Object> 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<String, Object> 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<String, Object> 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. 按表维度拆分缓存同步逻辑 | 业务无感知,支持水平扩展 |
面试终极加分项 💎
- 能说出 "先更库再删缓存" 的极端不一致场景发生的概率极低的原因:需要同时满足 "缓存刚好失效 + 读线程查询慢 + 写线程更新快" 三个条件
- 能解释为什么不推荐使用更新缓存:除了写覆盖问题,还有缓存利用率低、计算成本高的问题
- 能结合自己的项目经验,说明不同业务场景下的方案选型:比如非核心业务用基础方案,核心业务用 MQ+Canal 方案
- 能提到降级策略:当 Redis 不可用时,直接读数据库,保证业务可用性
- 能说出监控指标:缓存命中率、缓存删除失败率、MQ 消息堆积量、Canal 同步延迟
真实面试模拟
真实面试模拟
面试官 😊:
“你在项目里肯定用过 Redis 做缓存吧,那如果让你设计一个高并发的读写场景,怎么保证 MySQL 和 Redis 双写一致性?先说说你的思路。”
候选人 🤔:
“好的面试官。这个问题我们线上实际踩过坑,后来沉淀了一套方案。我先说两个最典型的错误做法,因为真实面试里直接说出正确方案前,理解为什么错更重要。”
面试官 👍:
“可以,说说看。”
候选人 💣:
“第一个坑是‘先删缓存,再更新数据库’。
我画个时序图就清楚了:”
“您看,读请求恰好插在删除缓存和更新DB之间,把旧数据刷回了缓存,导致长时间脏数据。这个在高并发下很容易复现。”
候选人 🔥:
“第二个坑是‘先更新数据库,再更新缓存’。
并发写会有顺序问题:”
“最终缓存里是10,DB是20,不一致。而且更新缓存还涉及序列化开销和写竞争,所以我们基本放弃了更新缓存的思路。”
面试官 💡:
“那你们线上最终怎么落地?”
候选人 ✨:
“核心是Cache Aside 模式的一个变种——流程只有两步:
- 先更新数据库
- 再删除缓存(注意是删除,不是更新)
读请求则保持查缓存 → miss → 查DB → 写回缓存,并给所有缓存设置过期时间兜底。
为什么删除而不是更新?因为删除是幂等的,避免并发写覆盖,而且让数据惰性加载,不读就不占内存。”
面试官 🧐:
“先更DB再删缓存,有没有极端情况仍然不一致?”
候选人 👇:
“有,不过概率极低。需要读写并发且时序恰好错位:
- 读请求缓存miss,读到DB旧数据(此时写请求还没删缓存)
- 写请求更新DB,删除缓存
- 读请求把刚才拿到的旧数据写回缓存
这个窗口非常窄,但我们还是做了保护——延迟双删。”
面试官 ⏱️:
“延迟双删怎么实现?同步 sleep 吗?”
候选人 🛠️:
“绝对不能同步 sleep,会阻塞主线程。我们用的是线程池异步,流程是:
更新DB → 删缓存 → 提交异步任务(休眠300ms) → 再次删除缓存如果第二次删缓存失败,投递到消息队列做重试,保证最终一定删掉。
300ms 是根据接口响应时间 P99 定的,基本能覆盖读请求写回缓存的时间。”
面试官 🚀:
“如果整个系统缓存更新逻辑很复杂,或者跨多个服务,光靠删缓存不够怎么办?”
候选人 🌊:
“我们上了基于 MySQL Binlog 的异步更新方案。
业务代码只写数据库,完全不管缓存。Canal 订阅 binlog,投递到 RocketMQ,由专门的缓存更新服务消费,架构图是这样的:”
“优点就是业务零侵入,MQ 重试 + 死信队列保证了最终一致。缺点是有百毫秒级延迟,只适合最终一致性场景。”
面试官 ⚖️:
“那如果涉及资金、库存这种,完全不能忍受短暂不一致的呢?”
候选人 🎯:
“这种场景我们就不追求缓存强一致了。
做法是:
- 把 Redis 当作只读缓存,TTL 设得很短(比如1秒),权威数据永远在 MySQL。
- 写操作直接走 DB,读操作在缓存 miss 时查 DB,用分布式锁避免热点 key 的缓存击穿。
- 真正的一致性靠数据库事务保证,Redis 只是性能层,不参与一致性的核心逻辑。”
面试官 📝:
“总结一下你的设计思路?”
候选人 🌟:
“三句话总结吧:
- 🚫绝不更新缓存,只删除缓存,用删除的幂等性避免并发写覆盖。
- ⏳所有缓存都要有过期时间,即使所有同步机制都失败,过期时间也能兜底。
- 📭异步重试 + 消息补偿,保证最终一致性,别为了强一致牺牲可用性。
方案选型看业务容忍度:
| 场景 | 方案 | 一致性 |
|---|---|---|
| 普通读多写少 | 先更DB + 删缓存 + 延迟双删 | 最终一致(窗口极小) |
| 跨系统/复杂更新 | Binlog + MQ 异步刷新 | 最终一致(百毫秒延迟) |
| 资金/库存核心链路 | 短TTL缓存 + 依赖DB事务 | 强一致 |
以上就是我们生产环境验证过的整套思路。”
📌 核心代码片段
🥇 延迟双删(异步 + 兜底重试)
@Service public class CacheAsideService { @Autowired private RedisTemplate<String, Object> 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<MessageExt> { @Autowired private RedisTemplate<String, Object> 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 自动重试,配合死信队列兜底人工介入。
- 操作都是幂等的(
delete、set),即使极端情况重复消费也安全。
🥉 热点 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 |
面试中我会特别强调:“设计的一致性方案,本质是用空间/复杂度,换业务可接受的最终一致”。没有绝对完美的强一致,只有与场景匹配的权衡。我们代码里每一个Thread.sleep(300)、每一个 MQ 补偿,都是在和分布式的不确定性作斗争 💪。
h5打开以查看