简单实现逻辑
1. 数据模型定义
- 存储: Redis ZSet 。
Score:任务触发的绝对 Unix 时间戳(例如:1710003600)。Value:任务唯一标识符(例如:order_id_999)。
- 通信: Redis Pub/Sub 。
Channel:delay_queue_signal。Message:仅作为触发信号,内容可固定(如"1")。
2. 完整逻辑链路
第一阶段:生产者 (Producer)
- 持久化: 业务数据写入数据库(状态为"待支付")。
- 入队: 执行
ZADD queue_key <Timestamp_30min_later> <Order_ID>。 - 信号发布: 执行
PUBLISH delay_queue_signal "1"。- 目的: 强制中断消费者当前的
SUBSCRIBE阻塞状态,触发其重新计算等待时间。
- 目的: 强制中断消费者当前的
第二阶段:消费者 (Consumer)
消费者运行在一个循环状态机中,包含 阻塞 、执行 、校准 三个环节。
1. 信号监听与阻塞:
消费者执行 SUBSCRIBE delay_queue_signal。
- 此操作通常配合客户端的
timeout参数使用。这个timeout是动态计算的。
2. 唤醒后的执行逻辑:
当消费者因为"收到信号"或"订阅超时"被唤醒后,进入 批量处理循环:
- Step A (探测): 执行
ZRANGEBYSCORE queue_key -inf <Current_Timestamp> LIMIT 0 <N>。 - Step B (争抢): 遍历返回的任务列表,逐个执行
ZREM queue_key <Order_ID>。 - Step C (处理): 若
ZREM返回1,则查询数据库。若状态仍为"待支付",执行取消订单逻辑。 - Step D (自驱): 只要 Step A 拿到了数据,重复执行 Step A,直到返回为空。
3. 下一次等待时间的校准:
当 ZSet 中已无当前时间点可执行的任务时,消费者执行:
- 查询:
ZRANGE queue_key 0 0 WITHSCORES(获取队列中距离现在最近的一个任务)。 - 计算: WaitTime=Task_Timestamp−Current_TimestampWaitTime = Task\_Timestamp - Current\_TimestampWaitTime=Task_Timestamp−Current_Timestamp。
- 进入阻塞: 重新执行
SUBSCRIBE,并将阻塞超时时间设为 WaitTimeWaitTimeWaitTime。
3. 核心问题深度对齐
为什么生产者在 T=0T=0T=0 (下单瞬间) 发送信号?
如果消费者当前处于 SUBSCRIBE 阻塞状态,且其预设的超时时间是基于上一个任务计算的(比如还要等 10 分钟),而新入队的任务需要在 2 分钟后执行,那么:
- 如果不发信号: 消费者会继续阻塞 10 分钟,导致 2 分钟的任务延迟 8 分钟处理。
- 发送信号: 信号会强制中断消费者的阻塞,使其立即执行"校准"环节。它会发现新任务只需等 2 分钟,从而将下一次阻塞时间更新为 2 分钟。
消费链路的批量处理逻辑
消费者被唤醒后,并非只处理一个任务,而是采用 "排空(Drain)"策略:
- 使用
LIMIT参数(如LIMIT 0 100)分批从 ZSet 获取已到期任务。 - 使用
while循环持续获取,直到ZRANGEBYSCORE返回结果为空集。 - 只有在确认"当前已无到期任务"后,才会进入下一次等待时间的计算和阻塞。
4. 异常补偿 (边缘逻辑)
由于 Pub/Sub 不保证送达,链路必须包含一个硬性超时:
- 即使计算出的 WaitTimeWaitTimeWaitTime 很大,消费者的
SUBSCRIBE阻塞也应设置一个最大值(如 30s)。 - 超时后自动触发一次全量扫描,确保在信号丢失的情况下,积压任务的延迟不超过该最大值。
Redisson.RDelayedQueue的实现逻辑
Redisson 官方实现了延迟队列,类名为 RDelayedQueue。
它并不是简单地对 ZSet 进行封装,而是通过 "目标队列 + 延时辅助队列 + 客户端调度器" 的组合逻辑,实现了一个高性能、低延迟的分布式延迟队列方案。
1. 核心逻辑模型:三位一体架构
Redisson 的延迟队列由三个物理组件协同工作:
- 目标队列 (Target Queue) :一个普通的
RBlockingQueue(本质是 Redis 的List)。消费者只需在这个队列上执行阻塞弹出(take())。 - 延时辅助队列 (Timeout Set) :一个 Redis ZSet 。存储所有未到期的任务,
Score为到期时间戳。 - 消息通道 (Pub/Sub):用于通知客户端拓扑变化和任务到期提醒。
2. 详细执行链路
Redisson 延迟队列的本质是:利用客户端的定时器,去触发服务端的 Lua 脚本,实现数据在不同 Redis 数据结构之间的"搬运"。
物理组件表(都在 Redis 服务端)
当你定义一个名为 my_queue 的延迟队列时,Redis 内部会自动创建三个物理对象:
| 对象名称 | 数据结构 | 作用 |
|---|---|---|
redisson_delay_queue_timeout:{my_queue} |
ZSet | 延时区 。存放所有还没到期的任务。Score 是到期时间戳。 |
redisson_delay_queue:{my_queue} |
List | 目标区。存放已经到期的、等待被取走的任务。 |
redisson_delay_queue_channel:{my_queue} |
Pub/Sub | 信号道。用于客户端之间同步"最近到期时间"。 |
A. 生产者入队逻辑 (offer)
当调用 delayedQueue.offer(msg, 10, TimeUnit.SECONDS) 时,Redisson 执行一段 Lua 脚本:
- 计算到期时间戳 T=Now+10sT = Now + 10sT=Now+10s。
- 将任务 ID 和内容存入 ZSet :
ZADD timeout_set T msg。 - 发布信号:
PUBLISH channel "T",告知消费者有新任务入队,且最近的到期时间可能是 TTT。
B. 内部转移逻辑 (Transfer - 核心创新)
这个过程是由 Redisson 客户端代码(你的 Java 进程) 驱动的,但执行是在 Redis 服务端完成的。
第一步:任务落位(入队)
当你调用 offer(msg, 10s):
- 客户端 :计算出 Now+10sNow + 10sNow+10s 的时间戳。
- 命令:向 Redis 发送一段 Lua 脚本。
- 服务端逻辑 :把
msg存入 ZSet(延时区)。 - 结果 :此时 List (目标区)是空的,你的
take()方法会阻塞在 List 上,拿不到任何东西。
第二步:客户端倒计时(监控)
- 客户端 :Redisson 启动一个后台线程(基于
HashedWheelTimer),它会查询 ZSet 中最早到期的任务。 - 逻辑 :它发现有个任务 10 秒后到期,于是这个后台线程会闭目养神 10 秒(不访问 Redis,不消耗 CPU)。
第三步:触发转移(关键点)
- 客户端:10 秒时间到,后台线程"醒来"。
- 命令 :客户端向 Redis 发送一段名为
transfer的 Lua 脚本。 - 服务端逻辑(Lua 内部执行) :
- 扫描 ZSet ,找出所有
Score <= 当前时间的元素。 - 将这些元素从 ZSet 中删除。
- 将这些元素
RPUSH到 List(目标区)中。
- 扫描 ZSet ,找出所有
- 结果:数据从"延时区"正式进入了"目标区"。
第四步:任务交付(消费)
- 服务端 :因为 List 里突然多出了数据。
- 客户端 :你那个一直阻塞在
take()上的业务线程(执行的是BLPOP指令)立即收到了 Redis 的推送。 - 结果 :
take()方法返回msg,你的业务逻辑开始执行。
3. 为什么不直接在 Redis 里等?
你可能会问:为什么 Redis 不能自己把数据从 ZSet 搬到 List?
- 原因 :Redis 是一个被动响应的内存数据库。它没有"定时触发器"这种主动逻辑。它只能在有人调用命令(比如
ZREM)时才干活。 - Redisson 的角色:它充当了**"报时员"**。由客户端盯着时间,时间到了,发个消息给 Redis:"喂,时间到了,快把那几个过期的任务从 ZSet 搬到 List 里去!"
如何使用 (Java 示例)
Redisson 将复杂的逻辑封装得非常透明:
java
// 1. 定义目标队列(消费者真正拿数据的地方)
RBlockingQueue<String> destinationQueue = redisson.getBlockingQueue("my_final_queue");
// 2. 定义延迟队列(生产者放数据的地方),并将它关联到目标队列
RDelayedQueue<String> delayedQueue = redisson.getDelayedQueue(destinationQueue);
// --- 生产者 ---
// 10秒后到期
delayedQueue.offer("order_001", 10, TimeUnit.SECONDS);
// 1分钟后到期
delayedQueue.offer("order_002", 1, TimeUnit.MINUTES);
// --- 消费者 ---
new Thread(() -> {
while (true) {
try {
// 这里会一直阻塞,直到有任务从延迟队列"掉进"目标队列
String orderId = destinationQueue.take();
System.out.println("处理过期订单: " + orderId);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();