延时队列的实现方式

4,419 阅读6分钟

这是我参与 8 月更文挑战的第 18 天,活动详情查看: 8月更文挑战

当应用要求操作事件是顺序的,并且事件由同一个终端设备发送,通过设备ID计算Hash到同一个节点服务处理,这之中不存在时钟一致性问题,但由于事件发送是异步的,所以接收可能乱序,再比如在大数据系统中分析OAuth关系,OAuth表记录的是A应用的X用户与B应用的Y用户的关联(如果B应用没有对应的用户则Y用户为新增记录),但用户表、应用表和OAuth表都是分开采集的,即不能保证分析OAuth表时用户表对应的用户就一定已经存在。对于这类需求比较通用的解决方案是使用延迟队列。

什么是延时队列?顾名思义:首先它要具有队列的特性,再给它附加一个延迟消费队列消息的功能,也就是说可以指定队列中的消息在哪个时间点被消费。

DelayQueue

DelayQueue是一个BlockingQueue(无界阻塞)队列,它封装了一个使用完全二叉堆排序元素的PriorityQueue(优先队列),在添加元素时,使用Delay(延迟时间)作为排序条件,延迟最小的元素会优先放在队首。我们可以队列中的元素只有到了Delay时间才允许从队列中取出,从而得到延时队列。

image.png

@Data
public class Order implements Delayed {

  private final long timestamp;

  private final String name;

  public Order(String name, long timestamp, TimeUnit unit) {
    this.timestamp = System.currentTimeMillis() + (timestamp > 0 ? unit.toMillis(timestamp) : 0);
    this.name = name;
  }

  /**
   * 返回剩余的延迟
   * poll方法根据该方法判断,只有延迟<0才出队
   */
  @Override
  public long getDelay(TimeUnit unit) {
    return timestamp - System.currentTimeMillis();
  }

  /**
   * 决定堆排序
   */
  @Override
  public int compareTo(Delayed o) {
    Order Order = (Order) o;
    long diff = this.timestamp - Order.timestamp;
    if (diff <= 0) {
      return -1;
    } else {
      return 1;
    }
  }
}

DelayQueueput方法内部使用了ReentrantLock锁进行线程同步,因此是线程安全的。DelayQueue还提供了两种出队的方法poll()take()  , poll()为非阻塞获取,没有到期的元素直接返回null;*take()*阻塞方式获取,没有到期的元素线程将会等待。

public static void main(String[] args) throws InterruptedException {
    Order order1 = new Order("订单1", 5, TimeUnit.SECONDS);
    Order order2 = new Order("订单2", 10, TimeUnit.SECONDS);
    Order order3 = new Order("订单3", 15, TimeUnit.SECONDS);
    DelayQueue<Order> delayQueue = new DelayQueue<>();
    delayQueue.put(order1);
    delayQueue.put(order3);
    delayQueue.put(order2);

    System.out.println("订单延迟队列开始时间:" + new DateTime().toString("HH:mm:ss"));
    while (delayQueue.size() != 0) {
      // 取队列头部元素是否过期
      Order task = delayQueue.poll();
      if (task != null) {
        System.out.format("%s被取消, 时间:{%s}\n", task.getName(), new DateTime().toString("HH:mm:ss"));
      }
      Thread.sleep(1000);
    }
}

结果

订单1被取消, 时间:{15:49:38}
订单2被取消, 时间:{15:49:43}
订单3被取消, 时间:{15:49:49}

Quartz 定时任务

Quartz一款非常经典任务调度框架,在RedisRabbitMQ还未广泛应用时,超时未支付取消订单功能都是由定时任务实现的。定时任务它有一定的周期性,可能很多单子已经超时,但还没到达触发执行的时间点,那么就会造成订单处理的不够及时。

Redis Zset

利用RedisZset可以实现延迟队列的效果,通过设置Score属性为集合中的成员进行从小到大的排序。

// ----------- 延迟消息写入逻辑 -----------
// 保存消息内容,kind是消息类型,如订单到期、OAuth延迟处理等,id是消息的记录Id
redis.hset("delay:body:"+timerTaskReq.kind, timerTaskReq.id, toJsonString(timerTaskReq))
// 删除之前的延迟时间(如果存在的话)
redis.zrem("delay:queue:" + timerTaskReq.kind, timerTaskReq.id)
// 添加新的延迟时间,timerTaskReq.execMs为期望执行(到期)的时间,这里用这个时间做为评分
redis.zadd("delay:queue:" + timerTaskReq.kind, timerTaskReq.execMs, timerTaskReq.id)
// ----------------------------------------

