业务日记——延时队列/延时消费

一、基本定义

延时/定时消息是指生产者(producer)发送消息到server后,server并不将消息立即发送给消费者(consumer),而是在producer指定的时间之后送达。

二、常用的使用场景

  • 任务超时处理。例如 订单系统中,如果某个用户在 30分钟内没有支付,则订单自动进行过期处理
  • 任务延时处理。例如订餐系统中,下单成功60s之后 给用户发短信通知
  • 定时任务。例如现在通过手机发送指令,在30分钟之后开启空调之类的。

三、实现方式

一般常用的有一下几种方式

  • 定时器轮询遍历数据库记录
  • JDK的DelayQueue
  • JDK的ScheduledExecutorService
  • Redis的ZSet实现
  • RabbitMQ的延时队列
  • 时间轮(netty/kafka等)
3.1 数据库轮询

比较简单常用的方式,把相对应的消息体存放在数据库中。然后启一个线程定时去扫数据库。找到超时的数据,做相对应的业务处理。

优点

  • 方式很简单,不会引入其他的技术,开发周期短。

缺点

  • 数据量过大时会消耗太多的IO资源,效率太低
  • 而且当数量过大的时候,定时误差也较大
3.2 基于JDK的DelayQueue

Java中的DelayQueue位于java.util.concurrent包下,作为单机实现,它很好的实现了延迟一段时间后触发事件的需求。并且是线程安全,它可以有多个消费者和多个生产者,从而在某些情况下可以提升性能。
DelayQueue本质是封装了一个PriorityQueue。用户排序。这样可以保证后来插入消息的但是延时时间更短的会在前面。内部使用最小堆来实现排序队列,队首的,最先被消费者拿到的就是最小的那个。使用最小堆让队列在数据量较大的时候比较有优势。时间复杂度相对都比较好,都是O(logN)。

实现伪代码如下:

public class MyDelayed implements Delayed{
     
     // 延迟的时间
    private long delayTime;
     //进入队列的时间
    private long enterTime;
	 @Override
    public int compareTo(Delayed o) {
     
        return (int) (this.getDelay(TimeUnit.MILLISECONDS) -o.getDelay(TimeUnit.MILLISECONDS));
    }
	/**
     * 返回只小于等于0表示当前时间已经过去
     */
    @Override
    public long getDelay(TimeUnit unit) {
     
        long expire = this.enterTime + delayTime;
        return unit.convert(expire - System.currentTimeMillis(),TimeUnit.MILLISECONDS);
    }
	// ...中间省略
	//实现方式
	DelayQueue<MyDelayed> delayQueue = new DelayQueue<>();
	delayQueue.add(new MyDelayed(5000));
  	while (delayQueue.size() > 0){
     
         // 没有满足延时的元素 用poll返回 null
          // 没有满足延时的元素 用take会阻塞
          MyDelayed take = delayQueue.take();  
          //todo 业务处理
    }
}

**需要注意: ** 如果业务处理太慢,会影响定时任务执行,所以任务要尽量快

3.3 基于JDK的ScheduledExecutorService

JDK自带的一种线程池,它能调度一些命令在一段时间之后执行,或者周期性的执行。ScheduledExecutorService的实现类ScheduledThreadPoolExecutor提供了一种并行处理的模型,简化了线程的调度。DelayedWorkQueue是类似DelayQueue的实现,也是基于最小堆的、线程安全的数据结构,所以会有上例排序后输出的结果。
实现伪代码如下:

//创建任务线程池
ScheduledExecutorService executor = new ScheduledThreadPoolExecutor(10,Executors.defaultThreadFactory());

executor.schedule(() -> System.out.println(Thread.currentThread().getName()+"延迟8s执行"),8,TimeUnit.SECONDS);
executor.schedule(() -> System.out.println(Thread.currentThread().getName()+"延迟1s执行"),1,TimeUnit.SECONDS);

**需要注意: ** 因为使用了线程池,当在同一个线程中,当上任务的执行时间过长会影响下一个任务的定时任务调用。

3.4 基于Redis的ZSet实现

Redis中的ZSet是一个有序的Set,内部使用HashMap和跳表(SkipList)来保证数据的存储和有序,HashMap里放的是成员到score的映射,而跳跃表里存放的是所有的成员,排序依据是HashMap里存的score,使用跳跃表的结构可以获得比较高的查找效率,并且在实现上比较简单。
在用作延迟任务的时候,可以在添加数据的时候,使用zadd把score写成未来某个时刻的unix时间戳。消费者使用zrangeWithScores获取优先级最高的(最早开始的的)任务。注意,zrangeWithScores并不是取出来,只是看一下并不删除,类似于Queue的peek方法。程序对最早的这个消息进行验证,是否到达要运行的时间,如果是则执行,然后删除zset中的数据
如果在分布式环境下,可能会有并发的问题。即两个线程或者进程拿到了同一样的数据,然后都重复执行了任务并删除,这里可以使用分布式事务或者分布式锁来完成。


