在支付业务中经常会有一些轮询或者异步通知的场景,尤其是做为支付平台,往往接入多种支付渠道,需要轮询渠道交易结果或者为接入方提供标准的交易结果通知机制。有两种比较常见的场景:一、支付平台向渠道方下单成功后,由于不知道用户有没有支付,可能需要主动轮询渠道方获取交易结果。二、支付平台拿到交易结果后,可能需要以某种机制通知接入方,类似于支付宝/微信的通知机制(例如:通过一定的策略定期发起通知,如果接收方返回success,则退出通知,否则一直定期通知直到超过最大通知时效(如:通知频率为15s/15s/30s/3m/10m/20m/30m/30m/30m/60m/3h/3h/3h/6h/6h - 总计 24h4m))。
前段时间本人接到一个支付平台的需求,需要提供一个轮询机制(如:下单后开始轮询每3秒调一次渠道方,连续调20次,然后再加大直接间隔1min/1min/2min/3min/5min,最多轮询30min,如果某次轮询拿到了交易结果终态,则退出轮询,如果轮询结束还没有拿到终态,则关闭订单)。这种需求挺容易理解,但是如何实现,一开始却有些头疼。经过不断的摸索,最终选定用线程池和延时队列来实现这种机制。为啥选用线程池而不是直接在主方法上开启一个新线程?线程池能统一管理,方便监控所有轮询线程整体工作情况,调控整体性能。为啥选用延时队列?有队列的特性,同时具有延时消费的特点。
整体思路:有轮询需求时,从线程池里取一个线程;在run()方法里新建一个延时队列实例,往队列里加入任务体(任务体是自定义的一个对象,主要包含到期时间、订单号等属性);遍历消费延时队列,拿到到期的队首元素(任务),取出订单号,调渠道查询服务。
第一步:自定义任务对象。
要实现DelayQueue延时队列,队中元素要实现Delayed接口,这个接口里有一个getDelay方法,用于设置延期时间。实现类中compareTo方法负责对队列中的元素进行排序。代码如下:
package com.read.pojo;
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;
public class OrderDelayed implements Delayed {
/* 触发时间*/
private long time;
/* 订单号*/
String orderNo;
public OrderDelayed(long time, String orderNo, TimeUnit unit) {
this.time = System.currentTimeMillis() + (time > 0? unit.toMillis(time): 0);
this.orderNo = orderNo;
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(time - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
OrderDelayed orderDelayed = (OrderDelayed) o;
long diff = this.time - orderDelayed.time;
if (diff <= 0) {
return -1;
}else {
return 1;
}
}
public String getOrderNo() {
return orderNo;
}
}
第二步:写轮询机制的公共方法。 代码如下:
package com.read.service.impl;
import com.read.pojo.OrderDelayed;
import com.read.service.PollingOrderService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.DelayQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class PollingOrderServiceImpl implements PollingOrderService {
private Logger logger = LoggerFactory.getLogger(PollingOrderServiceImpl.class);
private ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(20, 100, 10, TimeUnit.SECONDS, new ArrayBlockingQueue(10000), new ThreadPoolExecutor.DiscardOldestPolicy());
/**
* @return void
* @Author read
* @Description 订单交易结果轮询
* @Date 20:00 2020/12/23
* @Param [order]
**/
@Override
public void pollingOrder(Map<String, String> order) throws Exception {
String orderNo = order.get("orderNo");
try {
threadPoolExecutor.execute(new Runnable() {
@Override
public void run() {
try {
DelayQueue<OrderDelayed> queue = new DelayQueue<>();
for (int i = 0; i < 20; i++) {
queue.put(new OrderDelayed(3 * (i + 1), orderNo, TimeUnit.SECONDS));//每隔3秒执行一次
}
OrderDelayed item1 = new OrderDelayed(90, orderNo, TimeUnit.SECONDS);//第90秒执行
OrderDelayed item2 = new OrderDelayed(120, orderNo, TimeUnit.SECONDS);//第120秒执行
OrderDelayed item3 = new OrderDelayed(180, orderNo, TimeUnit.SECONDS);//第180秒执行
OrderDelayed item4 = new OrderDelayed(300, orderNo, TimeUnit.SECONDS);//第300秒执行
OrderDelayed item5 = new OrderDelayed(60 * 10, orderNo, TimeUnit.SECONDS);//轮询最多10分钟
queue.put(item1);
queue.put(item2);
queue.put(item3);
queue.put(item4);
queue.put(item5);
int length = queue.size();
for (int i = 0; i < length; i++) {
OrderDelayed orderDelayed = queue.take();//获取到期的队首元素
logger.info("订单号【{}】的付款订单,第{}次轮询", orderDelayed.getOrderNo(), (i + 1));
//执行具体的业务方法
// 如果拿到终态(达到目标),直接break,退出轮询
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
} catch (Exception e) {
logger.info("轮询订单交易结果异常", e);
}
}
}
完成上面的工作之后,简单测试,可以按照设定的机制进行轮询任务,那么这个公共方法满足需求了吗?答案是远远没有,这对本人来说是个新东西,必须充分测试,涉及到多线程,性能如何,会不会导致系统资源过度占用,甚至内存耗尽等严重问题?果然,经过多次测试后发现两个严重问题。
一、仅仅是几个线程开始工作,就导致CPU占用严重,很快到了100%。排查了半天没有发现问题,一度让我放弃了使用延时队列,而改用比较笨的for循环里套用Thread.sleep()方式实现延时。后来还是不死心,感觉DelayQueue肯定有它存在的道理,必定是使用者的问题。通过查阅资料,发现有一个地方容易产生坑,果然我就是入坑了。
实现Delayed接口时,要实现一个getDelay方法。
这个参数极为重要,因为自定义的对象的time属性就是以毫秒计算的,所以此处也需要用毫秒。为啥呢?看下面DelayQueue里面的take()方法:
如果getDelay()方法第二个参数设置的是TimeUnit.NANOSECONDS话,返回的值其实比以前小了1000000倍,直接导致CPU严重“空转”,CPU迅速占满也就不难理解了。本人当时更蠢,直接把getDelay()这样写了:
找到问题后,改正,再测试,CPU正常了。
二、线程池工作机制问题。测试发现任务多了后,有些任务迟迟没有开始轮询。研究了下线程池的机制(当线程数小于核心线程数时,创建线程;当线程数大于等于核心线程数,且任务队列未满时,将任务放入任务队列;当线程数大于等于核心线程数,且任务队列已满,若线程数小于最大线程数,创建线程;若线程数等于最大线程数,根据设定的任务拒绝处理器抛出异常或拒绝任务)才发现问题。原来超出核心线程数的任务都进入了队列等待执行,而不是在不超过最大线程数的情况下创建线程。这显然是不符合实际需求的,实际需求需要立即开始轮询(极端情况除外)。
好在天无绝人之路,经过分析线程池的主要参数及方法,发现核心线程数等是可以动态设置的。于是就想到了如下方案:线程池初始化时设置较小的核心线程数;当检测到任务较多是对核心线程数进行扩容(比如+10),就是当系统业务量大的时候在不超过最大线程数的前提下动态提高处理能力;极端情况下业务量异常激增多出的任务暂时进入队列等待执行(算是一种服务降级吧);当业务量明显下降时可以动态对核心线程数进行缩容(比如-10),避免资源浪费。下面是本人写的一个动态调整核心线程数方法,仅供参考。
/**
* @Author read
* @Description 检查并动态调整线程池
* 基本思路:
* 1、以较小的核心线程数初始化,避免占用太多空闲资源;
* 2、业务量初步增长的时候动态扩张核心线程数,避免任务过早进入队列导致响应慢;(业务量大时增加并发处理能力)
* 3、业务量异常激增超过最大线程数时,此时线程池满负荷工作,超出的任务进入队列等待处理;(变相实现服务降级)
* 4、业务量下降时动态缩容核心线程数,避免过多资源浪费;
* @Date 23:50 2020/12/22
* @Param []
* @return void
**/
public void checkThreadPool() {
int activeCount = threadPoolExecutor.getActiveCount();
int corePoolSize = threadPoolExecutor.getCorePoolSize();
int maximumPoolSize = threadPoolExecutor.getMaximumPoolSize();
int usedSize = threadPoolExecutor.getQueue().size();
logger.info("负责轮询的线程池活动线程数{},核心线程数{},最大线程数{},队列任务数{}", activeCount, corePoolSize, maximumPoolSize, usedSize);
if ((activeCount > corePoolSize - 10) && (corePoolSize + 10 < maximumPoolSize)) {
threadPoolExecutor.setCorePoolSize(corePoolSize + 10);
logger.info("负责轮询的线程池核心线程数扩容10");
}
if (activeCount < corePoolSize - 25) {
threadPoolExecutor.setCorePoolSize(corePoolSize - 10);
logger.info("负责轮询的线程池核心线程数缩容10");
}
}
在订单轮询的主方法中,创建线程前和延时队列遍历完成后调用动态调整线程池方法,完善后的代码如下:
@Override
public void pollingOrder(Map<String, String> order) throws Exception {
String orderNo = order.get("orderNo");
try {
checkThreadPool();//动态调整线程池
threadPoolExecutor.execute(new Runnable() {
@Override
public void run() {
try {
DelayQueue<OrderDelayed> queue = new DelayQueue<>();
for (int i = 0; i < 20; i++) {
queue.put(new OrderDelayed(3 * (i + 1), orderNo, TimeUnit.SECONDS));//每隔3秒执行一次
}
OrderDelayed item1 = new OrderDelayed(90, orderNo, TimeUnit.SECONDS);//第90秒执行
OrderDelayed item2 = new OrderDelayed(120, orderNo, TimeUnit.SECONDS);//第120秒执行
OrderDelayed item3 = new OrderDelayed(180, orderNo, TimeUnit.SECONDS);//第180秒执行
OrderDelayed item4 = new OrderDelayed(300, orderNo, TimeUnit.SECONDS);//第300秒执行
OrderDelayed item5 = new OrderDelayed(60 * 10, orderNo, TimeUnit.SECONDS);//轮询最多10分钟
queue.put(item1);
queue.put(item2);
queue.put(item3);
queue.put(item4);
queue.put(item5);
int length = queue.size();
for (int i = 0; i < length; i++) {
OrderDelayed orderDelayed = queue.take();//获取到期的队首元素
logger.info("订单号【{}】的付款订单,第{}次轮询", orderDelayed.getOrderNo(), (i + 1));
//执行具体的业务方法
// 如果拿到终态(达到目标),直接break,退出轮询
}
checkThreadPool();//动态调整线程池
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
} catch (Exception e) {
logger.info("轮询订单交易结果异常", e);
}
}
测试后,达到预期效果。 以上是本人总结的一种轮询机制,其原理同样适用于通知机制或其他相似场景。初次尝试难免有不足之处,敬请斧正。