XXL-JOB源码(上)--执行器篇

664 阅读11分钟

XXL-JOB--执行器篇

Github地址: github.com/xuxueli/xxl…

一 引言

说到定时任务,大家一定不陌生,我们做项目必然会有大量需求需要使用定时任务,定时清理脏数据,定时清理备份数据等等

下面罗列了一下定时任务的一些基础实现方案

1 Thread

最简单的定时任务,实现起来也十分简单

while(true){
    doSomething();
    TimeUnit.DAYS.sleep(1);
}

2 Timer、ScheduledExecutorService

TimerTask、ScheduledExecutorService分别是JDK1.3 JDK1.5提供的定时任务框架

3 spring task

spring task 的核心注解是 @Scheduled,原理其实非常简单,就是帮我们创建自动ScheduledExecutorService并注入任务,有兴趣可以去看一下spring提供的bean后置处理器ScheduledAnnotationBeanPostProcessor

但是这些定时任务都有一个最大的缺点,在如今动不动就是分布式架构的大环境下,有时候我们只想一个微服务的实例去执行任务,这些居于JDK 主动执行 的定时任务框架,想要指定机器执行就变得力不从心了。

下面引出本文主角XXL-JOB

二 XXL-JOB 简介

官网文档: www.xuxueli.com/xxl-job/

这里就不简单介绍了,想要了解及使用可以阅读官方文档,这是官网给出的架构图

image.png

XXL-JOB主要分为两部分:调度中心执行器,回到上面的问题,如何保证只有一台机器执行定时任务。

执行这块 xxl-job给出的方案是由外部被动触发,通过调度中心调用执行器接口来执行。而想要做到指定机器,这点和注册中心一样,只需要维护一个服务地址的集合,需要执行任务时调用指定机器的接口即可。

三 执行器 xxl-job-core

如果是spring服务,xxljob会帮我们完成所有功能类的创建,和定时任务的自动注入,一切都要归功于XxlJobSpringExecutor,它继承了SmartInitializingSingleton,在所有单例bean完成初始化时,会回调afterSingletonsInstantiated()方法。

public class XxlJobSpringExecutor extends XxlJobExecutor implements ApplicationContextAware, SmartInitializingSingleton, DisposableBean {
    @Override
    public void afterSingletonsInstantiated() {
    
        // 扫描所有带有@XxlJob注解的方法 创建IJobHandler对象
        // 并注册到以name为key,IJobHandler为value的map中,方便获取
        initJobHandlerMethodRepository(applicationContext);
        
        // 获取GlueFactory实例工厂,不常用
        // 主要功能是吧调度中心的java代码转换成IJobHandler类对象
        GlueFactory.refreshInstance(1);
        
        // super start
        try {
            super.start();
        } catch (Exception e) {throw new RuntimeException(e);}
    }
}

3.1 initJobHandlerMethodRepository

