订单超时处理

发布时间 2023-04-23 16:28:17作者: 傅小灰

JDK自带的延时队列

  1. 把订单插入DelayQueue中,以超时时间作为排序条件,将订单按照超时时间从小到大排序。
  2. 起一个线程不停轮询队列的头部,如果订单的超时时间到了,就出队进行超时处理,并更新订单状态到数据库中。
  3. 为了防止机器重启导致内存中的DelayQueue数据丢失,每次机器启动的时候,需要从数据库中
  • 优点:简单,不需要第三方组件,成本低
  • 缺点:
    • 所有订单信息都要放入队列,内存占用大
    • 没办法分布式处理,效率低
    • 不适用订单量大的场景

RabbitMQ延时队列

RabbitMQ的延迟消息主要有两个解决方案

  1. RabbitMQ Delayed Message Plugin

    官方提供的延时消息插件,不是高可用的,节点挂了会丢失消息

  2. 消息TTL+死信Exchange

  • TTL:消息存活时间

    RabbitMQ可以对队列和消息分别设置TTL,如果对队列设置,则该队列中所有的消息都会具有相同的过期时间。如果超过了这个时间,我们就认为这个消息是死信。

  • 死信Exchange(DLX):死信交换机,在满足下面的条件时消息会进入该交换机

    • 一个消息被Consumer拒收,并且Reject方法的参数是false。也就是说不会被放入队列,被其他消费者重试
    • TTL到期的消息
    • 队列满了被丢弃的消息

一个延时消息的流程如下图

  • 优点:可以支持海量延时消息,支持分布式处理。

  • 缺点:

    • 不灵活,只能支持固定延时等级。
    • 使用复杂,要配置一堆延时队列

RocketMQ的定时消息

RocketMQ支持任意秒级的定时消息,只需要在发送消息的时候设置延时时间即可。

  • 优点:
    • 精度高,支持任意时刻(付费版)
    • 使用简单和普通消息一样
  • 缺点:
    • 使用限制,定时时长最大24小时。开源版本延迟时长固定18个级别
    • 使用成本高,每个订单都会生成一个定时消息,不会马上消费,占用MQ的存储
    • 同一时刻大量消息会导致消息延迟:定时消息的实现逻辑是通过时间轮算法,等定时时间到了以后才投递给消费者。如果同一时刻有大量消息需要处理,会照成系统压力过大,导致消息分发延迟,影响定时精度

Redis过期监听

  1. redis配置文件开启"notify-keyspace-events Ex"

  2. 监听key的过期回调事件

    var ret = ConnectionMultiplexer.Connect("127.0.0.1:6379,allowadmin=true");
    IDatabase database = ret.GetDatabase(0);
    ISubscriber subscriber = ret.GetSubscriber();
    subscriber.Subscribe("__keyevent@0__:expired", (channel, notificationType) =>
    {
    	Console.WriteLine(channel + "|" + notificationType);
    });
    Console.ReadKey();
    

使用Redis进行订单超时处理的流程图如下

这个方案表面看起来没问题,但是在实际生产上不推荐。

Redis主要使用定期删除和惰性删除两种策略来清理过期的key

  • 惰性删除:每次访问key的时候判断是否过期,如果过期就删除该key。若一个key过期了但是一直没有被访问,就会一直保存在数据库中
  • 定期删除:每隔一段时间(默认100ms)就随机抽取一些设置了过期时间的key,检查其是否过期,如果有过期就删除。之所以这么做,是为了通过限制删除操作的执行时长和频率来减少对CPU的影响。

activeExpireCycle函数每次运行时,都从一定数量的数据库中随机取出一定数量的键进行检查,并删除其中的过期键

从上面的过程中可以发现,Redis过期删除是不精准的。Redis真正发起过期通知不是在key过期的时候,而是在key被删除的时候。如果在Redis发起通知的时候,应用重启或者崩溃了,通知事件就有可能丢失了,导致订单一直无法关闭,有稳定性问题。如果一定要使用Redis过期监听方案,还是需要配合定时任务做补偿机制。

定时任务分布式批处理

通过定时任务不断轮询数据库订单,将超时的订单分配给不同机器分布式处理。

定时任务的优点:

  • 稳定性强:基于通知的方案(如MQ、Redis)需要考虑极端情况下的通知事件丢失的情况。定时任务只要保证业务幂等即可,哪怕这个批次有些订单没有处理或者处理过程中应用重启,下一个批次也可以继续处理,稳定性高。
  • 效率高:基于通知的方案需要一个订单一个定时消息,consumer消费者处理的时候也需要一个一个订单处理,对数据库TPS很高。使用定时任务可以批处理,每次取出一批数据,处理完成后批量更新数据库。
  • 可运维:基于数据库存储,可以很方便的对订单进行修改、暂停、删除等操作,如果业务执行失败也可以直接通过sql处理。
  • 成本低:相对于其他业务需要第三方组件,复用数据库的成本大大降低。

定时任务的天然缺点就是无法保证高精度。定时任务的延迟时间由执行周期决定,最大可能会有2倍执行周期的误差。如果执行频率很高,就会导致数据库QPS过高,数据库压力太大,影响正常业务。所以一般需要抽离出超时中心和超时库来单独做订单的超时调度。

总结

如果对于超时精度要求比较高,超时时间在24小时内且没有峰值压力的场景,推荐使用RocketMQ的定时消息为解决方案。

如果对于超时精度没那么敏感,并且有海量订单需要批处理,推荐使用基于定时任务的批处理方案。

PS:通过两种方式来判断订单是否关闭,首先判断状态字段,如果状态不是关闭的,再判断订单创建时间和当前时间之间的时间差,如果符合关闭时间,就返回订单已关闭。可以在下次被定时任务处理时,将状态设置为关闭。