GO实现类似quartz的超轻量分布式crontab
- 仅支持crontab表达式
- 仅依赖mysql
- 调度与执行不隔离
- 基于mysql的节点注册与发现
进程启动生成一次性唯一会话ID,插入到MYSQL会话表。
心跳定时更新MYSQL会话表,与MYSQL失联超过阀值自杀(防脑裂的常见手段)。
- 基于mysql的任务计划
用一张任务表维护所有crontab,主要包括信息:
任务名称、cron表达式、下次执行时间戳、任务启动时间、上一轮任务结束时间、任务启动唯一标识(会话ID+进程级计数)、执行的命令、任务状态、是否暂停、是否删除、任务归属会话。
其中任务状态枚举:空闲、预备、执行任务按顺序在3个状态之间轮转。
- 基于mysql的分布式锁
因为集群部署多个节点共同参与调度,所以在访问共享数据时需要分布式锁保护。
用一张锁表维护所有的分布式锁,基于innodb行锁实现。
包括2把锁:
1)任务表的锁:同一时刻只有一个节点可以进行任务管理。
2)会话表的锁:同一时刻只有一个节点可以进行会话管理。
根据quartz开源项目的原理,任务调度系统瓶颈通常不在于调度本身,悲观锁是可行的。
因为mysql事务提交成功/回滚后,需要做一些原子的业务逻辑,所以在mysql事务锁外层应用一个进程级的mutex锁。
- 调度线程
(获取进程任务锁)
(启动事务、获取任务锁)
(注:select的limit需要根据内存prepare+executing的任务数量调控)
周期性扫描任务表、查询出符合条件的任务(状态=空闲,暂停=0,删除=0,下次执行时间<=now+30),即未来30秒内即将到期的任务。
若这些任务没有出现在prepare和executing集合中,则将这些任务的状态更新为"预备",设置任务启动唯一标识、归属会话改为进程会话ID。
将这些任务放入内存中的任务prepare集合。
(提交事务、释放任务锁)
(释放进程任务锁)
定时检测prepare集合,若任务执行时间到期,则:
(获取进程任务锁)
(启动事务,获取任务锁)
更新任务(状态=执行,任务启动时间=now)
从内存prepare集合挪到executing集合
(提交事务,释放任务锁)
(释放进程任务锁)
将任务交给单独的执行线程,通过子进程运行。
- 执行线程
调度线程发起一个任务,启动独立执行线程调用Golang Exec包执行shell命令(bash -c "cmd"),阻塞等待进程退出。
子进程退出后,将结果发给结果处理线程。
- 遗漏回补线程
(获取进程任务锁)
(启动事务、获取任务锁)
周期性扫描任务表,找出(状态!=空闲,归属=节点会话ID)的任务。
对于每个任务,如果是预备状态且不在prepare集合,或者是执行状态并且不在executing集合,则说明因为MYSQL异常访问导致了调度遗漏。
更新每个任务(状态=空闲,下次调度时间=now)。
(提交事务、释放任务锁)
(释放进程任务锁)
- 结果处理线程
接收执行线程发来的任务结果。
(获取进程任务锁)
(启动事务,获取任务锁)
获取对应任务,更新记录(状态=空闲,下次调度时间),插入一条历史执行日志。
从executing集合删除对应记录。
(提交事务,释放任务锁)
(释放进程任务锁)
- 会话线程(DONE)
周期性心跳,
(启动事务,获取会话锁)
获取对应的会话,更新会话心跳时间。
(提交事务,释放会话锁)
- 会话检测线程(DONE)
周期性检测,
(启动事务,获取会话锁)
找出过期的会话,
(获取任务锁)
重置过期会话的任务(状态=空闲,下次调度时间)
(提交事务,释放任务锁和会话锁)
- API
对外暴露HTTP API,主要包括几个功能:
1)添加任务
2)删除任务
3)暂停任务
4)恢复任务
5)查看任务
6)查看历史
7)查看集群
- 异常处理
因为MYSQL可能访问异常,此时SQL执行是否成功是未知的(例如仅仅是response时网络中断)。
本程序最大难点在于数据库更新和内存更新的一致性问题。
实现不能做出假设,需对异常case考虑全面,下面是暂时想到的一些异常处理点:
1)空闲->预备 异常,不应该放入prepare集合。
SQL异常忽略,由补偿线程去发现不在prepare集合中的"预备"任务。
2)预备->执行 异常,不应该从prepare集合转移至executing集合,不应该启动执行线程。
SQL异常忽略,由补偿线程去发现不在executing集合中的"执行"任务。
3)执行->空闲 异常,不应该从executing集合删除。
立即无限重试即可,若发现状态=空闲,则幂等处理,即从exeuting集合删除。
4)预备/执行->空闲 异常
这是补偿线程的逻辑,下次重新select再次执行即可
- cron表达式解析与计算:https://github.com/gorhill/cronexpr
- mysql客户端:https://github.com/go-sql-driver/mysql