定时任务是那种"上线就忘"的功能——写完往那一挂,直到某天凌晨告警炸了才想起来还有这东西。
我项目里用 XXL-Job 跑数据同步、报表统计、库存对账这类活。踩过的坑攒了一堆,挑 5 个最典型的讲:每一个都是静默故障,不看告警根本发现不了。
坑 1:任务重复执行——集群部署后同一个任务跑了两遍
现象:本地单机测试一切正常,上线集群后对账任务每晚跑重复了两次,数据库多出一倍数据。
原因:这里有个常见误解——很多人以为 XXL-Job 会自动帮你去重,其实要看触发方式。
XXL-Job 的调度中心通过DB 锁保证"一次调度只触发一次执行"(这是它的第 22 条特性)。但这句话有个前提:走的是调度中心的调度。
如果执行器是手动触发、GLUE 里自己起了线程、或者业务代码里又调了一次同一个任务,DB 锁就管不着了。还有一种常见情况:路由策略选了"分片广播"或"轮询",但任务本身没做幂等—— 广播模式下每个执行器都会跑一次,如果任务写的是全局数据(不是按分片参数分区处理),就是 N 台机器重复写同一份数据。
解决:分两层防。
第一层,任务本身必须幂等——这是最根本的。定时任务和消息消费一样,永远假设自己会被执行不止一次:
@XxlJob("dailyReconcile")publicvoiddailyReconcile(){StringbizDate=XxlJobHelper.getJobParam();// 业务日期从参数拿,别用 now()if(StringUtils.isBlank(bizDate)){bizDate=LocalDate.now().minusDays(1).toString();}// 幂等前置检查:这一天已经跑过就跳过if(reconcileRecordMapper.existsByDate(bizDate)){XxlJobHelper.log("{} 已完成对账,跳过",bizDate);return;}doReconcile(bizDate);reconcileRecordMapper.save(bizDate);}关键点:任务参数里传业务日期,不要在任务内部用LocalDate.now()。否则重复执行时会按"今天"重算一遍,幂等检查也拦不住。
第二层,用 DB 唯一索引兜底:
CREATETABLEt_reconcile_record(idBIGINTPRIMARYKEYAUTO_INCREMENT,biz_dateDATENOTNULL,create_timeDATETIMEDEFAULTCURRENT_TIMESTAMP,UNIQUEKEYuk_biz_date(biz_date)-- 重复插入直接报错,跑不进去第二次);应用层幂等检查 + 数据库唯一索引,双保险。只靠应用层检查是不够的——两个执行器同时跑到检查那一步、都发现"没跑过",然后都往下执行,这就是并发漏洞。
坑 2:执行器掉线——任务状态一直是"运行中"
现象:某台执行器容器被 OOM Kill 重启,那个正在跑的任务在调度中心一直显示"运行中",再也不会被重新调度。
原因:XXL-Job 有"故障转移"(Failover)特性——路由策略选它,某台执行器挂了会自动切到正常的。但故障转移的前提是"调度时刻"发现机器不可用。已经在跑的任务,如果执行器突然死了,任务状态会卡在"运行中",直到超时时间到了才会被标记失败。
如果任务的超时时间设成了0(不限制),那这个任务可能永远卡在那,后续调度被阻塞处理策略判定为"正在运行"而跳过。
解决:三条一起上。
① 一定要设超时时间,别用 0:
// 在调度中心的任务配置里设置超时时间,比如 30 分钟// 超时后调度中心会主动中断任务,并把状态置为失败② 阻塞处理策略选对:默认是"单机串行"(排队等上一次跑完),另外两个选项是"丢弃后续调度"和"覆盖之前调度"。对账这类任务建议用"丢弃后续调度"——堆积没意义,等下一次调度就好。
③ 注册方式用自动注册:手填执行器地址(xxl.job.executor.address)在容器化环境里是灾难——Pod 一重建 IP 就变了。让执行器周期性自动上报,调度中心自动发现:
xxl:job:executor:appname:order-executor# 不写 address,让执行器自动注册(容器/云环境必做)ip:port:9999logpath:/data/applogs/xxl-job/jobhandlerlogretentiondays:30accessToken:${XXL_JOB_TOKEN}坑 3:分片广播用错了——数据被处理了 4 遍
现象:给数据同步任务配了"分片广播"路由策略,4 台执行器,想着能并行加速。结果每台机器都把全量数据同步了一遍,4 倍压力打到数据库。
原因:分片广播不是"自动帮你切数据",它只是把分片参数告诉你。每台执行器拿到的是"我是第几片、一共几片",具体怎么分数据要靠你自己写。
XXL-Job 的机制是:分片广播以执行器为维度分片,一次调度广播触发集群中所有执行器执行一次,通过分片参数支持开发分片任务(这是它的第 13、14 条特性)。
没写分片逻辑 = 每个执行器跑全量。
解决:任务内部必须按分片参数过滤数据:
@XxlJob("syncProduct")publicvoidsyncProduct(){// 取当前执行器的分片序号和总分片数intshardIndex=XxlJobHelper.getShardIndex();intshardTotal=XxlJobHelper.getShardTotal();// 关键:按 id 取模分片,每个执行器只处理属于自己那份List<Product>products=productMapper.selectByShard(shardIndex,shardTotal);for(Productproduct:products){doSync(product);}XxlJobHelper.log("分片 {}/{} 完成,处理 {} 条",shardIndex,shardTotal,products.size());}<!-- 按 id 取模分片,每台机器只捞自己那份 --><selectid="selectByShard"resultType="Product">SELECT*FROMt_productWHEREMOD(id,#{shardTotal}) = #{shardIndex}</select>收益:4 台机器并行,理论提速 4 倍。代价是必须确保分片规则稳定——如果中途有一台执行器下线,分片总数从 4 变成 3,取模结果全变了,同一批数据可能被两台机器分别处理。所以分片任务的幂等要求比单机任务更高。
坑 4:任务超时被中断,但业务代码还在跑
现象:设了 5 分钟超时,任务被调度中心标记为"超时失败"并触发了重试。但被中断的那次执行实际上还在后台跑着,和重试的那次撞在一起,数据乱了。
原因:XXL-Job 的超时中断是通过线程 interrupt 实现的。如果业务代码里有循环、等待、IO 阻塞这类不响应中断的操作,interrupt只是设了个标志位,代码该跑还是跑。
解决:业务代码要主动响应中断——在长循环里检查中断状态:
@XxlJob("batchImport")publicvoidbatchImport(){List<File>files=listPendingFiles();for(Filefile:files){// 每个文件开始处理前检查是否被要求中断if(Thread.currentThread().isInterrupted()){XxlJobHelper.log("收到中断信号,已完成 {}/{},安全退出",processed,files.size());return;}importFile(file);processed++;}}更稳的做法:给任务设计断点续传能力——记录已处理到的位置,下次启动从中断处继续,而不是从头再来。
// 每次处理完一批就落一次进度progressMapper.save(newProgress("batchImport",processed,files.size()));配合坑 1 的幂等表,被中断的任务重跑时能自动跳过已处理部分。超时中断 + 断点续传 + 幂等,这三个一起用才安全。
坑 5:日志看不见——出问题只能靠猜
现象:任务失败了,调度中心点"执行日志"只看到一句Handler error和一堆框架堆栈,看不到业务执行到哪一步。
原因:很多人习惯用log.info()打日志。XXL-Job 的执行日志(在调度中心页面上看的那个)必须用XxlJobHelper.log(),log.info打的内容在执行器的本地日志文件里,不在调度中心页面上。
而且要注意:调度中心只保留最近 30 天的日志(logretentiondays配置),排查陈年问题得去执行器本地看。
解决:关键节点全部用XxlJobHelper.log(),让调度中心页面能自解释:
@XxlJob("dailyReport")publicvoiddailyReport(){XxlJobHelper.log("开始生成日报,日期={}",bizDate);try{List<Order>orders=orderMapper.selectByDate(bizDate);XxlJobHelper.log("查出订单 {} 条",orders.size());reportService.generate(bizDate,orders);XxlJobHelper.log("日报生成完成");}catch(Exceptione){// 异常必须打日志再抛,否则页面上什么都看不到XxlJobHelper.log("日报生成失败: {}",e.getMessage());XxlJobHelper.log(e);throwe;// 抛出去让调度中心标记为失败,才能触发重试和告警}}关键认知:异常要打日志 + 抛出。只打日志不抛,调度中心认为任务"成功",不会重试也不会告警——这是最隐蔽的坑,任务明明失败了但你永远收不到通知。
总结
| 坑 | 本质 | 解决 |
|---|---|---|
| 1 重复执行 | 任务不幂等 / 分片任务写全局数据 | 业务日期从参数传 + 唯一索引兜底 |
| 2 执行器掉线卡"运行中" | 没设超时,任务永远卡住 | 设超时时间 + “丢弃后续调度” + 自动注册 |
| 3 分片广播失效 | 只拿参数不写分片逻辑 | getShardIndex/Total+MOD(id, total)分片 |
| 4 超时中断但代码还在跑 | interrupt 不被响应 | 循环里检查isInterrupted()+ 断点续传 |
| 5 日志看不见 | 用了log.info而非XxlJobHelper.log | 关键节点用XxlJobHelper.log,异常打日志后抛出 |
核心心法:定时任务的设计原则只有一条——假设它会被执行 0 次、1 次或 N 次。
0 次:要有兜底扫描(比如"每天检查有没有漏跑的任务");N 次:要幂等。围绕这条原则设计,上面 5 个坑基本都能提前避开。
你们项目用的什么调度框架?踩过重复执行或者分片失效吗?评论区聊聊。
如果这篇帮你避开凌晨三点被叫醒,转给组里维护定时任务的同事。
「今天写BUG了么」,关注看更多