一 需求背景
当前参与的大型分布式项目中,需要一个点单部署的服务环境,以定时调度的方式来使一些处理任务能够于后台执行一些特殊功能,包括数据加工、外发通讯以及信息推送等。
按常规的设计思路应该以常驻任务的方式来实现,比如在应用程序内部死循环、阻塞等。但是考虑到Java对线程资源的消耗问题,需要尽量合理的利用线程资源,而且这些原本应常驻的任务也并非无时无刻的在处理其他系统资源,以提升系统性能,提高系统吞吐量,所以调整为周期性的调度方式是较为合理的。
随之而来的问题是,如何在保证服务不停机的情况下,能够对调度任务进行生命周期控制,以及对任务执行过程中的性能情况进行监控。延伸开来,还需要考虑单点服务的高可用,特殊情况下如后台任务量较多时,需要支持多节点部署,但每个节点运行的后台任务需保证单例。当然还需要服务能够支持优雅下线,保证需处理的任务不丢失,正执行的任务不会异常结束等。
还有更多、更细节的需求场景这里不再多说,本文主要介绍下我是如何设计能够满足这样需求的一个调度处理器,设计方案未必最佳,但是思路还是较为清晰的,并在此前提下介绍下各类Java基础技术的一些使用,仅供参考。
二 如何处理工作任务
按如上需求我首先想到了Timer,因为Timer就是用来处理延迟和周期任务的,而我们的需求看似正好符合它的处理场景,然而Timer本身是存在一定的缺陷:
- 执行周期不准
Timer在处理任务时会创建一个线程,如果一个TimerTask的执行时间远超过Timer的调度周期,比如说任务每次执行需要100s,而调度周期为10s,那么在任务第一次执行结束后可能会出先任务的连续调用,或者出现调度失效,结果取决于设置Timer时究竟是以固定速率来设置调度周期,还是以固定的延迟时间来设计调度周期。 - 不可控的异常问题
如果工作任务TimeTask在执行时抛出了某个未经检查的异常,Timer线程并不会对异常进行捕获,这时候Timer会认为被取消,尚未执行的任务不会再执行,新的任务也不再会被调度,我们称之为“线程泄漏”。 - 多任务的生命周期难以控制
针对单个线程的生命周期控制尚且难以处理,多个任务并发执行时对整个服务环境来说就已经变得极难控制。当希望所有任务的调度过程可控,且服务环境需要提供一些必要的生命周期管理功能时,Timer绝不是最优选择。
因此我选择以线程池替代Timer,将任务调度的目标从Thread转移到Excutor框架。
二 线程池选择
接下来需要考虑的是选用哪种线程池,以何种方式调度方案更优。
这里需要介绍下Java目前提供的几种线程池类型,通过Executors中静态工程方法可以创建若干类型的线程池资源:
- newFixedThreadPool()方法将返回一个固定线程数量的线程池,每次提交任务便会创建一个线程,直到到达线程池允许的最大线程数量,这时候线程池规模不再延伸。
- newCacheThreadPool()方法将返回一个可缓存的线程池,如果当前池中的线程数已经大于需处理的任务量,那么将回收空闲线程资源,而处理任务的需求量增加时线程池会创建新的线程来处理,池的规模无限制。
- newScheduledThreadPool()方法将返回一个固定规模的线程池,而且支持以延迟或定时的方式来执行任务,类似于定时器Timer。
需要注意的是,如上方法返回的类型都是ExecutorService,ExecutorService是一组服务接口定义,Executor就是通过扩展了这些接口来补充了用于生命周期管理相关方法的,我们所谓的线程池正是Executor框架的一部分。当然Executors中提供的方法不仅仅这些,其他信息读者可查阅源码或官方文档获得。
显而易见的ScheduledThreadPoolExecutor是最优选择,因为它提供了延迟或周期性执行策略。
三 并不采用周期调度模式
虽然需求中描述的是以固定的时间间隔来调度工作任务,但是设计上我并不建议直接采用周期调度模式,原因是我希望每一次任务的执行都能在可控范围内,当任务执行失败(并非业务逻辑处理失败,包括因锁导致数据库访问超时、网络阻塞、同步阻塞方法调用等)时可以被监控,并被调度框架处理,而非因独占线程导致资源泄漏。
而且大型分布式系统中,各种资源的高可用配置决定了调度任务的执行必须能够灵活配置,包括运行状态中的生命周期管理。如果采用周期调度模式,无论以固定速率还是以固定时间的方式来处理,都很难在服务启动后对整个任务群进行干预。
所以最终我选择以延迟处理模式来执行任务,那么如何实现周期性的调度呢?其实很简单,处理思路是每一个任务都以延迟的方式交给线程池管理,当任务执行结束后,再将自己以新的工作任务方式归还线程池,这样设计就给了我很大的处理空间,任务自身的创建(无论是从DB或是Redis等获取初始化信息)、归还(归还还是取消、归还前是否需要其他处理)等动作都可以被再次封装,以扩展需求。
四 数据模型设计
且不论工作任务初始化时的成员信息从何而来,但是能够确定的是它必须包含如下属性:
- ProcessParam,执行参数
- ScheduleStartime,调度开始时间
- ScheduleOvertime,调度结束时间
- SchedultInterval,调度间隔
之所以需要调度的开始结束时间,是为了能够更加灵活的实现任务执行策略,让线程池将更加充分的利用线程资源。补充了执行参数是为了能让相同程序以不同参数执行时可以处理不同的业务场景,这样可以绕过单例达到多实例但执行不同的处理流程来更加高效的处理需求的目的。
当然,我更希望这些工作任务在脱离当前调度框架时依然能够执行,任务本身应该应该就是可执行的,所以我对工作任务做了如下设计:
class Work implements Runnable {
private String param; // 执行参数
private int startime; // 调度开始时间,整形例112356
private int overtime; // 调度结束时间
private int interval; // 调度间隔
// Getter and Setter
@Override
public void run() {
process();
}
private void process() {
// 业务逻辑
}
}
五 封装工作任务
Work本身实现了Runnable接口,它可以被任何线程执行,当然为了能够在调度框架中执行,我对它再次封装:
class SchedultTask implements Runnable {
private Work work;
SchedultTask(Work work) {
this.work = work;
}
@Override
public void run() {
this.work.run();
// 调度任务,由调度框架提供
schedultTask(work);
}
}
当Work以调度任务执行在调度框架时,那么run()方法会被当作普通方法执行(业务逻辑执行入口)。scheduleTask()方法实现的主要逻辑是按调度参数将当前任务重新归还线程池,该方法由调度框架提供,后文会介绍。
六 调度框架设计
调度框架在运行环境准备完毕后,线程池就已经就绪了,那么在服务启动时需要获取工作任务信息来实例化各工作任务类,并将其置入调度线程池,简单提供下初始化及服务关闭的实现:
public class ScheduleTaskProcessor {
private ScheduledExecutorService executor = Executors.newScheduledThreadPool(10);
public void startService() {
if (executor == null) {
executor = Executors.newScheduledThreadPool(10);
}
processSchedultTask();
}
public void finishService() {
if (executor != null) {
executor.shutdown();
}
……
}
proessSchedultTask()方法即服务启动后的执行入口,它首先需要获取实例化工作任务的信息:
private List<Work> getWorks() {
// From DB or Redis
return null;
}
然后需要判断当前时间是否允许工作任务执行,这里使用了LocalTime类来对实现时间范围的比较,更多接口信息读者可参阅API文档:
private boolean isPeriod(int from, int to) {
LocalTime now = LocalTime.now();
LocalTime startTime = LocalTime.of(from / 10000, from / 100 % 100, from % 100);
LocalTime endTime = LocalTime.of(to / 10000, to / 100 % 100, to % 100);
return now.isAfter(startTime) && now.isBefore(endTime);
}
当前时间如果不允许工作任务执行,那么还需要获取工作延迟时间,需要注意如果当前时间已经晚于任务调度的结束时间,且早于调度开始时间,则需要取当前时间和调度开始时间的绝对值,否则会出现负值:
private long getDelayMillins(int from) {
LocalTime now = LocalTime.now();
LocalTime startTime = LocalTime.of(from / 10000, from / 100 % 100, from % 100);
return Math.abs(ChronoUnit.MILLIS.between(now, startTime));
}
所以调度逻辑的具体实现为:
private void schedultTask(Work work) {
SchedultTask schedultTask = new SchedultTask(work);
if (isPeriod(work.getStartime(), work.getOvertime())) {
executor.schedule(schedultTask, work.getInterval(), TimeUnit.MILLISECONDS);
} else {
executor.schedule(schedultTask, getDelayMillins(work.getStartime()) + work.getInterval(), TimeUnit.MILLISECONDS);
}
}
最终调度框架的主处理方法processSchedultTask()的实现应该如下:
private void processSchedultTask() {
List<Work> works = getWorks();
if (works == null || !works.isEmpty())
return;
for (Work work : works) {
// 服务器启动以及工作任务处理结束时均会调用该方法
schedultTask(work);
}
}
七 结语
全文没有更为具体的代码实现,仅提供概要的设计思路,因为具体的实现过程涉及的方面极为庞大,包括数据库链接的访问超时处理、业务逻辑的异常处理、高可用处理、多数据源访问处理、性能监控处理以及集群内节点间通信处理等等,这些实现对于不同的需求场景而言不具备分享意义。
概括的说本文目的是分享如何灵活的使用Java提供的Executor框架来实现并发场景下的多后台任务调度需求,文中涉及的API和整体设计思路才是读者应该关注的重点,笔者水平有限,如果读者有更好的想法望不吝赐教。