<导航

基于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

 

posted @ 2021-11-11 09:25  字节悦动  阅读(12102)  评论(5编辑  收藏  举报