1 背景概述
任务调度在日常开发中是非常常见的业务场景,需要周期性,指定时间节点等方式自动触发异步业务逻辑。如何实现任务调度,有什么思路?
2 集中式任务调度
与分布式任务调度恰好相反,集中式任务就是单机任务,一个项目,一台机器,单体应用。
3 分布式任务调度
3.1 集中式的问题
分布式集群的模式下,才用集中式调度会带来一系列问题:
1.多台机器部署的定时任务如何保证不会重复执行?
2.如何动态调整定时任务的执行时间?
3.部署定时任务的机器发送故障后如何实现故障转移?
4.如何对定时任务进行监控?
5.当业务量比较大时,单机性能瓶颈问题,如何扩展?
3.2 解决
1.数据库唯一约束,避免定时任务重复执行。
2.使用配置文件、redis、mysql作为调度开关。
3.使用分布式锁实现调度的控制。
4.使用分布式任务调度平台Elastric-job、xxl-job等
4 xxl-job整体架构
服务端(调度中心,就是一个web管理后台)
客户端(执行器,在执行器里写用于调度的业务逻辑)
xxl-job-core(核心中间件,用于服务端和客户端的交互,使用时导入pom依赖)
4.1 使用
1.在数据库中创建执行db文件sql
2.修改admin配置文件的数据库地址等属性.
3.在客户端(执行器)中导入xxl-job-core依赖,编写配置类和配置文件, 编写调度逻辑。
5 执行器配置文件(application.properties)
//日志文件位置 logging.config=classpath:logback.xml // xxl-job服务端地址,可部署多个,用逗号隔开 xxl.job.admin.addresses=http://127.0.0.1:8080/xxl-job-admin //执行器通讯令牌,服务端与客户端令牌要一致 xxl.job.accessToken=default_token //执行器的名称 用于服务端(调度中心)识别执行器 xxl.job.executor.appname=xxl-job-executor-sample //执行器注册地址 //当为空时使用"ip=PORT"作为注册地址 xxl.job.executor.address= //执行器ip,默认为空表示自动获取ip,多网卡时可指定ip xxl.job.executor.ip= //执行器端口号,小于等于0时自动获取,默认为9999 xxl.job.executor.port=9999 //日志存放位置 xxl.job.executor.logpath=/data/applogs/xxl-job/jobhandler //日志存放天数 xxl.job.executor.logretentiondays=30
6 执行器配置类(XxlJobConfig)
读取配置文件中的属性,放入到一个xxljobSpringExecutor对象中,该类为core核心包中的类
@Bean public XxlJobSpringExecutor xxlJobExecutor() { logger.info(">>>>>>>>>>> xxl-job config init."); XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor(); xxlJobSpringExecutor.setAdminAddresses(adminAddresses); xxlJobSpringExecutor.setAppname(appname); xxlJobSpringExecutor.setAddress(address); xxlJobSpringExecutor.setIp(ip); xxlJobSpringExecutor.setPort(port); xxlJobSpringExecutor.setAccessToken(accessToken); xxlJobSpringExecutor.setLogPath(logPath); xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays); return xxlJobSpringExecutor; }
7.调度中心任务管理配置
1.jobHandler 填在执行器中@xxlJob(" ")中的名称。
2.路由策略 当后台部署多个项目且用了同一个jobHandler时,选择执行哪一个项目。
3.阻塞处理策略 当前任务卡住时 可以选择
3.1 单机穿行 等待当前任务疏通
3.2 丢弃后续调度 当下一个调度开始时仍在堵塞,丢弃后续调度
3.3 覆盖之前调度 当下一个调度开始时仍在堵塞,覆盖之前调度
4.任务超时时间 如果为0则没有设置,任务执行的时间必须在规定时间内,否则视为执行失败。
8 xxl-job-admin启动之WebMvcConfig配置类
8.1 该类实现了WebMvcConfigurer接口,该接口为springMvc提供的接口,通过实现该接口可以让该框架被springMvc识别。
public class WebMvcConfig implements WebMvcConfigurer { @Resource private PermissionInterceptor permissionInterceptor; @Resource private CookieInterceptor cookieInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(permissionInterceptor).addPathPatterns("/**"); registry.addInterceptor(cookieInterceptor).addPathPatterns("/**"); } }
权限拦截器和cookie拦截器均实现了AsyncHandlerInterceptor接口,AsyncHandlerInterceptor则继承了属于web.servlet包下的HandlerInterceptor。
在AsyncHandlerInterceptor接口中额外提供了afterConcurrentHandlingStarted方法,该方法是用来处理异步请求,可以在Controller方法异步执行时开始执行。
HandlerInterceptor有三个方法,preHandle,postHandle,afterCompletion。执行顺序为preHandle>postHandle>afterCompletion。preHandle在控制器方法执行之前调用,post在控制器方法执行后,视图渲染之前调用。afterCompletion视图渲染之后调用。
8.2 PermissionInterceptor 权限拦截器
@Component public class PermissionInterceptor implements AsyncHandlerInterceptor { @Resource private LoginService loginService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //判断是不是请求控制层方法,如果不是直接返回 if (!(handler instanceof HandlerMethod)) { return true; // proceed with the next interceptor } // if need login boolean needLogin = true;//是否需要登录 默认需要 boolean needAdminuser = false;//是否需要管理员权限 默认不需要 HandlerMethod method = (HandlerMethod)handler; PermissionLimit permission = method.getMethodAnnotation(PermissionLimit.class); //获取permissionLimit注解 if (permission!=null) { needLogin = permission.limit(); needAdminuser = permission.adminuser(); } //如果需要登录 if (needLogin) { //根据请求,判断是否登录 XxlJobUser loginUser = loginService.ifLogin(request, response); //没有用户登录 返回false 不许通过 if (loginUser == null) { response.setStatus(302); response.setHeader("location", request.getContextPath()+"/toLogin"); return false; } //是否需要管理员权限(getRole=1代表超级管理员) if (needAdminuser && loginUser.getRole()!=1) { throw new RuntimeException(I18nUtil.getString("system_permission_limit")); } //符合要求,允许通过 request.setAttribute(LoginService.LOGIN_IDENTITY_KEY, loginUser); } //如果不需要登录 则直接通过 return true; // proceed with the next interceptor } }
8.2.1 PermissionLimit注解
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface PermissionLimit { // 登录拦截 (默认为true,默认拦截) boolean limit() default true; // 要求管理员权限 boolean adminuser() default false; }
该注解常在controller层中的接口上使用。limit为true则需要登录拦截,为false则不需要拦截。adminuser为true则说明只有管理员才能使用该接口,默认为false。
8.3 CookieInterceptor cookie拦截器
@Component public class CookieInterceptor implements AsyncHandlerInterceptor { @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { // modelAndView为controller执行完毕后返回的数据 // 如果modelAndView和cookie均不为空 if (modelAndView!=null && request.getCookies()!=null && request.getCookies().length>0) { HashMap<String, Cookie> cookieMap = new HashMap<String, Cookie>(); //遍历前端请求的cookie 放入到新创建的map中 for (Cookie ck : request.getCookies()) { cookieMap.put(ck.getName(), ck); } //将存放了cookie的map放入到modelAndView中 modelAndView.addObject("cookieMap", cookieMap); } // static method // 页面字体的配置 if (modelAndView != null) { modelAndView.addObject("I18nUtil", FtlUtil.generateStaticModel(I18nUtil.class.getName())); } } }
执行完权限拦截器后,执行controller层接口,接口执行完毕后,返回到页面时,执行cookie拦截器。该拦截器的作用是获取到前端的cookie存放到model中。
8.3.1 ModeAndView 模型数据和视图信息
在springMvc中,控制器通过返回一个字符串作为视图名称,由视图解析器解析该名称并返回相应视图。有时需要额外返回一些模型数据,这时就可以使用ModelAndView。
Model
一个map接口实例,同于存储模型数据,可以使用addObject(Sring Name,Object value)方法添加模型数据,可以在视图中使用,以动态生成页面内容。
View Name
其实就是一个字符串,表示要渲染的视图的名称,由视图解析器将其解析为视图对象。
8.3.2 I18Util,FtlUtil工具类
I18Util工具类 读取语言配置的文件
public class I18nUtil { private static Logger logger = LoggerFactory.getLogger(I18nUtil.class); private static Properties prop = null; public static Properties loadI18nProp(){ if (prop != null) { return prop; } try { // build i18n prop String i18n = XxlJobAdminConfig.getAdminConfig().getI18n(); String i18nFile = MessageFormat.format("i18n/message_{0}.properties", i18n); // load prop Resource resource = new ClassPathResource(i18nFile); EncodedResource encodedResource = new EncodedResource(resource,"UTF-8"); prop = PropertiesLoaderUtils.loadProperties(encodedResource); } catch (IOException e) { logger.error(e.getMessage(), e); } return prop; } public static String getString(String key) { return loadI18nProp().getProperty(key); } public static String getMultString(String... keys) { Map<String, String> map = new HashMap<String, String>(); Properties prop = loadI18nProp(); if (keys!=null && keys.length>0) { for (String key: keys) { map.put(key, prop.getProperty(key)); } } else { for (String key: prop.stringPropertyNames()) { map.put(key, prop.getProperty(key)); } } String json = JacksonUtil.writeValueAsString(map); return json; } }
I18Util工具类 读取语言配置的文件
定义一个属性类 private static Properies prop=null;
String i18n = XxlJobAdminConfig.getAdminConfig().getI18n();
String i18nFile = MessageFormat.format("i18n/message_{0}.properties",i18n)
这两句代码是用来拼接语言配置文件的相对路径。
Resource resource = new ClassPathResource(i18nFile);
EncodedResource encodedResource = new EncodedResource(resource,"UTF-8");
通过路径加载文件资源,并将资源编译为UTF-8的格式。
prop = PropertiesLoaderUtils.loadProperties(encodedResource);
最后通过工具类,将该资源加载到prop属性中。
getMultString()和getString()方法都是根据传入的key值,从已经赋值过的prop对象取值。
FtlUtil工具类 将我们自己的实体类 变为 前端可以识别的实体类
public class FtlUtil { private static Logger logger = LoggerFactory.getLogger(FtlUtil.class); private static BeansWrapper wrapper = new BeansWrapperBuilder(Configuration.DEFAULT_INCOMPATIBLE_IMPROVEMENTS).build(); //BeansWrapper.getDefaultInstance(); //该方法的参数为一个全路径,在上面的使用中,把i18工具类的全路径放进来了 public static TemplateHashModel generateStaticModel(String packageName) { try { TemplateHashModel staticModels = wrapper.getStaticModels(); TemplateHashModel fileStatics = (TemplateHashModel) staticModels.get(packageName); return fileStatics; } catch (Exception e) { logger.error(e.getMessage(), e); } return null; } }
private static BeansWrapper wrapper = new BeansWrapperBuilder(Configuration.DEFAULT_INCOMPATIBLE_IMPROVEMENTS).build();
这段代码创建了一个BeansWrapper对象的实例,是FreeWrapper模板引擎中的一个核心类,用于在模板中访问java对象和方法。
该代码通过使用BeansWrapperBuilder的builder()方法创建了一个beansWrapper对象。通过创建该对象,可以在使用类似${object.property}的语法访问对象属性。
TemplateHashModel staticModels = wrapper.getStaticModels();
该方法获取了一个用于存储的静态模型。
TemplateHashModel fileStatics = (TemplateHashModel) staticModels.get(packageName);
该方法获取了一个指定包名下的静态模型。
再返回该模型即可获取到可供前端识别的语言模板。
9 xxl-job-admin启动之XxlJobAdminConfig配置类
9.1 该配置类的主要功能是从yml文件中读取数据,该类实现了InitializingBean和DisposableBean接口,同样是为了让第三方框架识别自己写的类。
InitiallizingBean和DisposableBean接口
如果一个类实现了InitiallizingBean接口,该接口为bean提供了属性初始化后的处理方法,它只有afterPropertiesSet方法,凡是继承该接口的类,再bean属性初始化之后都会执行该方法。
@Override public void afterPropertiesSet() throws Exception { adminConfig = this; xxlJobScheduler = new XxlJobScheduler(); xxlJobScheduler.init(); }
该方法会在项目启动后立即执行。该方法创建一个本体(单例模式)和一个xxlJobScheduler对象。并调用了该对象的init()方法。
如果一个类实现了DisposableBean接口,该接口只有一个destory方法,该对象会在bean对象销毁之前调用。
@Override public void destroy() throws Exception { xxlJobScheduler.destroy(); }
当容器关闭时(项目停止,程序关闭时自动执行)执行该方法。销毁xxlJobScheduler对象。
9.2 XxlJobScheduler
9.2.1 init方法
该方法首先调用了initI18方法,
private void initI18n(){ for (ExecutorBlockStrategyEnum item:ExecutorBlockStrategyEnum.values()) { item.setTitle(I18nUtil.getString("jobconf_block_".concat(item.name()))); } }
该方法主要用于初始化任务阻塞状态的语言配置,该方法对一个枚举类型进行了遍历,该枚举类属于core核心包中的类。
该枚举类有三个状态,单行串行,丢弃后调度,覆盖之前调度。通过获取枚举状态名字,再进行字符串拼接和语言配置文件中的属性名一致,通过I18n工具类可以实现该枚举状态的语言类型切换。
9.2.2 JobTriggerPoolHelper.toStart方法
该类的主要职责是 任务投递 和 下次执行时间维护。
该类包含两个线程池,快速触发任务线程池和慢触发任务线程池。会根据每分钟的执行次数决定任务投递到快速触发线程池还是慢触发任务线程池。
在配置类中,快线程池的最大线程数为200,满线程池则为100个。
public void start(){ fastTriggerPool = new ThreadPoolExecutor( 10, XxlJobAdminConfig.getAdminConfig().getTriggerPoolFastMax(), 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(1000), new ThreadFactory() { @Override public Thread newThread(Runnable r) { return new Thread(r, "xxl-job, admin JobTriggerPoolHelper-fastTriggerPool-" + r.hashCode()); } }); 核心线程数为10,最大线程数从配置类中读取,线程持续时间为60s,队列设置了1000个。 slowTriggerPool = new ThreadPoolExecutor( 10, XxlJobAdminConfig.getAdminConfig().getTriggerPoolSlowMax(), 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(2000), new ThreadFactory() { @Override public Thread newThread(Runnable r) { return new Thread(r, "xxl-job, admin JobTriggerPoolHelper-slowTriggerPool-" + r.hashCode()); } }); }
该方法创建了两个线程池,JobTriggerPoolHelper.toStart()方法就是执行了该start方法。
public void stop() { //triggerPool.shutdown(); fastTriggerPool.shutdownNow(); slowTriggerPool.shutdownNow(); logger.info(">>>>>>>>> xxl-job trigger thread pool shutdown success."); }
该方法为关闭线程池。
9.2.3 JobRegistryHelper.getInstance.start()方法
该类的任务就是注册线程池,使用单例模式。
1 保证 任务执行时拿到的任务列表都是正在运行的。
2 每30秒查询数据库中自动注册的执行器。
3 查询90秒内没有再次注册的执行器。
4 清除90秒内没有再次注册的执行器。(register表)
5 更新group表的addressList。
private ThreadPoolExecutor registryOrRemoveThreadPool = null; 注册或移除线程池 private Thread registryMonitorThread; 监控线程。
其中的start方法就是创建该线程池和监控线程。
其中监控线程会在start方法中无线循环执行,直至项目关闭。
在循环体中:
1. List<XxlJobGroup> groupList = XxlJobAdminConfig.getAdminConfig().getXxlJobGroupDao().findByAddressType(0);
该代码的作用是获取执行器表中所有自动注册的执行器。
当获取的groupList不为空时
2. List<Integer> ids = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().findDead(RegistryConfig.DEAD_TIMEOUT, new Date());
该行代码的作用是查询出超时的地址。
其中RegistryConfig为注册配置类,其中包含了一个枚举注册类型,有自动注册和手动注册两个状态。findDead方法的作用是查询组测表中所有超时的服务,注册表中的update_time字段会实时更新,通过传入规定超时时间和当前时间,如果当前时间减去规定超时时间还大于更新时间,则说明当前服务超时。
3.
XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().removeDead(ids);
将超时的服务地址从注册表中移除。
4.
List<XxlJobRegistry> list = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().findAll(RegistryConfig.DEAD_TIMEOUT, new Date());
该行代码的作用是查询出没有超时的服务。
5.
for (XxlJobRegistry item: list) { if (RegistryConfig.RegistType.EXECUTOR.name().equals(item.getRegistryGroup())) { String appname = item.getRegistryKey(); List<String> registryList = appAddressMap.get(appname); if (registryList == null) { registryList = new ArrayList<String>(); } if (!registryList.contains(item.getRegistryValue())) { registryList.add(item.getRegistryValue()); } appAddressMap.put(appname, registryList); } }
对没有超时的服务地址进行遍历,如果为自动注册,获取appName并将其作为appAddressMap的key。将ip地址作为appAddressMap的值。因为可能存在对同一个项目搭建集群,会有多个ip的情况,值类型为list<string>类型。
此时appAddressMap将会动态存储已注册的执行器名称和对应ip,如果某个项目关闭了,该map也会清除相关信息。
6.
for (XxlJobGroup group: groupList) { List<String> registryList = appAddressMap.get(group.getAppname()); String addressListStr = null; if (registryList!=null && !registryList.isEmpty()) { Collections.sort(registryList); StringBuilder addressListSB = new StringBuilder(); for (String item:registryList) { addressListSB.append(item).append(","); } addressListStr = addressListSB.toString(); addressListStr = addressListStr.substring(0, addressListStr.length()-1); } group.setAddressList(addressListStr); group.setUpdateTime(new Date()); XxlJobAdminConfig.getAdminConfig().getXxlJobGroupDao().update(group); }
同时对groupList进行遍历。通过其的appName获取到对应appAddressMap的地址ip。
用新创建的变量addressListStr来接收从appAddressMap获取到的ip集合。
如果ip为多个,则用 “,”隔开。
最后,将拼接完成ip地址放入到group中,再对group表进行同步更新。
7.
TimeUnit.SECONDS.sleep(RegistryConfig.BEAT_TIMEOUT);
停留30秒,该循环30秒执行一次。
registryMonitorThread.setDaemon(true); registryMonitorThread.setName("xxl-job, admin JobRegistryMonitorHelper-registryMonitorThread"); registryMonitorThread.start();
以上逻辑均写在runnable中的run方法中,这就是该监控线程所执行的逻辑。就是实时删除超时的服务,将正在注册的服务的信息(ip地址)同步到group表中。
8.
public void toStop(){ toStop = true; // 尝试立即停止所有正在执行的任务 registryOrRemoveThreadPool.shutdownNow(); // stop monitir (interrupt and wait) 优雅关闭 // 向该线程发送中断信号,该方法会将线程中断状态设为true registryMonitorThread.interrupt(); try { //join方法会阻塞当前线程,直到该线程执行完成或被中断 registryMonitorThread.join(); } catch (InterruptedException e) { logger.error(e.getMessage(), e); } }
关闭注册与删除线程池和监控线程。
。