private void initJobHandlerMethodRepository(ApplicationContext applicationContext) {
    if (applicationContext == null) {
        return;
    }
    // 拿到所有单例bean的名称
    String[] beanDefinitionNames = applicationContext.getBeanNamesForType(Object.class, false, true);
    for (String beanDefinitionName : beanDefinitionNames) {
        Object bean = applicationContext.getBean(beanDefinitionName);
        // 拿到所有bean对象,遍历所有方法解析带有@xxljob的方法
        Map<Method, XxlJob> annotatedMethods = null;
        try {
            annotatedMethods = MethodIntrospector.selectMethods(bean.getClass(),
                    new MethodIntrospector.MetadataLookup<XxlJob>() {
                        @Override
                        public XxlJob inspect(Method method) {
                            return AnnotatedElementUtils.findMergedAnnotation(method, XxlJob.class);
                        }
                    });
        } catch (Throwable ex) {
            logger.error("xxl-job method-jobhandler resolve error for bean[" + beanDefinitionName + "].", ex);
        }
        if (annotatedMethods==null || annotatedMethods.isEmpty()) {
            continue;
        }
        // 遍历获得的Map<Method, XxlJob>对象
        for (Map.Entry<Method, XxlJob> methodXxlJobEntry : annotatedMethods.entrySet()) {
            Method method = methodXxlJobEntry.getKey();
            XxlJob xxlJob = methodXxlJobEntry.getValue();
            // 校验注解及注解的值的正确性
            if (xxlJob == null) {continue;}
            String name = xxlJob.value();
            if (name.trim().length() == 0) {
                throw new RuntimeException("xxl-job method-jobhandler name invalid, for[" + bean.getClass() + "#" + method.getName() + "] .");}
            if (loadJobHandler(name) != null) {
                throw new RuntimeException("xxl-job jobhandler[" + name + "] naming conflicts.");}

            // 强制指定方法参数是String 类型,返回值是ReturnT<String>
            if (!(method.getParameterTypes().length == 1 && method.getParameterTypes()[0].isAssignableFrom(String.class))) {
                throw new RuntimeException("xxl-job method-jobhandler param-classtype invalid, for[" + bean.getClass() + "#" + method.getName() + "] , " +
                        "The correct method format like " public ReturnT<String> execute(String param) " .");}
            if (!method.getReturnType().isAssignableFrom(ReturnT.class)) {
                throw new RuntimeException("xxl-job method-jobhandler return-classtype invalid, for[" + bean.getClass() + "#" + method.getName() + "] , " +
                        "The correct method format like " public ReturnT<String> execute(String param) " .");}
            // 指定方法是可访问的,防止反射调用失败
            method.setAccessible(true);

            // 初始化 注解上的两个方法
            Method initMethod = null;
            Method destroyMethod = null;
            // 校验注解上的方法并设置访问权限
            if (xxlJob.init().trim().length() > 0) {
                try {
                    initMethod = bean.getClass().getDeclaredMethod(xxlJob.init());
                    initMethod.setAccessible(true);
                } catch (NoSuchMethodException e) {
                    throw new RuntimeException("xxl-job method-jobhandler initMethod invalid, for[" + bean.getClass() + "#" + method.getName() + "] .");
                }
            }
            if (xxlJob.destroy().trim().length() > 0) {
                try {
                    destroyMethod = bean.getClass().getDeclaredMethod(xxlJob.destroy());
                    destroyMethod.setAccessible(true);
                } catch (NoSuchMethodException e) {
                    throw new RuntimeException("xxl-job method-jobhandler destroyMethod invalid, for[" + bean.getClass() + "#" + method.getName() + "] .");
                }
            }
            // 注册JobHandler
            registJobHandler(name, new MethodJobHandler(bean, method, initMethod, destroyMethod));
        }
    }
}

// 这是一个线程安全的map对象
private static ConcurrentMap<String, IJobHandler> jobHandlerRepository = new ConcurrentHashMap<String, IJobHandler>();
public static IJobHandler registJobHandler(String name, IJobHandler jobHandler){
    logger.info(">>>>>>>>>>> xxl-job register jobhandler success, name:{}, jobHandler:{}", name, jobHandler);
    return jobHandlerRepository.put(name, jobHandler);
}

和以往定时任务不同的是IJobHandler对象非常简单,以MethodJobHandler为例,只维护了反射的两个必要参数(方法参数执行是提供,且限定了String类型),和初始、销毁时的调用方法。

public class MethodJobHandler extends IJobHandler {
    private final Object target;
    private final Method method;
    private Method initMethod;
    private Method destroyMethod;
    public MethodJobHandler(Object target, Method method, Method initMethod, Method destroyMethod) {
        this.target = target;
        this.method = method;
        this.initMethod =initMethod;
        this.destroyMethod =destroyMethod;
    }
    。。。。。
}

有关于方法应该什么时候执行,多久执行一次,执行参数是什么我们都不用关心,全盘交由调度中心,我们只需要负责好我们的业务逻辑即可。

