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();

相关推荐
小陈工6 小时前
Python Web开发入门(十七):Vue.js与Python后端集成——让前后端真正“握手言和“
开发语言·前端·javascript·数据库·vue.js·人工智能·python
科技小花11 小时前
数据治理平台架构演进观察:AI原生设计如何重构企业数据管理范式
数据库·重构·架构·数据治理·ai-native·ai原生
一江寒逸11 小时前
零基础从入门到精通MySQL(中篇):进阶篇——吃透多表查询、事务核心与高级特性,搞定复杂业务SQL
数据库·sql·mysql
D4c-lovetrain11 小时前
linux个人心得22 (mysql)
数据库·mysql
阿里小阿希11 小时前
CentOS7 PostgreSQL 9.2 升级到 15 完整教程
数据库·postgresql
荒川之神11 小时前
Oracle 数据仓库雪花模型设计(完整实战方案)
数据库·数据仓库·oracle
做个文艺程序员12 小时前
MySQL安全加固十大硬核操作
数据库·mysql·安全
不吃香菜学java12 小时前
Redis简单应用
数据库·spring boot·tomcat·maven
一个天蝎座 白勺 程序猿12 小时前
Apache IoTDB(15):IoTDB查询写回(INTO子句)深度解析——从语法到实战的ETL全链路指南
数据库·apache·etl·iotdb
不知名的老吴12 小时前
Redis的延迟瓶颈:TCP栈开销无法避免
数据库·redis·缓存