缓存删除三级补偿方案:延迟队列+消息队列+定时任务兜底

问题背景:
在 Cache-Aside 模式中,更新数据库后删除缓存失败会导致数据不一致。本文提供工业级三级补偿方案,实现最终一致性保障。

整体架构:

 
更新操作触发 → 一级延迟队列 → 二级消息队列 → 三级定时任务

 方案实现:


一、第一级补偿:延迟队列(快速重试)
核心代码:

// 延迟队列初始化
@PostConstruct
public void init() {
    deleteQueue = redisson.getQueue("cache:delete:queue");
    delayedQueue = redisson.getDelayedQueue(deleteQueue);
}

// 更新操作切面
@Around("@annotation(cacheUpdate)")
public Object aroundUpdate(...) {
    // 首次删除缓存
    redisson.getBucket(key).delete();
    
    // 数据库操作
    Object result = joinPoint.proceed();
    
    // 加入延迟队列(1秒后二次删除)
    delayedQueue.offer(key, 1, TimeUnit.SECONDS); 
    return result;
}

// 消费延迟队列(独立线程)
@EventListener(ApplicationReadyEvent.class)
public void startDelayConsumer() {
    new Thread(() -> {
        while (true) {
            String key = deleteQueue.poll(10, TimeUnit.SECONDS);
            if (key != null) {
                redisson.getBucket(key).delete();
            }
        }
    }).start();
}

特点:
响应时间:秒级
适用场景:高频更新业务
防抖设计:单线程顺序消费

二、第二级补偿:消息队列(可靠重试)
RocketMQ 集成示例:

// 消息监听器
@RocketMQMessageListener(topic = "CACHE_DELETE", consumerGroup = "cache-group")
public void handleDelete(String key) {
    try {
        if (!redisson.getBucket(key).delete()) {
            retryWithBackoff(key, 3); // 指数退避重试
        }
    } catch (Exception e) {
        // 进入死信队列
    }
}

// 退避策略实现
private void retryWithBackoff(String key, int retryCount) {
    for (int i = 1; i <= retryCount; i++) {
        Thread.sleep(1000 * i);
        if (redisson.getBucket(key).delete()) break;
    }
}

// 延迟队列异常处理
delayedQueue.offer(...).exceptionally(e -> {
    rocketMQTemplate.send("CACHE_DELETE", key); 
    return null;
});

特点:
可靠性:99.9%+ 送达保障
重试策略:3次指数退避
容错机制:死信队列隔离异常

三、第三级补偿:定时任务(全量兜底)
Spring Scheduler 实现:

// 每天凌晨执行全量比对
@Scheduled(cron = "0 0 3 * * ?") 
public void scanAndFix() {
    redisson.getKeys()
           .getKeysByPattern("user:*")
           .forEach(key -> {
               Long userId = extractUserId(key);
               User dbUser = databaseService.getUserFromDB(userId);
               User cacheUser = (User) redisson.getBucket(key).get();
               
               // 判断是否需要删除
               if ((cacheUser != null && dbUser == null) || 
                   (dbUser != null && cacheUser.getVersion() < dbUser.getVersion())) {
                   redisson.getBucket(key).delete();
               }
           });
}

比对策略:
缓存存在但数据库已物理删除
数据版本号不一致
逻辑删除标记状态不一致

防抖容错设计
1. 防重复删除机制

String deleteFlagKey = key + ":deleting";
if (redis.setnx(deleteFlagKey, "1")) {
    redis.expire(deleteFlagKey, 30); // 30秒窗口期
    performDelete(key); 
}

2. 监控报警体系

各级补偿对比

