事件驱动型延时处理“需求的程序设计方案

关于"事件驱动型延时处理"需求的程序设计方案

背景:

最近在做一个关于竞拍物资的网站,其中有一个需求点是当用户发布了一条竞价单时,系统会根据竞拍单的开启竞价时间自动的修改竞价单的状态为已开启竞价,这时其他用户才会允许发布竞价单价。这时一个典型的事件驱动型延时处理”需求,触发条件是用户发布竞价单,到规定的开启竞价时间后处理。还有一种业务场景就是,当最后一个用户发送了竞价信息后,三分钟之后,没有用户再发送竞价信息,那么此次的竞价自动结束,不在允许用户发送竞价操作。那么对于这么一个常见的业务场景,作为java开发者来说,你会怎样设计你的功能呢?

1、定时任务处理(最不推荐)

一般情况下,最不推荐的方式就是定时任务方式,原因我们可以看下面的图来说明

我们假设,定时任务时间为10分钟执行一次,定时任务间隔也是10分钟;通过上图我们看出,如果在第1分钟下单,在第20分钟的时候才能被扫描到执行的单据,这样误差达到10分钟,这在很多场景下是不可接受的,如果设置每秒执行一次,需要频繁扫描数据库造成网络IO和磁盘IO的消耗,对实时交易造成一定的冲击,所以PASS。

2、JDK DelayQueue实现延时处理

1.1DelayQueue介绍

DelayQueue 是 Java 并发包 java.util.concurrent 下的一个 Class。DelayQueue是一个无界的BlockingQueue,是线程安全的。

无界队列:通过调用DelayQueue的offer方法(或add方法),把待执行的任务对象放入队列,该方法是非阻塞的。这个队列是无界队列,内存足够的情况下,理论上存放的任务对象数是无限的。

阻塞队列:DelayQueue实现了BlockingQueue接口,是一个阻塞队列。但该队列只是在取对象时阻塞,对应两个方法:1、take()方法,获取并移除队列头的对象,如果时间还未到,就阻塞等待。2、poll(long timeout, TimeUnit unit) 方法,阻塞时间长度为timeout,然后获取并移除队列头的对象,如果对象延迟时间还未到,就返回null。

优先队列:DelayQueue的一个重要的成员是一个优先队列PriorityQueue,PriorityQueue内部是一个二叉小顶堆实现,其特点就是头部元素对应的权值是队列中最小的,也就是通过poll()方法获取到的对象是最优先的。

延迟队列还拥有自己如下的特点:

  1. 可重入锁,用于保证线程安全

  2. DelayQueue 的实现依赖于 PriorityQueue(优先队列),用于存储元素,并按过期时间优先排序,保证每次从头部取出的对象,是应该最先被执行的。

DelayQueue中存入的必须是实现了Delayed接口的对象(Delayed定义了一个getDelay的方法,用来判断排序后的元素是否可以从Queue中取出,并且Delayed接口还继承了Comparable用于排序),插入Queue中的数据根据compareTo方法进行排序(DelayQueue的底层存储是一个PriorityQueue,PriorityQueue是一个可排序的Queue,其中的元素必须实现Comparable接口的compareTo方法),并通过getDelay方法返回的时间确定元素是否可以出队,只有小于等于0的元素(即延迟到期的元素)才能够被取出,延迟队列不接收null元素。

基于延迟队列,是可以实现延迟关闭的,首先,在用户创建订单的时候,把订单加入到DelayQueue中,然后,还需要一个常驻任务不断的从队列中取出那些到了超时时间的订单,然后在把他们进行关单,之后再从队列中删除掉。这个方案需要有一个线程,不断的从队列中取出需要关单的订单。一般在这个线程中需要加一个while(true)循环,这样才能确保任务不断的执行并且能够及时的取出超时订单。

参考文档:延迟队列 DelayQueue 详解

DelayQueue实战:DelayQueue 实现延时队列

1.2 DelayQueue的作用

