先讲一个我们财务部门最爱的场景:每月 1 号凌晨 2 点,系统自动生成上个月的对账单,然后给客户发通知。某个月 1 号早上 9 点,客服群里炸了:“为什么有客户收到了两条对账单通知?”我还没来得及解释,隔壁群又来一句:“我们分公司的没收到,是不是没跑?”
一个定时任务,同时出现了跑了两遍和一遍没跑,简直是量子叠加态。我盯着 XXL-JOB 的后台,脑海里只有一句话:“薛定谔的调度”。这篇就是当时的排查记录,把 XXL-JOB 调度链路上最容易出问题的几个点,逐一拆开讲清楚。
一、现象:三种灵异表现
先把“任务不对劲”分成三类,因为它们的排查方向完全不同:
- 重复执行:同一次调度,业务代码跑了两次或更多(或者本该一次的分片跑了多份);
- 没有触发:到点了,调度日志里没有记录,或者记录是“调度失败”;
- 执行器离线:调度中心“执行器管理”里,OnLine 机器地址是空的,或者是一个奇怪的地址。
保留现场的动作:别急着重启调度中心或执行器。先截图“调度日志”页面(时间、执行器地址、调度结果、执行结果),再保存执行器本机的日志文件。XXL-JOB 有两份日志,读懂它们是本文的主线:
- 调度日志(调度中心视角):调度时间、执行器地址、JobHandler、任务参数、调度结果、调度备注。它回答“调度中心有没有把请求发出去、发给了谁、对方回了什么”。
- 执行日志(执行器视角):执行时间、执行结果、执行备注,以及业务里用
XxlJobHelper.log()打的日志。它回答“执行器收到之后干了什么、有没有干完”。
官方文档把这两个日志的字段也列得很清楚:调度日志包含执行地址、JobHandler、参数、调度时间、调度结果和备注;执行日志包含执行时间、结果、备注、执行器地址,以及业务输出的完整日志,通过滚动加载。
二、先看全貌:一次调度到底经过了哪些环节
XXL-JOB 是“调度与执行分离”的架构:调度中心(xxl-job-admin)负责按 cron 计时、选执行器、发起 HTTP 调用;执行器(你的 Spring Boot 应用里内嵌的一个小 Netty HTTP 服务,默认端口 9999)负责接收任务并执行。两者之间有注册和调度两条链路:
三条箭头对应三类问题:
- 箭头 ①(注册)出问题 → 执行器离线或地址不可达 → 调度没触发(失败);
- 箭头 ②(调度)里有路由策略、阻塞策略、accessToken → 决定发给谁、会不会跑重复、会不会被拒;
- 箭头 ③(回调)出问题 → 调度中心以为任务没结束,或者标记失败,可能又触发失败重试而造成重复。
三、动手:按类型逐个排
A 类:执行器离线——先看注册
在调度中心后台“执行器管理”里点开对应执行器,看“在线机器地址”。执行器每 30 秒向调度中心注册一次(官方文档说法是:执行器以一倍 Beat 注册,注册信息失效时间是三倍 Beat,默认 Beat 为 30 秒,也就是大约 90 秒没心跳就会被踢下线)。地址栏为空 = 心跳没到;地址栏有值但是奇怪 = 注册了错误的地址。
第一步,从执行器本机确认它发出去的是什么。我一般先看启动日志和 admin 收到的注册记录:
$ grep -E "xxl-job|ExecutorRegistryThread|registry" /data/logs/order-svc/app.log | tail -5
2026-09-01 01:58:02.117 INFO 1 --- [main] c.x.job.core.executor.XxlJobExecutor : >>>>>>>>>>> xxl-job remoting server start success, nettype = class com.xxl.job.core.server.EmbedServer, port = 9999
2026-09-01 01:58:02.131 DEBUG 1 --- [ExecutorRegistryThread] c.x.j.core.thread.ExecutorRegistryThread : >>>>>>>>>>> xxl-job registry success, registryParam:RegistryParam{registryGroup='EXECUTOR', registryKey='order-job-executor', registryValue='http://172.17.0.5:9999/'}, registryResult:ReturnT [code=200, msg=null, content=null]重点是 registryValue='http://172.17.0.5:9999/'。这是一个 Docker 桥接网络的容器 IP。调度中心在另一台机器上,根本访问不到 172.17.0.5,注册“成功”(注册请求是执行器主动发给 admin 的,走得通),但调度“必失败”。这种“注册成功却调度失败”的现象非常有迷惑性。
从调度中心侧验证连通性:
$ curl -s -m 3 -X POST http://172.17.0.5:9999/beat -H 'XXL-JOB-ACCESS-TOKEN: xxxx'
curl: (28) Connection timed out after 3001 milliseconds
$ curl -s -m 3 -X POST http://10.0.2.21:9999/beat -H 'XXL-JOB-ACCESS-TOKEN: xxxx'
{"code":200,"msg":null}说明:/beat 是执行器提供的心跳接口,返回 200 代表网络通、token 对、执行器活着。这里的 token 我用 xxxx 占位,实际值请从配置里取,别贴进聊天群。用容器宿主机地址 10.0.2.21 就通了,说明网络层面没问题,是注册的地址不对。
为什么会注册成容器 IP?官方源码里的逻辑(社区文章里也分析得很清楚):如果没有配置 xxl.job.executor.address,执行器就用 xxl.job.executor.ip(没配就自动探测:先 InetAddress.getLocalHost(),失败再遍历网卡)加上 xxl.job.executor.port(默认 9999)拼出地址。在容器里,探测出来的当然是容器自己的 IP。GitHub 上也有 issue 反映“docker 部署的执行器注册到 admin 的 IP 不正确”,答案本质都是显式指定注册地址。
修法有三种,按推荐顺序:
# application.yml:显式指定“调度中心能访问到的”地址,优先级最高
xxl:
job:
admin:
addresses: http://xxl-admin.demo.example.com/xxl-job-admin
accessToken: ${XXL_JOB_TOKEN} # 从环境变量注入,不要写死进仓库
executor:
appname: order-job-executor
address: http://${HOST_IP}:9999 # 宿主机 IP + 映射到宿主机的端口
port: 9999
logpath: /data/applogs/xxl-job/jobhandler
logretentiondays: 30# docker 启动:把宿主机 IP 传进去,并把 9999 映射出来
$ docker run -d --name order-svc \
-e HOST_IP=10.0.2.21 -e XXL_JOB_TOKEN=... \
-p 8080:8080 -p 9999:9999 order-svc:1.0另外两种:容器用 --net=host(探测到的就是宿主机 IP,简单但端口易冲突);或在 K8s 里用 Service/Ingress 暴露稳定域名,然后 address 写域名。注意:同一台宿主机跑多个执行器容器,宿主机映射端口不能重复,否则地址冲突,要么起不来,要么两个实例注册成同一个地址。
B 类:没有触发——读调度日志的“调度备注”
调度日志里,调度结果为“失败(500)”时,“调度备注”会把整个调度过程逐行写出来,类似这样:
任务触发类型:Cron触发
调度机器:10.0.2.5
执行器-注册方式:自动注册
执行器-地址列表:[http://172.17.0.5:9999/]
路由策略:第一个
阻塞处理策略:单机串行
任务超时时间:0
失败重试次数:0
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> 触发调度 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
触发调度:
address:http://172.17.0.5:9999/
code:500
msg:Connect to 172.17.0.5:9999 timed out这份备注的价值在于“一页看全”:注册方式、地址列表、路由、阻塞、超时、重试,最后才是“打给谁、返回了啥”。常见的几种 msg,对应不同根因:
| 调度备注里的关键字 | 含义 | 去哪里修 |
|---|---|---|
| Connect ... timed out / Connection refused | 地址不可达,或端口没起来 | 看注册地址(A 类),查防火墙、安全组、端口映射 |
| The access token is wrong | accessToken 与执行器不一致 | 调度中心 xxl.job.accessToken 与执行器 xxl.job.executor.accessToken/xxl.job.accessToken 必须相同,注意不同版本配置项名字略有区别 |
| 执行器-地址列表 为空 / 调度失败:执行器地址为空 | appname 下没有在线执行器 | 看注册(appname 拼错、执行器已下线、被踢出) |
| job handler [xxx] not found. | 执行器里没有这个 JobHandler | 核对任务里的 JobHandler 名称与 @XxlJob("xxx") 的 value 是否一致,该实例是否是最新版本 |
| 作业未触发,且调度日志里根本没有记录 | 任务被停了,或 cron 写错,或调度线程延迟 | 看任务状态(RUNNING/STOP)、cron 是否合法、调度中心是否正常 |
顺带说一下 appname。任务选执行器,靠的是任务绑定的执行器 appname(AppName),然后从注册表里拿这个 appname 下的所有在线地址。这里有两个经典事故:
- appname 写错一个字符:执行器实际注册在
order-job-executor,任务绑定了order-job-exector,调度中心找不到在线地址,永远“没有触发”; - 两个不同的服务误配了同一个 appname:比如拷贝服务时忘了改配置,订单服务和库存服务都注册到
demo-executor。这时调度中心以为这个执行器集群里有两台机器,用“轮询”或“第一个”把任务发给另一个服务,那个服务里没有对应 JobHandler,就报job handler not found,或者更糟——恰好有同名 handler,但跑的是完全不同的业务。我见过一个团队因此“每月账单跑出了库存数据”,非常哲学。
排查这个问题只需要看“执行器管理”里的在线机器列表,一个 appname 下出现了你没预期的机器地址,就是它。
C 类:重复执行——先分清是“调度了两次”还是“执行了两次”
重复执行的分析核心是看调度日志里有几条记录:
- 调度日志有两条(时间几乎相同或相差几秒到几十秒)→ 调度中心触发了两次,或者是失败重试触发了第二次;
- 调度日志只有一条,但执行器日志里业务方法跑了两遍 → 业务代码自己的重入、路由到多台机器、分片广播被当成普通任务用,或阻塞策略触发了另一个线程。
下面是我们常见的四种“重复”成因,配上诊断方法。
成因 1:路由策略选“分片广播”,但业务没按分片写
官方文档列出的路由策略有:FIRST(第一个)、LAST(最后一个)、ROUND(轮询)、RANDOM(随机)、CONSISTENT_HASH(一致性 HASH)、LEAST_FREQUENTLY_USED(最不经常使用)、LEAST_RECENTLY_USED(最近最久未使用)、FAILOVER(故障转移)、BUSYOVER(忙碌转移)、SHARDING_BROADCAST(分片广播)。
其中“分片广播”是把同一次调度同时发给集群里所有执行器实例,每个实例收到不同的分片序号。如果你用它却不看分片参数,结果就是:3 台机器,任务同时跑 3 遍,每遍都全量处理。调度日志里能看到的是一条调度、多个地址的记录(备注里会列出每个地址的触发结果)。
正确用法是用分片参数把数据切开:
@XxlJob("statementGenerateJob")
public void generate() {
int shardIndex = XxlJobHelper.getShardIndex(); // 当前分片序号,从 0 开始
int shardTotal = XxlJobHelper.getShardTotal(); // 分片总数 = 在线执行器数量
XxlJobHelper.log("shard {}/{} start", shardIndex, shardTotal);
// 按机构 id 取模,每台机器只处理自己那一份
List<Long> orgIds = orgMapper.listAllIds();
for (Long orgId : orgIds) {
if (orgId % shardTotal != shardIndex) continue;
statementService.generateFor(orgId);
}
XxlJobHelper.handleSuccess();
}如果任务本身不适合切分(比如只发一次的全局通知),就不要选分片广播,选 FIRST 或者 CONSISTENT_HASH,并给它做幂等。
成因 2:阻塞处理策略与“执行时间超过调度周期”
阻塞处理策略是:“上一次调度的任务还没执行完,这一次又来了”怎么办。官方提供三种:单机串行(默认)、丢弃后续调度、覆盖之前调度。
| 策略 | 行为 | 适合 | 风险 |
|---|---|---|---|
| 单机串行(默认) | 新调度进入 JobThread 队列,排队等上一次跑完再执行 | 每次调度都必须被执行的任务 | 任务耗时长于周期时,队列越堆越长,执行“追赶”——看起来像疯狂重复;整点任务也会被拖迟 |
| 丢弃后续调度 | 上一次还在跑,本次调度直接失败丢弃 | 定时扫描类、允许漏一次的任务 | 日志里出现“阻塞丢弃”的失败,容易被误当成“没触发” |
| 覆盖之前调度 | 终止正在运行的任务,用新的替换 | 只关心最新一次结果的任务 | 被终止的任务半路中断,数据可能只处理了一半,且它是靠线程中断实现的,业务不响应中断就会“杀不死”,出现两份并存 |
我们那次“重复发对账单”,根因之一就是:任务配的是每 10 分钟一次,但月初数据量大,一次要跑 25 分钟,阻塞策略是默认的单机串行。于是队列里排了好几次调度,前一次刚跑完,下一次立刻开始,而每一次都在处理“尚未标记已发送”的同一批数据——因为每一轮都会先查出“待发送”再统一改状态,两轮重叠时就会出现同一批被处理两遍。
诊断方法:在执行日志里对比“开始时间”和“结束时间”,如果耗时 > 调度周期,一定会出问题。同时看该任务的“运行中”标记:调度中心的任务列表里会显示当前是否有运行中实例。
成因 3:调度过期策略(misfire)与调度中心“补跑”
调度中心不是每一秒都在盯着所有任务。它的调度线程会预读即将到期的任务,放进时间轮里等着触发。如果因为调度中心 GC 停顿、数据库慢、整体宕机重启等,某个任务的触发时间已经过去了,就要看这个任务配置的“调度过期策略”:官方有两种——忽略(错过了就算了,重新计算下一次触发时间)和立即执行一次(补跑一次,再重新计算)。
对“每月 1 号发对账单”这种任务,“立即执行一次”往往是想要的(宁可晚跑,不能不跑);对“每 5 分钟同步一次”的任务,“忽略”更合适。如果调度中心停机 1 小时,然后重启,你的“立即执行一次”任务会在重启后补跑一次——如果这时上一次其实已经跑成功了(比如是被人手动触发过),就构成了重复。这是“重复”里最隐蔽的一类,只能靠幂等兜底。
成因 4:失败重试、任务超时 + 回调丢失
XXL-JOB 支持“失败重试次数”和“任务超时时间”。两个功能叠加时有陷阱:
- 业务执行成功,但执行器回调调度中心失败(网络抖动或回调地址不通),调度中心认为这次任务没有返回结果 → 判为失败 → 按配置再重试一次 → 业务被执行第二遍;
- 任务超时时间设得太短(比如 60 秒),实际业务要跑 90 秒:超时到了,执行器主动中断线程;若业务代码没有正确响应中断,任务实际上还在跑,但调度中心已经判定失败并重试 → 两份并存;
- 如果开启了失败重试且路由策略是“故障转移”,还可能把同一次调度转移到另一台机器。
排查提示:调度日志里,同一次触发会出现“重试”标记的记录,“触发类型”也会不同;如果一次调度里出现了“失败重试”的第二条日志,而执行日志里第一条其实是成功的,那就是回调丢失。
成因 5(补充):同一个任务在多个执行器分组里配了两次
比较低级但真实发生过的:迁移服务时,新旧两套执行器(旧的 appname 是 demo-executor,新的是 demo-executor-v2)都配了同一个任务,忘了停旧的。两条调度线各跑各的,所以每次调度都是两条日志、来自两个不同的任务 ID。排查时,把“任务管理”按 JobHandler 名称搜索一下,即可发现。
调度中心本身如果做了集群部署,重复触发不会发生:官方设计里调度中心集群通过数据库行锁(xxl_job_lock 表)保证同一时刻只有一个节点在做调度,前提是它们连的是同一个数据库。如果两个调度中心误连了两个不同的库(比如测试库和生产库串了),就会各自触发——这也是需要在“重复执行”时排除的一项。
四、排查决策树
把上面这一堆整理成一张可以直接按图索骥的决策树,值班的时候打开就用:
五、我们这次的根因
回到那个“量子叠加”的 1 号。查完之后,是三个问题叠在一起:
- 重复的那部分:月初数据量暴增,一次执行超过调度周期;任务阻塞策略是默认的“单机串行”,调度排队,前后两轮重叠处理同一批“待发送”记录;
- 没收到的那部分:某个新扩容的执行器实例部署在 Docker 里,没配
executor.address,注册了容器 IP,路由策略选了“轮询”,轮到它的那些次调度全部“Connect timed out”,而调度日志里的失败没人看; - 业务层没有幂等:“查待发送 → 发送 → 更新状态”不是原子的,两轮重叠或重试时,同一个客户就会被处理两次。
所以严格说,XXL-JOB 并没有“坏”,它老老实实按配置做事。问题是我们用“任务不会重复、一定会执行”这种假设写业务,而分布式调度天然是“至少一次”(at-least-once)语义。
六、止血与修复
止血
- 先把对账单任务暂停,防止继续重复推送;
- 对已经重复发送的客户,按“对账单编号 + 客户”查出重复记录,发一条更正通知;
- 把出问题的执行器实例摘掉或改配置重启(正确的
address),确认后台在线机器地址是宿主机 IP; - 没有触发的分公司数据,手动“执行一次”补跑(在任务管理里选择该任务,点击“执行一次”,可以指定机器地址与任务参数)。
根治 1:配置层
- 所有执行器显式配置
address(容器化场景必须),appname 统一命名规范,避免拷贝配置忘改; - 调度中心与执行器用同一个足够长的 accessToken,从配置中心/环境变量注入,不要用官方文档里的默认值;
- 耗时任务:调大调度周期,或者改阻塞策略为“丢弃后续调度”,并设置合理的任务超时时间与告警;
- “失败重试”只给幂等的任务开;
- 调度过期策略根据业务语义选择:必须补跑的选“立即执行一次”,可丢的选“忽略”。
根治 2:幂等设计(这是真正的护城河)
因为“至少一次”是无法通过配置彻底消灭的,业务方法必须能被重复调用而不出错。常见两招:
招 1:业务唯一键 + 状态机。对账单按“机构 ID + 账期”建唯一索引,插入时靠唯一键去重;状态更新用条件 UPDATE:
-- 唯一约束:同一机构同一账期只能有一张对账单
ALTER TABLE statement ADD UNIQUE KEY uk_org_period (org_id, period);
-- 抢占式更新:只有从 PENDING 变 SENDING 成功的那个线程才有资格发送
UPDATE statement_notify
SET status = 'SENDING', update_time = NOW()
WHERE id = ? AND status = 'PENDING';
-- 影响行数 = 1 才继续发送;= 0 说明已经有人在处理,直接跳过招 2:分布式锁。在任务入口加锁,锁 key 里带上“任务 + 调度周期”,锁的过期时间要大于最长执行时间,或者使用带看门狗续期的实现(Redisson):
@XxlJob("statementNotifyJob")
public void notifyJob() {
String period = LocalDate.now().withDayOfMonth(1).toString(); // 例如 2026-09-01
RLock lock = redisson.getLock("job:statement-notify:" + period);
if (!lock.tryLock()) { // 拿不到锁:别处正在跑
XxlJobHelper.log("another instance is running, skip");
XxlJobHelper.handleSuccess("skipped");
return;
}
try {
doNotify(period);
} finally {
if (lock.isHeldByCurrentThread()) lock.unlock();
}
}注意这里的两个细节:tryLock() 不带等待时间,拿不到直接放弃,避免“重复的任务排队后依次再执行一遍”;finally 里用 isHeldByCurrentThread 判断,避免锁到期被别人拿走后误删。锁只解决“同时跑”,不解决“先后各跑一次”,后者还是要靠唯一键/状态机。所以生产上是“锁 + 唯一键”双保险,锁减少无谓的重复计算,唯一键保证最终正确。
另外,业务里把关键进度写在执行日志里,用 XxlJobHelper.log(),配合 shardIndex/shardTotal,出问题时在后台“执行日志”里就能看到每台机器的每一步,不用再登服务器 grep。
根治 3:可观测
- 调度失败告警:调度中心配置报警邮件(或用回调集成钉钉/企业微信),失败必须有人知道,而不是等客户来告诉你;
- 监控“任务耗时/调度周期”的比值,超过 0.5 就该优化;
- 定期看执行器管理里的在线机器:与实例数量对不上就告警;
- 日志保留期
logretentiondays(默认 30,最小 3)要够用,用于回溯月度任务的问题。
七、复盘
| 做法 | 为什么 |
|---|---|
| 先分清“调度了几次”和“执行了几次” | 决定了排查方向是调度中心、路由/阻塞策略,还是业务代码自身 |
容器/多网卡环境显式配置 xxl.job.executor.address | 自动探测到的往往是容器内网 IP,调度中心根本打不通 |
用 /beat 接口验证连通与 token | 一条 curl 分清“网络通不通”和“注册对不对” |
| appname 命名规范 + 上线后核对在线机器 | 拷贝服务忘改 appname 是最经典的串任务事故 |
| 分片广播必须按 shardIndex/shardTotal 切分数据 | 否则 N 台机器就是 N 倍的重复 |
| 耗时任务选“丢弃后续调度”或拉长周期 | 单机串行会排队追赶,造成重叠处理 |
| 失败重试只给幂等任务开 | 回调丢失时“成功”会被误判为失败并重试 |
| 业务唯一键 + 状态机 + 分布式锁 | 调度是至少一次语义,幂等是唯一的终极兜底 |
| 调度失败必须告警 | “没触发”比“重复”更难被发现,往往是客户先发现 |
一句话备忘:重复/没触发/离线 → 先数调度日志几条 → 读“调度备注”(地址、token、handler)→ 查注册地址与 appname → 查路由/阻塞/重试/misfire → 业务用“唯一键 + 锁”做幂等,分片广播必须按分片切数据。
最后想说,定时任务这东西,运行得好的时候没人记得它,出问题的时候全公司都记得。从那以后,我在每个任务的注释第一行都写上一句话:“本任务可能被执行 0 次、1 次或 N 次,请写得像你不怕这三种情况。”——写完发现,代码质量提升得比任何一次 Code Review 都快。
参考资料
- XXL-JOB 官方文档(GitHub 仓库 doc 目录)——路由策略、阻塞处理策略、调度过期策略、执行器配置项(appname/ip/port/accessToken)、分片广播用法、注册心跳周期与日志字段说明。
- XXL-JOB 官方站点——项目定位与文档入口,用于确认官方文档的位置。
- GitHub issue #1121:执行器部署在 docker,注册到 xxl-job-admin 的 ip 不正确——容器内执行器注册容器 IP 导致调度中心无法访问的真实反馈,以及显式指定注册地址、host 网络等解决思路。
- 腾讯云开发者社区:xxl-job 执行器自动注册是怎么做的——执行器注册线程 30 秒心跳、IP 自动探测逻辑(getLocalHost 与遍历网卡)、address/ip/port 配置优先级。
一起聊聊