14 |定时任务:如何让框架支持分布式定时脚本?

使用 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 开启计时器,等待计时器结束后解开文件锁,并且删除文件锁对应的文件。

【小结】:

  1. 常规的定时任务通常使用linux的crontab,这里我们写入到框架中,让在命令行启动时就启动后台定时任务
  2. 代码中定时任务编码在业务区,可能后续使用配置文件的方式,这样任务变更不用更改程序代码
  3. 单机版程序定任务由一个进程运行,有宕机风险,就引入了分布式的架构
  4. 分布式重要的是选举策略,相同的定时任务(即代码中的服务)竞争获取,且只能有一个能获取到。选举策略根据时机情况做选择,比如使用redis实现,这里简单使用一个文件锁,来测试同一台机器上两个进程的竞争获取。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值