基于redis,redisson的延迟队列实践
使用场景
1、下单成功,30分钟未支付。支付超时,自动取消订单
2、订单签收,签收后7天未进行评价。订单超时未评价,系统默认好评
3、下单成功,商家5分钟未接单,订单取消
4、配送超时,推送短信提醒
5、三天会员试用期,三天到期后准时准点通知用户,试用产品到期了
......
对于延时比较长的场景、实时性不高的场景,我们可以采用任务调度的方式定时轮询处理。如:xxl-job。
今天我们讲解延迟队列的实现方式,而延迟队列有很多种实现方式,普遍会采用如下等方式,如:
- 1.如基于RabbitMQ的队列ttl+死信路由策略:通过设置一个队列的超时未消费时间,配合死信路由策略,到达时间未消费后,回会将此消息路由到指定队列
- 2.基于RabbitMQ延迟队列插件(rabbitmq-delayed-message-exchange):发送消息时通过在请求头添加延时参数(headers.put("x-delay", 5000))即可达到延迟队列的效果。(顺便说一句阿里云的收费版rabbitMQ当前可支持一天以内的延迟消息)
- 3.使用redis的zset有序性,轮询zset中的每个元素,到点后将内容迁移至待消费的队列,(redisson已有实现)
- 4.使用redis的key的过期通知策略,设置一个key的过期时间为延迟时间,过期后通知客户端(此方式依赖redis过期检查机制key多后延迟会比较严重;Redis的pubsub不会被持久化,服务器宕机就会被丢弃)。
下面要介绍的是redisson中的延迟队列实现
其官方文档相关讲解
一、先整合实践一下
1、引入 Redisson 依赖并配置redis
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.10.5</version> </dependency>
spring: redis: host: 127.0.0.1 port: 6379 password: 123456 database: 12 timeout: 3000
2、创建 RedissonConfig 配置
/** * @author: shf description:Redission配置类 * date: 2021/8/3 13:40 */ @Slf4j @Configuration public class RedissionConfig { private final String REDISSON_PREFIX = "redis://"; private final RedisProperties redisProperties; public RedissionConfig(RedisProperties redisProperties) { this.redisProperties = redisProperties; } @Bean public RedissonClient redissonClient() { Config config = new Config(); String url = REDISSON_PREFIX + redisProperties.getHost() + ":" + redisProperties.getPort(); // 这里以单台redis服务器为例 config.useSingleServer() .setAddress(url) .setPassword(redisProperties.getPassword()) .setDatabase(redisProperties.getDatabase()) .setPingConnectionInterval(2000); config.setLockWatchdogTimeout(10000L); // 实际开发过程中应该为cluster或者哨兵模式,这里以cluster为例 //String[] urls = {"127.0.0.1:6379", "127.0.0.2:6379"}; //config.useClusterServers() // .addNodeAddress(urls); try { return Redisson.create(config); } catch (Exception e) { log.error("RedissonClient init redis url:[{}], Exception:", url, e); return null; } } }
3、封装 Redis 延迟队列工具类
/** * @author: shf description: 分布式延时队列工具类 date: 2021/8/27 13:41 */ @Slf4j @Component @ConditionalOnBean({RedissonClient.class}) public class RedisDelayQueueUtil { @Resource private RedissonClient redissonClient; /** * 添加延迟队列 * * @param value 队列值 * @param delay 延迟时间 * @param timeUnit 时间单位 * @param queueCode 队列键 * @param <T> */ public <T> boolean addDelayQueue(@NonNull T value, @NonNull long delay, @NonNull TimeUnit timeUnit, @NonNull String queueCode) { if (StringUtils.isBlank(queueCode) || Objects.isNull(value)) { return false; } try { RBlockingDeque<Object> blockingDeque = redissonClient.getBlockingDeque(queueCode); RDelayedQueue<Object> delayedQueue = redissonClient.getDelayedQueue(blockingDeque); delayedQueue.offer(value, delay, timeUnit); //delayedQueue.destroy(); log.info("(添加延时队列成功) 队列键:{},队列值:{},延迟时间:{}", queueCode, value, timeUnit.toSeconds(delay) + "秒"); } catch (Exception e) { log.error("(添加延时队列失败) {}", e.getMessage()); throw new RuntimeException("(添加延时队列失败)"); } return true; } /** * 获取延迟队列 * * @param queueCode * @param <T> */ public <T> T getDelayQueue(@NonNull String queueCode) throws InterruptedException { if (StringUtils.isBlank(queueCode)) { return null; } RBlockingDeque<Map> blockingDeque = redissonClient.getBlockingDeque(queueCode); RDelayedQueue<Object> delayedQueue = redissonClient.getDelayedQueue(blockingDeque); T value = (T) blockingDeque.poll(); return value; } /** * 删除指定队列中的消息 * * @param o 指定删除的消息对象队列值(同队列需保证唯一性) * @param queueCode 指定队列键 */ public boolean removeDelayedQueue(@NonNull Object o, @NonNull String queueCode) { if (StringUtils.isBlank(queueCode) || Objects.isNull(o)) { return false; } RBlockingDeque<Object> blockingDeque = redissonClient.getBlockingDeque(queueCode); RDelayedQueue<Object> delayedQueue = redissonClient.getDelayedQueue(blockingDeque); boolean flag = delayedQueue.remove(o); //delayedQueue.destroy(); return flag; } }
4、创建延迟队列业务枚举
/** * 延迟队列业务枚举 */ @Getter @NoArgsConstructor @AllArgsConstructor public enum RedisDelayQueueEnum { ORDER_PAYMENT_TIMEOUT("ORDER_PAYMENT_TIMEOUT","订单支付超时,自动取消订单", "orderPaymentTimeout"), ORDER_TIMEOUT_NOT_EVALUATED("ORDER_TIMEOUT_NOT_EVALUATED", "订单超时未评价,系统默认好评", "orderTimeoutNotEvaluated"); /** * 延迟队列 Redis Key */ private String code; /** * 中文描述 */ private String name; /** * 延迟队列具体业务实现的 Bean * 可通过 Spring 的上下文获取 */ private String beanId; }
5、定义延迟队列执行器
/** * 延迟队列执行器 */ public interface RedisDelayQueueHandle<T> { void execute(T t); }
6、创建枚举中定义的Bean,并
实现延迟队列执行器 OrderPaymentTimeout:订单支付超时延迟队列处理类
/** * 订单支付超时处理类 */ @Component @Slf4j public class OrderPaymentTimeout implements RedisDelayQueueHandle<Map> { @Override public void execute(Map map) { log.info("(收到订单支付超时延迟消息) {}", map); // TODO 订单支付超时,自动取消订单处理业务... } }
OrderTimeoutNotEvaluated:订单超时未评价延迟队列处理类
/** * 订单超时未评价处理类 */ @Component @Slf4j public class OrderTimeoutNotEvaluated implements RedisDelayQueueHandle<Map> { @Override public void execute(Map map) { log.info("(收到订单超时未评价延迟消息) {}", map); // TODO 订单超时未评价,系统默认好评处理业务... } }
7、创建延迟队列消费线程,项目启动完成后开启
/** * description: 启动延迟队列监测扫描 * @author: shf * date: 2021/8/27 14:16 */ @Slf4j @Component public class RedisDelayQueueRunner implements CommandLineRunner { @Autowired private RedisDelayQueueUtil redisDelayQueueUtil; @Autowired private ApplicationContext context; @Autowired private ThreadPoolTaskExecutor ptask; ThreadPoolExecutor executorService = new ThreadPoolExecutor(3, 5, 30, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(1000),new ThreadFactoryBuilder().setNameFormat("order-delay-%d").build()); @Override public void run(String... args) throws Exception { ptask.execute(() -> { while (true){ try { RedisDelayQueueEnum[] queueEnums = RedisDelayQueueEnum.values(); for (RedisDelayQueueEnum queueEnum : queueEnums) { Object value = redisDelayQueueUtil.getDelayQueue(queueEnum.getCode()); if (value != null) { RedisDelayQueueHandle<Object> redisDelayQueueHandle = (RedisDelayQueueHandle<Object>)context.getBean(queueEnum.getBeanId()); executorService.execute(() -> {redisDelayQueueHandle.execute(value);}); } } TimeUnit.MILLISECONDS.sleep(500); } catch (InterruptedException e) { log.error("(Redission延迟队列监测异常中断) {}", e.getMessage()); } } }); log.info("(Redission延迟队列监测启动成功)"); } }
以上步骤,Redis 延迟队列核心代码已经完成,下面我们写一个测试接口,用 PostMan 模拟测试一下
8、创建一个测试接口,模拟添加延迟队列
/** * 延迟队列测试 * Created by LPB on 2020/04/20. */ @RestController public class RedisDelayQueueController { @Autowired private RedisDelayQueueUtil redisDelayQueueUtil; @PostMapping("/addQueue") public void addQueue() { Map<String, String> map1 = new HashMap<>(); map1.put("orderId", "100"); map1.put("remark", "订单支付超时,自动取消订单"); Map<String, String> map2 = new HashMap<>(); map2.put("orderId", "200"); map2.put("remark", "订单超时未评价,系统默认好评"); // 添加订单支付超时,自动取消订单延迟队列。为了测试效果,延迟10秒钟 redisDelayQueueUtil.addDelayQueue(map1, 10, TimeUnit.SECONDS, RedisDelayQueueEnum.ORDER_PAYMENT_TIMEOUT.getCode()); // 订单超时未评价,系统默认好评。为了测试效果,延迟20秒钟 redisDelayQueueUtil.addDelayQueue(map2, 20, TimeUnit.SECONDS, RedisDelayQueueEnum.ORDER_TIMEOUT_NOT_EVALUATED.getCode()); } }
启动 SpringBoot 项目,用 PostMan 调用接口添加延迟队列 通过 Redis 客户端可看到两个延迟队列已添加成功
查看 IDEA 控制台日志可看到延迟队列已消费成功
二、接下来探究一下实现原理
Demo
public static void main(String[] args) throws InterruptedException, UnsupportedEncodingException { Config config = new Config(); config.useSingleServer().setAddress("redis://172.29.2.10:7000"); RedissonClient redisson = Redisson.create(config); RBlockingQueue<String> blockingQueue = redisson.getBlockingQueue("dest_queue1"); RDelayedQueue<String> 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("fffffffff"+i, 13, TimeUnit.SECONDS); } }
上面构造了Redisson 阻塞延时队列,然后向里面塞了5条数据,都是13秒后到期。我们先不启动程序,先打开redis执行:
[root@localhost redis-cluster]# redis-cli -c -p 7000 -h 172.29.2.10 --raw 172.29.2.10:7000> monitor OK
ps:如果是windows本地redis,启动后在控制台执行如下指令:E:\redis\redisbin>redis-cli.exe -h 127.0.0.1 -p 6379
monitor 命令可以监控redis执行了哪些命令,注意线上不要乱搞,耗性能的。然后我们启动程序,观察redis执行命令情况,这里分为三个阶段:
第一介段: 客户端程序启动,offer方法执行之前 ,redis服务会收到如下redis命令:
1610452446.652126 [0 172.29.2.194:65025] "SUBSCRIBE" "redisson_delay_queue_channel:{dest_queue1}" 1610452446.672009 [0 lua] "zrangebyscore" "redisson_delay_queue_timeout:{dest_queue1}" "0" "1610452442403" "limit" "0" "2" 1610452446.672018 [0 lua] "zrange" "redisson_delay_queue_timeout:{dest_queue1}" "0" "0" "WITHSCORES" 1610452446.673896 [0 172.29.2.194:65034] "BLPOP" "dest_queue1" "0"
SUBSCRIBE
这里订阅了一个固定的队列 redisson_delay_queue_channel:{dest_queue1}, 就是为了开启进程里面的延时任务,很重要,redisson延时取数据都靠它了。后面会说。
zrangebyscore
zrangebyscore用法扫盲 >> zrangebyscore key min max [WITHSCORES] [LIMIT offset count] 分页获取指定区间内(min - max),带有分数值(可选)的有序集成员的列表。
redisson_delay_queue_timeout:{dest_queue1} 是一个zset,当有延时数据存入Redisson队列时,就会在此队列中插入 数据,排序分数为延时的时间戳。
zrangebyscore就是取出前2条(源码是100条,如下图)过了当前时间的数据。如果取的是0的话就执行下面的zrange, 这里程序刚启动肯定是0(除非是之前的队列数据没有取完)。之所以在刚启动时 这样取数据就是为了把上次进程宕机后没发完的数据发完。
zrange
取出第一个数,也就是判断上面的还有不有下一页。
BLPOP
移出并获取 dest_queue1 列表的第一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止 , 这里显然没有元素 ,就会一直阻塞。
第二介段: 执行offer向Redisson队列设置值
这个阶段我们发现redis干了下面事情:
1610452446.684465 [0 lua] "zadd" "redisson_delay_queue_timeout:{dest_queue1}" "1610452455407" ":\xdf\x0eO\x8c\xa7\xd4C\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff1" 1610452446.684480 [0 lua] "rpush" "redisson_delay_queue:{dest_queue1}" ":\xdf\x0eO\x8c\xa7\xd4C\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff1" 1610452446.684492 [0 lua] "zrange" "redisson_delay_queue_timeout:{dest_queue1}" "0" "0" 1610452446.684498 [0 lua] "publish" "redisson_delay_queue_channel:{dest_queue1}" "1610452455407" 1610452446.687922 [0 lua] "zadd" "redisson_delay_queue_timeout:{dest_queue1}" "1610452455422" "e\xfd\xfe?j?\xdbC\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff2" 1610452446.687943 [0 lua] "rpush" "redisson_delay_queue:{dest_queue1}" "e\xfd\xfe?j?\xdbC\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff2" 1610452446.687958 [0 lua] "zrange" "redisson_delay_queue_timeout:{dest_queue1}" "0" "0" 1610452446.690478 [0 lua] "zadd" "redisson_delay_queue_timeout:{dest_queue1}" "1610452455424" "\x80J\x01j\x11\xee\xda\xc3\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff3" 1610452446.690492 [0 lua] "rpush" "redisson_delay_queue:{dest_queue1}" "\x80J\x01j\x11\xee\xda\xc3\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff3" 1610452446.690502 [0 lua] "zrange" "redisson_delay_queue_timeout:{dest_queue1}" "0" "0" 1610452446.692661 [0 lua] "zadd" "redisson_delay_queue_timeout:{dest_queue1}" "1610452455427" "v\xb5\xd0r\xb48\xd4\xc3\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff4" 1610452446.692674 [0 lua] "rpush" "redisson_delay_queue:{dest_queue1}" "v\xb5\xd0r\xb48\xd4\xc3\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff4" 1610452446.692683 [0 lua] "zrange" "redisson_delay_queue_timeout:{dest_queue1}" "0" "0" 1610452446.696054 [0 lua] "zadd" "redisson_delay_queue_timeout:{dest_queue1}" "1610452455429" "\xe7\a\x8b\xee\t-\x94C\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff5" 1610452446.696081 [0 lua] "rpush" "redisson_delay_queue:{dest_queue1}" "\xe7\a\x8b\xee\t-\x94C\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff5" 1610452446.696098 [0 lua] "zrange" "redisson_delay_queue_timeout:{dest_queue1}" "0" "0"
我们客户端是设置了5条数据。上面也可以看出来。
zadd
往我们zset里面设置 数据截止的时间戳(当前执行的时间戳+延时的时间毫秒值),内容为我们的ffffff1 ,不过特殊编码了,加了点什么,不用管。
rpush
同步一份数据到list队列,这里也不知道干嘛的,先放到这里。
zrange+publish
取出排序好的第一个数据,也就是最临近要触发的数据,然后发送通知 (之前订阅了的客户端,可能是微服务就有多个客户端),内容为将要触发的时间。客户端收到通知后,就在自己进程里面开启延时任务(HashedWheelTimer),到时间后就可以从redis取数据发送。
后面又是我们的5条循环的设置数据 zadd...
第三介段:到延时时间取redis数据
1610452459.680953 [0 lua] "zrangebyscore" "redisson_delay_queue_timeout:{dest_queue1}" "0" "1610452455416" "limit" "0" "2" 1610452459.680967 [0 lua] "rpush" "dest_queue1" "\x04>\nfffffffff1" 1610452459.680976 [0 lua] "lrem" "redisson_delay_queue:{dest_queue1}" "1" ":\xdf\x0eO\x8c\xa7\xd4C\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff1" 1610452459.680984 [0 lua] "zrem" "redisson_delay_queue_timeout:{dest_queue1}" ":\xdf\x0eO\x8c\xa7\xd4C\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff1" 1610452459.680991 [0 lua] "zrange" "redisson_delay_queue_timeout:{dest_queue1}" "0" "0" "WITHSCORES" // 判断是否有值 1610452459.745813 [0 lua] "zrangebyscore" "redisson_delay_queue_timeout:{dest_queue1}" "0" "1610452455480" "limit" "0" "2" 1610452459.745829 [0 lua] "rpush" "dest_queue1" "\x04>\nfffffffff2" 1610452459.745837 [0 lua] "lrem" "redisson_delay_queue:{dest_queue1}" "1" "e\xfd\xfe?j?\xdbC\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff2" 1610452459.745845 [0 lua] "rpush" "dest_queue1" "\x04>\nfffffffff3" 1610452459.745848 [0 lua] "lrem" "redisson_delay_queue:{dest_queue1}" "1" "\x80J\x01j\x11\xee\xda\xc3\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff3" 1610452459.745855 [0 lua] "zrem" "redisson_delay_queue_timeout:{dest_queue1}" "e\xfd\xfe?j?\xdbC\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff2" "\x80J\x01j\x11\xee\xda\xc3\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff3" 1610452459.745864 [0 lua] "zrange" "redisson_delay_queue_timeout:{dest_queue1}" "0" "0" "WITHSCORES" 1610452459.756909 [0 172.29.2.194:65026] "BLPOP" "dest_queue1" "0" 1610452459.758092 [0 lua] "zrangebyscore" "redisson_delay_queue_timeout:{dest_queue1}" "0" "1610452455493" "limit" "0" "2" 1610452459.758108 [0 lua] "rpush" "dest_queue1" "\x04>\nfffffffff4" 1610452459.758114 [0 lua] "lrem" "redisson_delay_queue:{dest_queue1}" "1" "v\xb5\xd0r\xb48\xd4\xc3\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff4" 1610452459.758121 [0 lua] "rpush" "dest_queue1" "\x04>\nfffffffff5" 1610452459.758124 [0 lua] "lrem" "redisson_delay_queue:{dest_queue1}" "1" "\xe7\a\x8b\xee\t-\x94C\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff5" 1610452459.758133 [0 lua] "zrem" "redisson_delay_queue_timeout:{dest_queue1}" "v\xb5\xd0r\xb48\xd4\xc3\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff4" "\xe7\a\x8b\xee\t-\x94C\r\x00\x00\x00\x00\x00\x00\x00\x04>\nfffffffff5" 1610452459.758143 [0 lua] "zrange" "redisson_delay_queue_timeout:{dest_queue1}" "0" "0" "WITHSCORES" 1610452459.759030 [0 172.29.2.194:65037] "BLPOP" "dest_queue1" "0" 1610452459.760933 [0 172.29.2.194:65036] "BLPOP" "dest_queue1" "0" 1610452459.763913 [0 172.29.2.194:65038] "BLPOP" "dest_queue1" "0" 1610452459.765999 [0 172.29.2.194:65039] "BLPOP" "dest_queue1" "0"
这个阶段是由客户端进程里面的延时任务执行的,延时任务是在第二阶段构造的,已经说了(通过redis的订阅/发布实现)。
zrangebyscore
取出前2条到时间的数据,第一阶段已说。
rpush
将上面取到的数据push到阻塞队列,注意我们第一阶段已经监听了这个阻塞队列
"BLPOP" "dest_queue1" "0"
所以这里就会通知客户端取数据。
lrem + zrem
将取完的数据删掉。
zrange
取zset第一个数据,有的话继续上面逻辑取数据,否则进入下面。
BLPOP
继续监听这个阻塞队列。以便下次用。
小结一下
- 客户端启动,redisson先订阅一个key,同时 BLPOP key 0 无限监听一个阻塞队列(等里面有数据了就返回)。
- 当有数据put时,redisson先把数据放到一个zset集合(按延时到期时间的时间戳为分数排序),同时发布上面订阅的key,发布内容为数据到期的timeout,此时客户端进程开启一个延时任务,延时时间为发布的timeout。
- 客户端进程的延时任务到了时间执行,从zset分页取出过了当前时间的数据,然后将数据rpush到第一步的阻塞队列里。然后将当前数据从zset移除,取完之后,又执行 BLPOP key 0 无限监听一个阻塞队列。
- 上一步客户端监听的阻塞队列返回取到数据,回调到 RBlockingQueue 的 take方法。于是,我们就收到了数据。
大致原理就是这样,redisson不是通过轮询zset的,将延时任务执行放到进程里面实现,只有到时间才会取redis zset。
三、源码
RedissonDelayedQueue初始化
protected RedissonDelayedQueue(QueueTransferService queueTransferService, Codec codec, final CommandAsyncExecutor commandExecutor, String name) { super(codec, commandExecutor, name); channelName = prefixName("redisson_delay_queue_channel", getName()); queueName = prefixName("redisson_delay_queue", getName()); timeoutSetName = prefixName("redisson_delay_queue_timeout", getName()); //新建一个调度任务 QueueTransferTask task = new QueueTransferTask(commandExecutor.getConnectionManager()) { //pushTaskAsync:异步将到期元素转移到阻塞队列 @Override protected RFuture<Long> pushTaskAsync() { //这里使用一段lua脚本,KEYS[1]为getName(),KEYS[2]为timeoutSetName,KEYS[3]为queueName;ARGV[1]为当前时间戳,ARGV[2]为100 return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_LONG, //这里调用zrangebyscore,对timeoutSetName的zset使用timeout参数进行排序,取得分介于0和当前时间戳的元素(即到期的元素),取前100条 "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);" //调用rpush移交到阻塞队列 + "redis.call('rpush', KEYS[1], value);" //调用lrem从元素队列移除 + "redis.call('lrem', KEYS[3], 1, v);" + "end; " //从timeoutSetName的zset中删除掉已经处理的这些元素 + "redis.call('zrem', KEYS[2], unpack(expiredValues));" + "end; " // get startTime from scheduler queue head task //取timeoutSetName的zset的第一个元素的得分返回,如果没有返回nil,后面有用 + "local v = redis.call('zrange', KEYS[2], 0, 0, 'WITHSCORES'); " + "if v[1] ~= nil then " + "return v[2]; " + "end " + "return nil;", Arrays.<Object>asList(getName(), timeoutSetName, queueName), System.currentTimeMillis(), 100); } @Override protected RTopic getTopic() { return new RedissonTopic(LongCodec.INSTANCE, commandExecutor, channelName); } }; //开启任务调度 queueTransferService.schedule(queueName, task); this.queueTransferService = queueTransferService; }
QueueTransferService.schedule
public class QueueTransferService { private final ConcurrentMap<String, QueueTransferTask> tasks = PlatformDependent.newConcurrentHashMap(); public synchronized void schedule(String name, QueueTransferTask task) { QueueTransferTask oldTask = tasks.putIfAbsent(name, task); if (oldTask == null) { //旧调度不存在,直接开启 task.start(); } else { //调度已存在,则数量+1 oldTask.incUsage(); } } public synchronized void remove(String name) { QueueTransferTask task = tasks.get(name); if (task != null) { if (task.decUsage() == 0) { tasks.remove(name, task); task.stop(); } } } }
QueueTransferTask.start
public void start() { RTopic<Long> schedulerTopic = getTopic(); //订阅时候触发pushTask statusListenerId = schedulerTopic.addListener(new BaseStatusListener() { @Override public void onSubscribe(String channel) { pushTask(); } }); //监听消息,收到消息执行scheduleTask messageListenerId = schedulerTopic.addListener(new MessageListener<Long>() { @Override public void onMessage(CharSequence channel, Long startTime) { scheduleTask(startTime); } }); } private void scheduleTask(final Long startTime) { TimeoutTask oldTimeout = lastTimeout.get(); if (startTime == null) { return; } if (oldTimeout != null) { oldTimeout.getTask().cancel(); } //delay:即还有多久到期 long delay = startTime - System.currentTimeMillis(); if (delay > 10) { //delay大于10ms,则新建一个定时器,到期之后再执行pushTask //这里底层通过HashedWheelTimer实现 Timeout timeout = connectionManager.newTimeout(new TimerTask() { @Override public void run(Timeout timeout) throws Exception { pushTask(); TimeoutTask currentTimeout = lastTimeout.get(); if (currentTimeout.getTask() == timeout) { lastTimeout.compareAndSet(currentTimeout, null); } } }, delay, TimeUnit.MILLISECONDS); if (!lastTimeout.compareAndSet(oldTimeout, new TimeoutTask(startTime, timeout))) { timeout.cancel(); } } else { //delay小于10ms,立即执行pushTask pushTask(); } } private void pushTask() { //这里就是执行初始化时候构建的QueueTransferTask的pushTaskAsync方法 RFuture<Long> startTimeFuture = pushTaskAsync(); startTimeFuture.onComplete((res, e) -> { if (e != null) { if (e instanceof RedissonShutdownException) { return; } log.error(e.getMessage(), e); //如果执行异常,则5s之后继续调度 scheduleTask(System.currentTimeMillis() + 5 * 1000L); return; } //res即pushTaskAsync方法最后取timeoutSetName的zset的第一个元素的得分返回 if (res != null) { //如果不为空,继续执行调度 scheduleTask(res); } }); }
RedissonDelayedQueue.offer
@Override public void offer(V e, long delay, TimeUnit timeUnit) { get(offerAsync(e, delay, timeUnit)); } @Override 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; long randomId = ThreadLocalRandom.current().nextLong(); //这里使用的是一段lua脚本,其中keys参数数组有四个值,KEYS[1]为getName(), KEYS[2]为timeoutSetName, KEYS[3]为queueName, KEYS[4]为channelName //变量有三个,ARGV[1]为timeout,ARGV[2]为randomId,ARGV[3]为encode(e) return commandExecutor.evalWriteAsync(getName(), codec, RedisCommands.EVAL_VOID, "local value = struct.pack('dLc0', tonumber(ARGV[2]), string.len(ARGV[3]), ARGV[3]);" //对timeoutSetName的zset添加一个结构体,其score为timeout值 + "redis.call('zadd', KEYS[2], ARGV[1], value);" //对queueName的list的表尾添加结构体 + "redis.call('rpush', KEYS[3], value);" // if new object added to queue head when publish its startTime // to all scheduler workers //判断timeoutSetName的zset的第一个元素是否是当前的结构体,如果是则对channel发布timeout消息 //这个作用是判断第一个添加的元素,触发定时器(初始化时候订阅了channel->onMessage->scheduleTask) + "local v = redis.call('zrange', KEYS[2], 0, 0); " + "if v[1] == value then " + "redis.call('publish', KEYS[4], ARGV[1]); " + "end;", Arrays.<Object>asList(getName(), timeoutSetName, queueName, channelName), timeout, randomId, encode(e)); }
- Redisson延迟队列使用三个结构来存储,一个是queueName的list,值是添加的元素;一个是timeoutSetName的zset,值是添加的元素,score为timeout值;还有一个是getName()的blockingQueue,值是到期的元素。
- 主要方法是逻辑是:将元素及延时信息入队,之后定时任务将到期的元素转移到阻塞队列。
- 使用HashedWheelTimer做定时,定时到期之后从zset中取头部100个到期元素,所以定时和转移到阻塞队列是解耦的,无论是哪个task触发的pushTask,最终都是先取zset的头部先到期的元素。
- 元素数据都是存在redis服务端的,客户端只是执行HashedWheelTimer任务,所以单个客户端挂了不影响服务端数据,做到分布式的高可用。
参考文章:
https://www.yht7.com/news/141305
https://zhuanlan.zhihu.com/p/343811173
https://www.jianshu.com/p/eae22e9ee9d8