水煮Redisson(三)- 延迟队列原理剖析

552 阅读3分钟

前言

本质上,这个功能是依赖redisson的RDelayedQueue做的一个特殊实现,目的是为了是为了在消息发送后,将其暂时存储在队列中,然后在一定时间后再将其投递给消费者。这种机制通常用于需要在一定时间后执行某些操作的场景,例如延迟任务、定时提醒等。通过延迟队列,可以实现延迟消息的可靠投递,确保消息在指定时间后被消费者接收。

总体逻辑

先说明一下总逻辑

  1. redissonClient.getDelayedQueue这一步执行之后,会创建和blockingQueueName【最终消费队列】绑定的三个redis集合,
  • topic数据类型:channelName = prefixName("redisson_delay_queue_channel", getName());
  • 临时队列数据类型:queueName = prefixName("redisson_delay_queue", getName());
  • zset数据类型:timeoutSetName = prefixName("redisson_delay_queue_timeout", getName());
    然后添加一个task到QueueTransferService的taskmap中,代码:queueTransferService.schedule(queueName, task);
  1. 下次再执行getDelayedQueue添加task时,会判断taskMap中是否存在有这个blockingQueueName的task,如果有,则继续使用,usage加一;这里有个问题,如果usage一直增加,可能会溢出;
  2. 任务执行时,会开启两个listener一直监听消费topic的数据,一旦有任务到了执行时间,会将zset里的待执行数据转移到阻塞队列;RedissonDelayedQueue第58行,pushTaskAsync,从zset中读取前100条数据【zrangebyscore】,放到最终消费队列中
  3. destroy时,task的usage减一,如果减到零,则停止消费上面的topic;

写入逻辑

  1. 先将数据写到有序集合;
  2. 数据写到临时队列;
  3. 取出有序集合第一个元素,如果是当前写入的元素,则将到期时间戳发布到topic;
    image.png
public RFuture<Void> offerAsync(V e, long delay, TimeUnit timeUnit) {  
    // 延迟时间
    long delayInMs = timeUnit.toMillis(delay);  
    // 任务到期时间
    long timeout = System.currentTimeMillis() + delayInMs;  
    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));  
}

删除逻辑

  1. 遍历临时队列;
  2. 如果当前遍历出来的元素与需要删除的数据相同,则从有序集合和临时队列中删除
  3. 返回删除数量;
    image.png
protected RFuture<Boolean> removeAsync(Object o, int count) {  
    return commandExecutor.evalWriteAsync(getRawName(), codec, RedisCommands.EVAL_BOOLEAN,  
        "local s = redis.call('llen', KEYS[1]);" +  
        "for i = 0, s-1, 1 do "  
        + "local v = redis.call('lindex', KEYS[1], i);"  
        + "local randomId, value = struct.unpack('Bc0Lc0', v);"  
        + "if ARGV[1] == value then "  
        + "redis.call('zrem', KEYS[2], v);"  
        + "redis.call('lrem', KEYS[1], 1, v);"  
        + "return 1;"  
        + "end; "  
        + "end;" +  
        "return 0;",  
        Arrays.<Object>asList(queueName, timeoutSetName), encode(o));  
}

定时任务逻辑

topic消费者拿到的数据类型为每个延时数据的到期时间戳,根据拿到的时间戳来生成一个定时任务;在任务中,会执行以下逻辑;

  1. 取出score小于当前时间戳的前100条记录,然后从有序集合中删除这些数据;
  2. 从临时队列中删除;
  3. 将数据写到最终消费的队列中;
  4. 这100条数据处理完成之后,再取有序队列的第一条数据,并返回时间戳;
  5. 根据Job返回的时间戳,再生成一个Job;如果时间戳为空,则生成一个延迟5秒的Job;
    image.png

我的看法

总的来说,这个方案在使用上比较顺手,但是设计过于复杂,对于新手来说难以理解。一般情况下,可以用redis的ScoreSet来自己实现一个延迟队列,大概思路:

  1. 到期时间的time作为score,放入到scoreSet里;
  2. 定时读取第一个元素,如果time小于当前时间,则表示已经到期,需要取出来执行;
  3. 如果第一个元素都没有到期,则后续所有任务都不会到期;