Redis延迟队列

简单实现逻辑

1. 数据模型定义

  • 存储: Redis ZSet
    • Score:任务触发的绝对 Unix 时间戳(例如:1710003600)。
    • Value:任务唯一标识符(例如:order_id_999)。
  • 通信: Redis Pub/Sub
    • Channeldelay_queue_signal
    • Message:仅作为触发信号,内容可固定(如 "1")。

2. 完整逻辑链路

第一阶段:生产者 (Producer)
  1. 持久化: 业务数据写入数据库(状态为"待支付")。
  2. 入队: 执行 ZADD queue_key <Timestamp_30min_later> <Order_ID>
  3. 信号发布: 执行 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)"策略

  1. 使用 LIMIT 参数(如 LIMIT 0 100)分批从 ZSet 获取已到期任务。
  2. 使用 while 循环持续获取,直到 ZRANGEBYSCORE 返回结果为空集。
  3. 只有在确认"当前已无到期任务"后,才会进入下一次等待时间的计算和阻塞。

4. 异常补偿 (边缘逻辑)

由于 Pub/Sub 不保证送达,链路必须包含一个硬性超时

  • 即使计算出的 WaitTimeWaitTimeWaitTime 很大,消费者的 SUBSCRIBE 阻塞也应设置一个最大值(如 30s)。
  • 超时后自动触发一次全量扫描,确保在信号丢失的情况下,积压任务的延迟不超过该最大值。

Redisson.RDelayedQueue的实现逻辑

Redisson 官方实现了延迟队列,类名为 RDelayedQueue

它并不是简单地对 ZSet 进行封装,而是通过 "目标队列 + 延时辅助队列 + 客户端调度器" 的组合逻辑,实现了一个高性能、低延迟的分布式延迟队列方案。

1. 核心逻辑模型:三位一体架构

Redisson 的延迟队列由三个物理组件协同工作:

  1. 目标队列 (Target Queue) :一个普通的 RBlockingQueue(本质是 Redis 的 List)。消费者只需在这个队列上执行阻塞弹出(take())。
  2. 延时辅助队列 (Timeout Set) :一个 Redis ZSet 。存储所有未到期的任务,Score 为到期时间戳。
  3. 消息通道 (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 脚本

  1. 计算到期时间戳 T=Now+10sT = Now + 10sT=Now+10s。
  2. 将任务 ID 和内容存入 ZSetZADD timeout_set T msg
  3. 发布信号:PUBLISH channel "T",告知消费者有新任务入队,且最近的到期时间可能是 TTT。
B. 内部转移逻辑 (Transfer - 核心创新)

这个过程是由 Redisson 客户端代码(你的 Java 进程) 驱动的,但执行是在 Redis 服务端完成的。

第一步:任务落位(入队)

当你调用 offer(msg, 10s)

  1. 客户端 :计算出 Now+10sNow + 10sNow+10s 的时间戳。
  2. 命令:向 Redis 发送一段 Lua 脚本。
  3. 服务端逻辑 :把 msg 存入 ZSet(延时区)。
  4. 结果 :此时 List (目标区)是空的,你的 take() 方法会阻塞在 List 上,拿不到任何东西。
第二步:客户端倒计时(监控)
  1. 客户端 :Redisson 启动一个后台线程(基于 HashedWheelTimer),它会查询 ZSet 中最早到期的任务。
  2. 逻辑 :它发现有个任务 10 秒后到期,于是这个后台线程会闭目养神 10 秒(不访问 Redis,不消耗 CPU)。
第三步:触发转移(关键点)
  1. 客户端:10 秒时间到,后台线程"醒来"。
  2. 命令 :客户端向 Redis 发送一段名为 transferLua 脚本
  3. 服务端逻辑(Lua 内部执行)
    • 扫描 ZSet ,找出所有 Score <= 当前时间 的元素。
    • 将这些元素从 ZSet 中删除。
    • 将这些元素 RPUSHList(目标区)中。
  4. 结果:数据从"延时区"正式进入了"目标区"。
第四步:任务交付(消费)
  1. 服务端 :因为 List 里突然多出了数据。
  2. 客户端 :你那个一直阻塞在 take() 上的业务线程(执行的是 BLPOP 指令)立即收到了 Redis 的推送。
  3. 结果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();

相关推荐
毅炼2 小时前
Spring总结(2)
java·数据库·sql·spring
三金121382 小时前
Redis常见命令
数据库·redis·缓存
V1ncent Chen2 小时前
SQL大师之路 15 条件分支
数据库·sql·mysql·数据分析
DomDanrtsey2 小时前
国产数据库TiDB使用dumpling卸数与tidb-lightning导娄
数据库·tidb
九章-2 小时前
金仓数据库迁移评估系统(KDMS)使用指南(一)
数据库·数据库迁移工具·kdms
@土豆2 小时前
ETCD集群部署指导(复制粘贴即可完成构建)
服务器·数据库·etcd
白叔King2 小时前
支付中 同步回调和异步回调的区别与作用!
数据库
如意机反光镜裸2 小时前
excel怎么快速导入oracle
数据库·oracle·excel
卤炖阑尾炎2 小时前
MySQL 数据库操作从入门到精通
数据库·mysql