使用 cron 包定时执行命令
用法如下:
// 创建一个cron实例
c := cron.New()
// 每整点30分钟执行一次
c.AddFunc("30 * * * *", func() {
fmt.Println("Every hour on the half hour")
})
// 上午3-6点,下午8-11点的30分钟执行
c.AddFunc("30 3-6,20-23 * * *", func() {
fmt.Println(".. in the range 3-6am, 8-11pm")
})
// 东京时间4:30执行一次
c.AddFunc("CRON_TZ=Asia/Tokyo 30 04 * * *", func() {
fmt.Println("Runs at 04:30 Tokyo time every day")
})
// 从现在开始每小时执行一次
c.AddFunc("@hourly", func() {
fmt.Println("Every hour, starting an hour from now")
})
// 从现在开始,每一个半小时执行一次
c.AddFunc("@every 1h30m", func() {
fmt.Println("Every hour thirty, starting an hour thirty from now")
})
// 启动cron
c.Start()
...
// 在cron运行过程中增加任务
c.AddFunc("@daily", func() { fmt.Println("Every day") })
..
// 查看运行中的任务
inspect(c.Entries())
..
// 停止cron的运行,优雅停止,所有正在运行中的任务不会停止。
c.Stop()
- 它具有极其丰富的“时间描述语言”。和 Linux 的 crontab 一样的语法,维度可以是分钟、小时、日、月、星期
- 具备符号 @ 和非常语义化的 hourly、every 1h30m 的描述语句
- 支持秒级别的定时
// 创建一个cron实例
c := cron.New(cron.WithParser(cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)))
// 每秒执行一次
c.AddFunc("* * * * * *", func() {
fmt.Println("Every hour on the half hour")
})
定时命令的思路与实现
设想在框架中支持如下的语句:
// 每秒调用一次Foo命令
rootCmd.AddCronCommand("* * * * * *", demo.FooCommand)
第一个参数对应 cron 库中的“时间描述语言”,第二个参数对应我们的 Command 结构。
从刚才的两个使用小例子能看到,在 cron 库中,增加一个定时任务的 AddFunc 方法,有两个参数:时间描述语言、匿名函数。那么明显,AddCronCommand 函数中核心要做的,就是将 Command 结构的执行封装成一个匿名函数,再调用 cron 的 AddFunc 方法就可以了。
同上节课的container一样,初始化的 Cron 对象也放入根 Command。
cron 的初始化和回调
AddCronCommand函数,要将一个command添加到根command的cron任务中,以供程序启动时启动根command的cron。
// AddCronCommand 是用来创建一个Cron任务的
func (c *Command) AddCronCommand(spec string, cmd *Command) {
// cron结构是挂载在根Command上的
root := c.Root()
if root.Cron == nil {
// 初始化cron
root.Cron = cron.New(cron.WithParser(cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)))
root.CronSpecs = []CronSpec{}
}
// 增加说明信息
root.CronSpecs = append(root.CronSpecs, CronSpec{
Type: "normal-cron",
Cmd: cmd,
Spec: spec,
})
// 制作一个rootCommand
var cronCmd Command
ctx := root.Context()
cronCmd = *cmd
cronCmd.args = []string{}
cronCmd.SetParentNull()
cronCmd.SetContainer(root.GetContainer())
// 增加调用函数
root.Cron.AddFunc(spec, func() {
// 如果后续的command出现panic,这里要捕获
defer func() {
if err := recover(); err != nil {
log.Println(err)
}
}()
err := cronCmd.ExecuteContext(ctx)
if err != nil {
// 打印出err信息
log.Println(err)
}
})
}
cron的启动时机
将cron的启动配置到命令行中:
~: ./hade cron
定时任务相关命令
Usage:
hade cron [flags]
hade cron [command]
Available Commands:
list 列出所有的定时任务
restart 重启cron常驻进程
start 启动cron常驻进程
state cron常驻进程状态
stop 停止cron常驻进程
Flags:
-h, --help help for cron
Use "hade cron [command] --help" for more information about a command.
并使用daemon整个flag来做后台或前台启动。
如何实现分布式定时器
但现在的定时器还是单机版本的定时器,容灾性很低,如果有很多定时任务都挂载在一个进程中,一旦这个进程或者这个机器出现灾难性不可恢复,那么定时任务就直接无法运行了。
容灾性更高的是分布式定时器。也就是很多机器都同时挂载定时任务,在同一时间都启动任务,只有一台机器能抢占到这个定时任务并且执行,其他机器由于抢占不到定时任务,不执行任何操作。
还是这样思考问题就比较低层次了。在第十、十一章,我们花了大量的篇幅讲了面向接口编程的思想,这里就可以用到这个思想。先定义接口,这个接口的功能是一个分布式的选择器,当有很多节点要执行某个服务的时候,只选择出其中一个节点。这样不管底层是否用 Redis 实现分布式选择器,在业务层我们都可以不用关心。
在文件 framework/contract/distributed.go 文件中。我们先定义接口 Distributed。其中有一个分布式选举方法 Select。它的参数有三个,serviceName 代表服务名字、appID 代表节点的 ID、holdTime 表示这个选择结果持续多久,也就是在选举出来之后多久内有效。它返回两个值,selectAppID 表示选举的结果,即最终哪个节点被选举出来了,另一个返回值 error 表示异常。
package contract
import "time"
// DistributedKey 定义字符串凭证
const DistributedKey = "hade:distributed"
// Distributed 分布式服务
type Distributed interface {
// Select 分布式选择器, 所有节点对某个服务进行抢占,只选择其中一个节点
// ServiceName 服务名字
// appID 当前的AppID
// holdTime 分布式选择器hold住的时间
// 返回值
// selectAppID 分布式选择器最终选择的App
// err 异常才返回,如果没有被选择,不返回err
Select(serviceName string, appID string, holdTime time.Duration) (selectAppID string, err error)
}
分布式服务 Distributed 的接口定义好,我们再回到它的具体实现。
这个具体实现就有很多方式了,Redis 只是其中一种而已,而且 Redis 要到后面章节才能引入。这里就实现一个本地文件锁。当一个服务器上有多个进程需要进行抢锁操作,文件锁是一种单机多进程抢占的很简易的实现方式。在 Golang 中,其使用方法也比较简单。
多个进程同时使用 os.OpenFile 打开一个文件,并使用 syscall.Flock 带上 syscall.LOCK_EX 参数来对这个文件加文件锁,这里只会有一个进程抢占到文件锁,而其他抢占不到的进程从 syscall.Flock 函数中获取到的就是 error。根据这个 error 是否为空,我们就能判断是否抢占到了文件锁。
释放文件锁有两种方式,一种方式是调用 syscall.Flock 带上 syscall.LOCK_UN 的参数,另外一种方式是抢占到锁的进程结束,也会自动释放文件锁。
来看分布式选择器的本地文件锁的具体实现,在代码 framework/provider/distributed/service_local.go 文件中:
// Select 为分布式选择器
func (s LocalDistributedService) Select(serviceName string, appID string, holdTime time.Duration) (selectAppID string, err error) {
appService := s.container.MustMake(contract.AppKey).(contract.App)
runtimeFolder := appService.RuntimeFolder()
lockFile := filepath.Join(runtimeFolder, "disribute_"+serviceName)
// 打开文件锁
lock, err := os.OpenFile(lockFile, os.O_RDWR|os.O_CREATE, 0666)
if err != nil {
return "", err
}
// 尝试独占文件锁
err = syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
// 抢不到文件锁
if err != nil {
// 读取被选择的appid
selectAppIDByt, err := ioutil.ReadAll(lock)
if err != nil {
return "", err
}
return string(selectAppIDByt), err
}
// 在一段时间内,选举有效,其他节点在这段时间不能再进行抢占
go func() {
defer func() {
// 释放文件锁
syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
// 释放文件
lock.Close()
// 删除文件锁对应的文件
os.Remove(lockFile)
}()
// 创建选举结果有效的计时器
timer := time.NewTimer(holdTime)
// 等待计时器结束
<-timer.C
}()
// 这里已经是抢占到了,将抢占到的appID写入文件
if _, err := lock.WriteString(appID); err != nil {
return "", err
}
return appID, nil
}
大概逻辑是先打开或者创建文件锁对应的文件,然后在这个文件上加上文件锁,加上锁的过程就是抢占的过程。对于没有抢占到的,文件中内容为抢占到的那个应用的 ID,将应用 ID 返回;抢占到的,就写入自己的应用 ID 到文件中,并且通过一个新的 Goroutine 开启计时器,等待计时器结束后解开文件锁,并且删除文件锁对应的文件。
【小结】:
- 常规的定时任务通常使用linux的crontab,这里我们写入到框架中,让在命令行启动时就启动后台定时任务
- 代码中定时任务编码在业务区,可能后续使用配置文件的方式,这样任务变更不用更改程序代码
- 单机版程序定任务由一个进程运行,有宕机风险,就引入了分布式的架构
- 分布式重要的是选举策略,相同的定时任务(即代码中的服务)竞争获取,且只能有一个能获取到。选举策略根据时机情况做选择,比如使用redis实现,这里简单使用一个文件锁,来测试同一台机器上两个进程的竞争获取。