详解RDelayedQueue底层原理

176 阅读7分钟

RDelayedQueue 是 Redisson(一个强大的 Redis Java 客户端)提供的一个分布式延迟队列实现。它允许你将元素(任务)放入队列,并指定一个延迟时间,元素只有在延迟时间过后才会被消费者取出处理。其核心依赖于 Redis 的有序集合(ZSET)和列表(LIST)数据结构,并辅以发布/订阅(PUB/SUB)机制和后台线程来实现高效的延迟触发。

以下详细解析其底层原理,包括添加任务、取消任务的执行流程:

核心数据结构与角色

  1. 目标队列 (destinationQueue):

    • 类型:Redis 列表 (LIST)。
    • 作用:存储已到期、可供消费者立即消费的任务元素。
    • Key 格式:{queueName} (用户定义的队列名)。
  2. 延迟有序集合 (delayedQueueName):

    • 类型:Redis 有序集合 (ZSET)。
    • 作用:存储所有未到期的任务元素及其对应的到期时间戳(毫秒)。
    • Key 格式:redisson_delay_queue:{destinationQueue} (由 Redisson 自动生成)。
    • 结构:member = 任务元素 (序列化后的值), score = 该元素的到期时间戳。
  3. 到期时间通道 (timeoutSetName):

    • 类型:Redis 频道 (PUB/SUB)。
    • 作用:用于在任务到期时通知 Redisson 客户端将任务从 ZSET 转移到目标队列。
    • Key 格式:redisson_delay_queue_channel:{destinationQueue} (由 Redisson 自动生成)。
  4. 转移任务 (TransferTask):

    • 角色:Redisson 客户端内部运行的一个后台调度线程
    • 作用:定期扫描 delayedQueueName ZSET 中 score (到期时间戳) 小于等于当前时间戳的元素,将这些到期的元素转移到 destinationQueue LIST 中。
    • 扫描间隔:可配置(默认为 1 秒)。

添加任务 (offer) 的执行流程

  1. 客户端调用 offer(elem, delay, timeUnit)

    • 用户传入要延迟执行的任务元素 elem、延迟时长 delay 及其单位 timeUnit
  2. 计算到期时间戳:

    • Redisson 计算:expirationTime = System.currentTimeMillis() + timeUnit.toMillis(delay)
  3. 原子性操作 (Lua 脚本):

    • Redisson 向 Redis 发送一个原子性的 Lua 脚本执行以下操作:
    • 步骤 1: 将任务元素 elem (序列化后的值) 作为 member,计算出的 expirationTime 作为 score添加/更新到延迟有序集合 delayedQueueName (ZADD delayedQueueName expirationTime serializedElem)。如果元素已存在(根据 member 判断),则更新其 score(到期时间)。
    • 步骤 2: 检查 delayedQueueName ZSET 中 score 最小的元素(即最早到期的元素)的 score (minScore)。
    • 步骤 3: 比较 expirationTimeminScore
      • 如果 expirationTime < minScore 或 ZSET 之前为空(即新添加的元素是新的最早到期元素):
        • 向到期时间通道 timeoutSetName 发布一条消息,消息内容就是这个新元素的到期时间戳 expirationTime (PUBLISH timeoutSetName expirationTime)。
      • 否则(新元素到期时间晚于当前最早到期元素):不做发布操作。
  4. Redisson 客户端监听通道:

    • 每个使用 RDelayedQueue 的 Redisson 客户端在启动时,都会订阅 timeoutSetName 频道。
    • 当它收到 timeoutSetName 频道发布的消息(一个时间戳 ts)时:
      • 它会将这个时间戳 ts 作为参数,唤醒或调度其内部的 TransferTask 后台线程,让它ts 这个时间点(或稍后)执行一次扫描转移操作

任务到期转移流程 (TransferTask)

  1. 触发时机:

    • TransferTask 被调度唤醒(通常因为收到了 timeoutSetName 频道发布的到期时间戳消息,指示有新的最早到期元素)。
    • 或者 TransferTask定时扫描周期到达(即使没有新消息,也会每秒检查一次,防止消息丢失或客户端断开连接)。
  2. 扫描到期元素:

    • TransferTask 获取当前时间戳 now
    • 向 Redis 发送命令:ZRANGEBYSCORE delayedQueueName 0 now WITHSCORES LIMIT 0 100 (或其他合理的分页大小)。 这获取所有 score (到期时间) <= now 的元素(及其分数),每次最多取 100 个(避免大 key 操作阻塞 Redis)。
  3. 转移元素 (Lua 脚本):

    • 对于扫描到的一批到期元素(序列化值 serializedElem 和其 score),Redisson 会发送另一个原子性的 Lua 脚本
      • 步骤 1: 尝试从 delayedQueueName ZSET 中 精确移除 (ZREM delayedQueueName serializedElem) 扫描到的元素。这是为了处理潜在的并发修改(比如任务被提前取消)。
      • 步骤 2: 如果移除成功(即该元素在扫描后、移除前未被其他操作删除),则将该元素 RPUSH 到目标队列 destinationQueue LIST (RPUSH destinationQueue serializedElem)。
      • 步骤 3: 如果目标队列 destinationQueue 之前是空的,并且有阻塞等待的消费者(例如 BLPOP),这一步推送操作会唤醒其中一个消费者。
  4. 循环处理:

    • 如果步骤 2 扫描返回了元素(说明还有更多到期元素),TransferTask 会重复步骤 2 和步骤 3,继续处理下一批到期元素,直到扫描不到新的到期元素为止。
  5. 检查并设置下次唤醒:

    • 在转移完所有当前到期的元素后,TransferTask 会查询 delayedQueueName ZSET 中当前 score 最小的元素(ZRANGE delayedQueueName 0 0 WITHSCORES)。
    • 如果 ZSET 不为空(存在 minScore):
      • 计算 minScore 与当前时间 now 的差值 nextDelay = minScore - now
      • 重新调度自身: 安排在 nextDelay 毫秒后(或者如果 nextDelay <= 0 则立即)再次运行,以便在下一个最早到期元素到期时及时处理。
    • 如果 ZSET 为空:
      • 等待下一次定时扫描周期(例如 1 秒后)或等待新的 timeoutSetName 频道消息唤醒。