到这里,我们已经准备好了我们的jobHandlerRepository(ConcurrentMap<String, IJobHandler>,map的key是我们@Xxljob注解的name属性,map的value是执行反射的必要信息。那接下来如果要完成基础功能,调度中心只需要告诉执行器相应的IJobHandler名称name,我们就能具体准确的完成定时任务的调用。

3.2 start启动

接下来是XxlJobExecutor的启动(即调用父类的start()方法),可以分为五步。

public void start() throws Exception {
    // 1 初始化日志路径
    XxlJobFileAppender.initLogPath(logPath);
    // 2 初始化调度中心地址和访问令牌
    initAdminBizList(adminAddresses, accessToken);
    // 3 初始化日志清理线程
    JobLogFileCleanThread.getInstance().start(logRetentionDays);
    // 4 初始化回调线程
    TriggerCallbackThread.getInstance().start();
    // 5 初始化调度器服务器
    initEmbedServer(address, ip, port, appname, accessToken);
}

3.2.1 初始化日志

这里还是主要看一下第三步启动日志清理线程,这也算是一种最简单的定时任务,每天执行一次日志文件的清理

localThread = new Thread(new Runnable() {
        @Override
        public void run() {
            while (!toStop) {
                // 文件清理
                。。。。。。。。
                try {
                    TimeUnit.DAYS.sleep(1);
                } catch (InterruptedException e) {
                    if (!toStop) {logger.error(e.getMessage(), e);}
                }
            }
            logger.info(">>>>>>>>>>> xxl-job, executor JobLogFileCleanThread thread destory.");
        }
    });
    localThread.setDaemon(true);
    localThread.setName("xxl-job, executor JobLogFileCleanThread");
    localThread.start();
}

3.2.2初始化调度器服务器

第五步是最关键的一步,启动netty服务端的核心代码

private void initEmbedServer(String address, String ip, int port, String appname, String accessToken) throws Exception {

    // 进行端口探测,默认情况下9999端口,如果被占用会逐次加1
    port = port>0?port: NetUtil.findAvailablePort(9999);
    // 获取本机ip地址
    ip = (ip!=null&&ip.trim().length()>0)?ip: IpUtil.getIp();
    // 拼接服务地址
    if (address==null || address.trim().length()==0) {
        String ip_port_address = IpUtil.getIpPort(ip, port); 
        address to registry , otherwise use ip:port if address is null
        address = "http://{ip_port}/".replace("{ip_port}", ip_port_address);
    }
    // 启动netty服务器
    embedServer = new EmbedServer();
    embedServer.start(address, port, appname, accessToken);
}

由于过于重要,这里单独分一章节讲解EmbedServer类

四 netty服务处理

启动netty服务端也就是embedServer的start方法,我们一行一行的来解析

 // 创建服务端对象
ServerBootstrap bootstrap = new ServerBootstrap();
// 设置EventLoopGroup,可以理解为线程池
// bossGroup处理连接,workerGroup负责处理读写
bootstrap.group(bossGroup, workerGroup)
    // 设置使用的channel
    .channel(NioServerSocketChannel.class)
    // 设置处理器
    .childHandler(new ChannelInitializer<SocketChannel>() {
        @Override
        public void initChannel(SocketChannel channel) throws Exception {
            channel.pipeline()
                    // 心跳处理器 90s没有读写操作执行其他handler的userEventTrigger的方法
                    .addLast(new IdleStateHandler(0, 0, 30 * 3, TimeUnit.SECONDS))  
                    // http编解码器
                    .addLast(new HttpServerCodec())
                    // HttpRequestDecoder会将消息分块,这里将其聚合成一个http对象
                    .addLast(new HttpObjectAggregator(5 * 1024 * 1024))  
                    // 自定义处理器
                    .addLast(new EmbedHttpServerHandler(executorBiz, accessToken, bizThreadPool));
        }
    })
    // 开启 SO_KEEPALIVE,类似于心跳检测
    .childOption(ChannelOption.SO_KEEPALIVE, true);
// 绑定端口,sync 阻塞,不阻塞的化下面的操作都不无法进行
ChannelFuture future = bootstrap.bind(port).sync();
// 注册执行器,向调度中心发送注册信息
startRegistry(appname, address);
// 一致阻塞
future.channel().closeFuture().sync();

重点关注这个xxljob自定义的处理器 EmbedHttpServerHandler,该处理器父类SimpleChannelInboundHandler,而父类又继承ChannelInboundHandlerAdapter,所以是 入站处理器 重写封装过的channelRead0()方法即可。

在这段代码的最后又调用ctx.writeAndFlush(response) 方法,又会依次执行出站处理器的方法(例如HttpServerCodec会对FullHttpResponse 进行http格式的编码)。

