主题
缓存与数据库一致性:Cache Aside 并发陷阱、延迟双删与 Binlog 订阅
问题:为什么"删缓存"不是那么简单
只要用 Redis 做缓存,面试必问:怎么保证缓存和数据库一致?
很多人的回答是"先更新数据库,再删除缓存"——Cache Aside 模式的标准写法。但面试官会接着问:
- 删缓存失败了怎么办?
- 为什么是"删缓存"而不是"更新缓存"?
- 先删缓存再更新数据库,并发时有什么问题?
- 读写分离场景下,延迟双删的延迟时间怎么定?
这几个问题拆开看,每个都有对应的生产事故。
分析:四种策略的并发时序
策略一:先更新数据库,再删除缓存(推荐)
这是 Cache Aside 的推荐写法。流程:
线程A:写请求 → 更新DB → 删除缓存
线程B:读请求 → 缓存未命中 → 读DB → 回填缓存并发脏数据的情况:线程A更新DB后、删除缓存前,线程B读到的还是旧缓存。但这个窗口极短——只有A删缓存到B查DB之间的时间差。大多数场景下可接受。
真正的问题:删缓存失败。如果Redis挂了或者网络超时,缓存没删掉,后续所有请求读到的都是旧数据。
解决方案:删缓存失败后重试(带指数退避)或投递到消息队列兜底——下文展开。
策略二:先删缓存,再更新数据库
很多人想"先删缓存,再更新DB,这样读请求永远读不到老数据"——但并发时序里有坑:
时间线:
1. 线程A:删除缓存
2. 线程B:读请求 → 缓存未命中 → 读DB(此时DB还未更新,读到旧数据)
3. 线程B:将旧数据写入缓存
4. 线程A:更新DB结果:缓存里是旧数据,DB是新数据,不一致。而且这个不一致可能持续到缓存过期。
为什么还能看到有人用? 写操作远少于读操作的场景(比如每分钟更新一次配置),这个窗口很小,但不推荐用于生产。
策略三:更新缓存而不是删除缓存
有人觉得"删了缓存下次读又要查DB,浪费性能,直接更新缓存多好"。但并发场景下问题更大:
时间线:
1. 线程A:更新DB → 准备更新缓存(写 value = 200)
2. 线程B:更新DB → 更新缓存(写 value = 100,晚于A的更新但先执行了)
3. 线程A:更新缓存(写 value = 200,覆盖了B的更新)结果:缓存是200,DB是100,不一致。写写并发下,更新缓存的操作顺序无法保证。
这也是为什么 Cache Aside 的作者推荐删缓存而不更新缓存——删除是幂等的,下一次读的时候自然会把最新值写进去。懒加载 + 天然避免了并发覆盖的问题。
策略四:延迟双删
先删缓存、更新DB、再删一次缓存:
1. 删缓存
2. 更新DB
3. sleep(N毫秒)
4. 再删缓存为什么需要第二次删除:针对策略二的并发问题——线程B在步骤1和2之间读到旧数据并写回缓存,第二次删除就是清掉这个"脏数据"。
延迟时间怎么定:关键是N的取值。N要大于"读请求写回缓存"的最大耗时。生产上一般取500ms~1s,但要看业务场景:
- 读写分离场景:读请求可能打到从库,主从延迟越大,N越大。如果主从延迟是1s,N至少1.5s。
- 高并发场景:N太大,脏数据窗口也大。核心矛盾在于:延迟双删是"以时间换一致性"的方案,不适合严格要求一致性的场景。
延迟双删的缺点:sleep阻塞写线程,影响写性能。改进方法是用异步队列延迟执行第二次删除,不让写线程等。
容错:删缓存失败怎么办
不管用哪种策略,删缓存都可能失败——Redis超时、网络闪断、Redis实例宕机。
方案一:本地重试 + 指数退避
java
public void deleteCacheWithRetry(String key, int maxRetries) {
for (int i = 0; i < maxRetries; i++) {
try {
redisTemplate.delete(key);
return; // 成功直接返回
} catch (Exception e) {
if (i == maxRetries - 1) {
// 最后一次重试也失败,丢到MQ兜底
sendToRetryQueue(key);
log.error("删除缓存失败,投递到MQ兜底, key={}", key, e);
return;
}
// 指数退避:100ms, 200ms, 400ms, 800ms...
Thread.sleep(100 * (long) Math.pow(2, i));
}
}
}方案二:MQ异步补偿
删缓存失败后,把key投递到延迟队列,延迟几秒后重新执行删除。如果重试还失败,发告警人工介入。
java
// 投递到延迟队列
retryQueue.send(new CacheDeleteMessage(key), 3, TimeUnit.SECONDS);
// 消费者处理
@RabbitListener(queues = "cache.delete.retry")
public void handleCacheDelete(CacheDeleteMessage msg) {
redisTemplate.delete(msg.getKey());
}方案三:订阅 MySQL binlog(推荐方案)
这是目前大型互联网公司的主流方案——不依赖业务代码的删缓存逻辑,而是监听 MySQL binlog 的变化,异步删除或刷新缓存。
主流方案:监听 binlog 同步缓存
架构
应用 → 更新DB
MySQL binlog → Canal/Debezium 监听 → MQ → 消费端 → 删除/刷新缓存核心思想:业务代码只负责写DB,不关心缓存。缓存的更新由binlog消费端异步完成。
为什么是主流方案
- 解耦:业务代码不需要写任何缓存操作,只管update DB。缓存一致性由独立组件保证。
- 可靠:binlog是MySQL的主从同步机制,数据不丢。Canal消费binlog,即使消费端重启,也能从上次位置继续消费。
- 覆盖所有变更:不仅是应用代码的update,后台DBA直接改数据、数据迁移工具批量导入——所有通过binlog落盘的变更都能被捕获。
实现要点
java
// Canal 消费端伪代码
@CanalTable(value = "product")
public void handleProductChange(CanalEntry.Entry entry) {
String tableName = entry.getHeader().getTableName();
String eventType = entry.getEntryType().name(); // INSERT / UPDATE / DELETE
for (CanalEntry.RowData rowData : entry.getRowDataList()) {
String key = buildCacheKey(tableName, rowData.getAfterColumnsList());
switch (eventType) {
case "UPDATE":
case "DELETE":
redisTemplate.delete(key);
break;
case "INSERT":
// 新插入的数据根据业务决定是否预热缓存
break;
}
}
}最终一致性的代价
binlog方案保证的是最终一致性——从DB更新到缓存刷新之间有延迟(binlog拉取 + MQ排队 + 消费处理),通常几百毫秒到几秒。
延迟的主要来源:
- Canal拉取binlog的频率(默认1秒一次)
- MQ消息堆积
- 消费端处理耗时
对外展示的场景(商品详情页、文章列表、用户信息)接受几秒的延迟很常见。但涉及钱的场景(库存扣减、余额变动)不适合——这类场景应该直接走DB,不走缓存,或者用分布式锁+缓存+DB的强一致方案。
总结
- 删缓存而不是更新缓存:避免并发覆盖,懒加载保证读到最新值
- 先更新DB再删缓存:比先删缓存再更新DB的脏数据窗口更可控
- 删缓存失败要兜底:重试 + MQ异步补偿,或直接上binlog订阅
- 延迟双删:适合读多写少、对脏数据窗口敏感但能接受写阻塞的场景
- binlog订阅:大型系统的一致性底座,解耦最彻底,但最终一致,不适合强一致场景
- 强一致别无选择:不走缓存,或者用分布式锁串行化读写
参考
- Redis 缓存穿透、击穿、雪崩
- 本地缓存 + Redis 二级缓存架构
- Cache Aside 模式初探
- Martin Kleppmann, Designing Data-Intensive Applications, Chapter 4 (Encoding, Consistency)
- Canal 官方文档:https://github.com/alibaba/canal