跳到正文

心跳与故障剔除 ​

一句话:Executor 每 30 秒向 Admin 的 /api/registry 报一次到,Admin 每 30 秒扫一次 xxl_job_registry,把超过 90 秒没更新的行删掉并重写执行器组的地址列表;30 秒是心跳间隔,90 秒是三次心跳的容忍窗口,不是租约。

适用版本:XXL-JOB 2.3.x / 2.4.0。SQL 以 MySQL 5.7/8.0 + 默认 schema 为准。

为什么需要 ​

Admin 要发 /run,必须知道执行器现在有哪些地址。执行器会扩容、缩容、重启、掉电,地址列表不能靠人工维护。

引入 ZooKeeper 能解决服务发现,但会多一套需要运维的集群。XXL-JOB 的判断是:执行器上下线频率是分钟级,Admin 只需要一张「最近还活着」的地址表,用 MySQL 一张小表就够。

核心机制 ​

注册表里存的是什么 ​

xxl_job_registry 的一行由三个字段定位:

字段Executor 注册时的值
registry_groupEXECUTOR
registry_keyxxl.job.executor.appname,例如 order-service
registry_value执行器地址,例如 http://10.0.0.12:9999/

表上有唯一键 (registry_group, registry_key, registry_value)。同一个 appname 下有多个实例,就是同一个 registry_key 的多行,靠 registry_value 区分。不要把它简化成「只有 app-name 一个 key」。

xxl_job_group 是配置表:app_name、address_type(0 自动注册 / 1 手动录入)、address_list。xxl_job_registry 是动态心跳表;Admin 把心跳表里的存活地址聚合后,回写到 xxl_job_group.address_list,调度时读的是后者。

Executor 侧:30 秒一次 ​

ExecutorRegistryThread 启动后每隔 RegistryConfig.BEAT_TIMEOUT(30 秒)向配置的每个 Admin 地址发一次 POST /api/registry,请求体就是上面三个字段。Admin 收到后:

sql
-- 伪 SQL,说明 upsert 语义
UPDATE xxl_job_registry SET update_time = ? 
WHERE registry_group = ? AND registry_key = ? AND registry_value = ?;
-- 影响行数为 0 时再 INSERT

所以注册和心跳是同一个动作:第一次插入,后续更新 update_time。Executor 正常停机时会调 /api/registryRemove 主动删行;异常宕机则只能等过期剔除。

Admin 侧:30 秒扫一次,90 秒判死 ​

负责维护的是 JobRegistryHelper 里的 registryMonitorThread(早期版本文档里写作 JobRegistryMonitorHelper;2.x 没有 JobRegistryMonitorThread 这个类)。每 30 秒做一轮:

  1. 查 address_type=0 的执行器组(只有自动注册的组才需要刷新地址)。
  2. findDead(90s):删掉 update_time < now - 90s 的行。
  3. findAll(90s):取出仍然存活的行,过滤 registry_group='EXECUTOR',按 registry_key(appname)聚合成地址列表。
  4. 把地址列表写回对应 xxl_job_group.address_list。

两个常量在 RegistryConfig 里:BEAT_TIMEOUT = 30、DEAD_TIMEOUT = 30 * 3 = 90。2.3.x/2.4.0 是硬编码常量,不是配置文件项。

观测到的时间是 90–120 秒,不是精确 90 秒 ​

假设最后一次成功心跳把 update_time 写成 T:

  • T+90s:这一行刚刚满足过期条件。
  • Admin 的清理线程每 30 秒才跑一次,所以真正删除发生在 T+90s 到 T+120s 之间的某次扫描。
  • 页面上的在线地址数和 xxl_job_group.address_list 也在同一轮更新。

所以「停掉执行器后 90 秒从注册表消失」是近似说法;实测窗口是 90–120 秒。恢复时相反:Executor 下一次心跳(≤30 秒)重新插入行,Admin 下一轮扫描(≤30 秒)刷新地址列表,最坏约 60 秒重新出现在页面上。

为什么是 30 秒 + 90 秒 ​

  • 30 秒:100 个执行器也只有约 3.3 次/秒的注册请求,对 Admin 和 MySQL 都很轻;再短会增加无意义的写。
  • 90 秒 = 连续 3 个心跳周期:允许偶发丢一两次心跳而不至于误剔除,同时把「地址已经失效但还在列表里」的窗口控制在约 2 分钟。

官方没有给出「单次丢包率 5%、连续三次丢失概率」这类推导,不要把社区里的概率数字当成源码保证。它是一个工程折中,不是算出来的最优解。

多个 Admin 时 ​