@Override
protected void channelRead0(final ChannelHandlerContext ctx, FullHttpRequest msg) throws Exception {
    // 解析请求
    String requestData = msg.content().toString(CharsetUtil.UTF_8);
    String uri = msg.uri();
    HttpMethod httpMethod = msg.method();
    boolean keepAlive = HttpUtil.isKeepAlive(msg);
    String accessTokenReq = msg.headers().get(XxlJobRemotingUtil.XXL_JOB_ACCESS_TOKEN);
    // 大概是防止调度任务执行过长,单独开一个线程处理
    bizThreadPool.execute(new Runnable() {
        @Override
        public void run() {
            // 处理各种api的请求
            Object responseObj = process(httpMethod, uri, requestData, accessTokenReq);
            // 转json
            String responseJson = GsonTool.toJson(responseObj);
            // 写入消息
            writeResponse(ctx, keepAlive, responseJson);
        }
    });
}
private Object process(HttpMethod httpMethod, String uri, String requestData, String accessTokenReq) {

    // 校验参数,略
    。。。。。
    // 处理请求
    try {
        if ("/beat".equals(uri)) {
            return executorBiz.beat();
        } else if ("/idleBeat".equals(uri)) {
            IdleBeatParam idleBeatParam = GsonTool.fromJson(requestData, IdleBeatParam.class);
            return executorBiz.idleBeat(idleBeatParam);
        } else if ("/run".equals(uri)) {
            TriggerParam triggerParam = GsonTool.fromJson(requestData, TriggerParam.class);
            return executorBiz.run(triggerParam);
        } else if ("/kill".equals(uri)) {
            KillParam killParam = GsonTool.fromJson(requestData, KillParam.class);
            return executorBiz.kill(killParam);
        } else if ("/log".equals(uri)) {
            LogParam logParam = GsonTool.fromJson(requestData, LogParam.class);
            return executorBiz.log(logParam);
        } else {
            return new ReturnT<String>(ReturnT.FAIL_CODE, "invalid request, uri-mapping("+ uri +") not found.");
        }
    } catch (Exception e) {
        logger.error(e.getMessage(), e);
        return new ReturnT<String>(ReturnT.FAIL_CODE, "request error:" + ThrowableUtil.toString(e));
    }
}

private void writeResponse(ChannelHandlerContext ctx, boolean keepAlive, String responseJson) {
    // 设置响应
    FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, Unpooled.copiedBuffer(responseJson, CharsetUtil.UTF_8));   //  Unpooled.wrappedBuffer(responseJson)
    response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/html;charset=UTF-8");       // HttpHeaderValues.TEXT_PLAIN.toString()
    response.headers().set(HttpHeaderNames.CONTENT_LENGTH, response.content().readableBytes());
    if (keepAlive) {
        response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
    }
    // 将数据写入并刷出
    ctx.writeAndFlush(response);
}

刚好对应了官网介绍的五种接口

/beat 调度中心检测执行器是否在线时使用
/idleBeat 调度中心检测指定执行器上指定任务是否忙碌(运行中)时使用
/run 触发任务执行
/kill 终止任务
/log 查看日志,滚动方式加载

具体接口文档详细可以去翻阅官网,执行器的所有请求都会在这里处理,然后根据路径分发给不同处理逻辑的方法

下一章挑选最重要的run方法进行分析

五 触发任务

我觉得解决方案和springmvc类似,把方法注册到map,然后根据传入的定时任务名称找到对象的method,反射调用。

事实上一切并没有我们想的那么简单。xxljob还要处理重复调用、日志记录、结果返回等等一系列问题

// 用来维护正在执行的 JobThread
// JobThread继承了thread
private static ConcurrentMap<Integer, JobThread> jobThreadRepository = new ConcurrentHashMap<Integer, JobThread>();

