Skip to content

缓存与数据库一致性: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消费端异步完成。

为什么是主流方案

  1. 解耦:业务代码不需要写任何缓存操作,只管update DB。缓存一致性由独立组件保证。
  2. 可靠:binlog是MySQL的主从同步机制,数据不丢。Canal消费binlog,即使消费端重启,也能从上次位置继续消费。
  3. 覆盖所有变更:不仅是应用代码的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订阅:大型系统的一致性底座,解耦最彻底,但最终一致,不适合强一致场景
  • 强一致别无选择:不走缓存,或者用分布式锁串行化读写

参考

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1