每个 Admin 都独立收心跳、独立跑清理线程、独立更新同一张 xxl_job_registry 和 xxl_job_group。没有选主,也不需要同步内存。

风险在时钟:update_time 由处理注册请求的 Admin 用自己的 new Date() 写入,过期判断也用 Admin 的当前时间。多台 Admin 时钟偏差过大时,可能出现一台刚写入、另一台立刻判死的抖动。上线前给 Admin 机器配 NTP。

心跳不等于健康 ​

心跳只证明「Executor 的 HTTP Server 还能响应注册请求」。它不证明:

  • Handler 线程池还有空位;
  • JVM 没有在频繁 Full GC;
  • 业务依赖的数据库/下游可用;
  • 正在跑的任务没有卡死。

所以会出现「页面在线、调度全部超时」的假活状态,排查时要看执行器自身的线程、GC 和下游指标。

实战场景 ​

一、实例下线后 2 分钟仍被调度,任务报“连接失败” ​

现象:执行器滚动发布期间,每次发布后的 1~2 分钟里总有一两个任务报 trigger_code=500,trigger_msg 是 connect timed out / java.net.ConnectException。开发以为是网络问题,重跑就成功,长期被当成“偶发失败”。

排查:异常宕机只能等心跳过期剔除,观测到的窗口是 90~120 秒而不是精确 90 秒。

定位层取证据判定
是否主动摘除停机日志里是否有 /api/registryRemove 调用正常停机才调;kill -9 或容器直接杀就跳过
注册表残留xxl_job_registry 中该地址的 update_time 与当前时间差小于 90s 的行仍算存活,会被聚合进地址列表
聚合回写时机Admin 的 registryMonitorThread 每 30s 一轮,findDead(90s) 后才刷新 address_list剔除最坏延迟接近 120s
失败分布500 日志按执行器地址聚合集中在旧实例地址即命中
是否被重试掩盖失败重试次数把这次调度挪到别的实例掩盖症状,不消除窗口
Executor 每 30 秒心跳一次(RegistryConfig.BEAT_TIMEOUT)
Admin 每 30 秒扫一轮,删除 update_time < now - 90s 的行,
再把存活行按 appname 聚合回写 address_list
→ 观测到的“死实例仍在列表”窗口:90~120 秒

对症做法:

java
// 1. 停机走优雅下线:先向 Admin 发 registryRemove,再停在途任务
private final XxlJobSpringExecutor xxlJobExecutor;   // 由容器注入

@PreDestroy
public void gracefulStop() throws Exception {
    xxlJobExecutor.stop();          // 内部 stopRegister() 会请求 Admin 的 /api/registryRemove
    jobThreadManager.stopAndAwait(Duration.ofSeconds(30));   // 等本机在途任务收尾
    xxlJobExecutor.destroy();
}
yaml
# 2. 容器侧:SIGTERM 之前留出一个剔除窗口,别让 Pod 立刻消失
lifecycle:
  preStop:
    exec: { command: ["/bin/sh", "-c", "sleep 120"] }   # 覆盖 90~120s 观测窗口
terminationGracePeriodSeconds: 180
text
注意 registryRemove 是 Executor 调用 Admin 的接口,不是暴露给运维的 HTTP 端点:
K8s 里不要试图用 curl 打执行器端口来摘注册,交给应用内的 shutdown 逻辑做。
text
3. 发布顺序:先摘注册 → 等一个剔除窗口(≥120s)→ 再停进程;
   批量发布要分批,避免同一 appname 下所有实例同时进入“已死未剔除”状态。
4. 调度侧兜底:路由策略用 FAILover(失败重试一次换地址)而不是 ROUND,
   旧地址连不上时立刻换实例。

复盘要点:判据是“发布窗口的 trigger_code=500 数量”——注册与优雅下线做好后应接近 0。同时要把这 90~120 秒写进容量与发布 SOP:它是协议带来的窗口,把 90 秒调成 30 秒会让网络抖动的实例被频繁误剔除,反而更糟。

二、实例“活着”但完全跑不动:心跳不等于健康 ​

现象:某执行器实例的 JVM 频繁 Full GC、JobThread 队列积压 300 个任务,心跳却一直正常,Admin 仍按 ROUND 把任务派给它。结果是几十分钟里多个任务延迟触发甚至超时,而调度中心侧的“执行器在线”指标全绿。

排查:注册只证明“进程能发 HTTP”,不证明“能执行任务”。