public static JobThread loadJobThread(int jobId){
    JobThread jobThread = jobThreadRepository.get(jobId);
    return jobThread;
}
@Override
public ReturnT<String> run(TriggerParam triggerParam) {
    // 找到当前id,有没有已经注册的线程
    JobThread jobThread = XxlJobExecutor.loadJobThread(triggerParam.getJobId());
    // 找到当前线程的处理器
    IJobHandler jobHandler = jobThread!=null?jobThread.getHandler():null;
    String removeOldReason = null;

    // 获取任务类型(这里只分析BEAN类型)
    GlueTypeEnum glueTypeEnum = GlueTypeEnum.match(triggerParam.getGlueType());
    if (GlueTypeEnum.BEAN == glueTypeEnum) {

        // 根据参数的处理器获得任务处理器
        IJobHandler newJobHandler = XxlJobExecutor.loadJobHandler(triggerParam.getExecutorHandler());

        // 如果当前两个任务处理器不相同,则代表需要更换处理器,清杀掉当前任务
        if (jobThread!=null && jobHandler != newJobHandler) {
            removeOldReason = "change jobhandler or glue type, and terminate the old job thread.";
            jobThread = null;
            jobHandler = null;
        }

        // 判断老处理器是否为空,如果为空则吧新的传递给他
        if (jobHandler == null) {
            jobHandler = newJobHandler;
            if (jobHandler == null) {
                return new ReturnT<String>(ReturnT.FAIL_CODE, "job handler [" + triggerParam.getExecutorHandler() + "] not found.");
            }
        }

    } else if (GlueTypeEnum.GLUE_GROOVY == glueTypeEnum) {
          。。。
    } else if (glueTypeEnum!=null && glueTypeEnum.isScript()) {
          。。。
    } else {
        return new ReturnT<String>(ReturnT.FAIL_CODE, "glueType[" + triggerParam.getGlueType() + "] is not valid.");
    }


    /**
        处理阻塞策略,有三类策略
        SERIAL_EXECUTION("Serial execution"),串行
        DISCARD_LATER("Discard Later"), 丢弃新的
        COVER_EARLY("Cover Early");杀掉老线程,执行新的
    **/
    if (jobThread != null) {
        ExecutorBlockStrategyEnum blockStrategy = ExecutorBlockStrategyEnum.match(triggerParam.getExecutorBlockStrategy(), null);
        if (ExecutorBlockStrategyEnum.DISCARD_LATER == blockStrategy) {
            // 判断一下当前任务是否在运行
            // 如果在运行则啥也不干直接翻译一个
            if (jobThread.isRunningOrHasQueue()) {
                return new ReturnT<String>(ReturnT.FAIL_CODE, "block strategy effect:"+ExecutorBlockStrategyEnum.DISCARD_LATER.getTitle());
            }
        } else if (ExecutorBlockStrategyEnum.COVER_EARLY == blockStrategy) {
            // 设空 正在运行的线程
            if (jobThread.isRunningOrHasQueue()) {
                removeOldReason = "block strategy effect:" + ExecutorBlockStrategyEnum.COVER_EARLY.getTitle();
                jobThread = null;
            }
        } else {
        }
    }
   

    // 如果没有找到该任务的旧线程
    if (jobThread == null) {
        // 创建一个新的jobThread并注册到jobThreadRepository,同样也是这个任务创建的核心
        jobThread = XxlJobExecutor.registJobThread(triggerParam.getJobId(), jobHandler, removeOldReason);
    }

    // 把triggerParam详细信息放入当前线程
    ReturnT<String> pushResult = jobThread.pushTriggerQueue(triggerParam);
    return pushResult;
}
private LinkedBlockingQueue<TriggerParam> triggerQueue;
public ReturnT<String> pushTriggerQueue(TriggerParam triggerParam) {
   if (triggerLogIdSet.contains(triggerParam.getLogId())) {
      logger.info(">>>>>>>>>>> repeate trigger job, logId:{}", triggerParam.getLogId());
      return new ReturnT<String>(ReturnT.FAIL_CODE, "repeate trigger job, logId:" + triggerParam.getLogId());
   }

   triggerLogIdSet.add(triggerParam.getLogId());
   // 放入队列
   triggerQueue.add(triggerParam);
       return ReturnT.SUCCESS;
}

这里主要是做了一下防重复任务,和任务阻塞策略的一个处理,核心还是registJobThread方法

// 可以理解为任务池
private static ConcurrentMap<Integer, JobThread> jobThreadRepository = new ConcurrentHashMap<Integer, JobThread>();
public static JobThread registJobThread(int jobId, IJobHandler handler, String removeOldReason){
    // 创建一个JobThread类(继承了Thread)
    JobThread newJobThread = new JobThread(jobId, handler);
    // 开启线程执行run方法
    newJobThread.start();
    logger.info(">>>>>>>>>>> xxl-job regist JobThread success, jobId:{}, handler:{}", new Object[]{jobId, handler});
    // 去jobThreadRepository任务池替换任务
    JobThread oldJobThread = jobThreadRepository.put(jobId, newJobThread);
    // 老任务kill掉
    if (oldJobThread != null) {
        oldJobThread.toStop(removeOldReason);
        oldJobThread.interrupt();
    }
    return newJobThread;
}

