上图是目前扫码支付中普遍的数据流转情况。在此场景中,异步结果通知 承担着保证两系统(支付渠道和商户)之间 数据一致性 的工作。当有支付结果时,为保证时效性,必须 立即 通知给下游商户,且当通知失败时需要尽量保证系统间数据一致性,即遵循约定的 重试策略。由此可以看出是十分重要的一个环节。 关于异步通知的实现,本人结合实际经验和网上一些业界流行的解决方案,整理出了几篇相关的笔记,在这里做一下记录。本文是第一篇,主要讲 内存中的简单实现,其中也包含着三种不同的做法,分别是:扫表实现、基于 BlockingQueue 实现、基于 DelayQueue 实现,各有优劣,下面就展开讲讲。
首先来看看,扫表实现,碍于篇幅(后续存在大量代码,着重讲后两种实现),该种实现不演示代码,只提供一些思路:
1、新建异步通知表,当需要通知时,先新增一条记录(存在通知时间、通知次数、通知状态等字段) 2、新建扫表任务和异步通知工作线程池,扫表任务负责把满足通知条件的记录从表里捞出来,并把该消息推入工作线程池。工作线程通过网络交互负责将消息推给下游商户,根据商户响应内容,更新 异步通知表 记录的相关信息
实现简单,且当应用重启时,不存在通知消息数据丢失问题。但缺点在于扫表任务的频率难以设置。如若数据量大,扫表频率势必要高,就造成 CPU 频繁占用问题,影响其他业务处理。此时如果将频率变缓,又会存在通知时效性问题。那如何解决这问题呢?
基于上面扫表的缺点,来详细讲讲后两种实现。先讲 基于 BlockingQueue 实现。下面是整个通知框架的设计图:
和扫表做法一样,此时还是需要一个消费者线程。区别在于,并不是按照固定的频率去扫表,而是调用队列的 java.util.concurrent.BlockingQueue#take 方法,来 “监听” 队列,有消息时立即返回;当不存在消息时,该方法会一直阻塞。 所以,触发动作就变成,调用者 将消息 投递 到消息队列,而不是 新增表记录 让扫表线程主动去捞。将 异常|失败重试队列 和 一般的队列 分开,是为了不让失败的消息太多导致积压,影响正常通知。 由于后面的几种方式都是基于 异步通知框架,这里有必要详细讲下代码设计和实现(不仅仅适用于异步通知): // 将任务抽象成接口
public interface DelayRunnable {
void run(DelayTaskContext context);
}
// 新建任务消息基类
public class DelayTaskBaseMessage {
}
复制代码
新建任务执行上下环境类(DelayTaskContext),包含
public class DelayTaskContext {
private DelayRunnable runnable;
private DelayTaskBaseMessage message;
// 非必填,当不填时,使用默认的线程池工作
private ExecutorService workerThreadPool;
// 非必填,计算 executeTime、count 的策略
// 当不填时,默认使用 WeChatReTryStrategy
private BaseSchedulerStrategy strategy;
public DelayTaskContext(DelayRunnable runnable, DelayTaskBaseMessage message,
BaseSchedulerStrategy strategy, ExecutorService workerThreadPool) {
this.runnable = runnable;
this.message = message;
this.strategy = strategy;
this.workerThreadPool = workerThreadPool;
}
// 省略 get、set
}
由于系统中必然会存在多种通知策略,例如:固定次数和频率的策略,固定次数但频率变化的策略。所以让调用方自定义策略非常有必要。 新建抽象通知策略(BaseSchedulerStrategy),具有 执行次数 和 下次执行时间 两个属性,提供 重新计算抽象方法 交给子类去自定义实现。
@Data // Lombok
public abstract class BaseSchedulerStrategy {
// 剩余可执行次数
private Integer count;
// 下次执行时间
private Date executeTime;
public BaseSchedulerStrategy(Integer count, Date executeTime) {
this.count = count;
this.executeTime = executeTime;
}
public void caclAndResetParam(DelayTaskContext context) {
// 默认剩余可通知次数 -1
this.setCount(this.getCount() - 1);
this.doCaclAndResetParam(context);
}
// 做成抽象方法,具体的策略,交给子类去实现
abstract void doCaclAndResetParam(DelayTaskContext context);
}
下面提供两个策略具体实现:
FixPeriodStrategy:最大次数+固定频率 WeChatReTryStrategy:最大次数+可配置频率
public class FixPeriodStrategy extends BaseSchedulerStrategy {
// 执行间隔,单位秒
private int periodSecond;
public FixPeriodStrategy(int initialDelay, int periodSecond, int maxExecuteCount) {
super(maxExecuteCount, DateUtils.addSeconds(new Date(), initialDelay));
this.periodSecond = periodSecond;
}
@Override
public void doCaclAndResetParam(DelayTaskContext context) {
Date time1 = DateUtils.addSeconds(super.getExecuteTime(), periodSecond);
super.setExecuteTime(time1);
}
}
public class WeChatReTryStrategy extends BaseSchedulerStrategy {
// 初始化通知时间间隔,以秒为单位
private static List<String> intervals;
static {
String defaultNoticeStrategy = "15s/15s/30s/3m/10m/20m/30m/30m/30m/60m/3h/3h/3h/6h/6h";
intervals = new ArrayList<>(Arrays.asList(defaultNoticeStrategy.split("/")));
// !省略 !格式化 m、h 等单位为秒,重新计算时间间隔
intervals = new ArrayList<>();
}
public WeChatReTryStrategy(){
super(intervals.size(), new Date());
}
@Override
public void doCaclAndResetParam(DelayTaskContext context) {
// 因为当通知失败就会立刻重新加入队列
// 所以这里视当前时间就为上一次通知完成时间,下标需要 -1
int index = (intervals.size() - super.getCount()) - 1;
Date nextExecuteTime = DateUtils.addSeconds(new Date(), Integer.parseInt(intervals.get(index)));
super.setExecuteTime(nextExecuteTime);
}
接着实现最关键的调度类(DelayTaskScheduler)
// com.google.common.util.concurrent.ThreadFactoryBuilder
@Slf4j
public final class DelayTaskScheduler {
private DelayTaskScheduler(){}
private static final Integer worker_num = 3;
private static final Integer queue_size = 10000;
private static ExecutorService defaultWorkerThreadPool = new ThreadPoolExecutor(worker_num, worker_num, 0L,
TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(queue_size), new ThreadFactoryBuilder().setNameFormat("Scheduler 默认 worker 线程").build());
// 一般队列
private static final BlockingQueue<DelayTaskContext> defaultQueue = new LinkedBlockingQueue<>(queue_size);
// 异常重试队列
private static final BlockingQueue<DelayTaskContext> failRetryQueue = new LinkedBlockingQueue<>(queue_size);
// 建议在项目启动时,调用该方法初始化任务环境
public static void init() {
Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("队列消费者线程").build()).execute(() -> {
while (true) {
takeAndDispatch(defaultQueue, defaultWorkerThreadPool);
}
});
Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("重新执行队列消费者线程").build()).execute(() -> {
while (true) {
takeAndDispatch(failRetryQueue, defaultWorkerThreadPool);
}
});
}
// 使用默认策略(微信)加入队列
public static void putWithDefaultStrategy(DelayRunnable runnable, DelayTaskBaseMessage message,
ExecutorService workerThreadPool) {
WeChatReTryStrategy strategy = new WeChatReTryStrategy();
put(runnable, message, strategy, workerThreadPool);
}
public static void put(DelayRunnable runnable, DelayTaskBaseMessage message,
BaseSchedulerStrategy schedulerStrategy, ExecutorService workerThreadPool) {
DelayTaskContext context = new DelayTaskContext(runnable, message, schedulerStrategy, workerThreadPool);
try {
defaultQueue.put(context);
} catch (Exception e) {
log.info("投递异常:", e);
}
}
public static void rePut(DelayTaskContext context) {
try {
// 调用子类通知策略,重新计算通知参数
context.getStrategy().caclAndResetParam(context);
failRetryQueue.put(context);
}
catch (Exception e) {
log.info("投递异常:", e);
}
}
private static void takeAndDispatch(BlockingQueue<DelayTaskContext> queue, ExecutorService workerThreadPool) {
try {
DelayTaskContext context = queue.take();
if (context.getStrategy().getExecuteTime().compareTo(new Date()) > 0) {
// 当不满足时间条件时,放回队列
queue.put(context);
return ;
}
//
if (context.getStrategy().getCount() > 0) {
log.info("取出固定次数队列中消息:{},投递到线程池中执行", context.getMessage());
if (context.getWorkerThreadPool() != null) {
// 如果指定自定义线程池执行
context.getWorkerThreadPool().execute(() -> context.getRunnable().run(context));
}
else {
workerThreadPool.execute(() -> context.getRunnable().run(context));
}
}
} catch (Exception e) {
log.error("延时队列,消费轮训线程异常", e);
try { Thread.sleep(30 * 1000);} catch (Exception e1) {e1.printStackTrace();}
}
}
}
对外提供 init 初始化方法,建议在项目启动后立即调用。启动时创建两个消费者线程,分别 “监听” 两个队列。 对外提供 put、reput 方法,实际动作就如字面意思,将消息加入、重新加入队列。在 reput 方法中需要注意的是,需要调用重新计算执行参数(策略中的次数和下次执行时间)。 takeAndDispatch 方法的作用是负责将消息取出,假如满足执行条件,则投递到对应的线程池中;不满足则扔回原来队列。
下面来看看如何使用:
public class Test {
static {
DelayTaskScheduler.init();
}
public static void main(String[] args) throws Exception {
DelayTaskScheduler.put(new TestTask(), new TestMessage("消息正文1"), new FixPeriodStrategy(0, 1, 3), null);
DelayTaskScheduler.put(new TestTask(), new TestMessage("消息正文2"), new WeChatReTryStrategy(), null);
Thread.sleep(10 * 1000);
}
private static class TestMessage extends DelayTaskBaseMessage {
private String content;
public TestMessage(String content) {
this.content = content;
}
// 省略 get、set
}
private static class TestTask implements DelayRunnable {
@Override
public void run(DelayTaskContext context) {
TestMessage message = (TestMessage) context.getMessage();
System.out.println(message.getContent());
DelayTaskScheduler.rePut(context);
}
}
}
现在,我们已经实现了一个 简单的支持自定义策略、线程池的异步任务框架 了。但还存在问题:我们都知道 Queue 是一个先进先出(FIFO)的队列。假如当前任务数量非常多,但排在队列前端的可能是执行时间较后的任务,而那些当前需要立即执行的排在了后面,就有可能导致新消息不能被及时消费。 那么我们可以设想的是,在队列中元素最好是按照执行时间进行排序的。
于是就引出了 PriorityQueue(优先级队列),该类支持在构造方法中传入 Comparable 比较器,用于在入队时判断元素的优先级,所以在队列中的元素都是有序的。那是不是直接把队列的数据结构从 BlockingQueue 换成 PriorityQueue 就行了呢?答案当然是不行。PriorityQueue 并不是阻塞队列,并没有 take 这种阻塞式操作,换句话说,需要对 获取队列元素这个方法 进行不停地调用,会频繁占用 CPU。 Java 前辈也想到了这点,刚好给我们提供了这样子的数据结构 DelayQueue(延迟队列),既是 BlockingQueue,又具有优先级功能。 从类名字中的 Delay 关键字就可以看出是比较的维度是时间。队列元素必须实现 Delayed 接口,加上该接口又扩展了 Comparable 接口,所以在定义队列元素时必须覆写两个方法:
private static class DelayMsg implements Delayed {
private LocalDateTime executeTime;
public DelayMsg(LocalDateTime executeTime) {
this.executeTime = executeTime;
}
// 返回当前元素相比当前时间需要延迟执行毫秒数
@Override
public long getDelay(TimeUnit unit) {
long milli = executeTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
long res = unit.convert(milli - System.currentTimeMillis(), MILLISECONDS);
System.out.println("计算得到,延时:" + res);
return res;
}
// 时间值比较大靠后
@Override
public int compareTo(Delayed o) {
DelayMsg b = (DelayMsg) o;
if (b.getExecuteTime().compareTo(executeTime) > 0) {
return -1;
}
if (b.getExecuteTime().compareTo(executeTime) < 0) {
return 1;
}
return 0;
}
// 省略 get 方法...
}
getDelay:返回队列元素相比于当前时间需要延迟执行的时间跨度 compareTo:定义队列元素之间的排序方式
接着改造之前的代码。因为队列里的元素是自己封装的 DelayTaskContext 类,所以需要该类就要实现 Delayed 接口,如下:
public class DelayTaskContext implements Delayed {
// 省略其他的参数..上文有完整的
@Override
public long getDelay(TimeUnit unit) {
long nextTime = this.getStrategy().getExecuteTime().getTime();
return unit.convert(nextTime - System.currentTimeMillis(), MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
DelayTaskContext b = (DelayTaskContext) o;
if (b.getStrategy().getExecuteTime().compareTo(this.getStrategy().getExecuteTime()) > 0) {
return -1;
}
if (b.getStrategy().getExecuteTime().compareTo(this.getStrategy().getExecuteTime()) < 0) {
return 1;
}
return 0;
}
}
队列在声明时,用的是 BlockingQueue 接口,故在 DelayTaskScheduler 类中,只要将 defaultQueue、failRetryQueue 的数据结构变为 DelayQueue 即可:
private static final BlockingQueue<DelayTaskContext> defaultQueue = new DelayQueue<>();
private static final BlockingQueue<DelayTaskContext> failRetryQueue = new DelayQueue<>();
同时,DelayQueue 内部已经对时间做了延时判断,所以在 DelayTaskScheduler#takeAndDispatch 方法中可以将时间判断逻辑删除,代码相比之下更加精简:
private static void takeAndDispatch(BlockingQueue<DelayTaskContext> queue, ExecutorService workerThreadPool) {
try {
DelayTaskContext context = queue.take();
// 删除了时间判断逻辑
//
if (context.getStrategy().getCount() > 0) {
log.info("取出固定次数队列中消息:{},投递到线程池中执行", context.getMessage());
if (context.getWorkerThreadPool() != null) {
context.getWorkerThreadPool().execute(() -> context.getRunnable().run(context));
}
else {
workerThreadPool.execute(() -> context.getRunnable().run(context));
}
}
}
catch (Exception e) {
log.error("延时队列,消费轮训线程异常", e);
try { Thread.sleep(30 * 1000);} catch (Exception e1) {e1.printStackTrace();}
}
}
其余都不做修改,这就完成了改造。
以上就是内存中实现异步通知的几种方式,同时这个框架不仅仅适用于通知,其他的简单 异步延迟场景 也能适用,如 订单过期、缴费成功消息推送等等。不过缺点也很明显,前一种方式无法保证时效性。后两种虽然时效性问题到了解决,但引进了消息持久化问题。后面一篇笔记会引进 Redis 中间件解决这些问题。