任务队列,延迟任务,这些往往是生产者消费者模型中不可缺少的一种模式
主流的开源消息消费框架,Kafka,RocketMQ里都有延迟消息的应用场景,RocketMQ开源版实现了18个等级的延迟队列,但是并没有任意延时时间的实现,Kafka是基于时间轮实现了延迟消息的功能。
除了消息队列框架,任务框架本身也是延迟任务的应用,开源的xxljob就是这么一个基于数据库的任务调度框架
至于为什么从xxljob上入手,还是从源码复杂度上而言,xxljob已经是最简单的时间轮实现了。
直接进入主题
1、时间轮的模型
private volatile static Map<Integer, List<Integer>> ringData = new ConcurrentHashMap<>();
基本结构:一张图,一行代码,Map的key是时间,val List<Integer>是任务链,Integer是任务的唯一ID
轮的体现:round轮次,如果当前在0位置,那么2S后的任务应该在第0轮次的2号槽位上,10S后的任务应该在第1轮次的2号槽位上,18S后的任务应该在第3轮次的2号槽位上,依次类推
1、抽象任务和时间轮模型
相信有了以上的讲解,你大致可以写几行代码至少,那么我们直接以xxljob里源码起步,看一个青春且完整的时间轮任务版本。
我们先讲述目标场景,我们希望以任意延迟,完成任务调度和执行功能,至于任务的内容,可以是分发消息,可以是业务代码无所谓,我们只关注任务调度就可以。
那么我们先要抽象一个任务,我们不需要子任务和一些乱起八糟的东西,先从青春版开始:
XxlJobInfo:
public class XxlJobInfo {
private int id; // 主键ID
private int jobGroup; // 执行器主键ID
private String jobDesc;
private Date addTime;
private Date updateTime;
private String scheduleType; // 调度类型
private String scheduleConf; // 调度配置,值含义取决于调度类型
private String misfireStrategy; // 调度过期策略
private String executorRouteStrategy; // 执行器路由策略
private String executorHandler; // 执行器,任务Handler名称
private String executorParam; // 执行器,任务参数
private String executorBlockStrategy; // 阻塞处理策略
private int executorTimeout; // 任务执行超时时间,单位秒
private int executorFailRetryCount; // 失败重试次数
private int triggerStatus; // 调度状态:0-停止,1-运行
private long triggerLastTime; // 上次调度时间
private long triggerNextTime; // 下次调度时间
}
时间轮:
private volatile static Map<Integer, List<Integer>> ringData = new ConcurrentHashMap<>();
给时间轮添加任务:
private void pushTimeRing(int ringSecond, int jobId){
// push async ring
// 获取槽位任务链
List<Integer> ringItemData = ringData.get(ringSecond);
if (ringItemData == null) {
ringItemData = new ArrayList<Integer>();
ringData.put(ringSecond, ringItemData);
}
ringItemData.add(jobId);
}
那么任务存在哪呢,xxljob是基于数据库的,这里直接读了数据库拿到人物列表,我们掠过这部分代码,还是假设我们能不断拿到任务,注意此时我们处在从任务生产者上拿到任务的阶段里,我们接下来需要做的事情的思路是:执行要执行的任务,延迟的任务要放入时间轮
那么会有以下几个问题:
(1)那到底应该在什么时刻拿任务
(2)是么时刻的任务应该执行
(3)什么时刻的任务又应该放入时间轮
(4)时间轮里的任务又是什么时候被执行的呢?
解决以上四个问题,基本就能完成一个青春版了
当然我们也先把奇奇怪怪的异常情况提出来放到这,不影响主流程但是有助于思考:任务真的不会超时么,任务失败了怎么办?
2、提交任务
带着以上问题,我们先实现把数据库变为可执行的过程提交给任务线程池
继续,这里我总结一下xxl的做法:
先关注任务的几个参数:
private long triggerLastTime; // 上次调度时间
private long triggerNextTime; // 下次调度时间
(1)如果当前时间已经超过triggerNextTime 5S以上,说明我们任务已经过期,这里xxljob模仿了Quartz框架里miss任务的解决方式提供了两种策略:
do nothing 什么都不做
fire once now 马上开火,立刻补偿执行一次
默认fire once now,发现没有执行的超时任务立马执行一次,如果任务不止触发一次,就更新tiggerNextTime
(2)如果当前时间在triggerNextTime和triggerNextTime+5S以内,我都放入执行线程池立即执行
(3)如果当前时间在triggerNextTime之前,就根据下次任务执行时间,放入时间轮里
这就是整个Schedule线程管理时间轮的过程,Schedule这个概念和Yarn里Schedule的概念很像,就是专门负责调度
调度线程的线程方法源代码:
scheduleThread = new Thread(new Runnable() {
@Override
public void run() {
try {
TimeUnit.MILLISECONDS.sleep(5000 - System.currentTimeMillis()%1000 );
} catch (InterruptedException e) {
if (!scheduleThreadToStop) {
logger.error(e.getMessage(), e);
}
}
logger.info(">>>>>>>>> init xxl-job admin scheduler success.");
// pre-read count: treadpool-size * trigger-qps (each trigger cost 50ms, qps = 1000/50 = 20)
int preReadCount = (XxlJobAdminConfig.getAdminConfig().getTriggerPoolFastMax() + XxlJobAdminConfig.getAdminConfig().getTriggerPoolSlowMax()) * 20;
while (!scheduleThreadToStop) {
// Scan Job
long start = System.currentTimeMillis();
Connection conn = null;
Boolean connAutoCommit = null;
PreparedStatement preparedStatement = null;
boolean preReadSuc = true;
try {
conn = XxlJobAdminConfig.getAdminConfig().getDataSource().getConnection();
connAutoCommit = conn.getAutoCommit();
conn.setAutoCommit(false);
preparedStatement = conn.prepareStatement( "select * from xxl_job_lock where lock_name = 'schedule_lock' for update" );
preparedStatement.execute();
// tx start
// 1、pre read
long nowTime = System.currentTimeMillis();
List<XxlJobInfo> scheduleList = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleJobQuery(nowTime + PRE_READ_MS, preReadCount);
if (scheduleList!=null && scheduleList.size()>0) {
// 2、push time-ring
for (XxlJobInfo jobInfo: scheduleList) {
// time-ring jump
if (nowTime > jobInfo.getTriggerNextTime() + PRE_READ_MS) {
// 2.1、trigger-expire > 5s:pass && make next-trigger-time
logger.warn(">>>>>>>>>>> xxl-job, schedule misfire, jobId = " + jobInfo.getId());
// 1、misfire match
MisfireStrategyEnum misfireStrategyEnum = MisfireStrategyEnum.match(jobInfo.getMisfireStrategy(), MisfireStrategyEnum.DO_NOTHING);
if (MisfireStrategyEnum.FIRE_ONCE_NOW == misfireStrategyEnum) {
// FIRE_ONCE_NOW 》 trigger
JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.MISFIRE, -1, null, null, null);
logger.debug(">>>>>>>>>>> xxl-job, schedule push trigger : jobId = " + jobInfo.getId() );
}
// 2、fresh next
refreshNextValidTime(jobInfo, new Date());
} else if (nowTime > jobInfo.getTriggerNextTime()) {
// 2.2、trigger-expire < 5s:direct-trigger && make next-trigger-time
// 1、trigger
JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.CRON, -1, null, null, null);
logger.debug(">>>>>>>>>>> xxl-job, schedule push trigger : jobId = " + jobInfo.getId() );
// 2、fresh next
refreshNextValidTime(jobInfo, new Date());
// next-trigger-time in 5s, pre-read again
if (jobInfo.getTriggerStatus()==1 && nowTime + PRE_READ_MS > jobInfo.getTriggerNextTime()) {
// 1、make ring second
int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);
// 2、push time ring
pushTimeRing(ringSecond, jobInfo.getId());
// 3、fresh next
refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));
}
} else {
// 2.3、trigger-pre-read:time-ring trigger && make next-trigger-time
// 1、make ring second
int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);
// 2、push time ring
pushTimeRing(ringSecond, jobInfo.getId());
// 3、fresh next
refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));
}
}
// 3、update trigger info
for (XxlJobInfo jobInfo: scheduleList) {
XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleUpdate(jobInfo);
}
} else {
preReadSuc = false;
}
// tx stop
} catch (Exception e) {
if (!scheduleThreadToStop) {
logger.error(">>>>>>>>>>> xxl-job, JobScheduleHelper#scheduleThread error:{}", e);
}
} finally {
// commit
if (conn != null) {
try {
conn.commit();
} catch (SQLException e) {
if (!scheduleThreadToStop) {
logger.error(e.getMessage(), e);
}
}
try {
conn.setAutoCommit(connAutoCommit);
} catch (SQLException e) {
if (!scheduleThreadToStop) {
logger.error(e.getMessage(), e);
}
}
try {
conn.close();
} catch (SQLException e) {
if (!scheduleThreadToStop) {
logger.error(e.getMessage(), e);
}
}
}
// close PreparedStatement
if (null != preparedStatement) {
try {
preparedStatement.close();
} catch (SQLException e) {
if (!scheduleThreadToStop) {
logger.error(e.getMessage(), e);
}
}
}
}
long cost = System.currentTimeMillis()-start;
// Wait seconds, align second
if (cost < 1000) { // scan-overtime, not wait
try {
// pre-read period: success > scan each second; fail > skip this period;
TimeUnit.MILLISECONDS.sleep((preReadSuc?1000:PRE_READ_MS) - System.currentTimeMillis()%1000);
} catch (InterruptedException e) {
if (!scheduleThreadToStop) {
logger.error(e.getMessage(), e);
}
}
}
}
logger.info(">>>>>>>>>>> xxl-job, JobScheduleHelper#scheduleThread stop");
}
});
scheduleThread.setDaemon(true);
scheduleThread.setName("xxl-job, admin JobScheduleHelper#scheduleThread");
scheduleThread.start();
3、时间轮的执行
至此我们大致实现了怎么把生产者任务进行执行,但是时间轮好像和我们当前任务执行没有什么关系,时间轮到底运行了么?又是怎么运行的呢?
xxl的做法是维护了一个时间轮线程维护时间轮的运行
(1)如果当前时间不是整数秒,那么就sleep到整数秒(可能为了更准确的调度吧)
(2)睡完之后取当前秒和下一秒两个槽位上的任务列表,向工作队列提交任务
时间轮线程的代码:
ringThread = new Thread(new Runnable() {
@Override
public void run() {
while (!ringThreadToStop) {
// align second
try {
TimeUnit.MILLISECONDS.sleep(1000 - System.currentTimeMillis() % 1000);
} catch (InterruptedException e) {
if (!ringThreadToStop) {
logger.error(e.getMessage(), e);
}
}
try {
// second data
List<Integer> ringItemData = new ArrayList<>();
//获取当前时间
int nowSecond = Calendar.getInstance().get(Calendar.SECOND);
// 避免处理耗时太长,跨过刻度,向前校验一个刻度;
for (int i = 0; i < 2; i++) {
//获取任务队列,从槽上拿当前秒和下一秒的两个任务队列
List<Integer> tmpData = ringData.remove( (nowSecond+60-i)%60 );
if (tmpData != null) {
ringItemData.addAll(tmpData);
}
}
// ring trigger
logger.debug(">>>>>>>>>>> xxl-job, time-ring beat : " + nowSecond + " = " + Arrays.asList(ringItemData) );
if (ringItemData.size() > 0) {
// do trigger
for (int jobId: ringItemData) {
// do trigger
JobTriggerPoolHelper.trigger(jobId, TriggerTypeEnum.CRON, -1, null, null, null);
}
// clear
ringItemData.clear();
}
} catch (Exception e) {
if (!ringThreadToStop) {
logger.error(">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread error:{}", e);
}
}
}
logger.info(">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread stop");
}
});
ringThread.setDaemon(true);
ringThread.setName("xxl-job, admin JobScheduleHelper#ringThread");
ringThread.start();
}
回过头来看这几个问题:
(1)那到底应该在什么时刻拿任务
答:注意schedule源码里有代码:
try {
TimeUnit.MILLISECONDS.sleep(5000 - System.currentTimeMillis()%1000 );
} catch (InterruptedException e) {
if (!scheduleThreadToStop) {
logger.error(e.getMessage(), e);
}
}
那么你应该知道他是每五秒去拿一次,那拿多少呢到底?
int preReadCount = (XxlJobAdminConfig.getAdminConfig().getTriggerPoolFastMax() + XxlJobAdminConfig.getAdminConfig().getTriggerPoolSlowMax()) * 20;
快慢线程池的快慢任务池最大值*20,默认(200+100)*20 = 6000,从当前任务开始到下个5S之内取6000个任务。你也许会说5S内执行6000个任务或许有点夸张,注意这里xxljob只负责了任务分发,调度和执行分开真正的执行器是注册到xxljob调度中心的各个执行器。
(2)什么时刻的任务应该执行
答:这里我们也可以看出,xxljob最好的使用方式是上线之后一段时间开始执行任务,比如晚上12点上线,第二天早上8点开始执行任务
(3)什么时刻的任务又应该放入时间轮
答:下一个5S内待执行的任务,一次取6000,部分执行,部分不执行
(4)时间轮里的任务又是什么时候被执行的呢?
时间轮线程单独负责任务分发,每秒做一次分发
4、xxljob和netty
其实延迟任务这块就已经说完了,往下的部分是xxljob维护各个执行器连接的部分,这块涉及netty的部分,可以拿出来看看
还是先明确需求场景:我们作为调度中心,要和执行器建立连接,确定我们能发往具体哪个执行器执行当前任务,这个场景基本就是典型的netty场景,你去看RocketMQ,Kafka,绝壁都长得一样,非常合理。
xxljob也一样,只要你理解NIO,使用netty应该不难,自定义可能你需要多做两次难度也不大,直接看看代码:
public void start(final String address, final int port, final String appname, final String accessToken) {
//伴随xxljob启动而启动
executorBiz = new ExecutorBizImpl();
thread = new Thread(new Runnable() {
@Override
public void run() {
// 父子工作组
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
ThreadPoolExecutor bizThreadPool = new ThreadPoolExecutor(
0,
200,
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(2000),
new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "xxl-rpc, EmbedServer bizThreadPool-" + r.hashCode());
}
},
new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
throw new RuntimeException("xxl-job, EmbedServer bizThreadPool is EXHAUSTED!");
}
});
try {
// start server
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel channel) throws Exception {
channel.pipeline()
//开启心跳检测
//第一个参数如果channelRead()方法超过readerIdleTime时间未被调用则会触发超时事件调用
//第二个参数如果write()方法超过writerIdleTime时间未被调用则会触发超时事件调用userEventTrigger()方法;
.addLast(new IdleStateHandler(0, 0, 30 * 3, TimeUnit.SECONDS)) // beat 3N, close if idle
//http编解码
.addLast(new HttpServerCodec())
//因为HttpServerCodec只能获取uri中参数,所以需要加上HttpObjectAggregator,从而可以获取post请求参数
.addLast(new HttpObjectAggregator(5 * 1024 * 1024)) // merge request & reponse to FULL
//继承自SimpleChannelInboundHandler入栈处理器,对url进行处理,然后触发事件,包括心跳,执行,杀死任务等等
.addLast(new EmbedHttpServerHandler(executorBiz, accessToken, bizThreadPool));
}
})
//TCP长连接
.childOption(ChannelOption.SO_KEEPALIVE, true);
// bind
ChannelFuture future = bootstrap.bind(port).sync();
logger.info(">>>>>>>>>>> xxl-job remoting server start success, nettype = {}, port = {}", EmbedServer.class, port);
// start registry
startRegistry(appname, address);
// wait util stop
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
if (e instanceof InterruptedException) {
logger.info(">>>>>>>>>>> xxl-job remoting server stop.");
} else {
logger.error(">>>>>>>>>>> xxl-job remoting server error.", e);
}
} finally {
// stop
try {
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
} catch (Exception e) {
logger.error(e.getMessage(), e);
}
}
}
});
thread.setDaemon(true); // daemon, service jvm, user thread leave >>> daemon leave >>> jvm leave
thread.start();
}
注册的过程就是聊天室模型,维护组的概念,这块可以下次单独再开一篇讲讲聊天室模型。
以上,往下准备开一个xxl的另一个框架,xxl-rpc,为什么不开dubbo还是因为dubbo太庞大了,其实基于netty的rpc是很好实现的,但是dubbo封装了太多东西