定位层取证据判定
心跳通道ExecutorRegistryThread 每 30 秒 POST /api/registry与业务线程池无关,GC 停顿短就不影响
线程池状态Executor 侧 JobThread 队列长度、活跃线程数队列持续增长才是真实健康度
触发响应耗时/run 的返回时间与队列提交结果返回 200 只表示入队成功
唯一键冲突xxl_job_registry 按 (registry_group, registry_key, registry_value) 定位一个 appname 多实例是多行,靠地址区分
实际执行延迟从 trigger_time 到 Handler 开始执行的差值差值大即排队,不是调度问题
组配置形态address_type=0(自动注册)才会被心跳表刷新手动录入组不受心跳影响,见下一场景

对症做法:

java
// 1. 把“执行能力”接入健康:队列长度与 GC 停顿进 readiness,超阈值就不接流量
@Component
class ExecutorHealthIndicator implements HealthIndicator {
    public Health health() {
        int queue = jobThreadManager.totalQueueLength();
        long gcPauseMs = gcStats.lastSecondPauseMillis();
        if (queue > 100 || gcPauseMs > 1000) {          // 阈值按压测校准,经验值
            registry.degrade();                          // 主动调 /api/registryRemove 摘除自己
            return Health.down().withDetail("queue", queue).withDetail("gcPauseMs", gcPauseMs).build();
        }
        return Health.up().build();
    }
}

// 2. 任务级隔离:长任务与短任务分开 appname,避免一个任务拖死整组
//    order-long-run / order-short-run 两个执行器组,各自独立线程池
text
3. 容量口径要按“组内实例数 × 单实例可并发任务数”估:
   JobThread 按 jobId 串行,同 jobId 不会并行,所以真正瓶颈是任务数与实例数的比值。
4. 观测补上“触发到开始执行的延迟”,这条指标能区分调度慢与执行排队,
   后者才需要在 Executor 侧扩容或拆组。

复盘要点:判据是把“执行器在线”这个指标从健康口径里去掉,换成“队列长度”“触发到开始执行的延迟”“/run 响应时间”三个可行动的数字。注册表机制上,同一 appname 多实例是同一 registry_key 下的多行、靠 registry_value 区分,所以摘除单个劣化实例是可行的,不必整组下线。

三、手动录入地址与自动注册混用,新实例收不到调度 ​

现象:执行器组原本用自动注册(address_type=0)。某次为了临时压测,运维在控制台手动填了 address_list,之后一直没清;新扩容的 3 个实例在注册表里有行、心跳正常,但任务始终只派给老的两台。控制台显示“机器在线 5 台、实际参与调度 2 台”。

排查:Admin 聚合心跳表只针对自动注册的组。

定位层取证据判定
组的注册方式xxl_job_group.address_type:0 自动注册 / 1 手动录入为 1 时不会被心跳表刷新覆盖
调度读的字段调度取的是 xxl_job_group.address_list手动填的值会被原样使用
心跳表现状xxl_job_registry 里该 appname 的行数有行但不参与聚合,说明组是手动模式
维护线程范围registryMonitorThread 第 1 步只查 address_type=0 的组手动组被跳过,属设计行为
变更留痕控制台的操作日志里是否有“修改地址列表”定位是谁、什么时候改的

xxl_job_registry 是动态心跳表,Admin 每 30 秒把存活地址聚合后回写到 xxl_job_group.address_list,调度读的是后者;一旦组被改成手动录入,这条链路就断了。

对症做法:

sql
-- 1. 确认组的注册方式
SELECT id, app_name, address_type, address_list FROM xxl_job_group WHERE app_name = 'order-executor';

-- 2. 恢复自动注册:清空手工地址并改回自动模式(改完等一轮聚合,约 30s)
UPDATE xxl_job_group SET address_type = 0, address_list = '' WHERE app_name = 'order-executor';

-- 3. 核对聚合结果是否等于注册表存活行
SELECT registry_value FROM xxl_job_registry
 WHERE registry_group = 'EXECUTOR' AND registry_key = 'order-executor'
   AND update_time > NOW() - INTERVAL 90 SECOND;
text
4. 权限收紧:address_list 的手工编辑只允许在“跨网段/无法注册”的例外场景,
   并且需要双人复核 + 到期恢复;压测用完立即还原。
5. 一致性巡检:每天比较 address_type=0 的组,其 address_list
   与心跳表存活地址集合是否相等,不等即告警——这条能在分钟级发现本类问题。
6. 确实需要手动录入(如固定 VIP 入口)时,把它当成静态配置管理:
   扩缩容要同步改这张表,并把“实例已注册但不参与调度”写进变更检查项。

