RDelayedQueue 是 Redisson(一个强大的 Redis Java 客户端)提供的一个分布式延迟队列实现。它允许你将元素(任务)放入队列,并指定一个延迟时间,元素只有在延迟时间过后才会被消费者取出处理。其核心依赖于 Redis 的有序集合(ZSET)和列表(LIST)数据结构,并辅以发布/订阅(PUB/SUB)机制和后台线程来实现高效的延迟触发。
以下详细解析其底层原理,包括添加任务、取消任务的执行流程:
核心数据结构与角色
-
目标队列 (
destinationQueue):- 类型:Redis 列表 (
LIST)。 - 作用:存储已到期、可供消费者立即消费的任务元素。
- Key 格式:
{queueName}(用户定义的队列名)。
- 类型:Redis 列表 (
-
延迟有序集合 (
delayedQueueName):- 类型:Redis 有序集合 (
ZSET)。 - 作用:存储所有未到期的任务元素及其对应的到期时间戳(毫秒)。
- Key 格式:
redisson_delay_queue:{destinationQueue}(由 Redisson 自动生成)。 - 结构:
member= 任务元素 (序列化后的值),score= 该元素的到期时间戳。
- 类型:Redis 有序集合 (
-
到期时间通道 (
timeoutSetName):- 类型:Redis 频道 (
PUB/SUB)。 - 作用:用于在任务到期时通知 Redisson 客户端将任务从 ZSET 转移到目标队列。
- Key 格式:
redisson_delay_queue_channel:{destinationQueue}(由 Redisson 自动生成)。
- 类型:Redis 频道 (
-
转移任务 (
TransferTask):- 角色:Redisson 客户端内部运行的一个后台调度线程。
- 作用:定期扫描
delayedQueueNameZSET 中score(到期时间戳) 小于等于当前时间戳的元素,将这些到期的元素转移到destinationQueueLIST 中。 - 扫描间隔:可配置(默认为 1 秒)。
添加任务 (offer) 的执行流程
-
客户端调用
offer(elem, delay, timeUnit):- 用户传入要延迟执行的任务元素
elem、延迟时长delay及其单位timeUnit。
- 用户传入要延迟执行的任务元素
-
计算到期时间戳:
- Redisson 计算:
expirationTime = System.currentTimeMillis() + timeUnit.toMillis(delay)。
- Redisson 计算:
-
原子性操作 (Lua 脚本):
- Redisson 向 Redis 发送一个原子性的 Lua 脚本执行以下操作:
- 步骤 1: 将任务元素
elem(序列化后的值) 作为member,计算出的expirationTime作为score,添加/更新到延迟有序集合delayedQueueName(ZADD delayedQueueName expirationTime serializedElem)。如果元素已存在(根据member判断),则更新其score(到期时间)。 - 步骤 2: 检查
delayedQueueNameZSET 中score最小的元素(即最早到期的元素)的score(minScore)。 - 步骤 3: 比较
expirationTime和minScore:- 如果
expirationTime < minScore或 ZSET 之前为空(即新添加的元素是新的最早到期元素):- 向到期时间通道
timeoutSetName发布一条消息,消息内容就是这个新元素的到期时间戳expirationTime(PUBLISH timeoutSetName expirationTime)。
- 向到期时间通道
- 否则(新元素到期时间晚于当前最早到期元素):不做发布操作。
- 如果
-
Redisson 客户端监听通道:
- 每个使用
RDelayedQueue的 Redisson 客户端在启动时,都会订阅timeoutSetName频道。 - 当它收到
timeoutSetName频道发布的消息(一个时间戳ts)时:- 它会将这个时间戳
ts作为参数,唤醒或调度其内部的TransferTask后台线程,让它在ts这个时间点(或稍后)执行一次扫描转移操作。
- 它会将这个时间戳
- 每个使用
任务到期转移流程 (TransferTask)
-
触发时机:
- 当
TransferTask被调度唤醒(通常因为收到了timeoutSetName频道发布的到期时间戳消息,指示有新的最早到期元素)。 - 或者
TransferTask的定时扫描周期到达(即使没有新消息,也会每秒检查一次,防止消息丢失或客户端断开连接)。
- 当
-
扫描到期元素:
TransferTask获取当前时间戳now。- 向 Redis 发送命令:
ZRANGEBYSCORE delayedQueueName 0 now WITHSCORES LIMIT 0 100(或其他合理的分页大小)。 这获取所有score(到期时间) <=now的元素(及其分数),每次最多取 100 个(避免大 key 操作阻塞 Redis)。
-
转移元素 (Lua 脚本):
- 对于扫描到的一批到期元素(序列化值
serializedElem和其score),Redisson 会发送另一个原子性的 Lua 脚本:- 步骤 1: 尝试从
delayedQueueNameZSET 中 精确移除 (ZREM delayedQueueName serializedElem) 扫描到的元素。这是为了处理潜在的并发修改(比如任务被提前取消)。 - 步骤 2: 如果移除成功(即该元素在扫描后、移除前未被其他操作删除),则将该元素 RPUSH 到目标队列
destinationQueueLIST (RPUSH destinationQueue serializedElem)。 - 步骤 3: 如果目标队列
destinationQueue之前是空的,并且有阻塞等待的消费者(例如BLPOP),这一步推送操作会唤醒其中一个消费者。
- 步骤 1: 尝试从
- 对于扫描到的一批到期元素(序列化值
-
循环处理:
- 如果步骤 2 扫描返回了元素(说明还有更多到期元素),
TransferTask会重复步骤 2 和步骤 3,继续处理下一批到期元素,直到扫描不到新的到期元素为止。
- 如果步骤 2 扫描返回了元素(说明还有更多到期元素),
-
检查并设置下次唤醒:
- 在转移完所有当前到期的元素后,
TransferTask会查询delayedQueueNameZSET 中当前score最小的元素(ZRANGE delayedQueueName 0 0 WITHSCORES)。 - 如果 ZSET 不为空(存在 minScore):
- 计算
minScore与当前时间now的差值nextDelay=minScore - now。 - 重新调度自身: 安排在
nextDelay毫秒后(或者如果nextDelay <= 0则立即)再次运行,以便在下一个最早到期元素到期时及时处理。
- 计算
- 如果 ZSET 为空:
- 等待下一次定时扫描周期(例如 1 秒后)或等待新的
timeoutSetName频道消息唤醒。
- 等待下一次定时扫描周期(例如 1 秒后)或等待新的
- 在转移完所有当前到期的元素后,
提前取消任务 (remove) 的执行流程
-
客户端调用
remove(elem):- 用户传入要取消的、尚未被转移的任务元素
elem。
- 用户传入要取消的、尚未被转移的任务元素
-
原子性移除 (Lua 脚本):
- Redisson 向 Redis 发送一个原子性的 Lua 脚本执行以下操作:
- 步骤 1: 直接从
delayedQueueNameZSET 中 移除 指定的任务元素elem(序列化后的值) (ZREM delayedQueueName serializedElem)。 - 步骤 2: 如果移除成功,返回成功标识(
true)。
- 步骤 1: 直接从
- 这个操作直接在 ZSET 中删除了元素,
TransferTask在后续扫描时就不会再找到它,因此它永远不会被转移到目标队列destinationQueue中。
- Redisson 向 Redis 发送一个原子性的 Lua 脚本执行以下操作:
-
(重要)无需更新
TransferTask调度:- 取消操作不会主动通知
TransferTask或更新其调度时间。 - 这是安全的,因为:
- 如果被取消的元素是当前的最早到期元素:
TransferTask下次被唤醒(根据旧的minScore)进行扫描时,会发现这个元素已经被移除,扫描不到它。接着它会查询新的minScore并重新调度自身到新的最早到期时间。 - 如果被取消的元素不是最早到期元素:
TransferTask的当前调度是基于最早元素的,取消非最早元素不影响当前调度。当TransferTask扫描到该元素的原始到期时间时,元素已经被移除,扫描不到。
- 如果被取消的元素是当前的最早到期元素:
- 取消操作不会主动通知
关键设计点与优势
- 原子性保证: 所有关键操作(添加、取消、扫描转移)都通过 Lua 脚本实现原子性,避免并发问题。
- 高效通知: 使用
PUB/SUB仅在新添加的元素成为新的最早到期元素时通知客户端,避免了不必要的扫描和唤醒。大部分添加操作不会触发通知。 - 后台线程 (
TransferTask): 负责实际的到期检测和转移工作,对客户端主线程透明。 - 双重触发机制:
PUB/SUB消息:确保新添加的、成为最早到期元素的任务能精准地在到期时刻被处理。- 定时扫描:作为兜底机制,防止消息丢失(Redis PUB/SUB 非持久化)、客户端宕机重启后恢复处理、以及处理在客户端断开连接期间到期的任务。
- 取消简单高效: 直接删除 ZSET 中的元素,
TransferTask能自然处理其消失。 - 分布式: 所有状态存储在 Redis 中,多个生产者/消费者客户端可以协作使用同一个延迟队列。
- 可扩展性: 扫描转移采用分页,避免大 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 的 ZSET、LIST、PUB/SUB 特性,并辅以客户端后台线程和原子性 Lua 脚本,高效、可靠地实现了分布式延迟队列的功能,包括任务的精准延迟添加和安全的提前取消。