公司内部系统有个痛点:十几个 Go 服务各自跑着 cron,日志、监控、去重各玩各的,出了问题要去十几个服务里翻。申请专项做统一调度被拒了(理由:收益不明确),于是用了两个月的晚间时间自己撸了个最小可用版本,先在自己负责的服务里跑起来,跑通再说。这篇记录核心设计。

需求收敛。 首先想清楚不做什么:不做秒级调度(cron 表达式到分钟足够)、不做依赖编排(任务依赖用上线时间错开解决)、不做可视化界面(CLI + 配置文件够用)。砍完之后剩下的核心就三件事:时间触发、任务分发、不重复执行

关键设计是抢锁。 多实例部署下同一个任务只能有一个实例执行,我们没有引入额外的调度中心,而是直接用 MySQL 的行锁:

-- t_job 表,每分钟由各实例尝试 CAS
UPDATE job_lock
SET owner = ?, locked_until = NOW() + INTERVAL 2 MINUTE
WHERE job_name = ?
  AND (locked_until < NOW() OR owner = ?);

抢到锁的实例负责执行并把心跳写进去,锁过期自然释放。整个方案只依赖已经有的 MySQL,没有新组件。有人会问为什么不用 Redis:因为任务幂等性出错的代价是重复扣库存,而 MySQL 事务的可靠性在我们团队是经过验证的,不熟的东西不引入。

分片是第二个关键。 单机任务量上来之后,把任务按 jobName 的 hash % 实例数 分片,每个实例只认领自己的那份。简单,但配合上面的锁,二十万级任务/天的量完全够。

踩的坑:时钟漂移。有两台机器的 NTP 没配好,相差 40 秒,导致任务在整点附近被执行了两次。锁的有效期改成大于任务最长执行时间 + 漂移容忍之后解决。现在新机器进集群先校时,成了硬性流程。

现在这个调度器稳定跑了三个月,另外两个组也接进来了。回头看,“先做一个丑但能跑的版本,让收益自己说话"这个策略是对的——下个季度统一调度的专项预算,批了。