Redission延迟队列解析

283 阅读4分钟

redis 应该怎么实现延时队列

正常使用 redis 可以通过 zset 去实现延迟队列,将 key 设置为延时对象字符串,value 设置为到期的时间戳 通过多个线程不断的去循环判断是否存在到期的任务,如果有就拿出来消费。

伪代码如下

while (!Thread.interrupted()) {
   // 根据分值取出 zset 中的内容,从 0到System.currentTimeMillis(),limit 0,1(只取一个值)
   Set<String> values = redisUtil.zRangeBySource(key, 0, System.currentTimeMillis(), 0, 1);
   if (values.isEmpty()) {
       // 没有到期的数据 1s 以后再重试
       Thread.sleep(1000);
       continue;
   }
   String value = values.get(0);
   // 当一个任务到期时,有多个线程同时拿到了该任务,通过 zrem 来判断到底是哪一个线程执行
   if (redisUtil.zrem(key, value) > 0) {
       handleMsg(value)
   }
}

但上面的代码会有一个问题:多个线程同时获取的values,但是只有一个线程能zrem成功。会增加zRangeBySource的消耗。

这个问题,可以通过 lua 去解决,让zRangeBySourcezrem同时执行。

Redission 是怎么实现延时队列的

下面进入正题,说说 redission 是怎么实现延时队列的,对比 redis 正常的实现有什么好处?

一段常用的使用延迟队列的代码:

// 初始化延迟队列
public void queueInit() {
    blockingQueue = redissonClient.getBlockingQueue(RedisConstant.MEMBER_CARD_ORDER_BLOCKING_QUEUE_NAME);
    delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
    startDelayQueueConsumer();
    log.info("MemberOrderCreateCmdExe queueInit success");
}

// 从队列中阻塞的拿任务
while (true) {
    try {
        DelayedCloseOrderBlockQueueBO queueBO = blockingQueue.take();
        if (!ObjectUtils.isEmpty(queueBO)) {
            log.info("监听到延迟关单消息:{}", JsonUtil.of(queueBO));
        }
    } catch (Exception e) {
        log.error("[MemberOrderCreateCmdExe]监听延迟关单消息异常: {}", e.getMessage());
    }
}

// 向延迟队列中添加任务
delayedQueue.offer(queueBO, 10, TimeUnit.MINUTES);

向延迟队列中添加任务

redissonClient.getBlockingQueue(RedisConstant.MEMBER_CARD_ORDER_BLOCKING_QUEUE_NAME);
delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
delayedQueue.offer(queueBO, 10, TimeUnit.MINUTES);

追踪源码最终调用

RedissonDelayedQueue类的 offer 方法

image.png

offerAsync 是异步调用,外层包裹 get 方法,用redisson 自己的异步执行器同步的获取结果。

要了解这段代码先来看看 timeoutSetName, queueName, channelName 这三个 redis key 分别代表什么

image.png 这里给出四个队列key的命名,后面的内容都以这四个名称命名这四个队列

  1. getRawName():阻塞队列
  2. timeoutSetName:过期时间队列
  3. queueName:有序延迟消息队列
  4. channelName:延迟队列频道

主要内容如下:

  1. 计算出 timeout : 现在的时间戳 + 延迟时间 = 延迟任务的到期时间

  2. 获取一个随机值

  3. 执行一段 lua 脚本

    • 使用 zadd 命令将 argv1(过期时间),value(根据encode(e)计算来 也就是咱们向延迟队列中添加的任务数据) 添加到 key2(过期时间队列) 这个有序列表中,有序列表的 value 是任务的到期时间(默认从小到大进行排序),过期时间近的排在 zset 的上面
    • 用 rpush 命令将 value 再添加到 key3(有序延迟消息队列) 列表中,这里的 key3 只是列表,排序规则就是先 offer 的就排前面
    • 用zrange 取出 key2(过期时间队列)有序列表中第一个元素,如果和本次插入的元素相同那么就publish 发布本次延迟任务过期时间的订阅消息到 key4(延迟队列频道)中

从延迟队列中拿任务

blockingQueue = redissonClient.getBlockingQueue(RedisConstant.MEMBER_CARD_ORDER_BLOCKING_QUEUE_NAME);
delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
DelayedCloseOrderBlockQueueBO queueBO = blockingQueue.take();
  1. 先初始化延迟队列
  2. take 方法底层调用blpop 方法从blockingQueue中阻塞的拿元素
@Override
public RFuture<V> takeAsync() {
    return commandExecutor.writeAsync(getName(), codec, RedisCommands.BLPOP_VALUE, getName(), 0);
}

取元素比较简单,只是阻塞的获取,但是会有两个问题

  1. 在添加延迟任务的时候,并没有操作blockingQueue(也就是 offer 对应 lua 脚本中的 key1),那为什么取元素会去那取
  2. 我们取任务的时候没有用到delayedQueue,为什么还需要初始化。

这两个问题在后面初始化延迟队列都会解答

初始化延迟队列

blockingQueue = redissonClient.getBlockingQueue(RedisConstant.MEMBER_CARD_ORDER_BLOCKING_QUEUE_NAME);
delayedQueue = redissonClient.getDelayedQueue(blockingQueue);

初始化延迟队列是最复杂的。 从redisson.getDelayedQueue(queue1);跟源码,找到

protected RedissonDelayedQueue(Codec codec, CommandAsyncExecutor commandExecutor, String name) {
    super(codec, commandExecutor, name);
    channelName = prefixName("redisson_delay_queue_channel", getRawName());
    queueName = prefixName("redisson_delay_queue", getRawName());
    timeoutSetName = prefixName("redisson_delay_queue_timeout", getRawName());
  
    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() {
            return RedissonTopic.createRaw(LongCodec.INSTANCE, commandExecutor, channelName);
        }
    };

    commandExecutor.getServiceManager().getQueueTransferService().schedule(queueName, task);
}

该方法大体逻辑是构造出了QueueTransferTask使用QueueTransferService执行该任务

继续跟源码:

image.png 进入 start 方法

image.png

收到订阅回调以后会调用 pushTask()方法,注意这里其实就是订阅了,所以会执行回调

image.png

先调用了 pushTaskAsync方法 这个方法是主要的逻辑实现:

image.png

再调用scheduleTask方法

image.png

总结上面的内容到流程图中:

redission延迟队列.drawio.png

还剩下一个问题为什么我们取元素的时候没有用到delayedQueue,还需要初始化?

从上面的源码中能看到初始化了delayedQueue就可以获取队列中已经到期的 100 个任务放到阻塞队列中,假如生产者生产了消息以后没有来的及把消息放到阻塞队列里去就挂了,这时候客户端去初始化了delayedQueue就能消费到之前的消息。