redisson看门狗延迟任务TimerTask,内部原理是什么?
Redisson 的延迟任务(基于 TimerTask
)主要依赖 时间轮(HashedWheelTimer) 和 Redis 延迟队列 两种机制实现。其核心原理是通过高效的任务调度算法和 Redis 的持久化能力,实现分布式环境下的延迟任务执行。以下是内部原理的详细解析:
⏱️ 一、时间轮(HashedWheelTimer)机制
时间轮是 Netty 提供的高效延时任务调度算法,Redisson 在分布式锁的 Watch Dog
续约、延迟任务触发等场景中广泛使用它159。
-
时间轮结构
-
环形数组:时间轮是一个固定大小的环形数组(如 1024 个槽),每个槽(
HashedWheelBucket
)对应一个双向链表,存储延时任务(HashedWheelTimeout
)19。 -
时间刻度(tickDuration):每个槽代表的时间跨度(如 100ms),指针按固定间隔移动一个槽位15。
-
任务圈数(remainingRounds):任务需在时间轮中转动的圈数,用于处理长延时任务(延时时间超过一轮总时长)19。
-
时间轮(HashedWheelTimer)机制和定时任务区别和优点
-
维度 定时任务(普通实现) 时间轮机制 实现逻辑 多基于优先级队列、延迟队列(如 Java 的 DelayQueue
)或直接依赖系统定时器(如cron
),通过 “等待最早到期任务” 触发执行。基于环形数组(“轮”),每个槽位对应固定时间间隔(如 1 秒),任务按到期时间放入对应槽位;随着 “指针” 周期性转动,依次执行槽内任务。支持多层时间轮(如 “秒轮→分轮→时轮”),处理长周期任务。 效率(任务量大时) 基于优先级队列的实现,添加 / 删除任务需排序,时间复杂度为 O(log n)
;任务量过大时,频繁排序会导致性能下降。添加 / 删除任务只需计算槽位索引,时间复杂度接近 O(1)
;任务触发时只需处理当前槽位,无需全局扫描,适合大量任务场景。适用场景 任务数量少(如单机少量定时任务)、精度要求不高、无高频动态添加 / 删除需求(如每天凌晨执行的日志清理)。 任务数量庞大(如分布式系统中百万级定时任务)、高频动态更新(如订单超时取消、心跳检测)、周期性任务(如每 10 秒检测连接)。 时间精度 精度较高(取决于系统定时器),但任务触发可能受前序任务阻塞影响(如单线程执行时,前一个任务耗时过长会延迟后续任务)。 精度由 “槽位间隔” 决定(如间隔 100ms,精度即为 100ms),适合对精度要求 “适中” 的场景;多层时间轮可平衡精度与内存占用。 动态性(任务更新) 动态添加 / 删除任务时,需重新调整队列排序,开销随任务量增长而增加。 动态更新任务只需重新计算槽位,开销稳定,适合频繁变更的场景(如动态调整定时任务的执行时间)。
-
-
-
任务添加与执行流程
-
添加任务:调用
newTimeout(task, delay, unit)
时,任务被放入阻塞队列。Worker 线程计算任务位置:long calculated = deadline / tickDuration; // 所需总槽位数 timeout.remainingRounds = (calculated - currentTick) / wheel.length; // 剩余圈数 int stopIndex = calculated & mask; // 槽位索引(取模) wheel[stopIndex].addTimeout(timeout); // 添加到对应槽位的链表:cite[1]:cite[5]
-
任务触发:指针每移动一个槽位,遍历当前槽位的任务链表:
-
若任务的
remainingRounds > 0
,则减少圈数,等待下一轮; -
若
remainingRounds = 0
且deadline <= 当前时间
,则执行TimerTask.run()
19。
-
-
-
优势
-
低时间复杂度:任务添加/删除 O(1),指针移动 O(n)(n 为当前槽位任务数)1。
-
单线程批处理:所有任务由单个 Worker 线程调度,避免多线程竞争5。
-
🔴 二、Redis 延迟队列(RDelayedQueue)
Redisson 还提供基于 Redis 的延迟队列,适用于分布式场景下的持久化延时任务(如订单超时关单)246。
-
外部数据结构
-
RBlockingQueue:实际存储任务数据的队列。
-
RDelayedQueue:延迟队列代理,管理任务的延时逻辑。
-
ZSet(有序集合):存储任务到期时间(Score = 提交时间 + 延迟时间),用于排序34。
-
-
核心数据结构及作用
1. ZSet(有序集合)
-
Key 格式:
{delayed_queue_name}:zset
-
作用:
-
存储所有延迟任务,按任务的到期时间戳(绝对时间)排序。
-
Score
= 任务到期时间戳(System.currentTimeMillis() + delay
) -
Value
= 序列化后的任务数据 + 元信息(长度校验等)。
-
-
操作:
-
添加任务:
ZADD {zset_key} {expire_timestamp} {encoded_task}
-
查询到期任务:
ZRANGEBYSCORE {zset_key} 0 {current_time} LIMIT 0 100
-
2. List(阻塞队列)
-
Key 格式:
{delayed_queue_name}:list
-
作用:
-
存储已到期就绪的任务,供消费者实时拉取。
-
任务从 ZSet 迁移到 List 后,消费者通过
BLPOP
/RPOP
消费。
-
-
操作:
-
任务迁移:
RPUSH {list_key} {encoded_task}
-
消费任务:
BLPOP {list_key} 30
(阻塞等待)
-
3. Pub/Sub Channel(发布订阅)
-
Key 格式:
{delayed_queue_name}:channel
-
作用:
-
通知调度器有新任务加入,触发到期时间重计算。
-
避免调度器频繁轮询 ZSet,减少空转开销。
-
-
操作:
-
发布消息:
PUBLISH {channel_key} {new_task_expire_timestamp}
-
订阅监听:
SUBSCRIBE {channel_key}
-
-
-
任务提交流程
通过delayedQueue.offer(task, delay, unit)
提交任务时,Redisson 执行 Lua 脚本:-- 1. 将任务+到期时间编码为二进制 local value = struct.pack('dLc0', tonumber(ARGV[2]), string.len(ARGV[3]), ARGV[3]); -- 2. 存入有序集合(ZSet) redis.call('zadd', KEYS[2], ARGV[1], value); -- 3. 存入队列 redis.call('rpush', KEYS[3], value); -- 4. 发布通知到Channel redis.call('publish', KEYS[4], ARGV[1]); :cite[3]
-
任务消费流程
-
监听线程:订阅 Redis Channel,接收新任务通知3。
-
调度逻辑:
-
计算任务剩余延时时间:
delay = startTime - System.currentTimeMillis()
。 -
若
delay ≤ 10ms
,立即将到期任务从 ZSet 迁移到RBlockingQueue
; -
若
delay > 10ms
,创建TimerTask
延时执行迁移操作34。
-
-
消费者线程:通过
blockingDeque.take()
阻塞获取就绪任务610。
-
⚙️ 三、关键设计对比
机制 | 适用场景 | 持久化 | 精度 | 资源消耗 |
---|---|---|---|---|
时间轮(HashedWheelTimer) | 高频短延时任务(如锁续约) | 不支持 | 高(毫秒级) | 单线程,内存占用低 |
Redis 延迟队列 | 分布式长延时任务(如订单超时) | 支持 | 依赖轮询间隔 | 需 Redis 资源 |
💎 四、实践注意事项
-
时间轮参数调优
-
tickDuration
:根据业务延时精度调整(建议 10ms–100ms)。 -
ticksPerWheel
:值越大,支持的最大延时越长(如 1024 槽位 + 100ms 刻度 ≈ 102.4s 最大延时)15。
-
-
Redis 队列稳定性
-
避免消息丢失:使用
RDelayedQueue
需确保 Redis 高可用26。 -
消费端容错:通过
ack
机制或重试策略处理消费失败10。
-
💎 总结
Redisson 的延迟任务通过 时间轮 实现高效内存调度,辅以 Redis 延迟队列 支持分布式持久化场景:
-
时间轮:适用于单机高频短延时任务(如锁续约),核心是环形数组+槽位链表+圈数计算;
-
Redis 队列:通过 ZSet 排序+RBlockingQueue 消费+TimerTask 调度 实现分布式延时,适合业务级长延时任务136。
两者结合,覆盖了从内存到分布式、从毫秒级到天级别的延迟任务需求。
核心数据结构及作用
1. ZSet(有序集合)
-
Key 格式:
{delayed_queue_name}:zset
-
作用:
-
存储所有延迟任务,按任务的到期时间戳(绝对时间)排序。
-
Score
= 任务到期时间戳(System.currentTimeMillis() + delay
) -
Value
= 序列化后的任务数据 + 元信息(长度校验等)。
-
-
操作:
-
添加任务:
ZADD {zset_key} {expire_timestamp} {encoded_task}
-
查询到期任务:
ZRANGEBYSCORE {zset_key} 0 {current_time} LIMIT 0 100
-
2. List(阻塞队列)
-
Key 格式:
{delayed_queue_name}:list
-
作用:
-
存储已到期就绪的任务,供消费者实时拉取。
-
任务从 ZSet 迁移到 List 后,消费者通过
BLPOP
/RPOP
消费。
-
-
操作:
-
任务迁移:
RPUSH {list_key} {encoded_task}
-
消费任务:
BLPOP {list_key} 30
(阻塞等待)
-
3. Pub/Sub Channel(发布订阅)
-
Key 格式:
{delayed_queue_name}:channel
-
作用:
-
通知调度器有新任务加入,触发到期时间重计算。
-
避免调度器频繁轮询 ZSet,减少空转开销。
-
-
操作:
-
发布消息:
PUBLISH {channel_key} {new_task_expire_timestamp}
-
订阅监听:
SUBSCRIBE {channel_key}
-
🔄 协同工作流程
1. 提交延迟任务
-- KEYS[1]: ZSet, KEYS[2]: List, KEYS[3]: Channel
-- ARGV[1]: 到期时间戳, ARGV[2]: 任务数据
local encodedTask = struct.pack("LLc0", string.len(ARGV[2]), ARGV[2])
redis.call("ZADD", KEYS[1], ARGV[1], encodedTask) -- 存入ZSet
redis.call("PUBLISH", KEYS[3], ARGV[1]) -- 发布新任务通知
2. 调度器迁移到期任务
-
监听新任务通知 → 计算最近到期任务的剩余延迟时间
delay
。 -
分两种情况处理:
-
立即迁移:若
delay <= 10ms
,执行迁移:local tasks = redis.call("ZRANGEBYSCORE", KEYS[1], 0, current_time) redis.call("ZREMRANGEBYSCORE", KEYS[1], 0, current_time) redis.call("RPUSH", KEYS[2], unpack(tasks)) -- 批量迁移到List
-
延迟迁移:若
delay > 10ms
,创建TimerTask
在delay
后触发迁移。
-
3. 消费者拉取任务
// 消费者线程(Java示例)
RBlockingQueue<T> queue = redisson.getBlockingQueue("{delayed_queue_name}:list");
T task = queue.take(); // 阻塞直到获取就绪任务
⚙️ 数据结构设计关键点
数据结构 | 核心作用 | 设计原因 |
---|---|---|
ZSet | 按到期时间排序任务 | 高效查询最近到期任务(ZRANGEBYSCORE O(log N)),避免全表扫描。 |
List | 存储就绪任务供消费 | 支持阻塞获取(BLPOP ),实现实时消费;保证任务 FIFO 顺序。 |
Pub/Sub | 新任务事件通知 | 减少调度器空转轮询,降低 Redis 压力。 |
🌰 实例演示
假设提交一个 30秒后过期 的订单关单任务:
-
提交任务:
-
ZSet 写入:
ZADD order_delay:zset 1710000000000 "order_123"
(到期时间=当前时间+30s) -
发布通知:
PUBLISH order_delay:channel 1710000000000
-
-
调度器行为:
-
收到通知 → 计算剩余延迟=30s > 10ms → 创建
TimerTask
在 30s 后触发。
-
-
30秒后:
-
迁移任务:从 ZSet 移除
order_123
→ 推入 List:RPUSH order_delay:list "order_123"
-
-
消费者:
-
BLPOP order_delay:list
获取到order_123
→ 执行关单逻辑。
-
⚠️ 注意事项
-
数据一致性
-
所有操作通过 Lua 脚本原子执行,避免迁移过程中任务丢失。
-
-
性能瓶颈
-
ZSet 的
ZRANGEBYSCORE
操作需控制单次迁移数量(如LIMIT 0 100
),防止大 Key 阻塞。
-
-
故障恢复
-
宕机时,未迁移任务仍在 ZSet 中,重启后调度器自动重新加载。
-
💎 总结
Redis 延迟队列的底层本质是:
ZSet(按时间排序任务) + List(存储就绪任务) + Pub/Sub(事件驱动调度)
通过三者的协同,以 O(log N) 的时间复杂度管理延迟任务,兼顾高效性与可靠性,成为分布式延迟场景的经典解决方案。