赞
踩
生活中,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中提供了一种延迟队列数据结构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键空间机制,在 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支持任意秒级的定时消息,使用门槛低,只需要在发送消息的时候设置延时时间即可
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带来很大的存储成本
同一个时刻大量消息会导致消息延迟:定时消息的实现逻辑需要先经过定时存储等待触发,定时时间到达后才会被投递给消费者。因此,如果将大量定时消息的定时时间设置为同一时刻,则到达该时刻后会有大量消息同时需要被处理,会造成系统压力过大,导致消息分发延迟,影响定时精度
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。