这是我参与 8 月更文挑战的第 18 天,活动详情查看: 8月更文挑战
当应用要求操作事件是顺序的,并且事件由同一个终端设备发送,通过设备ID计算Hash到同一个节点服务处理,这之中不存在时钟一致性问题,但由于事件发送是异步的,所以接收可能乱序,再比如在大数据系统中分析OAuth关系,OAuth表记录的是A应用的X用户与B应用的Y用户的关联(如果B应用没有对应的用户则Y用户为新增记录),但用户表、应用表和OAuth表都是分开采集的,即不能保证分析OAuth表时用户表对应的用户就一定已经存在。对于这类需求比较通用的解决方案是使用延迟队列。
什么是延时队列?顾名思义:首先它要具有队列的特性,再给它附加一个延迟消费队列消息的功能,也就是说可以指定队列中的消息在哪个时间点被消费。
DelayQueue
DelayQueue是一个BlockingQueue(无界阻塞)队列,它封装了一个使用完全二叉堆排序元素的PriorityQueue(优先队列),在添加元素时,使用Delay(延迟时间)作为排序条件,延迟最小的元素会优先放在队首。我们可以队列中的元素只有到了Delay时间才允许从队列中取出,从而得到延时队列。
@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;
}
}
}
DelayQueue的put方法内部使用了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一款非常经典任务调度框架,在Redis、RabbitMQ还未广泛应用时,超时未支付取消订单功能都是由定时任务实现的。定时任务它有一定的周期性,可能很多单子已经超时,但还没到达触发执行的时间点,那么就会造成订单处理的不够及时。
Redis Zset
利用Redis的Zset可以实现延迟队列的效果,通过设置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)
}
利用Redis的key过期回调事件:开启监听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-exchange(Dead Letter重新路的交换机)和x-dead-letter-routing-key(转发的队列)来实现。
发送消息时指定消息延迟的时间
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();
}
时间轮
时间轮能够高效地利用线程资源来进行批量化调度,将任务绑定到同一个的调度器上面,使用这个调度器来进行任务的管理、触发以及运行。时间轮的结构如下所示
左侧是一个存储定时任务的环形队列,底层采用数组实现,数组中的元素是一个存放定时任务的环形的双向链表,节点封装了真正的定时任务(TimerTask)。时间轮由多个时间格组成,每个时间格代表当前时间轮的基本时间跨度(tickDuration)。时间轮的时间格个数是固定的,可用 wheel.length 来表示。时间轮的表盘指针(tick)表示时间轮当前指针指向的bucket,此时处理对于列表中的所有任务。
时间轮运行逻辑
时间轮在启动时记录当前启动的时间startTime,在添加任务时首先计算延迟时间(deadline),比如一个任务的延迟时间为24ms,任务将放在:当前的时间(currentTime)+24ms-startTime 所在的bucket的队列中。
时间轮运行后,会遍历tick指向的TimerTask列表,计算如下参数:
- TimerTask的总共延迟的次数:将每个任务的延迟时间(deadline)/tickDuration 计算出tick需要总共跳动的次数。
- 时间轮round次数:(tick走的总次数-当前tick数量) / 时间格个数。比如tickDuration为1ms,时间格个数为20个,那么时间轮走一圈需要20ms,如果要添加进一个延时为24ms的数据,如果当前的tick为0,那么计算出的轮数为1,当指针运行第一圈时将round减一,运行第二圈才可以将轮数round减为0并会运行。
- 任务需要放置到时间轮的槽位,放入到槽位链表最后。