Lenny-Hu / note

blog
5 stars 1 forks source link

定时器在大型web项目中的应用和实现 #22

Open Lenny-Hu opened 5 years ago

Lenny-Hu commented 5 years ago

来源 https://segmentfault.com/a/1190000004736079

在大规模分布式系统中,每个业务都可能是集群,每个业务机都会产生定时任务,不同的业务会有不同的任务管理需求,统一的任务调度和管理变得非常有必要。

定时器在社会中有着广泛的应用,比如每天叫你起床的闹钟。在软件项目中,定时器也被应用到了各方各面,本文将从 web项目入手,讲述定时器,本文的例子都以 node为例。

为什么要用定时器?

没有什么比机器更加准时!在我接触单片机的时候,已经开始感叹,为什么机器时间可以做到这么准!

比如文章的定时发布、商品的准点开始抢购、活动定时上下架,肯定不会是一个又一个管理员在后台帮你点击按钮,完成操作!系统的准时可以定位到毫秒级,虽然每个用户可能和服务器的时间不一致,秒级的差别还是在可接受范围的,但是在某些领域也会有很多精细到毫秒级的定时任务需求,比如航空航天、定时炸弹等等。

定时器总类

定时器有两种 intervaltimeout, 对应重复任务和一次性任务。在我的理解里,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)
}

任务触发

成品 -- 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,定时任务管理平台分分钟就被搭建了(等我下一篇文章吧,分分钟搭建微服务)。