遇到一个场景,订单超时未支付自动关闭释放库存的场景,故做此纪录。
生活中,12306购票,京东,淘宝下单的时候,都会遇到30分钟内进行支付的场景,互联网电商的订单系统都需要解决订单超时的问题。
订单超时业务场景,符合"在一段时间之后,完成一个工作任务"的需求,总结了几种订单超时未支付自动关闭的实现方案和各自的优缺点,如下:
使用场景 | 实现方案 | 优点 | 缺点 |
---|---|---|---|
单机版系统用 | 定时任务 | 成本低,实现简单 | 时间不精确,增加服务器和数据库的压力 |
单机版系统用 | 被动取消 | 成本低,实现简单 | 依赖客户端,如果客户端不发起请求,订单可能永远没法过期,一直占用库存 |
一般不用 | jdk延迟队列 | 不依赖其他组件,不依赖数据库,实现简单 | 数据量大会导致OOM,jvm重启后数据会丢失。 |
一般不用 | redis过期通知 | 性能高,速度快 | redis5.0之前,没有消息确认机制,不适合可靠事件通知 |
分布式系统用 | rocketmq延迟队列 | 高可用、高性能,系统解耦,吞吐量高,支持万亿级数据量 | 相对上面来说mq是重量级组件,引入后,带来消息丢失,幂等性等问题加深了系统的复杂性 |
第一种:定时任务,定时任务实现大概分为两类:本地定时任务和分布式定时任务
本地定时任务:
- 永动机线程:开启一个线程,通过sleep去完成定时
- JDK Timer:JDK提供的Timer API
- 延迟线程池:JDK提供延迟线程池ScheduledExecutorService
- Spring Task:Spring框架提供的定时任务
- Quartz:Quartz任务调度框架
分布式定时任务:
6. xxl-job:大众点评的居于Mysql轻量级分布式定时任务框架
7. elastic-job:当当网的弹性分布式任务调度系统
1.引入maven依赖:
<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz</artifactId>
<version>2.2.2</version>
</dependency>
2.调用Demo类:
public class Demo implements Job {
public void execute(JobExecutionContext context) throws JobExecutionException {
System.out.println("扫描数据库---");
}
public static void main(String[] args) throws Exception {
// 创建任务
JobDetail jobDetail = JobBuilder.newJob(MyJob.class)
.withIdentity("job1", "group1").build();
// 创建触发器 每3秒钟执行一次
Trigger trigger = TriggerBuilder
.newTrigger()
.withIdentity("trigger1", "group3")
.withSchedule(
SimpleScheduleBuilder
.simpleSchedule()
.withIntervalInSeconds(3).
repeatForever())
.build();
Scheduler scheduler = new StdSchedulerFactory().getScheduler();
// 将任务及其触发器放入调度器
scheduler.scheduleJob(jobDetail, trigger);
// 调度器开始调度任务
scheduler.start();
}
}
//每隔 3 秒,输出"扫描数据库---"
优点:实现简单
缺点:对数据库的压力很大;计时不准,定时任务做不到非常精确的时间控制
第二种:被动取消
客户端计时+服务端检查。
1 用户留在收银台的时候,客户端倒计时+主动查询订单状态,服务端每次都去检查一下订单是否超时、剩余时间
2 用户每次进入订单相关的页面,查询订单的时候,服务端也检查一下订单是否超时
优点:实现简单
缺点:依赖客户端,如果客户端不发起请求,订单可能永远没法过期,一直占用库存
第三种:jdk自带的延时队列
JDK中提供了一种延迟队列数据结构DelayQueue
1.把订单插入DelayQueue中,以超时时间作为排序条件,将订单按照超时时间从小到大排序。
2.起一个线程不停轮询队列的头部,如果订单的超时时间到了,就出队进行超时处理,并更新订单状态到数据库中。
注意:此处可以扩展为了防止机器重启导致内存中的DelayQueue数据丢失,每次机器启动的时候,需要从数据库中初始化未结束的订单,加入到DelayQueue中。
public class DelayQueueDemo {
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
list.add("1");
list.add("2");
list.add("3");
list.add("4");
list.add("5");
list.add("6");
// 延时队列 ,消费者从其中获取消息进行消费
DelayQueue<PayOrderDelay> queue = new DelayQueue<PayOrderDelay>();
for (int i = 0; i < list.size(); i++) {
// 生产者,添加延时消息,1 延时3s 将延时消息放到延时队列中
queue.put(new PayOrderDelay(i, "订单" + list.get(i), TimeUnit.NANOSECONDS.convert(i + 1, TimeUnit.SECONDS)));
}
// 启动消费线程 消费添加到延时队列中的消息,前提是任务到了延期时间
ExecutorService exec = Executors.newFixedThreadPool(1);
exec.execute(new ConsumerThreadDemo(queue));
exec.shutdown();
}
//实现Delayed接口就是实现两个方法即compareTo 和 getDelay最重要的就是getDelay方法,这个方法用来判断是否到期……
static class PayOrderDelay implements Delayed {
//消息id
private int id;
//消息内容
private String orderId;
//延迟时长,
private long timeout;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getOrderId() {
return orderId;
}
public void setOrderId(String orderId) {
this.orderId = orderId;
}
public long getTimeout() {
return timeout;
}
public void setTimeout(long timeout) {
this.timeout = timeout;
}
PayOrderDelay(int id, String orderId, long timeout){
this.id = id;
this.orderId = orderId;
this.timeout = timeout + System.nanoTime();
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(timeout - System.nanoTime(), TimeUnit.NANOSECONDS);
}
// 自定义实现比较方法返回 1 0 -1三个参数
@Override
public int compareTo(Delayed other){
if(other == this){
return 0;
}
PayOrderDelay t = (PayOrderDelay) other;
long d = (getDelay(TimeUnit.NANOSECONDS) - t.getDelay(TimeUnit.NANOSECONDS));
return (d == 0) ? 0 : ((d < 0) ? -1 : 1);
}
}
static class ConsumerThreadDemo implements Runnable{
// 延时队列 ,消费者从其中获取消息进行消费
private DelayQueue<DelayQueueDemo.PayOrderDelay> queue;
public ConsumerThreadDemo(DelayQueue<DelayQueueDemo.PayOrderDelay> queue) {
this.queue = queue;
}
@Override
public void run() {
while (true) {
try {
DelayQueueDemo.PayOrderDelay take = queue.take();
System.out.println("消费消息id:" + take.getId() + " 消息订单" + take.getOrderId());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
优点:
效率高,任务触发时间延迟低
简单,不需要借助其他第三方组件,成本低。
缺点:
没法做到分布式处理,只能在集群中选一台leader专门处理,效率低
订单数太多,容易出现 OOM
服务器重启后,数据消失
第四种:redis过期通知
该方案使用 redis键空间机制,在 key 失效之后,提供一个回调,实际上是 redis 会给客户端发送一个消息。需要 redis 版本 2.8 以上。
1.redis配置文件开启"notify-keyspace-events Ex"
2.代码
public class RedisTest {
private static final String IP = "127.0.0.1";
private static final int PORT = 6379;
private static JedisPool jedis = new JedisPool(new GenericObjectPoolConfig(), IP, PORT, 10000, "xxxxxx", 0);
private static RedisSub sub = new RedisSub();
// 创建一个单线程的线程池
private static ExecutorService exec = Executors.newFixedThreadPool(1);
public static void main(String[] args) {
exec.submit(()->{
jedis.getResource().subscribe(sub, "__keyevent@0__:expired");
});
//消息发布者,向通道发送消息
for (int i = 0; i < 10; i++) {
jedis.getResource().setex(i+"", i+2, "订单"+i);
System.out.println("订单"+ i + "生成");
}
}
static class RedisSub extends JedisPubSub{
//消息消费者,消费消息
@Override
public void onMessage(String channel, String message){
System.out.println(message.toString() + "取消");
}
}
注意:
1.Redis过期删除不精准
Redis过期时间的原理: 当对一个key设置了过期时间,Redis就会把该key带上过期时间,存到过期字典中,在redisDb中通过expires字段维护;过期字典本质上是一个链表,每个节点的数据结构分为:key是一个指针,指向某个键对象;value是一个long long类型的整数,保存了key的过期时间。
Redis主要使用了定期删除和惰性删除策略来进行过期key的删除
定期删除:每隔一段时间(默认100ms)就随机抽取一些设置了过期时间的key,检查其是否过期,如果有过期就删除。之所以这么做,是为了通过限制删除操作的执行时长和频率来减少对cpu的影响。不然每隔100ms就要遍历所有设置过期时间的key,会导致cpu负载太大。
惰性删除:不主动删除过期的key,每次从数据库访问key时,都检测key是否过期,如果过期则删除该key。惰性删除有一个问题,如果这个key已经过期了,但是一直没有被访问,就会一直保存在数据库中。
从以上的原理可以得知,Redis过期删除是不精准的,在订单超时处理的场景下,惰性删除基本上也用不到,无法保证key在过期的时候可以立即删除,更不能保证能立即通知。如果订单量比较大,那么延迟几分钟也是有可能的。
2.消息的可靠性无法保证
redis 的 pub/sub 机制存在一个硬伤,官网内容如下“Because Redis Pub/Sub is fire and forget currently there is no way to use this feature if your application demands reliable notification of events, that is, if your Pub/Sub client disconnects, and reconnects later, all the events delivered during the time the client was disconnected are lost”
翻: Redis 的发布/订阅目前是即发即弃(fire and forget)模式的,因此无法实现事件的可靠通知。也就是说,如果发布/订阅的客户端断连之后又重连,则在客户端断连期间的所有事件都丢失了
优点:
性能高,速度快
缺点:
redis5.0之前,没有消息确认机制,消息的可靠性无法保证
Redis过期删除不精准的
第五种:rocketmq延迟队列
RocketMQ支持任意秒级的定时消息,使用门槛低,只需要在发送消息的时候设置延时时间即可
MessageBuilder messageBuilder = null;
Long deliverTimeStamp = System.currentTimeMillis() + 10L * 60 * 1000; //延迟10分钟
Message message = messageBuilder.setTopic("topic")
//设置消息索引键,可根据关键字精确查找某条消息。
.setKeys("messageKey")
//设置消息Tag,用于消费端根据指定Tag过滤消息。
.setTag("messageTag")
//设置延时时间
.setDeliveryTimestamp(deliverTimeStamp)
//消息体
.setBody("messageBody".getBytes())
.build();
SendReceipt sendReceipt = producer.send(message);
System.out.println(sendReceipt.getMessageId());
优点:
精度高,支持任意时刻
使用门槛低,和使用普通消息一样
缺点:
使用限制:定时时长最大值24小时
成本高:每个订单需要新增一个定时消息,且不会马上消费,给MQ带来很大的存储成本
同一个时刻大量消息会导致消息延迟:定时消息的实现逻辑需要先经过定时存储等待触发,定时时间到达后才会被投递给消费者。因此,如果将大量定时消息的定时时间设置为同一时刻,则到达该时刻后会有大量消息同时需要被处理,会造成系统压力过大,导致消息分发延迟,影响定时精度