<think>好的,用户现在想了解如何处理缓存与数据库双写不一致的问题,特别是在多读和多写场景下的解决方案。之前用户已经询问过关于Canal和binlog监听的问题,现在这个问题看起来是进一步的系统设计问题,特别是在缓存和数据库一致性方面。 首先,我需要回忆一下常见的缓存与数据库双写不一致的场景和解决方案。用户提到了两种场景:多读和多写。在多读场景下,用户提到了先更新数据库再删除缓存,并且使用消息队列或Canal+消息队列来处理缓存删除。而在多写场景下,用户提到了延迟双删的策略,结合分布式锁。 接下来,我需要确认这些方法的正确性和适用性。例如,先更新数据库再删除缓存是否真的能解决多读场景下的不一致问题?这里可能要考虑经典的缓存更新策略,比如Cache-Aside模式。通常,正确的做法是更新数据库后使缓存失效,而不是直接更新缓存,这样可以避免并发写导致的问题。因此,先更新数据库再删除缓存是合理的,但需要处理删除失败的情况,这时候引入消息队列或者通过Canal监听binlog异步删除缓存,可以增加可靠性,确保最终一致性。 对于多写场景,延迟双删的策略确实是一个常见的方法。先删除缓存,然后更新数据库,之后再次延迟删除缓存。这里的关键是延迟的时间需要足够长,以确保在数据库主从同步完成后再删除缓存,避免读到旧数据。同时,使用分布式锁来保证操作的原子性,防止多个线程同时操作导致的问题。需要确认这个延迟时间的设置,通常是根据业务的主从同步延迟来估算的,比如1-2秒。 另外,用户提到使用分布式锁,这部分需要详细说明,比如锁的范围、如何避免死锁,以及锁的粒度。比如,锁应该基于数据的主键或其他唯一标识,确保同一数据在同一时间只有一个线程在处理。同时,锁的释放时机也需要考虑,确保在数据库更新和第二次缓存删除之后释放,避免其他线程在中间状态介入。 还需要考虑异常处理的情况。比如,如果第一次删除缓存失败,或者数据库更新失败,或者第二次删除缓存失败,系统应该如何应对。这时候可能需要重试机制,或者将操作记录到消息队列中,由消费者负责重试,直到成功为止。此外,是否需要引入版本号或者时间戳来避免旧数据覆盖新数据,这也是需要考虑的点。 用户可能在实际应用中遇到了缓存不一致的问题,特别是在高并发场景下,比如电商秒杀、社交网络热点数据等。他们可能需要一个可靠的解决方案来保证数据的一致性,同时不影响系统性能。因此,除了理论上的方案,还需要给出实践中的注意事项,比如监控、日志记录、重试策略、超时设置等。 另外,用户可能对Canal的应用场景比较熟悉,所以他们可能会倾向于使用Canal来监听数据库变更,进而触发缓存删除。这种情况下,需要解释Canal如何与消息队列结合,异步处理缓存失效,从而解耦业务逻辑和缓存更新操作,提高系统的可靠性和扩展性。 最后,要对比不同的方案,比如先更新数据库再删除缓存与延迟双删的适用场景,以及各自的优缺点。帮助用户理解在不同情况下如何选择最合适的策略,以及如何结合使用这些策略来达到最佳效果。</think>处理缓存与数据库双写不一致的问题是高并发系统中的经典挑战。以下是针对 **多读场景** 和 **多写场景** 的常见解决方案,结合延迟双删、分布式锁、消息队列和 Canal 等技术实现最终一致性: --- ## **1. 多读场景(读多写少)** ### **核心问题** - 并发读时,缓存数据可能因数据库更新未及时同步而返回旧值。 - 直接“先更新数据库,再更新缓存”可能导致脏数据(例如并发写时的操作覆盖)。 ### **解决方案:先更新数据库,后删除缓存** - **操作步骤**: 1. **更新数据库**:优先保证数据库写入成功。 2. **删除缓存**:使旧缓存失效,后续读请求会从数据库加载最新数据到缓存。 - **关键点**: - **删除缓存而非更新缓存**:避免并发写导致缓存数据错乱(如两个线程先后更新缓存,顺序可能颠倒)。 - **异步化缓存删除**:通过消息队列或 Canal 监听 binlog 异步删除,降低对主流程的性能影响。 #### **实现方式** - **方式一:业务代码同步删除缓存** ```java // 伪代码示例 public void updateData(Data data) { // 1. 更新数据库 db.update(data); // 2. 同步删除缓存(可能失败,需重试) cache.delete(data.getId()); } ``` **缺点**:缓存删除失败会导致不一致(需重试机制补偿)。 - **方式二:消息队列异步删除(最终一致性)** ```java public void updateData(Data data) { // 1. 更新数据库 db.update(data); // 2. 发送删除缓存的消息到队列(解耦) mq.send("cache_delete_topic", data.getId()); } // 消费者处理消息 mq.consume("cache_delete_topic", (id) -> { cache.delete(id); }); ``` **优点**:解耦主流程,通过消息队列保证至少一次消费(需幂等性处理)。 - **方式三:Canal + 消息队列(无侵入式)** - 使用 **Canal 监听数据库 binlog**,捕获数据变更事件。 - 将变更事件(如 UPDATE)发送到消息队列,由消费者删除缓存。 ```java // Canal 解析到数据变更后,推送消息到队列 mq.send("cache_delete_topic", updatedId); // 消费者逻辑同上 ``` **优点**:业务代码无需感知缓存逻辑,适合老旧系统改造。 --- ## **2. 多写场景(写并发高)** ### **核心问题** - 高并发写场景下,多个线程同时操作数据库和缓存,可能导致缓存与数据库数据错乱。 - 例如:线程 A 删除缓存 → 线程 B 读缓存未命中 → 线程 B 读数据库旧值 → 线程 B 更新缓存 → 线程 A 更新数据库,导致缓存与数据库不一致。 ### **解决方案:延迟双删 + 分布式锁** - **操作步骤**: 1. **第一次删除缓存**:确保后续读请求强制回源数据库。 2. **更新数据库**:完成数据持久化。 3. **延迟后第二次删除缓存**:等待主从同步完成,确保删除的是可能的脏数据。 - **关键点**: - **延迟时间**:需覆盖主从同步延迟(通常 500ms~1s)。 - **分布式锁**:保证同一数据的更新操作串行化,避免并发写干扰。 #### **实现示例** ```java public void updateDataWithLock(Data data) { String lockKey = "LOCK_DATA_" + data.getId(); // 1. 获取分布式锁(如 Redis RedLock) boolean locked = redis.lock(lockKey, 10s); if (!locked) throw new RetryException(); try { // 2. 第一次删除缓存 cache.delete(data.getId()); // 3. 更新数据库 db.update(data); // 4. 延迟后再次删除缓存(异步执行) scheduleTask(() -> { cache.delete(data.getId()); }, 1s); // 延迟时间根据主从同步时间调整 } finally { // 释放锁 redis.unlock(lockKey); } } ``` #### **优化点** - **异步延迟删除**:通过消息队列延迟消息或线程池调度实现,避免阻塞主线程。 ```java // 发送延迟消息(如 RocketMQ 延迟消息) mq.sendDelayMessage("cache_delete_topic", data.getId(), 1s); ``` - **锁粒度细化**:锁的 Key 应基于数据唯一标识(如主键),减少锁竞争。 --- ## **3. 通用设计原则** ### **保证最终一致性** - **重试机制**:缓存删除失败时,通过消息队列重试或定时任务补偿。 - **数据版本号**:在缓存和数据库中添加版本号,仅允许高版本数据覆盖低版本。 - **读操作兜底**:读缓存未命中时,加分布式锁防止并发回源(缓存击穿)。 ### **监控与告警** - **缓存一致性对比**:定期对比缓存与数据库数据,触发告警。 - **同步延迟监控**:关注 Canal 或主从同步延迟,避免因延迟导致双删失效。 --- ## **方案对比与选型** | 场景 | 方案 | 优点 | 缺点 | |--------------|--------------------|-------------------------------|-------------------------------| | **多读场景** | 先更新DB + 删除缓存 | 简单,适合读多写少 | 需处理缓存删除失败 | | **多读场景** | Canal + 消息队列 | 业务无侵入,适合复杂系统 | 依赖中间件,运维成本高 | | **多写场景** | 延迟双删 + 分布式锁 | 有效降低并发写不一致概率 | 需合理设置延迟时间,锁影响性能 | --- ## **总结** - **多读场景**:优先选择 **先更新数据库,后异步删除缓存**,结合消息队列或 Canal 保证最终一致性。 - **多写场景**:使用 **延迟双删 + 分布式锁** 串行化写操作,确保缓存最终与数据库一致。 - **兜底措施**:始终设计重试、监控和对比机制,应对极端场景下的不一致问题。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

@淡 定

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值