延迟队列的作用显然就是用于执行延时任务,如:

  1. 淘宝订单业务:下单之后如果三十分钟之内没有付款就自动取消订单。

  2. 饿了吗订餐通知:下单成功后60s之后给用户发送短信通知。

  3. 关闭空闲连接。服务器中,有很多客户端的连接,空闲一段时间之后需要关闭之。

  4. 缓存。缓存中的对象,超过了空闲时间,需要从缓存中移出。

  5. 任务超时处理。在网络协议滑动窗口请求应答式交互时,处理超时未响应的请求等。

1.3 DelayQueue实现延时任务的优缺点

使用DelayQueue实现延时任务非常简单,而且简便,全部都是标准的JDK代码实现,不用引入第三方依赖(不依赖redis实现、消息队列实现等),非常的轻量级。

缺点

  • 所有的操作都是基于应用内存的,而且没有持久化策略,一旦出现应用单点故障,可能会造成延时任务数据的丢失。

  • 如果订单并发量非常大,因为DelayQueue是无界的,订单量越大,队列内的对象就越多,可能造成OOM的风险。

  • 没法做到分布式处理,只能在集群中选一台leader专门处理,效率低。

所以使用DelayQueue实现延时任务,只适用于任务量较小的情况。

1.4 DelayQueue实现

实现很简单,参考博客:基于DelayQueue实现的延时队列

3、使用MQ实现事件延时处理

使用DelayQueue的优点是JDK自身实现,使用方便,量小特别适用,但是整个队列处于jvm内存中,内容不能持久化,如果没有负载均衡机制,就不能支持分布式运行。如果你要考虑消息的持久化,那么MQ是一个不错的选择,比如RocketMQ的延时队列,MQTT的EQMX Broker的延时消息等,除了RocketMQ,RabbitMQ延时队列(TTL+DLX实现)也是一个不错的选择。

延迟消息:当消息写入到Broker后,不会立刻被消费者消费,需要等待指定的时长后才可被消费处理的消息,称为延时消息。

3.1 RocketMQ

定时消息是 Apache RocketMQ 提供的一种高级消息类型,消息被发送至服务端后,在指定时间后才能被消费者消费。通过设置一定的定时时间可以实现分布式场景的延时调度触发效果。

3.1.1 定时时间设置原则

  • Apache RocketMQ 定时消息设置的定时时间是一个预期触发的系统时间戳,延时时间也需要转换成当前系统时间后的某一个时间戳,而不是一段延时时长。

  • 定时时间的格式为毫秒级的Unix时间戳,您需要将要设置的时刻转换成时间戳形式。具体方式,请参见Unix时间戳转换工具

  • 定时时间必须设置在定时时长范围内,超过范围则定时不生效,服务端会立即投递消息。

  • 定时时长最大值默认为24小时,不支持自定义修改,更多信息,请参见参数限制

  • 定时时间必须设置为当前时间之后,若设置到当前时间之前,则定时不生效,服务端会立即投递消息。

这里要注意的是,定时时长的最大设置时长是当前时间的24小时后,也就是说,要想设置超过24小时的时间,是不被允许的,例如设置三天后的某个时间。

3.1.2 定时消息的实现

在RocketMQ中,使用了经典的时间轮算法。通过TimerWheel来描述时间轮不同的时刻,通过TimerLog来记录不同时刻的消息。

TimerWheel中的每一格代表着一个时刻,同时会有一个firstPos指向这个刻度下所有定时消息的首条TimerLog记录的地址,一个lastPos指向这个刻度下所有定时消息最后一条TimerLog的记录的地址。并且,对于所处于同一个刻度的的消息,其TimerLog会通过prevPos串联成一个链表。

