如何保证Mysql和Redis双写一致性
如何保证Mysql和Redis双写一致性
面试官,我是这么理解这个问题的
这个问题本质上是分布式系统中的数据一致性问题。因为 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. 消费者手动 ACK 3. 基于消息 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 监听所有分库的 binlog 2. 统一缓存 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 补偿,都是在和分布式的不确定性作斗争 💪。
