Skip to content

延迟双删延迟是怎么实现的

约 2881 字大约 10 分钟

Redis美团

2025-04-29

⭐ 题目日期:

美团 - 2025/4/25

📝 题解:

1. 概念解释

什么是“延迟双删” (Delayed Double Delete)?

“延迟双删”是一种用于缓解缓存与数据库数据不一致问题的策略,特别是在经典的“Cache-Aside Pattern”(旁路缓存模式)下进行数据更新操作时。

Cache-Aside Pattern 的基本流程:

  1. 读操作:先读缓存,缓存命中则直接返回;缓存未命中,则查询数据库,将结果写入缓存,然后返回。
  2. 写操作(更新/删除)先操作数据库,再删除缓存

为什么需要“延迟双删”?

在“先更新数据库,再删除缓存”的策略下,可能会出现以下并发场景导致数据不一致:

这个问题的核心在于:线程A的缓存删除操作发生在线程B读取旧数据之后、写入缓存之前。

延迟双删策略:

为了解决上述问题,延迟双删在原有步骤基础上增加了“延迟”和“再次删除”:

  1. 先删除缓存 (第一次删除)
  2. 再更新数据库
  3. 延迟 一段时间 (关键点)
  4. 再次删除缓存 (第二次删除)

第二次删除发生在延迟之后,目的是删除在延迟期间可能被其他读线程写入的脏数据。

2. 解题思路:如何实现“延迟”和“第二次删除”

这里的核心挑战在于如何可靠且异步地执行这个“延迟 + 第二次删除”的操作,同时不阻塞主业务流程(数据库更新)。以下是几种常见的实现方式:

方案一:基于消息队列 (MQ) 的延迟消息 (推荐)

  • 思路:利用消息队列(如 RocketMQ, RabbitMQ-delayed-message-exchange插件, Kafka with scheduled execution)提供的延迟消息功能。
  • 流程
    1. 业务线程完成数据库更新后。
    2. 发送一条延迟消息到 MQ。消息内容通常包含需要删除的缓存 Key。
    3. 设置消息的延迟时间(这个时间需要大于典型的数据库主从同步延迟 + 脏数据回写缓存的时间)。
    4. MQ Broker 会在延迟时间到达后,才将消息投递给消费者。
    5. 部署一个专门的消费者服务,监听此队列。
    6. 消费者收到消息后,执行缓存的第二次删除操作。
  • 优点
    • 解耦:业务逻辑与延迟删除逻辑分离。
    • 可靠性:MQ 提供消息持久化和重试机制,即使消费者瞬间宕机,消息也不会丢失,保证第二次删除最终会被执行 (at-least-once semantics)。
    • 异步化:不阻塞核心业务线程。
    • 削峰填谷:MQ 可以缓冲瞬时的大量删除请求。
  • 缺点
    • 引入外部依赖 (MQ),增加系统复杂度。
    • 需要保证 MQ 服务的高可用。
  • 时序图 (MQ 实现)

方案二:使用定时任务调度框架 (如 ScheduledExecutorService, Quartz, xxl-job)

  • 思路:利用 Java 内置的 ScheduledExecutorService 或成熟的分布式任务调度框架。
  • 流程 (ScheduledExecutorService)
    1. 业务线程完成数据库更新后。
    2. 通过 ScheduledExecutorService 提交一个一次性的延迟任务。
    3. executor.schedule(() -> deleteCache(key), delay, TimeUnit.MILLISECONDS);
    4. 调度器会在指定的 delay 时间后,在线程池中执行缓存删除操作。
  • 优点
    • 相对简单,特别是对于单体应用或小型系统,ScheduledExecutorService 是 JDK 自带的。
    • xxl-job 等分布式任务调度框架提供更完善的管理、监控和高可用特性。
  • 缺点
    • 可靠性问题 (单机 ScheduledExecutorService):如果应用实例在任务执行前宕机,该延迟任务会丢失,导致第二次删除失败。
    • 分布式环境问题:需要使用 Quartz (配合数据库持久化) 或 xxl-job 等分布式调度框架来保证任务的可靠性和分布式执行,增加了复杂性。
    • 状态管理:相比 MQ,任务状态的管理和失败重试逻辑可能需要自行实现或依赖框架特性。

