1 概述
首先了解下分布式调度:
1)运⾏在分布式集群环境下的调度任务(同⼀个定时任务程序部署多份,只应该有⼀个定时任务在执 ⾏)
2)分布式调度—>定时任务的分布式—>定时任务的拆分(即为把⼀个⼤的作业任务拆分为多个⼩的作 业任务,同时执⾏)
单体应用中所有的定时任务都在一个服务器的一套程序中运行,拆分成分布式之后,不同的定时任务会拆分到不同的子系统中去,而不同的子系统又部署了多份,那么问题来了,比如统计报表定时任务,如果两个定时任务同时跑,会不会重复处理?
定时任务与消息队列区别?
共同点:
1 异步处理,比如注册事件
2 应用解耦,不管定时任务作业还是MQ都可以作为两个应⽤之间的⻮轮实现应⽤解耦,这个⻮轮可以中转 数据,当然单体服务不需要考虑这些,服务拆分的时候往往都会考虑
3 流量削峰, 双⼗⼀的时候,任务作业和MQ都可以⽤来扛流量,后端系统根据服务能⼒定时处理订单或者 从MQ抓取订单抓取到⼀个订单到来事件的话触发处理,对于前端⽤户来说看到的结果是已经 下单成功了,下单是不受任何影响的。
不同点:
本质不同:定时任务作业是时间驱动,⽽MQ是事件驱动; 时间驱动是不可代替的,⽐如⾦融系统每⽇的利息结算,不是说利息来⼀条(利息到来事件)就算 ⼀下,⽽往往是通过定时任务批量计算;
定时任务倾向于批处理,MQ倾向于逐条处理。
2 实现方式
定时任务的实现⽅式有多种。早期没有定时任务框架的时候,我们会使⽤JDK中的Timer机制和多线程机 制(Runnable+线程休眠)来实现定时或者间隔⼀段时间执⾏某⼀段程序;后来有了定时任务框架,⽐ 如⼤名鼎鼎的Quartz任务调度框架,使⽤时间表达式(包括:秒、分、时、⽇、周、年)配置某⼀个任 务什么时间去执⾏。
2.1 Quartz
可以在单机上运行。
添加依赖:
<dependency> <groupId>org.quartz-scheduler</groupId> <artifactId>quartz</artifactId> <version>1.7.3</version> </dependency>
配置文件:
<?xml version='1.0' encoding='utf-8'?> <quartz xmlns="http://www.opensymphony.com/quartz/JobSchedulingData" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.opensymphony.com/quartz/JobSchedulingData http://www.opensymphony.com/quartz/xml/job_scheduling_data_1_5.xsd" version="1.5"> <calendar class-name="org.quartz.impl.calendar.HolidayCalendar" replace="true"> <name>holidayCalendar</name> <description>HolidayCalendar</description> <base-calendar class-name="org.quartz.impl.calendar.WeeklyCalendar"> <name>weeklyCalendar</name> <description>WeeklyCalendar</description> <base-calendar class-name="org.quartz.impl.calendar.AnnualCalendar"> <name>annualCalendar</name> <description>AnnualCalendar</description> </base-calendar> </base-calendar> </calendar> <job> <job-detail> <name>NewDayJob</name> <group>group1</group> <description>跨天重置</description> <job-class>cn.game.jobs.NewDayJob </job-class> <volatility>false</volatility> <durability>false</durability> <recover>false</recover> </job-detail> <trigger> <cron> <name>NewDayJob</name> <group>group1</group> <description>每日</description> <job-name>Job-NewDayJob</job-name> <job-group>job-group1</job-group> <cron-expression>0 0 0 * * ?</cron-expression> </cron> </trigger> </job> </quartz>
calendar中使用了HolidayCalendar用于从 Trigger 中排除节假日。
AnnualCalendar(天):指定每年的哪一天。使用方式如上例。
CronCalendar(秒):指定Cron表达式。精度取决于Cron表达式。
DailyCalendar(毫秒):指定每天的时间段(rangeStartingTime, rangeEndingTime),格式是HH:MM[:SS[:mmm]]
HolidayCalendar(天):指定特定的日期,比如20140613。
MonthlyCalendar(天):指定每月的几号。可选值为1-31。
WeeklyCalendar(天):指定每星期的星期几,可选值比如为java.util.Calendar.SUNDAY。
通过job可以添加多个定时任务,如上添加了跨天重置的定时任务,每日5点重置,在cron-expression中配置表达式。
{秒数} {分钟} {小时} {日期} {月份} {星期} {年份(可为空)}
范例:
每隔5秒执行一次:*/5 * * * * ?
每隔1分钟执行一次:0 */1 * * * ?
每天23点执行一次:0 0 23 * * ?
每天凌晨1点执行一次:0 0 1 * * ?
每月1号凌晨1点执行一次:0 0 1 1 * ?
每月最后一天23点执行一次:0 0 23 L * ?
每周星期天凌晨1点实行一次:0 0 1 ? * L
在26分、29分、33分执行一次:0 26,29,33 * * * ?
每天的0点、13点、18点、21点都执行一次:0 0 0,13,18,21 * * ?
cn.game.jobs.NewDayJob类实现了Job类并重写了execute方法,在方法中可以写任务逻辑。
2 分布式调度框架elastic-job
2.1 介绍
Elastic-Job是当当⽹开源的⼀个分布式调度解决⽅案,基于Quartz⼆次开发的,由两个相互独⽴的⼦项 ⽬Elastic-Job-Lite和Elastic-Job-Cloud组成。我们要学习的是 Elastic-Job-Lite,它定位为轻量级⽆中⼼ 化解决⽅案,使⽤Jar包的形式提供分布式任务的协调服务,⽽Elastic-Job-Cloud⼦项⽬需要结合Mesos 以及Docker在云环境下使⽤。
主要功能介绍:
·分布式调度协调 在分布式环境中,任务能够按指定的调度策略执⾏,并且能够避免同⼀任务多实例重复执⾏
·丰富的调度策略 基于成熟的定时任务作业框架Quartz cron表达式执⾏定时任务
·弹性扩容缩容 当集群中增加某⼀个实例,它应当也能够被选举并执⾏任务;当集群减少⼀个实例 时,它所执⾏的任务能被转移到别的实例来执⾏。
·失效转移 某实例在任务执⾏失败后,会被转移到其他实例执⾏ 错过执⾏作业重触发 若因某种原因导致作业错过执⾏,⾃动记录错过执⾏的作业,并在上次作业 完成后⾃动触发。
·⽀持并⾏调度 ⽀持任务分⽚,任务分⽚是指将⼀个任务分为多个⼩任务项在多个实例同时执⾏。
·作业分⽚⼀致性 当任务被分⽚后,保证同⼀分⽚在分布式环境中仅⼀个执⾏实例。
2.2 Elastic-Job-Lite应用
jar包(API) + 安装zk软件
Elastic-Job依赖于Zookeeper进⾏分布式协调,所以需要安装Zookeeper软件(3.4.6版本以上)。
1 安装zookeeper
1)我们使⽤3.4.10版本,在linux平台解压下载的zookeeper-3.4.10.tar.gz
2)进⼊conf⽬录,cp zoo_sample.cfg zoo.cfg
3) 进⼊bin⽬录,启动zk服务 启动 ./zkServer.sh start 停⽌ ./zkServer.sh stop 查看状态 ./zkServer.sh status
2 引入jar包
3 定时任务实例
需求:每隔两秒钟执⾏⼀次定时任务(resume表中未归档的数据归档到resume_bak表中, 每次归档1条记录) 1)resume_bak和resume表结构完全⼀样 2)resume表中数据归档之后不删除,只将state置为"已归档"。
数据库表结构:
CREATE TABLE `resume` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`sex` varchar(255) DEFAULT NULL,
`phone` varchar(255) DEFAULT NULL,
`address` varchar(255) DEFAULT NULL,
`education` varchar(255) DEFAULT NULL,
`state` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1001 DEFAULT CHARSET=utf8;
SET FOREIGN_KEY_CHECKS = 1;
定时任务类:
public class BackupJob implements SimpleJob { // 定时任务每执⾏⼀次都会执⾏如下的逻辑 @Override public void execute(ShardingContext shardingContext) { /* 从resume数据表查找1条未归档的数据,将其归档到resume_bak 表,并更新状态为已归档(不删除原数据) */ // 查询出⼀条数据 String selectSql = "select * from resume where state='未归档' limit 1"; List<Map<String, Object>> list = JdbcUtil.executeQuery(selectSql); if(list == null || list.size() == 0) { return; } Map<String, Object> stringObjectMap = list.get(0); long id = (long) stringObjectMap.get("id"); String name = (String) stringObjectMap.get("name"); String education = (String) stringObjectMap.get("education");// 更改状态 String updateSql = "update resume set state='已归档' where id=?"; JdbcUtil.executeUpdate(updateSql,id); // 归档这条记录 String insertSql = "insert into resume_bak select * from resume where id=?"; JdbcUtil.executeUpdate(insertSql,id); } }
主类:
public class ElasticJobMain { public static void main(String[] args) { // 配置注册中⼼zookeeper,zookeeper协调调度,不能让任务重复执⾏, //通过命名空间分类管理任务,对应到zookeeper的⽬录 ZookeeperConfiguration zookeeperConfiguration = new ZookeeperConfiguration("localhost:2181","data-archive-job"); CoordinatorRegistryCenter coordinatorRegistryCenter = new ZookeeperRegistryCenter(zookeeperConfiguration); coordinatorRegistryCenter.init(); // 配置任务 JobCoreConfiguration jobCoreConfiguration = JobCoreConfiguration.newBuilder("archive-job","*/2 * * * * ?",1).build(); SimpleJobConfiguration simpleJobConfiguration = new SimpleJobConfiguration(jobCoreConfiguration,BackupJob.class.getName()); // 启动任务 new JobScheduler(coordinatorRegistryCenter, LiteJobConfiguration.newBuilder(simpleJobConfiguration).build()).init(); } }
public class JdbcUtil { //url private static String url = "jdbc:mysql://localhost:3306/job? characterEncoding=utf8&useSSL=false"; //user private static String user = "root"; //password private static String password = "root"; //驱动程序类 private static String driver = "com.mysql.jdbc.Driver"; static { try { Class.forName(driver); } catch (ClassNotFoundException e) { // TODO Auto-generated catch block e.printStackTrace(); } } public static Connection getConnection() { try { return DriverManager.getConnection(url, user, password); } catch (SQLException e) { // TODO Auto-generated catch block e.printStackTrace(); } return null; } public static void close(ResultSet rs, PreparedStatement ps, Connection con) { if (rs != null) { try { rs.close(); } catch (SQLException e) { // TODO Auto-generated catch block e.printStackTrace(); } finally { if (ps != null) { try { ps.close(); } catch (SQLException e) { // TODO Auto-generated catch block e.printStackTrace(); } finally { if (con != null) { try { con.close(); } catch (SQLException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } } } } } public static void executeUpdate(String sql,Object...obj) { Connection con = getConnection(); PreparedStatement ps = null; try { ps = con.prepareStatement(sql); for (int i = 0; i < obj.length; i++) { ps.setObject(i + 1, obj[i]); } ps.executeUpdate(); } catch (SQLException e) { // TODO Auto-generated catch block e.printStackTrace(); } finally { close(null, ps, con); } } public static List<Map<String,Object>> executeQuery(String sql, Object...obj) { Connection con = getConnection(); ResultSet rs = null; PreparedStatement ps = null; try { ps = con.prepareStatement(sql); for (int i = 0; i < obj.length; i++) { ps.setObject(i + 1, obj[i]); } rs = ps.executeQuery(); List<Map<String, Object>> list = new ArrayList<>(); int count = rs.getMetaData().getColumnCount(); while (rs.next()) { Map<String, Object> map = new HashMap<String, Object>(); for (int i = 0; i < count; i++) { Object ob = rs.getObject(i + 1); String key = rs.getMetaData().getColumnName(i + 1); map.put(key, ob); } list.add(map); } return list; } catch (SQLException e) { // TODO Auto-generated catch block e.printStackTrace(); } finally { close(rs, ps, con); } return null; }}
测试:
1)可先启动⼀个进程,然后再启动⼀个进程(两个进程模拟分布式环境下,通⼀个定时任务 部署了两份在⼯作) 2)两个进程逐个启动,观察现象
3)关闭其中执⾏的进程,观察现象
Leader节点选举机制
每个Elastic-Job的任务执⾏实例App作为Zookeeper的客户端来操作ZooKeeper的znode
(1)多个实例同时创建/leader节点.
(2)/leader节点只能创建⼀个,后创建的会失败,创建成功的实例会被选为leader节点, 执⾏任务.
2.3 Elastic-Job-Lite轻量级去中⼼化的特点
轻量级:必须依赖仅仅zookeeper,并非独立部署的中间件,就是jar程序。
去中心化:执行节点对等(程序和jar一样,唯一不一样的可能是分片),定时调度触发(没有中心调度节点分配),服务自发现,主节点非固定。
任务分片:⼀个⼤的⾮常耗时的作业Job,⽐如:⼀次要处理⼀亿的数据,那这⼀亿的数据存储在数据库中,如果 ⽤⼀个作业节点处理⼀亿数据要很久,在互联⽹领域是不太能接受的,互联⽹领域更希望机器的增加去 横向扩展处理能⼒。所以,ElasticJob可以把作业分为多个的task(每⼀个task就是⼀个任务分⽚),每 ⼀个task交给具体的⼀个机器实例去处理(⼀个机器实例是可以处理多个task的),但是具体每个task 执⾏什么逻辑由我们⾃⼰来指定。
Strategy策略定义这些分⽚项怎么去分配到各个机器上去,默认是平均去分,可以定制,⽐如某⼀个机 器负载 ⽐较⾼或者预配置⽐较⾼,那么就可以写策略。分⽚和作业本身是通过⼀个注册中⼼协调的,因 为在分布式环境下,状态数据肯定集中到⼀点,才可以在分布式中沟通。
分片配置:
JobCoreConfiguration.newBuilder("archive-job","*/2 * * * * ?", 3)
.shardingItemParameters("0=bachelor,1=master,2=doctor")
.build();
public class BackupJob implements SimpleJob { // 定时任务每执⾏⼀次都会执⾏如下的逻辑 @Override public void execute(ShardingContext shardingContext) {
int shardingItem = shardingContext.getShardingItem();
System.out.println("当前分片:" +shardingItem);
//获取分片参数
String param = shardingContext.getShardingParameter();
String selectSql = "select * from resume where state='未归档' and eduction = ' " + param +
"' + limit 1";
...
}
2.4 弹性扩容
新增加⼀个运⾏实例app3,它会⾃动注册到注册中⼼,注册中⼼发现新的服务上线,注册中⼼会通知 ElasticJob 进⾏重新分⽚,那么总得分⽚项有多少,那么就可以搞多少个实例机器。
注意:
1)分⽚项也是⼀个JOB配置,修改配置,重新分⽚,在下⼀次定时运⾏之前会重新调⽤分⽚算法,那么 这个分⽚算法的结果就是:哪台机器运⾏哪⼀个⼀⽚,这个结果存储到zk中的,主节点会把分⽚给分好 放到注册中⼼去,然后执⾏节点从注册中⼼获取信息(执⾏节点在定时任务开启的时候获取相应的分 ⽚)。
2)如果所有的节点挂掉值剩下⼀个节点,所有分⽚都会指向剩下的⼀个节点,这也是ElasticJob的⾼可 ⽤。