复盘要点:判据是“自动注册组的 address_list 与心跳表存活集合一致”,这条完全可以脚本化巡检;控制台“在线”与“参与调度”要分成两个指标展示,否则这类配置错位会长期隐身。它同时说明一件事:XXL-JOB 的可用性链路里有三处独立状态(注册表、组配置、调度读取的地址),排障要逐处核对,任何一处不一致都不会报错,只会“少跑机器”。

最小上手 ​

先看当前注册表:

sql
-- MySQL 5.7/8.0
SELECT registry_group, registry_key, registry_value, update_time,
       TIMESTAMPDIFF(SECOND, update_time, NOW()) AS age_s
FROM xxl_job_registry
WHERE registry_group = 'EXECUTOR'
ORDER BY update_time DESC;

正常运行时 age_s 应在 0–30 秒之间。再停掉一个 Executor(假设它的地址是 10.0.0.12:9999),每 10 秒查一次:

sql
SELECT registry_value, update_time,
       TIMESTAMPDIFF(SECOND, update_time, NOW()) AS age_s
FROM xxl_job_registry
WHERE registry_group = 'EXECUTOR'
  AND registry_value LIKE '%10.0.0.12:9999%';

预期:前 90 秒行还在;90–120 秒之间某次扫描后,行消失。然后查执行器组:

sql
SELECT id, app_name, address_type, address_list, update_time
FROM xxl_job_group
WHERE app_name = 'xxl-job-executor-sample';

address_list 里应该也不再有这个地址。重新启动 Executor,最多约 60 秒后它重新出现。

生产动作 ​

项默认值(2.3.x/2.4.0)说明
RegistryConfig.BEAT_TIMEOUT30 秒Executor 心跳间隔,也是 Admin 扫描间隔
RegistryConfig.DEAD_TIMEOUT90 秒注册行过期阈值
xxl.job.executor.appname无默认,必填必须与 xxl_job_group.app_name 一致
xxl.job.executor.address / ip空address 空时自动拼 http://ip:port/;容器/多网卡用 ip 或 address 指定

不要把手动录入的组当成自动组:address_type=1 的 xxl_job_group 不会从注册表刷新地址,也不会自动删除失效地址。手工地址一旦过期,调度会持续连接超时,直到有人改配置。能自动注册就不要手填。

常见故障:

  1. 注册表完全没有行 → 查 Executor 到 Admin 的网络、accessToken 是否一致、xxl.job.admin.addresses 是否带上了 /xxl-job-admin 上下文。
  2. 注册地址是容器内网 IP → 配 xxl.job.executor.ip 或 xxl.job.executor.address。
  3. 在线数周期性抖动 → 查 Admin 机器时钟、DB 时区、网络是否间歇性丢包。
  4. 执行器已下线但地址还在 → 确认那行是不是 address_type=1 的手工地址;自动注册的行会在 90–120 秒内被清掉。

巡检阈值:注册行 age_s > 90 即可告警;这与剔除阈值一致,不要写成「5 分钟没更新才告警」。在线实例数短时间大幅下降可以作为辅助告警,但阈值要按自己的实例规模定,没有通用百分比。

边界 ​

  • 心跳不是租约,也不迁移任务:没有「持有 30 秒」的概念,执行器挂了,它负责的分片不会被另一个实例自动接管;要靠失败重试、业务补偿或下一次调度重新分片。
  • 90 秒内仍可能调度到死实例:执行器刚挂时,地址还在列表里,最多约 2 分钟的 /run 会失败。
  • 假活:进程活着但线程池满、GC 卡住、下游不可用时,心跳仍然正常;多 Admin 也没有共识协议,时钟偏差会让注册表状态抖动。

讲清楚 ​

XXL-JOB 的服务发现很轻:Executor 每 30 秒向 Admin 的 /api/registry 报一次 (EXECUTOR, appname, address),Admin 按三元组 upsert 一行,更新 update_time。Admin 每 30 秒扫一次表,删掉超过 90 秒没更新的行,再把存活地址按 appname 聚合,写回 xxl_job_group.address_list;调度时读的是这个地址列表。90 秒等于三个心跳周期,允许偶发丢包,但地址失效后最多还会被调度约 2 分钟,所以观测到的消失时间是 90–120 秒,不是精确 90 秒。它没有租约、没有选主、没有 Watch;多个 Admin 各自收心跳、各自写库,靠 NTP 保证时钟一致。心跳只能证明 HTTP Server 还活着,不能证明 Handler 能跑,所以「在线但全超时」时要去看执行器线程和 GC,而不是继续盯着注册表。

适用 XXL-JOB 2.3.x / 2.4.0 | 初稿 2026-10

把一门技术讲透到既懂也会用