微服务与中间件排查 列表全部文章
微服务与中间件排查

XXL-JOB 的三种“灵异现象”:重复执行、莫名不触发、执行器离线——一次讲透调度链路

同一个定时任务有时跑两遍、有时一遍都没跑、有时执行器在后台显示离线。沿着调度中心到执行器的链路,查注册地址、accessToken、路由与阻塞策略、misfire、执行超时,最后用幂等设计兜底。

更多

先讲一个我们财务部门最爱的场景:每月 1 号凌晨 2 点,系统自动生成上个月的对账单,然后给客户发通知。某个月 1 号早上 9 点,客服群里炸了:“为什么有客户收到了两条对账单通知?”我还没来得及解释,隔壁群又来一句:“我们分公司的没收到,是不是没跑?”

一个定时任务,同时出现了跑了两遍和一遍没跑,简直是量子叠加态。我盯着 XXL-JOB 的后台,脑海里只有一句话:“薛定谔的调度”。这篇就是当时的排查记录,把 XXL-JOB 调度链路上最容易出问题的几个点,逐一拆开讲清楚。

一、现象:三种灵异表现

先把“任务不对劲”分成三类,因为它们的排查方向完全不同:

  1. 重复执行:同一次调度,业务代码跑了两次或更多(或者本该一次的分片跑了多份);
  2. 没有触发:到点了,调度日志里没有记录,或者记录是“调度失败”;
  3. 执行器离线:调度中心“执行器管理”里,OnLine 机器地址是空的,或者是一个奇怪的地址。

保留现场的动作:别急着重启调度中心或执行器。先截图“调度日志”页面(时间、执行器地址、调度结果、执行结果),再保存执行器本机的日志文件。XXL-JOB 有两份日志,读懂它们是本文的主线:

  • 调度日志(调度中心视角):调度时间、执行器地址、JobHandler、任务参数、调度结果、调度备注。它回答“调度中心有没有把请求发出去、发给了谁、对方回了什么”。
  • 执行日志(执行器视角):执行时间、执行结果、执行备注,以及业务里用 XxlJobHelper.log() 打的日志。它回答“执行器收到之后干了什么、有没有干完”。

官方文档把这两个日志的字段也列得很清楚:调度日志包含执行地址、JobHandler、参数、调度时间、调度结果和备注;执行日志包含执行时间、结果、备注、执行器地址,以及业务输出的完整日志,通过滚动加载。

二、先看全貌:一次调度到底经过了哪些环节

XXL-JOB 是“调度与执行分离”的架构:调度中心(xxl-job-admin)负责按 cron 计时、选执行器、发起 HTTP 调用;执行器(你的 Spring Boot 应用里内嵌的一个小 Netty HTTP 服务,默认端口 9999)负责接收任务并执行。两者之间有注册和调度两条链路:

调度中心 xxl-job-admin 调度线程:预读 + 时间轮 路由:选出一个/所有地址 注册表:appname → 地址列表 数据库锁保证集群下同一任务只触发一次 执行器(业务应用,内嵌 9999 端口) 注册线程:每 30 秒心跳 阻塞策略:串行/丢弃/覆盖 JobThread 执行 @XxlJob 方法 执行完成后回调调度中心 ① 注册:appname + 自己的地址 ② 调度:HTTP run + accessToken ③ 回调:执行结果 故障点 ① 注册成容器/内网 IP → 调度不可达 ② accessToken 不一致 → 调用被拒 ③ 回调地址不通 → 结果丢失,任务一直“运行中”

三条箭头对应三类问题:

  • 箭头 ①(注册)出问题 → 执行器离线或地址不可达 → 调度没触发(失败);
  • 箭头 ②(调度)里有路由策略、阻塞策略、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 wrongaccessToken 与执行器不一致调度中心 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 表)保证同一时刻只有一个节点在做调度,前提是它们连的是同一个数据库。如果两个调度中心误连了两个不同的库(比如测试库和生产库串了),就会各自触发——这也是需要在“重复执行”时排除的一项。

四、排查决策树

把上面这一堆整理成一张可以直接按图索骥的决策树,值班的时候打开就用:

先看调度日志:有几条记录? 0 条 / 调度失败→ 没触发:看备注 1 条,执行器跑了 2 遍→ 执行侧重复 ≥ 2 条调度→ 调度侧重复 备注关键字 timed out / refused → 注册地址 access token wrong → token 不一致 地址为空 → appname / 已下线 handler not found → 名称/appname 串了 无记录 → 任务停了 / cron 错误 看路由与阻塞策略 分片广播且没按分片写? 调度周期 < 执行耗时?(串行堆积) 业务自身重入 / 多线程重复处理? 覆盖之前调度:中断是否生效? 看重试与补跑 失败重试 + 回调丢失? misfire:立即执行一次补跑? 同一任务在两个 appname 配了两次? 两个调度中心连了不同的库?

五、我们这次的根因

回到那个“量子叠加”的 1 号。查完之后,是三个问题叠在一起:

  1. 重复的那部分:月初数据量暴增,一次执行超过调度周期;任务阻塞策略是默认的“单机串行”,调度排队,前后两轮重叠处理同一批“待发送”记录;
  2. 没收到的那部分:某个新扩容的执行器实例部署在 Docker 里,没配 executor.address,注册了容器 IP,路由策略选了“轮询”,轮到它的那些次调度全部“Connect timed out”,而调度日志里的失败没人看;
  3. 业务层没有幂等:“查待发送 → 发送 → 更新状态”不是原子的,两轮重叠或重试时,同一个客户就会被处理两次。

所以严格说,XXL-JOB 并没有“坏”,它老老实实按配置做事。问题是我们用“任务不会重复、一定会执行”这种假设写业务,而分布式调度天然是“至少一次”(at-least-once)语义。

六、止血与修复

止血

  1. 先把对账单任务暂停,防止继续重复推送;
  2. 对已经重复发送的客户,按“对账单编号 + 客户”查出重复记录,发一条更正通知;
  3. 把出问题的执行器实例摘掉或改配置重启(正确的 address),确认后台在线机器地址是宿主机 IP;
  4. 没有触发的分公司数据,手动“执行一次”补跑(在任务管理里选择该任务,点击“执行一次”,可以指定机器地址与任务参数)。

根治 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 都快。

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人