外观
定时任务治理实战:分布式调度、分片与错峰重试的工程化方案
问题背景:@Scheduled 的快乐与代价
每个后端项目里都长着几十个 @Scheduled:对账、结算、缓存预热、超时关闭、数据归档。单机时代相安无事,服务一旦部署两个实例,同一秒两个节点同时开始给用户发结算短信——重复执行的问题就来了。更隐蔽的是另外三类问题:任务失败只有日志没有告警,一周后财务发现对账数据缺失;千万行数据的归档任务一条 SQL 全量加载,把数据库 IO 打满拖垮线上接口;所有任务都配在整点触发,CPU 和 DB 每到整点集体抖动。定时任务是典型的「平时没人看、出事就是大事」的基础设施,值得一次系统性的治理。
方案对比:从分布式锁到调度平台
单机注解 + 分布式锁:改造最小,setIfAbsent 抢锁成功才执行,适合任务量少、没有可视化诉求的小团队;缺点是没有执行历史、失败告警要自建、手动触发与依赖编排都靠人肉。xxl-job:中心化调度器 + 执行器架构,任务可视化配置、失败重试、分片广播、日志查看开箱即用,是国内事实标准,社区活跃、文档齐全。PowerJob:支持工作流依赖(任务 B 在任务 A 成功后触发)与 Map/MapReduce 编程模型,适合有复杂依赖链的大数据任务。K8s CronJob:容器化团队的原生选择,调度与业务解耦,但业务侧仍要自己解决并发与可观测。治理的常见演进路径:先用分布式锁止血,任务超过 30 个或出现分片诉求时引入调度平台。
实操步骤:以 xxl-job 为例的四步接入
第一步:执行器接入与任务注册。 引入 xxl-job-core 依赖,配置调度中心地址,把注解任务改造成 Handler:
yaml
xxl:
job:
admin:
addresses: http://scheduler.example.com/xxl-job-admin
executor:
appname: order-service # 执行器分组,调度中心按此路由
port: 9999java
@XxlJob("orderArchiveJob")
public void orderArchiveJob() {
int shardIndex = XxlJobHelper.getShardIndex(); // 当前分片序号
int shardTotal = XxlJobHelper.getShardTotal(); // 总分片数 = 执行器实例数
// 断点续跑:从检查点继续,任务失败重跑不从头再来
long lastId = checkpointService.load("order_archive", shardIndex);
List<Order> batch;
while (!(batch = orderMapper.scanByIdCursor(lastId, 500, shardIndex, shardTotal)).isEmpty()) {
archiveOrders(batch);
lastId = batch.get(batch.size() - 1).getId();
checkpointService.save("order_archive", shardIndex, lastId);
}
XxlJobHelper.handleSuccess("归档完成,本次推进至 ID: " + lastId);
}对应的查询用「游标 + 分片取模」条件,既避免深分页又让多实例并行分摊数据量:
sql
SELECT id, order_no, amount FROM payment_order
WHERE id > #{lastId} AND MOD(id, #{shardTotal}) = #{shardIndex}
ORDER BY id LIMIT 500;第二步:分布式锁作为双保险。 调度平台已保证路由策略(如第一个实例执行),但发布窗口、网络分区下仍有极小概率双跑,任务入口再设一道 Redis 锁兜底:
java
boolean locked = redis.setIfAbsent("job:lock:order_archive",
instanceId, 10, TimeUnit.MINUTES);
if (!locked) {
XxlJobHelper.handleFail("其他实例正在执行,本实例跳过");
return;
}
try {
doArchive();
} finally {
releaseLockSafely("job:lock:order_archive", instanceId); // Lua 原子校验后释放
}第三步:错峰与限流。 整点任务在 cron 上打散(0 7,23,41 * * * ? 之类错开分钟位),或代码内加随机抖动;批处理循环里对下游限流——每处理 500 条 sleep 或使用令牌桶,给线上流量留出余量。第四步:失败重试与告警分级。 调度中心配置失败重试 2 次、每次间隔 60 秒;对账、结算类任务失败直接进电话告警,预热类任务失败只进群通知。任务执行日志接入采集,执行时长趋势图能提前暴露数据量增长带来的隐患。
第五步:任务台账是治理的收口动作。 所有任务登记进统一台账:任务名、责任人、调度配置、数据来源与去向、失败影响面、告警策略,新增任务必须先补台账再上线。台账的价值在交接与事故复盘时集中体现——排查数据异常时,五分钟就能列出「哪些任务动过这张表」,而不再靠全员检索代码。平台选型的另一个维度是团队规模与运维能力:xxl-job 的调度中心依赖 MySQL 存任务配置,本身要纳入运维体系;K8s CronJob 依赖集群稳定性;小团队用数据库任务表加抢锁轮询自建轻量调度,未必是坏选择。治理的关键不在平台多强大,而在执行历史、失败告警、幂等设计这三件事有没有做实——工具可以晚点升级,这三个习惯从第一个任务起就该建立。
避坑总结
- 分布式锁与调度平台的角色要分清。 平台负责「谁该执行」,锁负责「万一都执行了怎么办」;只用锁的平台化,只用平台的没兜底。
- 分片任务必须幂等且可断点重跑。 按 ID 取模分片的前提是处理操作幂等(
INSERT ... ON DUPLICATE KEY UPDATE或先查后写),否则实例扩缩容导致分片重算时会产生重复数据。 - 大数据量任务永远用游标分页。
LIMIT 100000, 500式的偏移分页会越翻越慢,按唯一索引游标推进才是稳定耗时;同时单批条数要压测确定,别拍脑袋。 - 时钟与时区是隐形炸弹。 服务器统一 NTP 校时,调度中心与执行器时区显式一致,跨地域部署的任务尤其要确认,否则凌晨任务会在早上八点跑。
- 任务要有超时上限与资源隔离。 无超时的任务一旦卡住会永远占着调度额度;重 IO 任务部署到独立执行器分组,与线上服务隔离资源。
收尾
定时任务治理的本质,是把「藏在代码角落里的定时逻辑」升级为「有调度视图、有失败告警、有数据台账的一等公民基础设施」。治理完成的标准很具体:任何一个任务,你都能在三分钟内回答它上次何时执行、跑了多久、处理了多少数据、失败了没有。做到这一步,凌晨的任务告警才不会变成悬在头顶的达摩克利斯之剑。治理永远在演化——数据量每年增长,去年合理的分片数与批量大小今年就可能成为隐患,定期回头看任务台账,才是这套体系长期有效的保障。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。