订单超时处理

原文链接:订单超时处理的几种方案及分析-阿里云开发者社区 (aliyun.com)

介绍

在电商场景下,买卖双方没有面对面交易,许多情况下需要通过超时处理自动关闭订单,如:

  • 买家超时未付款
  • 卖家超时未发货
  • 买家超时自动收货

JDK自带DelayQueue

处理流程

image-20240319124145733

  1. 把订单插入DelayQueue中,以超时时间作为排序条件,将订单按照超时时间从小到大排序。
  2. 起一个线程不停轮询队列的头部,如果订单的超时时间到了,就出队进行超时处理,并更新订单状态到数据库中。
  3. 为了防止机器重启导致内存中的DelayQueue数据丢失,每次机器启动的时候,需要从数据库中初始化未结束的订单,加入到DelayQueue中。
  • 优点:简单,不需要借助其他第三方组件,成本低。

  • 缺点:

    • 所有超时处理订单都要加入到DelayQueue中,占用内存大。
    • 没法做到分布式处理,只能在集群中选一台leader专门处理,效率低。
    • 不适合订单量比较大的场景。

DelayQueue原理:

  • PriorityQueue(按照超时时间排序) + 锁
  • 每次取出队头,并且判断是否已经超时,如果没有超时需要等待;为了只有一个消费者等待队头,添加了一个leader
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
for (;;) {
E first = q.peek();
if (first == null)
available.await();
else {
long delay = first.getDelay(NANOSECONDS);
if (delay <= 0)
return q.poll();
first = null; // don't retain ref while waiting
if (leader != null)
available.await(); // 非leader一直等待
else {
Thread thisThread = Thread.currentThread();
leader = thisThread;
try {
available.awaitNanos(delay); // leader只等待队头元素过期时间
} finally {
if (leader == thisThread)
leader = null;
}
}
}
}
} finally {
if (leader == null && q.peek() != null)
available.signal();
lock.unlock();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public boolean offer(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
q.offer(e);
if (q.peek() == e) {
leader = null;
available.signal();
}
return true;
} finally {
lock.unlock();
}
}

RabbitMQ的延时消息

  • RabbitMQ Delayed Message Plugin(非高可用)
  • 消息的TTL+死信Exchange

image-20240319124838271

image-20240326121154965

  1. 定义一个BizQueue,用来接收死信消息,并进行业务消费。
  2. 定义一个死信交换机(DLXExchange),绑定BizQueue,接收延时队列的消息,并转发给BizQueue。
  3. 定义一组延时队列DelayQueue_xx,分别配置不同的TTL,用来处理固定延时5s、10s、30s等延时等级,并绑定到DLXExchange。
  4. 定义DelayExchange,用来接收业务发过来的延时消息,并根据延时时间转发到不同的延时队列中。
  • 优点:可以支持海量延时消息,支持分布式处理。

  • 缺点:

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

RocketMQ的定时消息

时间轮算法

image-20240319130800851

指针代表当前时刻,slot上有每个时刻上等待的任务,链表串起来

  • 时间转一下代表过去1s
  • 遍历slot上的链表,if round > 0 round – 否则开始处理消息
  • 新消息到达 Slot=(curTime + delay) mod total_slot,round = (curTime + delay) / total_slot

延时最大限制

定时时长最大值24小时,使用循环处理实现大延时

image-20240319131311872

  • 优点

    • 精度高,支持任意时刻。
    • 使用门槛低,和使用普通消息一样。
  • 缺点

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

Redis的过期监听

  1. redis配置文件开启”notify-keyspace-events Ex”
  2. 配置过期回调
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
public class RedisListenerConfig {
@Bean
RedisMessageListenerContainer container(RedisConnectionFactory factory){
RedisMessageListenerContainer container=new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
return container;
}
}

@Component
public class RedisKeyExpirationListerner extends KeyExpirationEventMessageListener {

public RedisKeyExpirationListerner(RedisMessageListenerContainer listenerContainer) {
super(listenerContainer);
}

@Override
public void onMessage(Message message, byte[] pattern) {
String keyExpira = message.toString();
System.out.println("监听到key:" + expiredKey + "已过期");
}
}

问题:redis的key是惰性删除+定期删除,延时不正确

定时任务分布式批处理

image-20240319135208503

  • 稳定性强:没有引入额外中间件
  • 效率高:批处理

但是使用定时任务有个天然的缺点:没法做到精度很高。定时任务的延迟时间,由定时任务的调度周期决定。如果把频率设置很小,就会导致数据库的qps比较高,容易造成数据库压力过大,从而影响线上的正常业务。

所以一般需要抽离出超时中心和超时库来单独做订单的超时调度,在阿里内部,几乎所有的业务都使用基于定时任务分布式批处理的超时中心来做订单超时处理,SLA可以做到30秒以内:

image-20240319135535171