3.1.3 RocketMQ优缺点

  • 优点

    • 精度高,支持任意时刻。

    • 使用门槛低,和使用普通消息一样。

  • 缺点

    • 使用限制:定时时长最大值24小时。

    • 成本高:每个订单需要新增一个定时消息,且不会马上消费,给MQ带来很大的存储成本。

    • 同一个时刻大量消息会导致消息延迟:定时消息的实现逻辑需要先经过定时存储等待触发,定时时间到达后才会被投递给消费者。因此,如果将大量定时消息的定时时间设置为同一时刻,则到达该时刻后会有大量消息同时需要被处理,会造成系统压力过大,导致消息分发延迟,影响定时精度。

综上所述,RocketMQ并不适用我们的业务场景一,但是适用于场景二

3.2 RabbitMQ

在 RabbitMQ 3.6.x 之前我们一般采用死信队列+TTL过期时间来实现延迟队列,我们这里不做过多介绍,可以参考文章来了解:TTL、死信队列

在 RabbitMQ 3.6.x 开始,RabbitMQ 官方提供了延迟队列的插件,可以下载放置到 RabbitMQ 根目录下的 plugins 下

由于死信队列的设计目的是为了存储没有被正常消费的消息,便于排查和重新投递。死信队列同样也没有对投递时间做出保证,在第一条消息成为死信之前,后面的消息即使过期也不会投递为死信。

为了解决这个问题,Rabbit 官方推出了延迟投递插件 rabbitmq-delayed-message-exchange ,推荐使用官方插件来做延时消息。基于RabbitMQ插件的方式可以实现延迟消息,并且不存在消息阻塞的问题,但是因为是基于插件的,而这个插件支持的最大延长时间是(2^32)-1 毫秒,大约49天,超过这个时间就会被立即消费。但是他基于RabbitMQ实现,所以在可用性、性能方便都很不错。

但是系统中并没有引用RabbitMQ作为消息队列的打算,个人感觉此组件重量级太大,不适合本系统使用,且配置插件的方式并不优雅,所以综合来看并不打算考虑。

这里说点题外话,使用 Redis 过期监听或者 RabbitMQ 死信队列做延时任务都是以设计者预想之外的方式使用中间件,这种出其不意必自毙的行为通常会存在某些隐患,比如缺乏一致性和可靠性保证,吞吐量较低、资源泄漏等。

3.3 EMQ X 延迟发布

基于MQTT通讯协议,结合EMQX Broker服务实现消息的延迟发布。

MQTT(Message Queuing Telemetry Transport,消息队列遥测传输协议),是一种基于发布/订阅(publish/subscribe)模式的"轻量级"通讯协议,该协议构建于TCP/IP协议上,由IBM在1999年发布。MQTT最大优点在于,可以以极少的代码和有限的带宽,为连接远程设备提供实时可靠的消息服务。作为一种低开销、低带宽占用的即时通讯协议,使其在物联网、小型设备、移动应用等方面有较广泛的应用。、

延迟发布可以实现按照用户配置的时间间隔延迟发布消息,当客户端使用特殊主题前缀 $delayed/{DelayInteval} 发布消息时,将触发延迟发布功能。

延迟发布主题的具体格式如下:

 $delayed/{DelayInterval}/{TopicName}
  • $delayed:使用 $delay 作为主题前缀的消息都将被视为需要延迟发布的消息。延迟间隔由下一主题层级中的内容决定。

  • {DelayInterval}:指定该 MQTT 消息延迟发布的时间间隔,单位是秒,允许的最大间隔是 4294967 秒。如果 {DelayInterval} 无法被解析为一个整型数字,EMQX 将丢弃该消息,客户端不会收到任何信息。

  • {TopicName}:MQTT 消息的主题名称。

开源版本的EMQ X 并不具有消息持久化的功能,因此存在消息无法恢复的情况。

4、时间轮算法

时间轮可以理解为一种环形结构,像钟表一样被分为多个 slot。每个 slot 代表一个时间段,每个 slot 中可以存放多个任务,使用的是链表结构保存该时间段到期的所有任务。时间轮通过一个时针随着时间一个个 slot 转动,并执行 slot 中的所有到期任务。