// -------- 延迟消息获取及发送逻辑 --------
// kinds为所有的消息类型
kinds.map{
    kind ->
    // 获取过期的消息Id,即评分为0到当前时间戳
    var expireTaskIds = redis.zrangebyscore("delay:queue:" + kind, 0, currentTimeMs)
    // 获取对应的消息内容
    var expireTasks = redis.hmget("delay:body:" + kind, expireTaskIds)
    // 删除过期的消息Id
    redis.zremMany("delay:queue:" + kind, expireTaskIds)
    // 删除过期的消息内容
    redis.hdelMany("delay:body:" + kind, expireTaskIds)
    // 发送消息
    sendTask(expireTasks)
}

利用Rediskey过期回调事件:开启监听key是否过期的事件,一旦key过期会触发一个callback事件。但不能应用其完成延迟队列,因为Redis只在过期键被删除的时候通知,而不是键的生存时间变为0的时候立马通知。过期键的删除是由一个后台任务执行,为不影响关键业务,后台任务被严格限制,默认为一秒执行10次,一次最多250ms,可通过hz参数调整,但当过期键比例很高时仍然会出现大量的通知的延迟。

RabbitMQ 延时队列

RabbitMQ 存在两个属性: *TTL(Time To Live)*和 DLX(Dead Letter Exchange)

TTL指消息存活的时间,RabbitMQ通过x-message-tt参数来设置指定队列(队列中所有消息都具有相同的过期时间)或消息(某一条消息设置过期时间)上消息的存活时间,它的值是一个非负整数,单位为微秒。如果同时设置队列和队列中消息的TTL,则以较小的值为准。超过TTL的消息则成为Dead Letter(死信)。

死信可以重新路由到另一个Exchange(交换机),让消息重新被消费。通过设置x-dead-letter-exchangeDead Letter重新路的交换机)和x-dead-letter-routing-key(转发的队列)来实现。 image.png 发送消息时指定消息延迟的时间

public void send(String delayTimes) {
    amqpTemplate.convertAndSend("order.pay.exchange", "order.pay.queue", "延迟数据", message -> {
        // 设置延迟毫秒值
        message.getMessageProperties().setExpiration(String.valueOf(delayTimes));
        return message;
    });
} 

设置延迟队列出现死信后的转发规则。

@Bean(name = "order.delay.queue")
public Queue getMessageQueue() {
    return QueueBuilder
            .durable(RabbitConstant.DEAD_LETTER_QUEUE)
            // 配置到期后转发的交换
            .withArgument("x-dead-letter-exchange", "order.close.exchange")
            // 配置到期后转发的路由键
            .withArgument("x-dead-letter-routing-key", "order.close.queue")
            .build();
} 

时间轮

www.cnblogs.com/luozhiyun/p…

时间轮能够高效地利用线程资源来进行批量化调度,将任务绑定到同一个的调度器上面,使用这个调度器来进行任务的管理、触发以及运行。时间轮的结构如下所示 image.png 左侧是一个存储定时任务的环形队列,底层采用数组实现,数组中的元素是一个存放定时任务的环形的双向链表,节点封装了真正的定时任务(TimerTask)。时间轮由多个时间格组成,每个时间格代表当前时间轮的基本时间跨度(tickDuration)。时间轮的时间格个数是固定的,可用 wheel.length 来表示。时间轮的表盘指针(tick)表示时间轮当前指针指向的bucket,此时处理对于列表中的所有任务。

时间轮运行逻辑

时间轮在启动时记录当前启动的时间startTime,在添加任务时首先计算延迟时间(deadline),比如一个任务的延迟时间为24ms,任务将放在:当前的时间(currentTime)+24ms-startTime 所在的bucket的队列中。

时间轮运行后,会遍历tick指向的TimerTask列表,计算如下参数:

  1. TimerTask的总共延迟的次数:将每个任务的延迟时间(deadline)/tickDuration 计算出tick需要总共跳动的次数。
  2. 时间轮round次数:(tick走的总次数-当前tick数量) / 时间格个数。比如tickDuration1ms,时间格个数为20个,那么时间轮走一圈需要20ms,如果要添加进一个延时为24ms的数据,如果当前的tick为0,那么计算出的轮数为1,当指针运行第一圈时将round减一,运行第二圈才可以将轮数round减为0并会运行。
  3. 任务需要放置到时间轮的槽位,放入到槽位链表最后。

参考:时间轮算法(TimingWheel)是如何实现的?