worker 功能点
- 获取ETCD中,被master写入的cmd任务;
- 根据corn表达式,确定任务调度列表;
- 根据任务列表,进行任务执行;
- 对Job做分布式锁,防止集群并发调用。
- 执行日志存储;
worker 启动
同master ( init args(flags解析) -> init config -> init job(etcd) ) //todo master 连接
worker 功能实现
解释:
- worker中的JobMgr负责向etcd进行任务监听,当其监听到任务后,根据任务类型进行简单封装,并将其投递到核心对象Scheduler进行调度;
- Scheduler 构建为单例,调度计划、执行计划与相应的任务执行结果都由该对象进行维护;
- Scheduler计算定时任务执行间隔,以便获得执行步长去做任务,而相应执行结果会通过channel的方式在投递回该对象;
- 其中有两点在设计的时候需要考虑 :
4.1 某任务若仍旧处于执行中,但该任务新一轮次的定时任务已经到点,则该任务不应该执行。
解决方式:通过构建一张任务执行表
避免该问题,既任务执行前后对该任务执行表
进行维护。
4.2 如何避免一个任务被多节点并行了?
解决方式:类似于elastic-job中呢样,建立一个分布式锁,去做任务的临界资源。
实体类介绍:
common.Protocol.go
// 任务调度计划
type JobSchedulePlan struct {
Job *Job // 要调度的任务信息
Expr *cronexpr.Expression // 解析好的cronexpr表达式
NextTime time.Time // 下次调度时间
}
// 任务执行状态
type JobExecuteInfo struct {
Job *Job // 任务信息
PlanTime time.Time // 理论上的调度时间
RealTime time.Time // 实际的调度时间
CancelCtx context.Context // 任务command的context
CancelFunc context.CancelFunc// 用于取消command执行的cancel函数
}
// 任务执行结果
type JobExecuteResult struct {
ExecuteInfo *JobExecuteInfo // 执行状态
Output []byte // 脚本输出
Err error // 脚本错误原因
StartTime time.Time // 启动时间
EndTime time.Time // 结束时间
}
worker.Scheduler.go
// 任务调度
type Scheduler struct {
jobEventChan chan *common.JobEvent // etcd任务事件队列
jobPlanTable map[string]*common.JobSchedulePlan // 任务调度计划表
jobExecutingTable map[string]*common.JobExecuteInfo // 任务执行表
jobResultChan chan *common.JobExecuteResult // 任务结果队列
}
worker.JobMgr.go
// 任务管理器
type JobMgr struct {
client *clientv3.Client
kv clientv3.KV
lease clientv3.Lease
watcher clientv3.Watcher
}
worker.Executor.go
// 任务执行器
type Executor struct {
}
var (
G_executor *Executor
)
功能实现
根据设计,master将任务被广播到etcd中,任务调度与执行基于worker来做。
呢么worker将会watch etcd中指定的prefix k,去领取任务,随手列入执行表,并最终执行;
JobMgr.go
//单例结构体赋值如下:
// 任务管理器
type JobMgr struct {
client *clientv3.Client
kv clientv3.KV
lease clientv3.Lease
watcher clientv3.Watcher
}
var (
// 单例
G_jobMgr *JobMgr
)
// ------------------------------
// 监听任务变化
func (jobMgr *JobMgr) watchJobs() (err error) {
var (
getResp *clientv3.GetResponse
kvpair *mvccpb.KeyValue
job *common.Job
watchStartRevision int64
watchChan clientv3.WatchChan
watchResp clientv3.WatchResponse
watchEvent *clientv3.Event
jobName string
jobEvent *common.JobEvent
)
// 1, get一下/cron/jobs/目录下的所有任务,并且获知当前集群的revision,用于监听该reversion之后的变化
if getResp, err = jobMgr.kv.Get(context.TODO(), common.JOB_SAVE_DIR, clientv3.WithPrefix()); err != nil {
return
}
// 当前有哪些任务
for _, kvpair = range getResp.Kvs {
// 反序列化json得到Job
if job, err = common.UnpackJob(kvpair.Value); err == nil {
//将当前监听到的任务放置到jobScheduler
jobEvent = common.BuildJobEvent(common.JOB_EVENT_SAVE, job)
同步给scheduler(调度协程,下文介绍)
G_scheduler.PushJobEvent(jobEvent)
}
}
// 2, 从该revision向后监听变化事件
go func() { // 监听协程
// 从GET时刻的后续版本开始监听变化
watchStartRevision = getResp.Header.Revision + 1
// 监听/cron/jobs/目录的后续变化
watchChan = jobMgr.watcher.Watch(context.TODO(),
common.JOB_SAVE_DIR, clientv3.WithRev(watchStartRevision), clientv3.WithPrefix())
// 处理监听事件
for watchResp = range watchChan {
for _, watchEvent = range watchResp.Events {
switch watchEvent.Type {
case mvccpb.PUT: // 任务保存事件
if job, err = common.UnpackJob(watchEvent.Kv.Value); err != nil {
continue
}
// 构建一个更新Event
jobEvent = common.BuildJobEvent(common.JOB_EVENT_SAVE, job)
case mvccpb.DELETE: // 任务被删除了
// Delete 若key为 /cron/jobs/job10 ,则需要删除的任务为 job10
jobName = common.ExtractJobName(string(watchEvent.Kv.Key))
job = &common.Job{Name: jobName}
// 构建一个删除Event
jobEvent = common.BuildJobEvent(common.JOB_EVENT_DELETE, job)
}
// 变化推给scheduler(下文介绍)
G_scheduler.PushJobEvent(jobEvent)
}
}
}()
return
}
common.Protocol.go.
// 反序列化Job字符串 (不知道怎么用Java呢样的泛型提供一个通用的方法)
func UnpackJob(value []byte) (ret *Job, err error) {
var (
job *Job
)
job = &Job{}
if err = json.Unmarshal(value, job); err != nil {
return
}
ret = job
return
}
//----------
// 定时任务
type Job struct {
Name string `json:"name"` // 任务名
Command string `json:"command"` // shell命令
CronExpr string `json:"cronExpr"` // cron表达式
}
//对Job进行一次包装,添加任务类型
type JobEvent struct {
EventType int // SAVE, DELETE
Job *Job
}
//-------
Scheduler.go
// 初始化调度器
func InitScheduler() (err error) {
G_scheduler = &Scheduler{
//当大于1000的时候,无法继续放进来,除非里面有被取走的元素
jobEventChan: make(chan *common.JobEvent, 1000),
//key - > jobName
jobPlanTable: make(map[string]*common.JobSchedulePlan), //调度计划表
// key - > jobName
jobExecutingTable: make(map[string]*common.JobExecuteInfo), //调度任务表
jobResultChan: make(chan *common.JobExecuteResult, 1000),
}
// 启动调度协程
go G_scheduler.scheduleLoop()
return
}
// 调度协程,检测是否有相应的任务需要执行
func (scheduler *Scheduler) scheduleLoop() {
var (
jobEvent *common.JobEvent
scheduleAfter time.Duration
scheduleTimer *time.Timer
//jobResult *common.JobExecuteResult
)
// 初始化一次(1秒)
scheduleAfter = scheduler.TrySchedule()
// 调度的延迟定时器
scheduleTimer = time.NewTimer(scheduleAfter)
// 定时任务common.Job
for {
//监听任务变化事件
select {
// 对内存中维护的任务列表做增删改查
case jobEvent = <-scheduler.jobEventChan:
//既对Scheduler.jobPlan map 进行修改 ;并且当任务需要执行时,将任务分发至执行器
scheduler.handleJobEvent(jobEvent)
// 最近的任务到期了
case <-scheduleTimer.C:
// 监听任务执行结果
case jobResult = <-scheduler.jobResultChan:
scheduler.handleJobResult(jobResult)
}
// 调度一次任务
scheduleAfter = scheduler.TrySchedule()
// 重置调度间隔
scheduleTimer.Reset(scheduleAfter)
}
}
// 推送任务变化事件
func (scheduler *Scheduler) PushJobEvent(jobEvent *common.JobEvent) {
scheduler.jobEventChan <- jobEvent
}
// 处理任务事件,维护任务计划表
func (scheduler *Scheduler) handleJobEvent(jobEvent *common.JobEvent) {
var (
jobSchedulePlan *common.JobSchedulePlan
jobExecuteInfo *common.JobExecuteInfo
jobExecuting bool
jobExisted bool
err error
)
switch jobEvent.EventType {
// 保存任务事件
case common.JOB_EVENT_SAVE:
if jobSchedulePlan, err = common.BuildJobSchedulePlan(jobEvent.Job); err != nil {
return
}
scheduler.jobPlanTable[jobEvent.Job.Name] = jobSchedulePlan
// 删除任务事件
case common.JOB_EVENT_DELETE:
//判断map中是否有这个元素
if jobSchedulePlan, jobExisted = scheduler.jobPlanTable[jobEvent.Job.Name]; jobExisted {
delete(scheduler.jobPlanTable, jobEvent.Job.Name)
}
// 强杀任务事件
case common.JOB_EVENT_KILL:
//取消掉Command执行, 判断任务是否在执行中
if jobExecuteInfo, jobExecuting = scheduler.jobExecutingTable[jobEvent.Job.Name]; jobExecuting {
jobExecuteInfo.CancelFunc() // 触发command杀死shell子进程, 任务得到退出
}
}
}
//遍历定时任务计划表
//1 : 如果定时任务已经到期需要执行了,呢么执行任务并设置该任务的下次执行时间;
//2 : 从计划任务表找出即将执行的下一个定时任务。并以与当前时间的时间差作为可休眠的时间。
func (scheduler *Scheduler) TrySchedule() (scheduleAfter time.Duration) {
var (
jobPlan *common.JobSchedulePlan
now time.Time
nearTime *time.Time
)
// 如果任务表为空话,随便睡眠多久
if len(scheduler.jobPlanTable) == 0 {
scheduleAfter = 1 * time.Second
return
}
// 当前时间
now = time.Now()
// 遍历所有任务
for _, jobPlan = range scheduler.jobPlanTable {
//任务到期,需要执行任务并且更新该任务的下次到期时间
if jobPlan.NextTime.Before(now) || jobPlan.NextTime.Equal(now) {
scheduler.TryStartJob(jobPlan)
jobPlan.NextTime = jobPlan.Expr.Next(now)
}
// 统计最近一个要过期的任务时间
if nearTime == nil || jobPlan.NextTime.Before(*nearTime) {
nearTime = &jobPlan.NextTime
}
}
// 下次调度间隔(最近要执行的任务调度时间 - 当前时间)
if nil != nearTime {
scheduleAfter = (*nearTime).Sub(now)
}
return
}
// 尝试执行任务,若任务仍在执行但又被安插了新一轮的任务过来,则应该规避
func (scheduler *Scheduler) TryStartJob(jobPlan *common.JobSchedulePlan) {
// 调度 和 执行 是2件事情
var (
jobExecuteInfo *common.JobExecuteInfo
jobExecuting bool
)
// 执行的任务可能运行很久, 1分钟会调度60次,但是只能执行1次, 防止并发!
// 如果任务正在执行,跳过本次调度
if jobExecuteInfo, jobExecuting = scheduler.jobExecutingTable[jobPlan.Job.Name]; jobExecuting {
// fmt.Println("尚未退出,跳过执行:", jobPlan.Job.Name)
return
}
// 构建执行状态信息
jobExecuteInfo = common.BuildJobExecuteInfo(jobPlan)
// 保存执行状态
scheduler.jobExecutingTable[jobPlan.Job.Name] = jobExecuteInfo
// 执行任务
fmt.Println("执行任务:", jobExecuteInfo.Job.Name, jobExecuteInfo.PlanTime, jobExecuteInfo.RealTime)
G_executor.ExecuteJob(jobExecuteInfo)
}
//回执任务结果
func (scheduler *Scheduler) PushJobResult(jobResult *common.JobExecuteResult) {
scheduler.jobResultChan <- jobResult
}
worker.Executor.go
// 执行一个任务
func (executor *Executor) ExecuteJob(info *common.JobExecuteInfo) {
go func() {
var (
cmd *exec.Cmd
err error
output []byte
result *common.JobExecuteResult
jobLock *JobLock
)
// 任务结果
result = &common.JobExecuteResult{
ExecuteInfo: info,
Output: make([]byte, 0),
}
// 初始化分布式锁
jobLock = G_jobMgr.CreateJobLock(info.Job.Name)
// 记录任务开始时间
result.StartTime = time.Now()
// 上锁
// 随机睡眠(0~1s)
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
err = jobLock.TryLock()
defer jobLock.Unlock()
if err != nil { // 上锁失败
result.Err = err
result.EndTime = time.Now()
} else {
// 上锁成功后,重置任务启动时间
result.StartTime = time.Now()
// 执行shell命令
cmd = exec.CommandContext(info.CancelCtx, "/bin/bash", "-c", info.Job.Command)
// 执行并捕获输出
output, err = cmd.CombinedOutput()
// 记录任务结束时间
result.EndTime = time.Now()
result.Output = output
result.Err = err
}
// 任务执行完成后,把执行的结果返回给Scheduler,Scheduler会从executingTable中删除掉执行记录
G_scheduler.PushJobResult(result)
}()
}
jobLock.go
// 初始化一把锁
func InitJobLock(jobName string, kv clientv3.KV, lease clientv3.Lease) (jobLock *JobLock) {
jobLock = &JobLock{
kv: kv,
lease: lease,
jobName: jobName,
}
return
}
// 尝试上锁
func (jobLock *JobLock) TryLock() (err error) {
var (
leaseGrantResp *clientv3.LeaseGrantResponse
cancelCtx context.Context
cancelFunc context.CancelFunc
leaseId clientv3.LeaseID
keepRespChan <- chan *clientv3.LeaseKeepAliveResponse
txn clientv3.Txn
lockKey string
txnResp *clientv3.TxnResponse
)
// 1, 创建租约(5秒)
if leaseGrantResp, err = jobLock.lease.Grant(context.TODO(), 5); err != nil {
return
}
// context用于取消自动续租
cancelCtx, cancelFunc = context.WithCancel(context.TODO())
// 租约ID
leaseId = leaseGrantResp.ID
// 2, 自动续租
if keepRespChan, err = jobLock.lease.KeepAlive(cancelCtx, leaseId); err != nil {
goto FAIL
}
// 3, 处理续租应答的协程
go func() {
var (
keepResp *clientv3.LeaseKeepAliveResponse
)
for {
select {
case keepResp = <- keepRespChan: // 自动续租的应答
if keepResp == nil {
goto END
}
}
}
END:
}()
// 4, 创建事务txn
txn = jobLock.kv.Txn(context.TODO())
// 锁路径
lockKey = common.JOB_LOCK_DIR + jobLock.jobName
// 5, 事务抢锁
txn.If(clientv3.Compare(clientv3.CreateRevision(lockKey), "=", 0)).
Then(clientv3.OpPut(lockKey, "", clientv3.WithLease(leaseId))).
Else(clientv3.OpGet(lockKey))
// 提交事务
if txnResp, err = txn.Commit(); err != nil {
goto FAIL
}
// 6, 成功返回, 失败释放租约
if !txnResp.Succeeded { // 锁被占用
err = common.ERR_LOCK_ALREADY_REQUIRED
goto FAIL
}
// 抢锁成功
jobLock.leaseId = leaseId
jobLock.cancelFunc = cancelFunc
jobLock.isLocked = true
return
FAIL:
cancelFunc() // 取消自动续租
jobLock.lease.Revoke(context.TODO(), leaseId) // 释放租约
return
}
// 释放锁
func (jobLock *JobLock) Unlock() {
if jobLock.isLocked {
jobLock.cancelFunc() // 取消我们程序自动续租的协程
jobLock.lease.Revoke(context.TODO(), jobLock.leaseId) // 释放租约
}
}
感悟与思考:
通过这一章节的慕课,对go里面的channel以及程序设计角度有了一定的认识。
案例中,是使用一个全局的单例对象对外开出channel,使相关handler做完工作后,将结果再通过channel告知回来,这样的耦合性自然而然的就下来了。
如同一个消息队列一样,发消息时候不关注订阅者会做什么,订阅者处理完了消息也不必着着急急的告知投递;