尝试解释Redisson延迟队列实现原理

119 阅读10分钟

版本

<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_queuetimeoutSetName: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_queueLIST业务消费队列,存放已到期任务
超时集合redisson_delay_queue_timeout:{test_delayed_queue}ZSET存储任务ID和到期时间
辅助队列redisson_delay_queue:{test_delayed_queue}LIST存储原始打包数据,

核心设计思想

  1. 批量到期处理

    • 使用zrangebyscore获取当前时间前到期的任务(分数0→当前时间)
    • 每次最多处理100个任务(避免阻塞)
  2. 任务转移机制 在这里插入图片描述

  3. 数据解析过程

原始二进制结构:
[1字节ID长度][ID内容][4字节值长度][值内容]

解包操作:
struct.unpack('Bc0Lc0', v) → (randomId, value)
  1. 动态调度反馈
  • 返回最近到期任务的超时时间
  • 若无任务返回nil(停止调度)
  • 实现精准定时:"最近到期时间→下次扫描时间"

设计优势

  1. 原子性保证
    • 所有操作在单次Lua调用中完成
    • 避免任务丢失或重复消费
  2. 性能优化
    • 批量处理(100条/次)
    • 直接操作二进制数据(避免序列化开销)
  3. 动态调度
    • 智能返回下次触发时间
    • 空队列时返回nil节省资源
  4. 数据一致性
    • 三端同步更新(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_queuetimeoutSetName: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)频道,通知调度器更新任务到期时
核心设计思想
  1. 双存储机制
    • 有序集合(ZSET):按到期时间排序,实现快速获取最近到期任务
    • 列表(LIST):保证任务插入顺序,用于消费端顺序处理
  2. 实时调度触发\ 当新加入的任务成为最早到期任务时(v[1] == value),通过PUBLISH通知所有监听该频道的调度器,触发以下操作:
    • 取消旧定时器
    • 基于新到期时间(ARGV[1])重置定时器
    • 确保任务精确按时执行
  3. 数据打包优化\ 使用struct.pack将元数据与业务数据打包为紧凑二进制格式:
[1字节ID长度][ID内容][4字节值长度][值内容]

优点:

  • 减少Redis存储开销
  • 保证ZSET中分数相同时仍能正确比较值
工作流程示意图

在这里插入图片描述

此设计通过Redis的原子操作保证分布式环境下任务调度的准确性,同时利用发布订阅机制实现调度器的动态协同,是分布式延迟队列的经典实现方案。

整体流程

执行流程原理示意图

在这里插入图片描述

关键数据结构关系

在这里插入图片描述

设计要点说明

  1. 三层存储结构
    • 有序集合(ZSET):按到期时间排序,用于快速获取到期任务
    • 辅助队列(LIST):存储原始数据,确保原子删除
    • 目标队列(LIST):实际业务消费队列
  2. 双触发机制
    • 主动通知:当新任务成为最早任务时通过PUBLISH通知
    • 被动轮询:调度线程定期检查(防止通知丢失)
  3. 时间精度控制
    • 每次转移任务后返回最近到期时间
    • 动态调整调度间隔(避免空轮询)
  4. 数据一致性保障
    • 所有操作在Lua脚本中原子执行
    • 先转移再删除的流程设计

当客户端关闭时,Redisson 会自动停止调度线程并释放资源,但Redis中的延迟任务会持久化保存,下次客户端启动后可以继续处理。