异步结果通知实现-整体介绍与内存中实现

转发自:https://juejin.cn/post/6936802804937785351

在这里插入图片描述

上图是目前扫码支付中普遍的数据流转情况。在此场景中,异步结果通知 承担着保证两系统(支付渠道和商户)之间 数据一致性 的工作。当有支付结果时,为保证时效性,必须 立即 通知给下游商户,且当通知失败时需要尽量保证系统间数据一致性,即遵循约定的 重试策略。由此可以看出是十分重要的一个环节。
关于异步通知的实现,本人结合实际经验和网上一些业界流行的解决方案,整理出了几篇相关的笔记,在这里做一下记录。本文是第一篇,主要讲 内存中的简单实现,其中也包含着三种不同的做法,分别是:扫表实现、基于 BlockingQueue 实现、基于 DelayQueue 实现,各有优劣,下面就展开讲讲。

定时扫表实现

在这里插入图片描述

首先来看看,扫表实现,碍于篇幅(后续存在大量代码,着重讲后两种实现),该种实现不演示代码,只提供一些思路:

1、新建异步通知表,当需要通知时,先新增一条记录(存在通知时间、通知次数、通知状态等字段)
2、新建扫表任务和异步通知工作线程池,扫表任务负责把满足通知条件的记录从表里捞出来,并把该消息推入工作线程池。工作线程通过网络交互负责将消息推给下游商户,根据商户响应内容,更新 异步通知表 记录的相关信息

实现简单,且当应用重启时,不存在通知消息数据丢失问题。但缺点在于扫表任务的频率难以设置。如若数据量大,扫表频率势必要高,就造成 CPU 频繁占用问题,影响其他业务处理。此时如果将频率变缓,又会存在通知时效性问题。那如何解决这问题呢?

BlockingQueue 实现

基于上面扫表的缺点,来详细讲讲后两种实现。先讲 基于 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)的队列。假如当前任务数量非常多,但排在队列前端的可能是执行时间较后的任务,而那些当前需要立即执行的排在了后面,就有可能导致新消息不能被及时消费。
那么我们可以设想的是,在队列中元素最好是按照执行时间进行排序的。

DelayQueue 实现

于是就引出了 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 中间件解决这些问题。

末尾附上项目代码
https://gitee.com/zhangquanxin1234/delay-queue-message-notice.git

猜你喜欢

转载自blog.csdn.net/qq_38205881/article/details/123700021