jedisCluster.zadd("key_1",System.currentTimeMillis()+10000,"uuid10");
jedisCluster.zadd("key_1",System.currentTimeMillis()+1000,"uuid1");
jedisCluster.zadd("key_1",System.currentTimeMillis()+2000,"uuid2");
while (true){
     
	//获取已经过失的操作
	Set<String> strings = jedisCluster.zrangeByScore("key_1", 0, System.currentTimeMillis());
	//对每一个members 进行分布式锁
	//进行业务的处理
}

优点

  • 解耦:把任务、任务发起者、任务执行者的三者分开,逻辑更加清晰,程序强壮性提升,有利于任务发起者和执行者各自迭代,适合多人协作。
  • 异常恢复:由于使用Redis作为消息通道,消息都存储在Redis中。如果发送程序或者任务处理程序挂了,重启之后,还有重新处理数据的可能性。
  • 分布式:如果数据量较大,程序执行时间比较长,我们可以针对任务发起者和任务执行者进行分布式部署。特别注意任务的执行者,也就是Redis的接收方需要考虑分布式锁的问题。
3.4 RabbitMQ的延时队列

参考相关资料

  • 一文带你搞定RabbitMQ死信队列
  • 一文带你搞定RabbitMQ延迟队列

RabbitMQ可以针对Queue和Message设置 x-message-tt,来控制消息的生存时间。如果消息超时未读,那么消息会进入到死信队列。那么我们只需要固定消费死信队列就可以实现消息的延时消费了。

相关配置代码如下:


    /**
     * 延时交换机
     */
    public static final String DELAY_EXCHANGE = "delay.exchange";
    /**
     * 延时队列
     */
    public static final String DELAY_QUEUE = "delay.queue";
    /**
     * 延时路由
     */
    public static final String DELAY_QUEUEA_ROUTING_KEY = "delay.queue.routingkey";
    
    /**
     * 死信交换机
     */
    public static final String DEAD_LETTER_EXCHANGE = "dead.letter.exchange";
    /**
     * 死信队列
     */
    public static final String DEAD_LETTER_QUEUE = "dead.letter.queue";
    /**
     * 死信路由
     */
    public static final String DEAD_LETTER_QUEUEA_ROUTING_KEY = "dead.letter.queue.routingkey";
    /**
     * @return 声明延时交互机
     */
    @Bean(DELAY_EXCHANGE)
    public DirectExchange delayExchange() {
     
        return new DirectExchange(DELAY_EXCHANGE);
    }
    /**
     * @return 声明延时队列
     */
    @Bean(DELAY_QUEUE)
    public Queue delayQueue() {
     
        Map<String, Object> args = new HashMap<>(2);
        // x-dead-letter-exchange    这里声明当前队列绑定的死信交换机
        args.put("x-dead-letter-exchange", DEAD_LETTER_EXCHANGE);
        // x-dead-letter-routing-key  这里声明当前队列的死信路由key
        args.put("x-dead-letter-routing-key", DEAD_LETTER_QUEUEA_ROUTING_KEY);
        // x-message-ttl  声明队列的TTL
        args.put("x-message-ttl", 9000);
        return QueueBuilder.durable(DELAY_QUEUE).withArguments(args).build();
    }
    /**
     * 绑定 延时交互机 和 延时队列
     */
    @Bean
    public Binding delayBinding(@Qualifier(DELAY_QUEUE) Queue queue,
                                 @Qualifier(DELAY_EXCHANGE) DirectExchange exchange){
     
        return BindingBuilder.bind(queue).to(exchange).with(DELAY_QUEUEA_ROUTING_KEY);
    }
    /**
     * @return 声明死信交互机
     */
    @Bean(DEAD_LETTER_EXCHANGE)
    public DirectExchange deadLetterExchange() {
     
        return new DirectExchange(DEAD_LETTER_EXCHANGE);
    }
    /**
     * @return 声明死信队列
     */
    @Bean(DEAD_LETTER_QUEUE)
    public Queue deadLetterQueue() {
     
        return new Queue(DEAD_LETTER_QUEUE);
    }
    /**
     * 绑定 死信交互机 和 死信队列
     */
    @Bean
    public Binding deadLetterBindingA(@Qualifier(DEAD_LETTER_QUEUE) Queue queue,
                                 @Qualifier(DEAD_LETTER_EXCHANGE) DirectExchange exchange){
     
        return BindingBuilder.bind(queue).to(exchange).with(DEAD_LETTER_QUEUEA_ROUTING_KEY);
    }
3.5 基于时间轮(netty/kafka等)

关于什么是时间轮,可以参考

  • Kafka解惑之时间轮(TimingWheel)
  • netty时间轮
  • netty时间轮的一些思考
  • 和一些kafka+时间轮的一些实现方案 [延时消息队列]{https://lchml.com/technology/delayq/}
	DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

	HashedWheelTimer hashedWheelTimer = new HashedWheelTimer(100, TimeUnit.MILLISECONDS);
	hashedWheelTimer.newTimeout(timeout -> System.out.println("task1:" + LocalDateTime.now().format(
                formatter)), 1, TimeUnit.SECONDS);
	hashedWheelTimer.newTimeout(timeout -> System.out.println("task2:" + LocalDateTime.now().format(
                formatter)), 4, TimeUnit.SECONDS);

参考

  • 你真的了解延时队列吗
  • 延时任务队列的原理与实现总结

你可能感兴趣的:(队列)