提前取消任务 (remove) 的执行流程

  1. 客户端调用 remove(elem)

    • 用户传入要取消的、尚未被转移的任务元素 elem
  2. 原子性移除 (Lua 脚本):

    • Redisson 向 Redis 发送一个原子性的 Lua 脚本执行以下操作:
      • 步骤 1: 直接从 delayedQueueName ZSET 中 移除 指定的任务元素 elem (序列化后的值) (ZREM delayedQueueName serializedElem)。
      • 步骤 2: 如果移除成功,返回成功标识(true)。
    • 这个操作直接在 ZSET 中删除了元素,TransferTask 在后续扫描时就不会再找到它,因此它永远不会被转移到目标队列 destinationQueue 中。
  3. (重要)无需更新 TransferTask 调度:

    • 取消操作不会主动通知 TransferTask 或更新其调度时间。
    • 这是安全的,因为:
      • 如果被取消的元素是当前的最早到期元素:TransferTask 下次被唤醒(根据旧的 minScore)进行扫描时,会发现这个元素已经被移除,扫描不到它。接着它会查询新的 minScore 并重新调度自身到新的最早到期时间。
      • 如果被取消的元素不是最早到期元素:TransferTask 的当前调度是基于最早元素的,取消非最早元素不影响当前调度。当 TransferTask 扫描到该元素的原始到期时间时,元素已经被移除,扫描不到。

关键设计点与优势

  1. 原子性保证: 所有关键操作(添加、取消、扫描转移)都通过 Lua 脚本实现原子性,避免并发问题。
  2. 高效通知: 使用 PUB/SUB 仅在新添加的元素成为新的最早到期元素时通知客户端,避免了不必要的扫描和唤醒。大部分添加操作不会触发通知。
  3. 后台线程 (TransferTask): 负责实际的到期检测和转移工作,对客户端主线程透明。
  4. 双重触发机制:
    • PUB/SUB 消息:确保新添加的、成为最早到期元素的任务能精准地在到期时刻被处理。
    • 定时扫描:作为兜底机制,防止消息丢失(Redis PUB/SUB 非持久化)、客户端宕机重启后恢复处理、以及处理在客户端断开连接期间到期的任务。
  5. 取消简单高效: 直接删除 ZSET 中的元素,TransferTask 能自然处理其消失。
  6. 分布式: 所有状态存储在 Redis 中,多个生产者/消费者客户端可以协作使用同一个延迟队列。
  7. 可扩展性: 扫描转移采用分页,避免大 key 操作阻塞 Redis。

总结流程图 (简化版)

+-----------------+          +----------------+         +-----------------+         +-----------------+
| Producer Client |          |   Redis Server |         | TransferTask    |         | Consumer Client |
| (offer/remove)  |          | (ZSET + LIST)  |         | (Backend Thread)|         | (BLPOP/ etc.)   |
+-----------------+          +----------------+         +-----------------+         +-----------------+
        |                           |                           |                           |
        | 1. offer(elem, delay)     |                           |                           |
        |-------------------------->|                           |                           |
        |   (Lua: ZADD, PUBLISH*)   |                           |                           |
        |<--------------------------|                           |                           |
        |                           | 2. PUBLISH ts (if needed) |                           |
        |                           |-------------------------->|                           |
        |                           |                           | 3. Schedule @ ts or now   |
        |                           |                           |<--------------------------|
        |                           |                           |                           |
        |                           |                           | 4. ZRANGEBYSCORE 0 now    |
        |                           |<--------------------------|                           |
        |                           |-------------------------->|                           |
        |                           |                           | 5. For each elem:         |
        |                           |                           |   (Lua: ZREM, RPUSH)      |
        |                           |<--------------------------|                           |
        |                           |-------------------------->|                           |
        |                           |                           | 6. Notify Consumer (if LIST was empty) |
        |                           |                           |-------------------------->|
        |                           |                           |                           | 7. BLPOP returns elem
        |                           |                           |                           |<--------------------------|
        |                           |                           |                           |
        | 8. remove(elem)           |                           |                           |
        |-------------------------->|                           |                           |
        |   (Lua: ZREM)             |                           |                           |
        |<--------------------------|                           |                           |
  • *:仅在添加的元素成为新的最早到期元素时发布。
  • ts:新元素的到期时间戳。

RDelayedQueue 通过巧妙地组合 Redis 的 ZSETLISTPUB/SUB 特性,并辅以客户端后台线程和原子性 Lua 脚本,高效、可靠地实现了分布式延迟队列的功能,包括任务的精准延迟添加和安全的提前取消。