下面将是本文的重中之重,之前的一切都可以是理解为逻辑校验,这里才是执行任务的源头

public class JobThread extends Thread{
   private int jobId;
   private IJobHandler handler;
   // 任务队列
   private LinkedBlockingQueue<TriggerParam> triggerQueue;
   private Set<Long> triggerLogIdSet;

    @Override
   public void run() {
       try {
         // 调用处理器的init方法(注解上可以配置)
         handler.init();
      } catch (Throwable e) {
          logger.error(e.getMessage(), e);
      }

      // execute
      while(!toStop){
            // 正在运行的标志位
            running = false;
            idleTimes++;
            TriggerParam triggerParam = null;
            ReturnT<String> executeResult = null;
            try {
            // 从任务队列获取任务
            triggerParam = triggerQueue.poll(3L, TimeUnit.SECONDS);
            if (triggerParam!=null) {
               running = true;
               idleTimes = 0;
               triggerLogIdSet.remove(triggerParam.getLogId());

               // 日志文件名 "logPath/yyyy-MM-dd/9999.log"
               String logFileName = XxlJobFileAppender.makeLogFileName(new Date(triggerParam.getLogDateTime()), triggerParam.getLogId());
               XxlJobFileAppender.contextHolder.set(logFileName);
               ShardingUtil.setShardingVo(new ShardingUtil.ShardingVO(triggerParam.getBroadcastIndex(), triggerParam.getBroadcastTotal()));
              
               if (triggerParam.getExecutorTimeout() > 0) {
                  // 如果配置了超时等待时间,或单独创建一个FutureTask
                  Thread futureThread = null;
                  try {
                     final TriggerParam triggerParamTmp = triggerParam;
                     FutureTask<ReturnT<String>> futureTask = new FutureTask<ReturnT<String>>(new Callable<ReturnT<String>>() {
                        @Override
                        public ReturnT<String> call() throws Exception {
                           return handler.execute(triggerParamTmp.getExecutorParams());
                        }
                     });
                     futureThread = new Thread(futureTask);
                     futureThread.start();
                     // get结果(该方法为阻塞方法,但是超时会抛出超时异常)
                     executeResult = futureTask.get(triggerParam.getExecutorTimeout(), TimeUnit.SECONDS);
                  } catch (TimeoutException e) {
                      。。。
                  } finally {
                     futureThread.interrupt();
                  }
               } else {
                  // 在当前线程,立刻执行
                  executeResult = handler.execute(triggerParam.getExecutorParams());
               }

            } else {
               if (idleTimes > 30) {
                  if(triggerQueue.size() == 0) {
                      // 如果长时间没有任务进来,就从注册的JobThread列表中移除当前JobThread
                     XxlJobExecutor.removeJobThread(jobId, "excutor idel times over limit.");
                  }
               }
            }
         } catch (Throwable e) {
             。。。
         } finally {
            。。。
            }
        }
      }
    // 调用销毁方法(注解上可配置)
    try {
       handler.destroy();
    } catch (Throwable e) {
       logger.error(e.getMessage(), e);
    }

}

为了便于阅读,我这里删去了大量异常判断,只展示了重要的逻辑部分。接着看handler的execute方法

@Override
public ReturnT<String> execute(String param) throws Exception {
    return (ReturnT<String>) method.invoke(target, new Object[]{param});
}

就简单的一行,反射调用,果然反射yyds。

六 总结

没啥好总结的,执行器部分逻辑还算清晰,但是关于netty的部分,虽然执行器的服务可能不是web服务,但我认为xxljob没有必要无脑的单独开一个服务器,即使netty很轻量。这里完全可以判断是不是web服务,如果是则共用一个端口,如果不是才考虑netty的方案,不知道作者是咋考虑的(ˉ▽ˉ;)...

下章将继续介绍xxljob的调度器篇。