基于Redis实现延时队列——Redisson延时队列解析

一、应用场景

  • 订单15分钟后不支付取消
  • 交易发生后5分钟给用户发送短信
    这里在我们项目中是来做一个延时的竞赛发布,指定几小时or几天后执行竞赛的发布流程,无需手动执行。

二、实现方式

Redis实现延时队列有两种实现方式:

  • key失效监听回调
  • zset分数存时间戳

三、方案选择

key失效监听存在两个问题:

  • Redis的pubsub不会被持久化,服务器宕机就会被丢弃
  • 没有高级特性,没有ack机制,可靠性不高
    zset的实现是,轮询队列头部来获取超期的时间戳,实现延时效果,可靠性更高。
    Redission的RDelayedQueue是一个封装好的zset实现的延时队列,最终选择了这个方案。

四、demo

这里公司的代码是封装好的,不适合做demo,so在网上找了一个合适的demo放在这里。

public static void main(String[] args) throws InterruptedException, UnsupportedEncodingException {
	Config config = new Config();
	config.useSingleServer().setAddress("redis://localhost:6379");
	RedissonClient redisson = Redisson.create(config);
	RBlockingQueue blockingQueue = redisson.getBlockingQueue("test_queue1");
	RDelayedQueue delayedQueue = redisson.getDelayedQueue(blockingQueue);
	new Thread() {
		public void run() {
			while(true) {
				try {
                                        //阻塞队列有数据就返回,否则wait
					System.err.println( blockingQueue.take());
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		};
	}.start();
	
	for(int i=1;i<=5;i++) {
                // 向阻塞队列放入数据
		delayedQueue.offer("test"+i, 13, TimeUnit.SECONDS);
	}
}

五、Redission延时队列实现原理

5.1 流程图示

基于Redis实现延时队列——Redisson延时队列解析_第1张图片

5.2 简单源码分析

5.2.1 offer入队操作

    public void offer(V e, long delay, TimeUnit timeUnit) {
        get(offerAsync(e, delay, timeUnit));
    }
    
    public RFuture offerAsync(V e, long delay, TimeUnit timeUnit) {
        long delayInMs = timeUnit.toMillis(delay);
        long timeout = System.currentTimeMillis() + delayInMs;
     
        long randomId = PlatformDependent.threadLocalRandom().nextLong();
        return commandExecutor.evalWriteAsync(getName(), codec, RedisCommands.EVAL_VOID,
                "local value = struct.pack('dLc0', tonumber(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(getName(), timeoutSetName, queueName, channelName), 
              timeout, randomId, encode(e));
    }
 
  
  • 首先对名为timeoutSet的zset做zadd操作,score为当前时间+延时时间,单位是时间戳。
  • 再对名为queue的list做rpush操作。
  • 之后判断timeoutSet第一个值是否是当前结构体,是的话发布timeOut消息。

5.2.2 转移至目标队列

        QueueTransferTask task = new QueueTransferTask(commandExecutor.getConnectionManager()) {
            
            @Override
            protected RFuture pushTaskAsync() {
                return commandExecutor.evalWriteAsync(getName(), 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('dLc0', 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(getName(), timeoutSetName, queueName), 
                      System.currentTimeMillis(), 100);
            }
            
            @Override
            protected RTopic getTopic() {
                return new RedissonTopic(LongCodec.INSTANCE, commandExecutor, channelName);
            }
        };
        
        queueTransferService.schedule(queueName, task);
 
  
  • 这里pushTaskAsync方法主要是将到期元素由元素队列移动到目标队列
  • 执行zrangebyscore取得分介于0到当前时间戳的元素(即过期元素),取前100条
  • 之后rpush移交到目标队列,在调用lrem删除元素,从timeoutSet中删除该元素

5.2.3 定时轮询

  • 这里就不贴源码了,实际上就是定时任务轮询延时队列,将到期的任务转移到延时队列。

5.2.4 总结

用到了3个队列or集合结构

  • 延时队列list:数据入队的队列
  • 目标队列list:过期数据所在的队列
  • timeoutSet过期时间zset:分数值为timeout值,辅助判断元素是否过期

六、参考资料

  • https://juejin.cn/post/6844903683247833102
  • https://zhuanlan.zhihu.com/p/343811173

你可能感兴趣的:(redis,redis,java)