在大规模分布式系统中,每个业务都可能是集群,每个业务机都会产生定时任务,不同的业务会有不同的任务管理需求,统一的任务调度和管理变得非常有必要。
- 定时如何准确,大量的定时被同时触发怎么办?
- 定时结束的时候,怎么通知业务机去处理呢?
- 某台业务机下线了怎么办?
- 如何提供任务更新、删除功能?
基本模型如下图:
定时器在社会中有着广泛的应用,比如每天叫你起床的闹钟。在软件项目中,定时器也被应用到了各方各面,本文将从 web 项目入手,讲述定时器,本文的例子都以 node
为例。
为什么要用定时器?
没有什么比机器更加准时!在我接触单片机的时候,已经开始感叹,为什么机器时间可以做到这么准!
比如文章的定时发布、商品的准点开始抢购、活动定时上下架,肯定不会是一个又一个管理员在后台帮你点击按钮,完成操作!系统的准时可以定位到毫秒级,虽然每个用户可能和服务器的时间不一致,秒级的差别还是在可接受范围的,但是在某些领域也会有很多精细到毫秒级的定时任务需求,比如航空航天、定时炸弹等等。
定时器总类
定时器有两种 interval
、timeout
, 对应重复任务和一次性任务。在我的理解里,interval 任务只是在 timeout 的时候再次注册了本任务。
// 重复性任务
var timer = setInterval(function(){
// do something
}, milliseconds)
// 一次性任务
var timer = setTimeout(function(){
// do something
}, milliseconds)
unix crontab 能解决问题吗?
crontab 并不能精确到秒,crontab 的最小粒度是分,即当第一位是「*/1」时,即最小单位是每分钟执行,(不排除你们有奇淫技巧可以做到秒级控制的)。unix 本身支持强大的定时任务管理 crontab,定时的格式也是强大得令人惊叹。
* * * * * *
┬ ┬ ┬ ┬ ┬ ┬
│ │ │ │ │ |
│ │ │ │ │ └ day of week (0 - 7) (0 or 7 is Sun)
│ │ │ │ └───── month (1 - 12)
│ │ │ └────────── day of month (1 - 31)
│ │ └─────────────── hour (0 - 23)
│ └──────────────────── minute (0 - 59)
└───────────────────────── second (0 - 59, optional)
1)Cron 表达式的格式:秒 分 时 日 月 周 年 (可选)。
字段名 允许的值 允许的特殊字符
秒 0-59 , - * /
分 0-59 , - * /
小时 0-23 , - * /
日 1-31 , - * ? / L W C
月 1-12 or JAN-DEC , - * /
周几 1-7 or SUN-SAT , - * ? / L C #
年 (可选字段) empty, 1970-2099 , - * /
「?」字符:表示不确定的值
「,」字符:指定数个值
「-」字符:指定一个值的范围
「/」字符:指定一个值的增加幅度。n/m 表示从 n 开始,每次增加 m
「L」字符:用在日表示一个月中的最后一天,用在周表示该月最后一个星期 X
「W」字符:指定离给定日期最近的工作日 (周一到周五)
「#」字符:表示该月第几个周 X。6#3 表示该月第 3 个周五
Cron 表达式范例:
每隔 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 * * ?
每种开发语言都提供了 crontab 的相关封装,让开发者调用起来得心应手。以 node
为例:
require('crontab').load(function(err, crontab) {
// create with string expression
var job = crontab.create('ls -la', '0 7 * * 1,2,3,4,5');
});
你在 github 搜索 crontab 能搜到主流语言的实现。
有个问题,定时器不准时!
setInterval 的回调函数并不是到时后立即执行,而是等系统计算资源空闲下来后才会执行。而下一次触发时间则是在 setInterval 回调函数执行完毕之后才开始计时,所以如果 setInterval 内执行的计算过于耗时,或者有其他耗时任务在执行,setInterval 的计时会越来越不准, 延迟很厉害。crontab 也是同样的原理。
var startTime = new Date().getTime();
var count = 0;
//耗时任务
setInterval(function(){
var i = 0;
while(i++ < 100000000);
}, 0);
setInterval(function(){
count++;
console.log(new Date().getTime() - (startTime + count * 1000));
}, 1000);
结果
126
176
163
112
109
107
203
189
170
当然,不排除你们有奇淫技巧可以做到秒级控制的。
成千上万定时任务时怎么管理?
Crontab 存在任务上限(其实我也不知道上限是多少,知道的麻烦告诉我),任务的同步、备份管理都比较麻烦,也会有比较多的并发问题需要处理。在分布式系统中,单独去部署一个定时任务机器也是可行的。不过任务调度、定时结束通知客户端也需要蛮多工作量的。
unix 的 crontab 不再是我们的第一选择,每种编程可能都有定时任务管理的相关框架。比如 java 的 Quartz,Python 的 APScheduler。nodejs 的 node-schedule。但是这些东西是否能真的满足你的需求呢?
So,我们需要一个定时任务管理平台。
思路和实现
目标
- 业务方可以定义定时时间、时间结束的触发任务
- 业务方可以更新或者删除已经发布的定时任务
- 定时任务管理平台统一接收和调度任务
主要解决两个问题:
- 设置准确的定时时间
- 时间结束触发客户端,不能重复消费
redis 在 2.8.X 版本可以开启了键空间通知,更多相关请移步 Redis Keyspace Notifications。(默认不开启,3.x 版本好像就失效了。),redis 支持的很多键空间事件,比如:DEL
,RENAME
,EXPIRE
等等,redis 本身可以定义某个键的过期时间,ttl key。
这个值正好用来设置为定时任务的时间。更多相关请移步 Redis Keyspace Notifications。如果客户端订阅了某种规则的键通知,比如过期,那么在某个键过期的时候就会收到一个通知,这个事件就是定时结束,可以告诉业务机可以开启任务了。
** 可如果有多个 redis 客户端订阅了某个键的过期时间,那么任务还是会被触发很多次。** 因为每个客户端
都是平等的,你能订阅,我同样可以订阅。解决办法就是 生产者和消费者模式。同一个过期消息只能被消费一次。
重点来了
把所有的定时任务按照定时开启的时间倒序排列,存入 sorted Sets , 把时间设置为 score。这样就会形成一个按照时间排好序的集合,可以按照时间先后依次取出所有的任务,需要新增和修改任务,也是可以通过 redis 的命令实现的。
定时管理服务器每 1000ms 去取 sorted sets 顶部的数据,如果获取到的 task 离触发小于 1s,那么就可以执行 pop() 操作,表示这个任务开始被调度执行,因为 redis 的 pop() 是原子性的,同一个 task 永远只会被消费一次。这样就解决了 redis 键空间通知会被重复消费的问题。
伪代码如下:
var taskSorts = new Sets(task1, task2, task3); // 在 redis 中建立按时间排序的集合
// 每隔一秒执行一下操作,
var newOne = taskSorts.zrank(-1); // 获取到最快发生的任务
if(newOne.time < 1000){ // 如果满足消费条件
newOne = taskSorts.pop(); // 消费该任务,重复此循环,继续消费下一个任务
setTimeout(function(){
// dosomething
}, newOne.time)
}
任务触发
- 任务的提交和触发都应该在业务方完成。定时任务管理平台只是帮助管理和调度任务。在定义的任务里面定义好任务执行的回调参数和接口。
- 客户端定义任务的时候,同时注册好定时结束的回调接口,或者应该在项目启动的时候,就注册好所有回调的接口。因为同一个业务的 A 机器提交了任务,触发的时候可能 A 机器下线了,只能定时任务平台只能去触发业务 A 的 B 机器了。
- 引入跨服务远程调用。业务和定时任务管理平台可能不在同一个机器,可能分布在不同的 ip。听起来很复杂,实际上跨语言的调用调用方式有很多,比如 REST API、消息队列、RPC。我的团队选择了 Thrift(Facebook 开源的,跨语言的,现在共享给了 Apache 基金)。以上的方式都可以实现任务只被触发了一次,远程通知给客户端(任务注册方)。
成品 -- nodejs 的实现 cron-redis
https://github.com/MZMonster/cron-redis
主要依赖 bull 实现了任务队列的管理功能实现的定时任务管理工具。
demo:
// 就这样定义,3 秒钟之后,hello 函数将被执行。
function hello (x, y){
console.log(new Date());
console.log(x + ' + '+ y +' = %s', x+y);
}
// 我是一个任务
var task1 = {
method: hello.name, // 任务回调的函数
params: [2, 3], // 任务执行的参数
rule: moment().add(3, 's').toDate() // 任务执行间隔,支持 crontab 格式
}
queue.register(hello)
queue.publish(task1);
如果你要求不高,unix 自带的 crontab 也足够你折腾了。使用 redis 来实现定时也是一种极好的思路,cron-redis 值得你去试一试。
该库只是一个定时任务的库,实际上可以通过以上的思路实现微服务————定时任务管理平台。通过 cron-redis 组合远程服务调用 thrift、服务的注册发现工具 zookeeper,定时任务管理平台分分钟就被搭建了(等我下一篇文章吧,分分钟搭建微服务)。