Redis延迟队列

简单实现逻辑

1. 数据模型定义

  • 存储: Redis ZSet 。
    • Score:任务触发的绝对 Unix 时间戳(例如:1710003600)。
    • Value:任务唯一标识符(例如:order_id_999)。
  • 通信: Redis Pub/Sub 。
    • Channel:delay_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 和内容存入 ZSet :ZADD 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 发送一段名为 transfer 的 Lua 脚本。
  3. 服务端逻辑(Lua 内部执行) :
    • 扫描 ZSet ,找出所有 Score <= 当前时间 的元素。
    • 将这些元素从 ZSet 中删除。
    • 将这些元素 RPUSH 到 List(目标区)中。
  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();

相关推荐
code2cat17 分钟前
【随笔】RAG语义缓存:问题很相似,答案就能直接复用吗
java·缓存·知识库·rag
l1t38 分钟前
DeepSeek总结的DuckDB 2.0.0 中的隐藏亮点
大数据·数据库·duckdb
程序员塔塔喵1 小时前
【MySQL篇|第八篇】索引底层实现
数据库·mysql·adb·索引
小静AI工程实验室1 小时前
AI 应用缓存键怎么设计?租户、模型、提示词与索引版本的 7 项离线验证
缓存
北京地铁1号线1 小时前
后端开发:缓存(Cache)简介
后端·缓存·cache
updayday8541 小时前
电脑换了硬盘、加了内存,资产台账却没更新 - Ping64 硬件资产管理
大数据·网络·数据库·安全·智能路由器
hhhjhl1 小时前
mime-types 跨端三方库仓颉适配全流程
数据库
广州智造1 小时前
Radioss中破坏模型FAILTAB1的详细介绍
数据库·人工智能·算法·机器学习·仿真软件·radioss
李兆龙的博客2 小时前
从一到无穷大 #101:SSD 的工作原理——从 NAND 到数据库读写
数据库·ssd·存储
吹什么轩2 小时前
MySQL 内置函数全景:日期 / 字符串 / 数学 / 其他,一次讲透(附索引杀手排查)
java·数据库·面试