方案三:利用 Redis 的 Keyspace Notifications (不常用,且不直接用于“延迟”)

  • 这种方式更多是监听事件,而不是主动安排一个延迟任务。可以监听 set 事件,如果发现 set 的是旧值(需要额外逻辑判断,复杂),再触发删除。但它不直接解决“延迟”的需求,实现复杂且可能引入循环,一般不推荐用于此场景。

方案四:简单 Thread.sleep + 异步线程 (不推荐)

  • 思路:在数据库更新后,新起一个线程,在线程内部 Thread.sleep(delayTime),然后执行删除。
  • 缺点
    • 资源消耗:为每个写操作创建新线程或频繁使用线程池可能造成资源浪费。
    • 可靠性极差:应用重启或宕机,所有内存中的延迟任务全部丢失。
    • 难以管理:无法有效监控和管理这些睡眠中的线程。
    • 绝对不适用于生产环境!

延迟时间的选择

这是一个关键问题。延迟时间需要略大于数据库主从同步的平均时间” + “读请求从数据库加载旧数据并写回缓存的平均时间”。这个值通常需要根据实际系统的监控数据(如主从延迟监控、请求耗时)来经验性地设定,例如 500ms、1s 或 2s。设置太短可能无法覆盖脏数据写入的窗口期,设置太长则会增加数据短暂不一致的时间窗口。

3. 知识扩展

  • 缓存一致性问题:这是分布式系统中的经典问题。除了延迟双删,还有其他策略,如:
    • 更新数据库时同步更新缓存:强一致,但性能较低,且可能更新失败导致不一致。
    • 基于数据库 Binlog 的异步更新/删除:通过 Canal 等工具订阅数据库变更日志,当数据变化时,由专门的服务去更新或删除缓存。这是更可靠、更优雅的方案,能更好地保证最终一致性,但实现复杂度更高。
  • CAP 理论与最终一致性:延迟双删本质上是为了在 AP (可用性、分区容忍性) 架构中,尽可能地保证 C (一致性),但它实现的是最终一致性,而非强一致性。在延迟窗口内,仍可能读到旧数据。
  • 消息队列 (MQ):在分布式系统中扮演重要角色,用于解耦、异步、削峰填谷。了解不同 MQ (RocketMQ, Kafka, RabbitMQ) 的特性和适用场景。
  • 分布式任务调度:了解 Quartz、xxl-job 等框架的原理和应用。

4. 实际应用

  • 场景:用户修改个人信息(如昵称、头像),商品信息更新(如价格、库存)。这些场景读多写少,对短暂的数据不一致有一定容忍度。
  • 案例:在一个电商平台的商品详情页,当运营人员修改商品价格后:
    1. 后台服务先删除 Redis 中的商品缓存 (product:id:123)。
    2. 然后更新数据库中的商品价格。
    3. 发送一条延迟 1 秒的消息到 RocketMQ,内容为 product:id:123
    4. 专门的缓存维护消费者在 1 秒后收到消息,再次尝试删除 Redis 缓存 product:id:123
    • 这样可以大概率确保,即使在更新DB到第二次删除缓存的这1秒内,有用户请求读到了旧的商品价格并写回了缓存,这个脏数据也会被第二次删除操作清理掉。

5. 常见陷阱

  • 认为延迟双删能保证强一致性:错误。它只能提高最终一致性实现的概率,降低不一致的窗口期,无法完全避免。在延迟期间,用户仍可能读到旧数据。
  • 延迟时间设置不当:设置过短,起不到作用;设置过长,不一致窗口增大。需要根据实际情况调整。
  • 第二次删除失败未处理:如果使用 MQ,要确保消费者有重试机制、死信队列等,保证删除操作最终成功。如果使用 ScheduledExecutorService 且应用重启,任务会丢失。
  • 过度设计:对于一致性要求不高、或写操作不频繁的场景,可能简单的“先更新DB,再删缓存”就够用了,或者可以容忍短暂的不一致。延迟双删增加了复杂度。
  • 忽略了第一次删除的必要性:有些同学可能只记得延迟和第二次删除。第一次删除的作用是,在更新数据库期间,如果有读请求进来,会让它直接穿透到数据库,避免读到更旧的缓存数据。
  • 依赖 Thread.sleep 实现延迟:面试中如果提出这种方案,通常会被认为是经验不足或对系统可靠性考虑不周。