版本
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.48.0</version>
</dependency>
代码示例
RBlockingQueue<String> queue = redissonClient.getBlockingQueue("test_delayed_queue");
RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(queue);
// 延迟5分钟
delayedQueue.offer("值", 5L, TimeUnit.MINUTES);
// 清空延迟的内容
delayedQueue.clear();
// 移除值
delayedQueue.remove("值");
// 另外线程消费RQueue
// RBlockingQueue<String> queue = redissonClient.getBlockingQueue("test_delayed_queue");
String take = queue.take()
结论
延迟队列的实现,需要一个Redisson的队列RBlockingQueue,用于接收延迟到期的数据。RDelayedQueue主要负责实现延迟。而RDelayedQueue使用redis的有序集合(Sorted Set),延迟的到期时间做为 Score。 RBlockingQueue使用LIST, 通过定时判断有序集合中的值是否过期,将其移动到RBlockingQueue中,通过另一个线程消费RQueue即可。
(整体流程在最后)
利用Redis的有序集合(Sorted Set),过期时间做为Score,定时获取超时数据,放到RBlockingQueue中(Redis的LIST实现),另起线程消费队列即可。具体细节,可以一步步往下看
源码分析
redissonClient.getBlockingQueue()主要体现在获取值上面,执行获取值的Lua脚本。
redissonClient.getDelayedQueue(queue)
进入到org.redisson.RedissonDelayedQueue#RedissonDelayedQueue方法中,主要是QueueTransferTask中的执行Lua脚本。事实上当第一次new RedissonDelayedQueue会执行Lua脚本,还有就是往队列中添加的时候也会执行此处的Lua脚本。
protected RedissonDelayedQueue(Codec codec, CommandAsyncExecutor commandExecutor, String name) {
super(codec, commandExecutor, name);
// 订阅channel的名称,根据上述内容:redisson_delay_queue_channel:{test_delayed_queue},
// getRawName() 获取到的值是上一行super所设置的,一步步进入即可看到
channelName = prefixName("redisson_delay_queue_channel", getRawName());
// 队列名称,该队列是一个辅助的队列(LIST), 作用是当有一些移除(remove())等操作时,
// 用做先或者后移动一些元素。因为有序集合无法知道哪个元素先哪个后进入队列。因此,移除时先返回queueName队列的数据,再执行zrem timeoutSetName value
queueName = prefixName("redisson_delay_queue", getRawName());
// 有序集合的名称:redisson_delay_queue_timeout:{test_delayed_queue}
timeoutSetName = getTimeoutSetName(getRawName());
// 创建一个任务,有序需要执行,会调用pushTaskAsync和getTopic
QueueTransferTask task = new QueueTransferTask(commandExecutor.getServiceManager()) {
@Override
protected RFuture<Long> pushTaskAsync() {
// 对有序集合,队列的操作,后面解释
return commandExecutor.evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_LONG,
"local expiredValues = redis.call('zrangebyscore', KEYS[2], 0, ARGV[1], 'limit', 0, ARGV[2]); "
+ "if #expiredValues > 0 then "
+ "for i, v in ipairs(expiredValues) do "
+ "local randomId, value = struct.unpack('Bc0Lc0', v);"
+ "redis.call('rpush', KEYS[1], value);"
+ "redis.call('lrem', KEYS[3], 1, v);"
+ "end; "
+ "redis.call('zrem', KEYS[2], unpack(expiredValues));"
+ "end; "
// get startTime from scheduler queue head task
+ "local v = redis.call('zrange', KEYS[2], 0, 0, 'WITHSCORES'); "
+ "if v[1] ~= nil then "
+ "return v[2]; "
+ "end "
+ "return nil;",
Arrays.asList(getRawName(), timeoutSetName, queueName),
System.currentTimeMillis(), 100);
}
@Override
protected RTopic getTopic() {
// 订阅channelName,主要监听是否是延迟队列只有当前一个数据。
return RedissonTopic.createRaw(LongCodec.INSTANCE, commandExecutor, channelName);
}
};
// 执行任务schedule(queueName, task) queueName redisson_delay_queue:{test_delayed_queue}
commandExecutor.getServiceManager().getQueueTransferService().schedule(queueName, task);
}
Lua脚本参数
在看Lua脚本前,,我觉得搞清楚里面的KEYS和ARGV是更好的。
KEYS:Arrays.asList(getRawName(), timeoutSetName, queueName),就这这几个值
| KEYS[1] | KEYS[2] | KEYS[3] |
|---|---|---|
| getRawName() :根据上述实例:test_delayed_queue | timeoutSetName:redisson_delay_queue_timeout:{test_delayed_queue} 有序集合的Key | queueName: redisson_delay_queue:{test_delayed_queue} |
ARGV:System.currentTimeMillis(), 100)
| ARGV[1] | ARGV[2] |
|---|---|
| System.currentTimeMillis() 当前系统时间 | 100 固定一个limit 值 |
解析Lua脚本
local expiredValues = redis.call('zrangebyscore', KEYS[2], 0, ARGV[1], 'limit', 0, ARGV[2]);
if #expiredValues > 0 then
// 遍历获取到的数据
for i, v in ipairs(expiredValues) do
// 解析数据,因为添加数据的时候有做包装
local randomId, value = struct.unpack('Bc0Lc0', v);
// 先将数据放到目标的队列中,LIST类型 test_delayed_queue
redis.call('rpush', KEYS[1], value);
// 移除redisson_delay_queue:{test_delayed_queue} 这个存在的目的就是辅助操作队列数据
redis.call('lrem', KEYS[3], 1, v);
end;
// 移除有序集合本次获取到的所有数据
redis.call('zrem', KEYS[2], unpack(expiredValues));
end;
// get startTime from scheduler queue head tas,
// 获取最近的一个要超时数据的过期时间,做下一次定时扫描过期数据的定时时间
local v = redis.call('zrange', KEYS[2], 0, 0, 'WITHSCORES');
if v[1] ~= nil then
return v[2];
end
return nil;
第1行:
redis.call('zrangebyscore', redisson_delay_queue_timeout:{test_delayed_queue} , 0, System.currentTimeMillis(), 'limit', 0, 100);
从有序集合中,获取Score从9到当前时间的100个数据,前面说过,过期时间为Score。所以这个命令可以获取到在当前时间过期的数据。
通过上面的解析,大概知道如何操作redis了,可能对于调用这个Lua脚本的时机还会存在疑惑
关键组件说明
| 组件 | Key示例 | 类型 | 作用 |
|---|---|---|---|
| 目标队列 | test_delayed_queue | LIST | 业务消费队列,存放已到期任务 |
| 超时集合 | redisson_delay_queue_timeout:{test_delayed_queue} | ZSET | 存储任务ID和到期时间 |
| 辅助队列 | redisson_delay_queue:{test_delayed_queue} | LIST | 存储原始打包数据, |
核心设计思想
-
批量到期处理
- 使用zrangebyscore获取当前时间前到期的任务(分数0→当前时间)
- 每次最多处理100个任务(避免阻塞)
-
任务转移机制
-
数据解析过程
原始二进制结构:
[1字节ID长度][ID内容][4字节值长度][值内容]
解包操作:
struct.unpack('Bc0Lc0', v) → (randomId, value)
- 动态调度反馈
- 返回最近到期任务的超时时间
- 若无任务返回nil(停止调度)
- 实现精准定时:"最近到期时间→下次扫描时间"
设计优势
- 原子性保证
- 所有操作在单次Lua调用中完成
- 避免任务丢失或重复消费
- 性能优化
- 批量处理(100条/次)
- 直接操作二进制数据(避免序列化开销)
- 动态调度
- 智能返回下次触发时间
- 空队列时返回nil节省资源
- 数据一致性
- 三端同步更新(ZSET/LIST/业务队列)
- 先推送到业务队列再删除原数据(安全)
执行流程示意图
schedule(queueName, task)
当创建RedissonDelayedQueue对象的时候,最后会执行schedule方法
不同版本这个地方的写法不一样,但都是操作Map
private final Map<String, QueueTransferTask> tasks = new ConcurrentHashMap<>();
public void schedule(String name, QueueTransferTask task) {
tasks.compute(name, (k, t) -> {
if (t == null) {
task.start();
return task;
}
t.incUsage();
return t;
});
}
然后就是执行 task.start();
调用org.redisson.QueueTransferTask#start。一步步Debug就能走到这里。而QueueTransferTask对象就是org.redisson.RedissonDelayedQueue#RedissonDelayedQueue中的实现。
调用QueueTransferTask的task方法:
public void start() {
RTopic schedulerTopic = getTopic();
statusListenerId = schedulerTopic.addListener(new BaseStatusListener() {
@Override
public void onSubscribe(String channel) {
pushTask();
}
});
messageListenerId = schedulerTopic.addListener(Long.class, new MessageListener<Long>() {
@Override
public void onMessage(CharSequence channel, Long startTime) {
scheduleTask(startTime);
}
});
}
第一步:订阅一个topic
这个topic正是
@Override
protected RTopic getTopic() {
// 订阅channelName,主要监听是否是延迟队列只有当前一个数据。
return RedissonTopic.createRaw(LongCodec.INSTANCE, commandExecutor, channelName);
}
第二步:监听订阅,当初始启动的时候可以利用这个启动定时任务,继续监听延迟
第三步:监听添加数据,当有一个初始的新数据,或者有更短的延迟则会监听到事件
pushTask();
调用RedissonDelayedQueue中的 protected RFuture pushTaskAsync(){}执行Lua,以及处理下一个更短的时间。
private void pushTask() {
if (usage == 0) {
return;
}
// 执行调用Lua
RFuture<Long> startTimeFuture = pushTaskAsync();
startTimeFuture.whenComplete((res, e) -> {
// 首次通过订阅事件调用pushTask, res和e都可能是空,只有等待添加数据的时候,再调用cheduleTask,处理数据
// 异常
if (e != null) {
if (serviceManager.isShuttingDown(e)) {
return;
}
log.error(e.getMessage(), e);
// 会重试
scheduleTask(System.currentTimeMillis() + 5 * 1000L);
return;
}
// res是Lua返回的时间
if (res != null) {
scheduleTask(res);
}
});
}
cheduleTask(final Long startTime)
startTime:到期的时间
根据条件,cheduleTask和pushTask循环调用
private void scheduleTask(final Long startTime) {
if (usage == 0) {
return;
}
if (startTime == null) {
return;
}
// 处理更短的时间,将老的定时任务取消
TimeoutTask oldTimeout = lastTimeout.get();
if (oldTimeout != null) {
oldTimeout.getTask().cancel();
}
// 如果过期时间小于当前时间10ms,则直接调用pushTask,处理过期数据
long delay = startTime - System.currentTimeMillis();
if (delay > 10) {
// netty的TimerTask 时间定时任务
Timeout timeout = serviceManager.newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
// 到期后调用pushTask()
pushTask();
// 清理掉遗留定时任务
TimeoutTask currentTimeout = lastTimeout.get();
if (currentTimeout != null
&& currentTimeout.getTask() == timeout) {
lastTimeout.compareAndSet(currentTimeout, null);
}
}
}, delay, TimeUnit.MILLISECONDS);
lastTimeout.compareAndSet(oldTimeout, new TimeoutTask(startTime, timeout));
} else {
pushTask();
}
}
延迟队列添加数据
例如
// 延迟5分钟
delayedQueue.offer("值", 5L, TimeUnit.MINUTES);
offerAsync(V e, long delay, TimeUnit timeUnit);
最后会调用org.redisson.RedissonDelayedQueue#offerAsync(V, long, java.util.concurrent.TimeUnit)
主要还是这个Lua脚本
public RFuture<Void> offerAsync(V e, long delay, TimeUnit timeUnit) {
if (delay < 0) {
throw new IllegalArgumentException("Delay can't be negative");
}
long delayInMs = timeUnit.toMillis(delay);
// 到期时间
long timeout = System.currentTimeMillis() + delayInMs;
// 一个随机id
byte[] random = getServiceManager().generateIdArray(8);
return commandExecutor.evalWriteNoRetryAsync(getRawName(), codec, RedisCommands.EVAL_VOID,
"local value = struct.pack('Bc0Lc0', string.len(ARGV[2]), ARGV[2], string.len(ARGV[3]), ARGV[3]);"
+ "redis.call('zadd', KEYS[2], ARGV[1], value);"
+ "redis.call('rpush', KEYS[3], value);"
// if new object added to queue head when publish its startTime
// to all scheduler workers
+ "local v = redis.call('zrange', KEYS[2], 0, 0); "
+ "if v[1] == value then "
+ "redis.call('publish', KEYS[4], ARGV[1]); "
+ "end;",
Arrays.asList(getRawName(), timeoutSetName, queueName, channelName),
timeout, random, encode(e));
}
Lua脚本参数
KEYS:Arrays.asList(getRawName(), timeoutSetName, queueName,channelName),就这这几个值
| KEYS[1] | KEYS[2] | KEYS[3] | KEYS[4] |
|---|---|---|---|
| getRawName() :根据上述实例:test_delayed_queue | timeoutSetName:redisson_delay_queue_timeout:{test_delayed_queue} 有序集合的Key | queueName: redisson_delay_queue:{test_delayed_queue} | channelName: org.redisson.RedissonDelayedQueue#channelName初始化的 redisson_delay_queue_channel:{test_delayed_queue} |
ARGV:timeout, random, encode(e)
| ARGV[1] | ARGV[2] | ARGV[3] |
|---|---|---|
| timeout 超时时间 | 随机id | 编码后的值 |
解析Lua脚本
-- 1. 数据打包:将任务ID(ARGV[2])和编码值(ARGV[3])打包为二进制结构
local value = struct.pack('Bc0Lc0',
string.len(ARGV[2]), -- 1字节无符号整数(ID长度)
ARGV[2], -- ID字符串(长度由前值决定)
string.len(ARGV[3]), -- 4字节无符号整数(值长度)
ARGV[3] -- 编码值字符串(长度由前值决定)
);
-- 2. 添加到有序集合:以超时时间(ARGV[1])为分数
redis.call('zadd', KEYS[2], ARGV[1], value);
-- 3. 推入队列尾部
redis.call('rpush', KEYS[3], value);
-- 4. 检查是否成为队列头(最早到期任务)
local v = redis.call('zrange', KEYS[2], 0, 0); -- 获取有序集合第一个元素
if v[1] == value then
-- 5. 发布通知:当新任务成为最早到期任务时
redis.call('publish', KEYS[4], ARGV[1]);
end;
关键组件说明
| 组件 | Key示例 | 作用 |
|---|---|---|
| 延迟队列本体 | test_delayed_queue | 业务层直接操作的队列名称 |
| 超时集合 | redisson_delay_queue_timeout:{test_delayed_queue} | 有序集合(ZSET),存储所有任务及其到期时间戳(score) |
| 任务队列 | redisson_delay_queue:{test_delayed_queue} | 列表(LIST),按添加顺序存储所有任务 |
| 通知频道 | redisson_delay_queue_channel:{test_delayed_queue} | 发布订阅(PUB/SUB)频道,通知调度器更新任务到期时 |
核心设计思想
- 双存储机制
- 有序集合(ZSET):按到期时间排序,实现快速获取最近到期任务
- 列表(LIST):保证任务插入顺序,用于消费端顺序处理
- 实时调度触发\ 当新加入的任务成为最早到期任务时(v[1] == value),通过PUBLISH通知所有监听该频道的调度器,触发以下操作:
- 取消旧定时器
- 基于新到期时间(ARGV[1])重置定时器
- 确保任务精确按时执行
- 数据打包优化\ 使用struct.pack将元数据与业务数据打包为紧凑二进制格式:
[1字节ID长度][ID内容][4字节值长度][值内容]
优点:
- 减少Redis存储开销
- 保证ZSET中分数相同时仍能正确比较值
工作流程示意图
此设计通过Redis的原子操作保证分布式环境下任务调度的准确性,同时利用发布订阅机制实现调度器的动态协同,是分布式延迟队列的经典实现方案。
整体流程
执行流程原理示意图
关键数据结构关系
设计要点说明
- 三层存储结构
- 有序集合(ZSET):按到期时间排序,用于快速获取到期任务
- 辅助队列(LIST):存储原始数据,确保原子删除
- 目标队列(LIST):实际业务消费队列
- 双触发机制
- 主动通知:当新任务成为最早任务时通过PUBLISH通知
- 被动轮询:调度线程定期检查(防止通知丢失)
- 时间精度控制
- 每次转移任务后返回最近到期时间
- 动态调整调度间隔(避免空轮询)
- 数据一致性保障
- 所有操作在Lua脚本中原子执行
- 先转移再删除的流程设计
当客户端关闭时,Redisson 会自动停止调度线程并释放资源,但Redis中的延迟任务会持久化保存,下次客户端启动后可以继续处理。