4.1 Netty的时间轮

基于Netty的HashedWheelTimer可以帮助我们快速的实现一个时间轮,这种方式和DelayQueue类似,缺点都是基于内存、集群扩展麻烦、内存有限制等等。

但是他相比DelayQueue的话,效率更高一些,任务触发的延迟更低。代码实现上面也更加精简。

所以,基于Netty的时间轮方案比基于JDK的DelayQueue效率更高,实现起来更简单,但是同样的,只适合在单机场景、并且数据量不大的场景中使用,如果涉及到分布式场景,那还是不建议使用。

4.2 Kafka的时间轮

既然基于Netty的时间轮存在一些问题,那么有没有其他的时间轮的实现呢?还真有的,那就是Kafka的时间轮,Kafka内部有很多延时性的操作,如延时生产,延时拉取,延时数据删除等,这些延时功能由内部的延时操作管理器来做专门的处理,其底层是采用时间轮实现的。

而且,为了解决有一些时间跨度大的延时任务,Kafka 还引入了层级时间轮,能更好控制时间粒度,可以应对更加复杂的定时任务处理场景。Kafka 中的时间轮的实现是 TimingWheel 类,位于 kafka.utils.timer 包中。基于Kafka的时间轮同样可以得到O(1)时间复杂度,性能上还是不错的。

基于Kafka的时间轮的实现方式,在实现方式上有点复杂,需要依赖kafka,但是他的稳定性和性能都要更高一些,而且适合用在分布式场景中。

参考文档:浅谈时间轮算法

5、Redisson DelayQueue

Redisson DelayQueue 是一种基于 Redis Zset 结构的延时队列实现。DelayQueue 中有一个名为 timeoutSetName 的有序集合,其中元素的 score 为投递时间戳。DelayQueue 会定时使用 zrangebyscore 扫描已到投递时间的消息,然后把它们移动到就绪消息列表中。其实就是在zset的基础上增加了一个基于内存的延迟队列。当我们要添加一个数据到延迟队列的时候,redission会把数据+超时时间放到zset中,并且起一个延时任务,当任务到期的时候,再去zset中把数据取出来,返回给客户端使用。

基于Redisson的实现方式,是可以解决基于zset方案中的并发重复问题的,而且还能实现方式也比较简单,稳定性、性能都比较高

Redisson的DelayedQueue最佳实践

浅析 Redisson 的分布式延时队列 DelayedQueue 运行流程

6、总结

我们介绍了很对种实现订单定时关闭的方案,其中不同的方案各自都有优缺点,也各自适用于不同的场景中。那我们尝试着总结一下:

实现的复杂度上(包含用到的框架的依赖及部署)

Redission > RabbitMQ插件 > RabbitMQ死信队列 > RocketMQ延迟消息 ≈ Redis的zset > Redis过期监听 ≈ kafka时间轮 > 定时任务 > Netty的时间轮 > JDK自带的DelayQueue > 被动关闭

方案的完整性:

Redission ≈ RabbitMQ插件 > kafka时间轮 > Redis的zset ≈ RocketMQ延迟消息 ≈ RabbitMQ死信队列 > Redis过期监听 > 定时任务 > Netty的时间轮 > JDK自带的DelayQueue > 被动关闭

不同的场景中也适合不同的方案:

  • 自己玩玩:被动关闭

  • 单体应用,业务量不大:Netty的时间轮、JDK自带的DelayQueue、定时任务

  • 分布式应用,业务量不大:Redis过期监听、RabbitMQ死信队列、Redis的zset、定时任务

  • 分布式应用,业务量大、并发高:Redission、RabbitMQ插件、kafka时间轮、RocketMQ延迟消息

总体考虑的话,考虑到成本,方案完整性、以及方案的复杂度,还有用到的第三方框架的流行度来说,个人比较建议优先考虑Redission+Redis、RabbitMQ插件、Redis的zset、RocketMQ延迟消息等方案。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值