ElasticJob 3.0.4 分片机制与故障接管实战

在一台应用节点(app1)上实测 ElasticJob 3.0.4 的分片机制全生命周期——单实例分片全量加载、双实例分片重分配、实例被杀后的故障接管时延、以及任务执行中被杀后的失效转移补偿,全部结论以 ZooKeeper 存储数据与作业日志实测回显为证。

ElasticJobElasticJobApache定时调度ZooKeeper分布式Java2026/10/24

ElasticJob 3.0.4 分片机制与故障接管实战

目标:在一台应用节点(app1)上实测 ElasticJob 3.0.4 的分片机制全生命周期——单实例分片全量加载、双实例分片重分配、实例被杀后的故障接管时延、以及任务执行中被杀后的失效转移补偿,全部结论以 ZooKeeper 存储数据与作业日志实测回显为证。所有回显均为 Ubuntu 22.04 + ZooKeeper 3.8.4 + OpenJDK 17 环境实测原文(系统时区 UTC)。

一、环境信息

项取值
ElasticJob 版本3.0.4(elasticjob-lite-core,Curator 5.5.0 + ZooKeeper 客户端 3.7.1)
ZooKeeper3.8.4 standalone,节点 zk1(10.89.64.10:2181)
应用节点app1(10.89.64.11),OpenJDK 17.0.20.1,JVM -Xms256m -Xmx512m
实测工程elasticjob-demo(复用《ElasticJob 3.0.4 安装指南》的工程骨架,/root/project/elasticjob-demo)
实测作业javaSimpleJob(快任务,cron 0/5 * * * * ?,3 分片)、slowJob(慢任务,cron 0/30 * * * * ?,执行 15 秒,3 分片)
观测工具zkCli.sh(存储层)、作业日志(触发时间线)、jps -lv(进程与 JVM 参数)

💡 阅读前提:本文每个实验都给出「操作命令 → 实测回显 → ZooKeeper 数据 → 结论」。日志时间戳为节点系统时间(UTC),分析时延按同一时钟计算。作业代码与工程构建见《ElasticJob 3.0.4 安装指南》,JobConfiguration 全参数语义见《ElasticJob 3.0.4 配置参数详解》。

二、分片机制原理

本节先把四个核心概念和存储结构讲清楚,后面的实测才能看懂 ZooKeeper 里每一个节点是谁、为什么变。

2.1 核心概念:总分片数、分片项、分片实例

概念含义本文取值
总分片数(shardingTotalCount)作业配置声明的分片总数,是「任务并行度」的抽象,与实例数无关3
分片项(shardingItem)0 到 shardingTotalCount-1 的整数编号,每个分片项在一个实例上独立执行0、1、2
分片实例(instance)一个正在运行的 ElasticJob 进程,实例 ID 格式 IP@-@进程PID10.89.64.11@-@40
分片参数(shardingItemParameters)每个分片项对应的业务参数,随 ShardingContext 传给作业0=A,1=B,2=C

三者的关系是一条分配关系:shardingItem → instance。总分片数是配置常量(3 个),实例数是运行时变量(1 个或 2 个),分片机制的本质就是把固定数量的分片项动态分配到数量可变的实例上,并在实例增减时重新分配。

💡 技术拓展:分片不是「把数据切三份存三处」。ElasticJob 的分片只做「任务并行度的切分」——业务代码收到 shardingItem 后自行决定处理哪段数据(如 id % 3 == shardingItem)。常见落库方案对比:

方案原理适用
取模路由业务ID % 总分片数 == shardingItem数据量均匀、分片数固定
分片参数表分片参数写明该片处理的库/表/区间(本文 0=A,1=B,2=C 即此形态)分库分表、区间拆分
范围路由按 ID/时间区间划分数据按时间递增

本文因演示 3 分片参数传递,采用「分片参数表」形态。

2.2 ZooKeeper 存储结构:分片信息存放在哪里

ElasticJob 3.0.4 把作业的全部运行时信息放在 /<命名空间>/<作业名>/ 下。实测查看(zk1 上执行 zkCli.sh):

[zk: localhost:2181(CONNECTED) 1] ls /elasticjob-failover/javaSimpleJob
[config, instances, leader, servers, sharding]

各节点职责与实测内容:

节点类型职责实测内容
config持久作业配置(作业存在即不丢)见 4.2 全文回显
instances临时子节点存活实例注册表,会话断开自动删除[10.89.64.11@-@40]
leader/election/instance临时主实例(负责分片计算)10.89.64.11@-@40
leader/sharding/needed持久「需要重新分片」标记存在标记时触发重分片
sharding/<item>/instance持久子节点分片项 → 实例 ID 的分配结果0/1/2 → 10.89.64.11@-@40
servers持久出现过的主机列表[10.89.64.11]

⚠️ 注意:分片分配结果在 3.x 中存于 sharding/<分片项>/instance 子节点(sharding/<分片项> 节点本身 dataLength=0),直接 get /sharding/0 会得到空值,必须 get /sharding/0/instance。这是与 2.x 文档描述差异最大、排查时最容易踩空的点。

2.3 分片分配算法

💡 技术拓展:ElasticJob 内置三个分片策略(JobShardingStrategy 接口实现),对比——

策略原理适用场景
平均分配 AverageAllocationJobShardingStrategy实例按实例 ID 排序,分片项按序轮转,多余的片给排前的实例(默认)常规均衡场景
奇偶分片 OdevitySortByNameJobShardingStrategy按作业名哈希值奇偶决定实例正排/倒排后平均分配避免多作业总压在同一批实例
轮转分片 RotateServerJobShardingStrategy按作业名哈希值对实例列表轮转移位后平均分配同上,轮转幅度更均匀

本文未显式配置策略,实际采用默认「平均分配 AverageAllocationJobShardingStrategy」,实测结果(五、5.2)与其算法一致。

2.4 故障接管与失效转移:两条不同时序的恢复路径

这两个词经常混用,在 ElasticJob 里是两条独立机制,触发条件、补偿内容、时延都不同——本文第六、七两章分别实测:

机制触发条件恢复内容实测时延是否需要 failover=true
故障接管(重新分片)实例下线(会话过期,临时节点 instances 消失)死亡实例名下全部分片项转给存活实例,按 cron 周期恢复执行实测 41 秒(第六章)否,恒发生
失效转移(failover)任务执行中实例崩溃,本次执行未完成崩溃分片被立即补执行一次(不等 cron),随后仍按 cron 正常实测 38 秒(第七章)是

⚠️ 两者都受 ZooKeeper 会话超时支配:实例被 kill -9(模拟宕机)后,其临时节点要等会话超时才被服务端删除,接管/补偿从这个时刻才开始。实测会话协商超时 40 秒(见 4.1 日志 negotiated timeout = 40000),因此两种机制的时延都约为 40 秒起步。若用 kill -15 优雅停机,进程关闭钩子会立即删除临时节点,恢复是秒级——生产变更加实例/停实例建议优雅停机。

三、前置条件:演示工程与作业代码

本文实测复用《ElasticJob 3.0.4 安装指南》构建的 elasticjob-demo 工程(Maven 依赖、target/classes 与 target/lib 就绪)。为让读者可以直接复现,给出工程内两个类的完整源码。

作业实现 JavaSimpleJob(每次触发打印分片上下文):

package com.example;

import org.apache.shardingsphere.elasticjob.api.ShardingContext;
import org.apache.shardingsphere.elasticjob.simple.job.SimpleJob;

/**
 * 演示用 SimpleJob:每次触发打印当前分片上下文。
 */
public final class JavaSimpleJob implements SimpleJob {

    @Override
    public void execute(final ShardingContext context) {
        System.out.printf("%s [%s] trigger | shardingItem=%d shardingTotalCount=%d "
                        + "shardingParameter=%s jobParameter=%s%n",
                java.time.LocalDateTime.now(), Thread.currentThread().getName(),
                context.getShardingItem(), context.getShardingTotalCount(),
                context.getShardingParameter(), context.getJobParameter());
    }
}

启动入口 JobMain(全部参数走 -D 系统属性,便于多实例实测):

package com.example;

import org.apache.shardingsphere.elasticjob.api.JobConfiguration;
import org.apache.shardingsphere.elasticjob.lite.api.bootstrap.impl.ScheduleJobBootstrap;
import org.apache.shardingsphere.elasticjob.reg.base.CoordinatorRegistryCenter;
import org.apache.shardingsphere.elasticjob.reg.zookeeper.ZookeeperConfiguration;
import org.apache.shardingsphere.elasticjob.reg.zookeeper.ZookeeperRegistryCenter;

public final class JobMain {

    public static void main(final String[] args) {
        String zk = prop("zk", "10.89.64.10:2181");
        String ns = prop("ns", "elasticjob-demo");
        String jobName = prop("job", "javaSimpleJob");
        String cron = prop("cron", "0/5 * * * * ?");
        int shards = Integer.parseInt(prop("shards", "3"));
        String items = prop("items", "0=A,1=B,2=C");
        boolean failover = Boolean.parseBoolean(prop("failover", "true"));
        boolean misfire = Boolean.parseBoolean(prop("misfire", "true"));
        boolean overwrite = Boolean.parseBoolean(prop("overwrite", "false"));
        boolean monitor = Boolean.parseBoolean(prop("monitor", "true"));
        String jobParam = prop("jobparam", "");
        String tag = prop("tag", "instance");

        CoordinatorRegistryCenter regCenter = new ZookeeperRegistryCenter(
                new ZookeeperConfiguration(zk, ns));
        regCenter.init();

        JobConfiguration config = JobConfiguration.newBuilder(jobName, shards)
                .cron(cron)
                .shardingItemParameters(items)
                .jobParameter(jobParam)
                .failover(failover)
                .misfire(misfire)
                .overwrite(overwrite)
                .monitorExecution(monitor)
                .build();

        new ScheduleJobBootstrap(regCenter, new JavaSimpleJob(), config).schedule();
    }

    private static String prop(final String key, final String defVal) {
        String v = System.getProperty(key);
        return v == null || v.isEmpty() ? defVal : v;
    }
}

关键参数逐项说明:

  • -Dzk:ZooKeeper 地址;-Dns:命名空间(同一 ZK 上多套环境用命名空间隔离,本文实测专用 elasticjob-failover);
  • -Dshards=3:总分片数;-Ditems=0=A,1=B,2=C:分片项参数;
  • -Dfailover=true:开启失效转移(第七章依赖);-Doverwrite=true:首个实例启动时用代码配置覆盖 ZooKeeper 里的 config;
  • JVM 堆限定 -Xms256m -Xmx512m(每个实例进程都是独立 JVM,多实例同机部署时务必逐进程限堆,避免默认大堆叠加撑爆内存)。

启动实例的命令形态(在 app1 的工程目录执行,nohup 后台运行、日志落 /root/d5run/):

cd /root/project/elasticjob-demo
nohup java -Xms256m -Xmx512m -Dtag=node-A -Dzk=10.89.64.10:2181 \
  -Dns=elasticjob-failover -Djob=javaSimpleJob -Dcron="0/5 * * * * ?" \
  -Dshards=3 -Ditems=0=A,1=B,2=C -Doverwrite=true \
  -cp target/classes:target/lib/* com.example.JobMain \
  > /root/d5run/exp1-node-a.log 2>&1 &

⚠️ nohup 启动(命令末尾加 & 后台运行)时,classpath 通配 target/lib/* 必须出现在 java 命令的同一条 shell 语句中。后台嵌套启动时通配符可能不被展开,导致 ClassNotFoundException: com.example.FailoverJobMain(本文实测踩过,见九、FAQ 问题 3)。稳妥做法是把启动命令包进一个启动脚本再 nohup 脚本 &。

四、单实例分片全量加载实测

本节验证最基本的事实:只有一个实例时,它独占全部 3 个分片项;ZooKeeper 里留下完整的注册与分配数据。

4.1 启动 node-A 并观察触发日志

按第三章命令启动实例(tag=node-A),等待约 17 秒后确认进程与触发日志。进程核实(jps -lv):

40 com.example.JobMain -Xms256m -Xmx512m -Dtag=node-A -Dzk=10.89.64.10:2181 -Dns=elasticjob-failover -Djob=javaSimpleJob -Dcron=0/5 * * * * ? -Dshards=3 -Ditems=0=A,1=B,2=C -Doverwrite=true

连接注册中心的会话参数(日志节选,实测首部):

12:18:58.507 [main] INFO  org.apache.zookeeper.ZooKeeper - Initiating client connection, connectString=10.89.64.10:2181 sessionTimeout=60000 watcher=org.apache.curator.ConnectionState@15761df8
12:18:58.522 [main-SendThread(10.89.64.10:2181)] INFO  org.apache.zookeeper.ClientCnxn - Session establishment complete on server elasticjob-3.0.4-zk.elasticjob-net/10.89.64.10:2181, session id = 0x10014305cb10066, negotiated timeout = 40000

⚠️ 两个超时值都记住:客户端请求的 sessionTimeout=60000,ZooKeeper 服务端协商后的 negotiated timeout = 40000。第六、七章实测的接管/补偿时延(41 秒、38 秒)都由这个 40 秒会话超时决定。

触发日志(实测节选,node-A 独占 3 分片,每轮 3 线程并发):

2026-10-24T12:19:25.042177798 [elasticjob-javaSimpleJob-1] trigger | shardingItem=0 shardingTotalCount=3 shardingParameter=A jobParameter=
2026-10-24T12:19:25.042187190 [elasticjob-javaSimpleJob-2] trigger | shardingItem=1 shardingTotalCount=3 shardingParameter=B jobParameter=
2026-10-24T12:19:25.042182629 [elasticjob-javaSimpleJob-3] trigger | shardingItem=2 shardingTotalCount=3 shardingParameter=C jobParameter=
2026-10-24T12:19:30.016171582 [elasticjob-javaSimpleJob-4] trigger | shardingItem=0 shardingTotalCount=3 shardingParameter=A jobParameter=
2026-10-24T12:19:30.016395643 [elasticjob-javaSimpleJob-5] trigger | shardingItem=1 shardingTotalCount=3 shardingParameter=B jobParameter=
2026-10-24T12:19:30.016580282 [elasticjob-javaSimpleJob-6] trigger | shardingItem=2 shardingTotalCount=3 shardingParameter=C jobParameter=

验证结果:每 5 秒一轮(cron 0/5),每轮 shardingItem=0/1/2 各触发一次,分片参数 A/B/C 对应无误——单实例拿到全部分片。

4.2 ZooKeeper 数据取证

zk1 上用 zkCli.sh 逐节点核对。实例注册与主实例:

[zk: localhost:2181(CONNECTED) 2] ls /elasticjob-failover/javaSimpleJob/instances
[10.89.64.11@-@40]
[zk: localhost:2181(CONNECTED) 5] get /elasticjob-failover/javaSimpleJob/leader/election/instance
10.89.64.11@-@40

config 节点(首个实例 -Doverwrite=true 写入,逐行实测):

[zk: localhost:2181(CONNECTED) 3] get /elasticjob-failover/javaSimpleJob/config
cron: 0/5 * * * * ?
description: ''
disabled: false
failover: true
jobName: javaSimpleJob
jobParameter: ''
maxTimeDiffSeconds: -1
misfire: true
monitorExecution: true
overwrite: true
reconcileIntervalMinutes: 10
shardingItemParameters: 0=A,1=B,2=C
shardingTotalCount: 3
staticSharding: false

💡 说明:reconcileIntervalMinutes: 10 是重新分片「对账」周期——即使事件监听遗漏,主实例每 10 分钟也会核查一次实例与分片的一致性,这是自愈兜底。全参数语义见《ElasticJob 3.0.4 配置参数详解》。

三个分片项的归属(分片分配结果在 /instance 子节点):

[zk: localhost:2181(CONNECTED) 0] get /elasticjob-failover/javaSimpleJob/sharding/0/instance
10.89.64.11@-@40
[zk: localhost:2181(CONNECTED) 1] get /elasticjob-failover/javaSimpleJob/sharding/1/instance
10.89.64.11@-@40
[zk: localhost:2181(CONNECTED) 2] get /elasticjob-failover/javaSimpleJob/sharding/2/instance
10.89.64.11@-@40

验证结果:instances 只有一个实例、sharding/0/1/2 全部指向它、它同时是 leader——单实例形态的完整数据画像。

五、双实例分片分布实测

本节让第二个实例上线,实测重新分片的过程与结果:分片怎么分给两个实例、ZooKeeper 与两边日志如何互证。

5.1 启动 node-B

在 app1 上启动第二个实例(tag=node-B,-Doverwrite=false 不覆盖配置):

cd /root/project/elasticjob-demo
nohup java -Xms256m -Xmx512m -Dtag=node-B -Dzk=10.89.64.10:2181 \
  -Dns=elasticjob-failover -Djob=javaSimpleJob -Dcron="0/5 * * * * ?" \
  -Dshards=3 -Ditems=0=A,1=B,2=C -Doverwrite=false \
  -cp target/classes:target/lib/* com.example.JobMain \
  > /root/d5run/exp2-node-b.log 2>&1 &

进程核实(两个实例并存,实例 ID 以进程 PID 区分):

150 com.example.JobMain -Xms256m -Xmx512m -Dtag=node-B -Dzk=10.89.64.10:2181 -Dns=elasticjob-failover -Djob=javaSimpleJob -Dcron=0/5 * * * * ? -Dshards=3 -Ditems=0=A,1=B,2=C -Doverwrite=false
40 com.example.JobMain -Xms256m -Xmx512m -Dtag=node-A -Dzk=10.89.64.10:2181 -Dns=elasticjob-failover -Djob=javaSimpleJob -Dcron=0/5 * * * * ? -Dshards=3 -Ditems=0=A,1=B,2=C -Doverwrite=true

5.2 重分配结果:日志与 ZooKeeper 互证

node-B 上线(12:20:40)后立刻重分片。node-B 日志(实测,上线后第一个触发轮就只跑自己的分片):

2026-10-24T12:20:45.136282463 [javaSimpleJob_Worker-1] trigger | shardingItem=1 shardingTotalCount=3 shardingParameter=B jobParameter=
2026-10-24T12:20:50.014488530 [javaSimpleJob_Worker-1] trigger | shardingItem=1 shardingTotalCount=3 shardingParameter=B jobParameter=
2026-10-24T12:20:55.015844169 [javaSimpleJob_Worker-1] trigger | shardingItem=1 shardingTotalCount=3 shardingParameter=B jobParameter=
2026-10-24T12:21:00.017953306 [javaSimpleJob_Worker-1] trigger | shardingItem=1 shardingTotalCount=3 shardingParameter=B jobParameter=

node-A 日志(实测节选,重分片后从「3 分片」收敛为「2 分片」):

2026-10-24T12:20:50.012000558 [elasticjob-javaSimpleJob-2] trigger | shardingItem=0 shardingTotalCount=3 shardingParameter=A jobParameter=
2026-10-24T12:20:50.012077769 [elasticjob-javaSimpleJob-4] trigger | shardingItem=2 shardingTotalCount=3 shardingParameter=C jobParameter=
2026-10-24T12:20:55.014003960 [elasticjob-javaSimpleJob-6] trigger | shardingItem=0 shardingTotalCount=3 shardingParameter=A jobParameter=
2026-10-24T12:20:55.014099300 [elasticjob-javaSimpleJob-6] trigger | shardingItem=2 shardingTotalCount=3 shardingParameter=C jobParameter=

ZooKeeper 取证(分配结果与两边日志完全对应):

[zk: localhost:2181(CONNECTED) 0] ls /elasticjob-failover/javaSimpleJob/instances
[10.89.64.11@-@150, 10.89.64.11@-@40]
[zk: localhost:2181(CONNECTED) 1] get /elasticjob-failover/javaSimpleJob/leader/election/instance
10.89.64.11@-@40
[zk: localhost:2181(CONNECTED) 2] get /elasticjob-failover/javaSimpleJob/sharding/0/instance
10.89.64.11@-@40
[zk: localhost:2181(CONNECTED) 3] get /elasticjob-failover/javaSimpleJob/sharding/1/instance
10.89.64.11@-@150
[zk: localhost:2181(CONNECTED) 4] get /elasticjob-failover/javaSimpleJob/sharding/2/instance
10.89.64.11@-@40

验证结果与结论:

分片项归属实例分片参数
0node-A(40)A
1node-B(150)B
2node-A(40)C
  • 3 分片 2 实例,平均分配策略把分片项按实例 ID 排序(40 < 150)依次轮转:0→40、1→150、2→40,与 2.3 节算法描述一致(多出的第 3 片给排前的 40);
  • 主实例仍是 node-A(先注册者),它负责了这次重分片计算;
  • node-B 从注册到拿到分片在一轮 cron 内完成(12:20:40 启动 → 12:20:45.136 首次触发即 item=1),说明实例上线事件触发的重分片是即时的,与第六章实例下线要等会话超时形成鲜明对比。

六、故障接管实测:kill -9 后分片全部回归

本节模拟 node-B 宕机(kill -9),完整记录 node-A 收回全部分片的时序,实测接管时延。

6.1 击杀时刻

记录精确时间后杀掉 node-B(进程 PID 150):

EXP3-KILL 2026-10-24T12:21:24Z     ← kill -9 150 执行时刻
EXP3-CHECK 2026-10-24T12:22:26Z    ← 62 秒后 node-A(40)仍存活

6.2 接管完成时刻与时延

对 node-A 日志中 shardingItem=1 的触发时间轴做汇总(实测,每行=该秒出现一次 item=1 触发,输出节选,中断窗口 12:20:40 → 12:22:05 之间无任何 item=1 触发):

      1 2026-10-24 12:20:40
      1 2026-10-24 12:22:05
      1 2026-10-24 12:22:10
      1 2026-10-24 12:22:15
      1 2026-10-24 12:22:20
      1 2026-10-24 12:22:25
      1 2026-10-24 12:22:30

node-A 恢复后的日志(3 分片同轮并发,实测):

2026-10-24T12:22:25.015483654 [elasticjob-javaSimpleJob-20] trigger | shardingItem=0 shardingTotalCount=3 shardingParameter=A jobParameter=
2026-10-24T12:22:25.015600442 [elasticjob-javaSimpleJob-23] trigger | shardingItem=2 shardingTotalCount=3 shardingParameter=C jobParameter=
2026-10-24T12:22:25.015552096 [elasticjob-javaSimpleJob-24] trigger | shardingItem=1 shardingTotalCount=3 shardingParameter=B jobParameter=
2026-10-24T12:22:30.013930030 [elasticjob-javaSimpleJob-22] trigger | shardingItem=0 shardingTotalCount=3 shardingParameter=A jobParameter=
2026-10-24T12:22:30.014074926 [elasticjob-javaSimpleJob-3] trigger | shardingItem=2 shardingTotalCount=3 shardingParameter=C jobParameter=
2026-10-24T12:22:30.014007585 [elasticjob-javaSimpleJob-1] trigger | shardingItem=1 shardingTotalCount=3 shardingParameter=B jobParameter=

ZooKeeper 数据同步收敛(实测):

[zk: localhost:2181(CONNECTED) 0] ls /elasticjob-failover/javaSimpleJob/instances
[10.89.64.11@-@40]
[zk: localhost:2181(CONNECTED) 1] get /elasticjob-failover/javaSimpleJob/sharding/1/instance
10.89.64.11@-@40

实测时延:12:21:24(kill)→ 12:22:05(item=1 回归)= 41 秒,与 4.1 节日志中的协商会话超时 40 秒吻合。

6.3 时延构成与运维含义

阶段耗时机制
kill -9 → 会话过期≈ 40 秒客户端死亡后无法主动断开,ZooKeeper 服务端等会话超时才删除 instances 临时节点
会话过期 → 重新分片秒级主实例(node-A)监听到实例节点删除,立即触发重分片并更新 sharding/*/instance
重新分片 → 下次 cron 恢复≤ 5 秒(一个 cron 周期)node-A 在下一个 cron 边界按新分片全集执行

⚠️ 运维结论:kill -9(或断电、断网)形态的故障,接管时延下限就是会话超时。想缩短只能调小 ZooKeeper 会话超时(ZookeeperConfiguration.sessionTimeoutMilliseconds),但调小会增加网络抖动引起的误判(活实例被踢)。计划内停机(发版、缩容)务必用 kill -15/systemctl stop 让进程走关闭钩子立即摘除临时节点,接管从 41 秒级降到秒级。

七、失效转移实测:任务执行中被杀,存活实例补偿执行

第六章的快任务「打印即返回」,kill -9 时不可能正好处于执行中,因此观测不到失效转移。本章改用慢任务(每次执行 15 秒),让 kill 精确落在执行窗口内,实测 failover 补偿。

7.1 慢任务代码与双实例启动

新增作业类 FailoverSlowJob(执行时长由 -Dsleepms 控制,START/END 标记便于在日志中定位执行窗口):

package com.example;

import org.apache.shardingsphere.elasticjob.api.ShardingContext;
import org.apache.shardingsphere.elasticjob.simple.job.SimpleJob;

public final class FailoverSlowJob implements SimpleJob {

    private final long sleepMs;

    public FailoverSlowJob(final long sleepMs) {
        this.sleepMs = sleepMs;
    }

    @Override
    public void execute(final ShardingContext context) {
        try {
            System.out.printf("%s [%s] SLOW-START | shardingItem=%d shardingTotalCount=%d shardingParameter=%s%n",
                    java.time.LocalDateTime.now(), Thread.currentThread().getName(),
                    context.getShardingItem(), context.getShardingTotalCount(),
                    context.getShardingParameter());
            Thread.sleep(sleepMs);
            System.out.printf("%s [%s] SLOW-END   | shardingItem=%d shardingTotalCount=%d shardingParameter=%s%n",
                    java.time.LocalDateTime.now(), Thread.currentThread().getName(),
                    context.getShardingItem(), context.getShardingTotalCount(),
                    context.getShardingParameter());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.out.printf("%s [%s] SLOW-ABORT | shardingItem=%d interrupted%n",
                    java.time.LocalDateTime.now(), Thread.currentThread().getName(),
                    context.getShardingItem());
        }
    }
}

启动入口 FailoverJobMain 与 JobMain 同构(作业类换成 FailoverSlowJob,cron 放宽为每 30 秒一轮、执行 15 秒,failover=true,作业名 slowJob)。启动方式:把启动命令写入脚本(规避第三章 FAQ 的后台通配问题)再 nohup:

printf '#!/bin/sh\ncd /root/project/elasticjob-demo\nexec java -Xms256m -Xmx512m -Dtag=node-B2 -Dzk=10.89.64.10:2181 -Dns=elasticjob-failover -Djob=slowJob -Dcron="0/30 * * * * ?" -Dshards=3 -Ditems=0=A,1=B,2=C -Dfailover=true -Doverwrite=false -Dsleepms=15000 -cp target/classes:target/lib/* com.example.FailoverJobMain\n' > /root/d5run/start-b2.sh
chmod +x /root/d5run/start-b2.sh
nohup /root/d5run/start-b2.sh > /root/d5run/exp4-node-b2.log 2>&1 &

启动 node-A2(12:23:24)与 node-B2(12:24:33)。node-B2 上线后经过一轮重分片,形成 node-A2=[1]、node-B2=[0,2] 的分布(与第五章同构,slowJob 作业名不同互不影响)。

7.2 执行中击杀与补偿取证

12:26:30 触发轮:node-B2 正在执行分片 0、2(15 秒窗口),node-A2 正在执行分片 1。12:26:37(node-B2 执行到第 7 秒)执行 kill -9 455:

BEFORE-KILL 12:26:37
2026-10-24T12:26:30.019424689 [elasticjob-slowJob-5] SLOW-START | shardingItem=0 shardingTotalCount=3 shardingParameter=A
2026-10-24T12:26:30.019552503 [elasticjob-slowJob-6] SLOW-START | shardingItem=2 shardingTotalCount=3 shardingParameter=C
KILLED 12:26:37

node-A2 日志完整时间线(实测节选,从击杀前一轮到恢复):

2026-10-24T12:26:30.013131055 [slowJob_Worker-1] SLOW-START | shardingItem=1 shardingTotalCount=3 shardingParameter=B
2026-10-24T12:26:45.013680486 [slowJob_Worker-1] SLOW-END   | shardingItem=1 shardingTotalCount=3 shardingParameter=B
2026-10-24T12:27:00.017076689 [slowJob_Worker-1] SLOW-START | shardingItem=1 shardingTotalCount=3 shardingParameter=B
2026-10-24T12:27:15.017312968 [slowJob_Worker-1] SLOW-END   | shardingItem=1 shardingTotalCount=3 shardingParameter=B
2026-10-24T12:27:15.040167202 [slowJob_Worker-1] SLOW-START | shardingItem=2 shardingTotalCount=3 shardingParameter=C
2026-10-24T12:27:30.040421401 [slowJob_Worker-1] SLOW-END   | shardingItem=2 shardingTotalCount=3 shardingParameter=C
2026-10-24T12:27:30.065095692 [slowJob_Worker-1] SLOW-START | shardingItem=0 shardingTotalCount=3 shardingParameter=A
2026-10-24T12:27:45.065342350 [slowJob_Worker-1] SLOW-END   | shardingItem=0 shardingTotalCount=3 shardingParameter=A
2026-10-24T12:28:00.027953320 [elasticjob-slowJob-10] SLOW-START | shardingItem=0 shardingTotalCount=3 shardingParameter=A
2026-10-24T12:28:00.028103942 [elasticjob-slowJob-11] SLOW-START | shardingItem=1 shardingTotalCount=3 shardingParameter=B
2026-10-24T12:28:00.028242133 [elasticjob-slowJob-12] SLOW-START | shardingItem=2 shardingTotalCount=3 shardingParameter=C
2026-10-24T12:28:15.028265206 [elasticjob-slowJob-10] SLOW-END   | shardingItem=0 shardingTotalCount=3 shardingParameter=A
2026-10-24T12:28:15.028422538 [elasticjob-slowJob-11] SLOW-END   | shardingItem=1 shardingTotalCount=3 shardingParameter=B
2026-10-24T12:28:15.028343007 [elasticjob-slowJob-12] SLOW-END   | shardingItem=2 shardingTotalCount=3 shardingParameter=C

逐行解读这份时间线(全部实测):

时刻事件机制
12:26:30–45node-A2 正常执行 item=1cron 触发,与 node-B2 死亡无关
12:27:00–15node-A2 正常执行 item=1node-B2 已死(12:26:37),但它名下 0/2 分片还没转出
12:27:15.040node-A2 执行 item=2失效转移补偿:node-B2 崩溃时 item=2 正在执行(未 END),node-B2 会话过期后该分片进入失效分片队列,node-A2 立即补执行——时点 12:27:15 不是 cron 边界(cron 只有 :00/:30),且时距击杀 38 秒 ≈ 会话超时 40 秒
12:27:30.065node-A2 执行 item=0同上,失效分片队列逐项消费(worker 单线程,item=2 结束后立刻补 item=0)
12:28:00node-A2 一轮并发 0/1/2重新分片完成,node-A2 独占全部分片,恢复 cron 节奏

ZooKeeper 终态(实测):

[zk: localhost:2181(CONNECTED) 0] ls /elasticjob-failover/slowJob/instances
[10.89.64.11@-@270]
[zk: localhost:2181(CONNECTED) 1] get /elasticjob-failover/slowJob/sharding/0/instance
10.89.64.11@-@270
[zk: localhost:2181(CONNECTED) 2] get /elasticjob-failover/slowJob/sharding/1/instance
10.89.64.11@-@270
[zk: localhost:2181(CONNECTED) 3] get /elasticjob-failover/slowJob/sharding/2/instance
10.89.64.11@-@270

验证结果:failover=true 时,「执行中被杀」的分片会得到一次性补偿执行(完整重跑该分片逻辑,从 SLOW-START 到 SLOW-END,不是断点续传),补偿发生在会话过期后的秒级窗口内;若作业配置 failover=false,被杀分片只按第六章的重新分片路径在下一 cron 周期恢复,不会有立即补偿。补偿时「同分片正在执行则本次 cron 跳过」的重叠保护也在时间线里可见(12:27:30 cron 边界被队列补偿占用,未再叠加触发)。

八、事件轨迹与运维观测

排障与日常巡检时,按「进程 → 日志 → ZooKeeper」三层观测即可还原分片事件全貌,本章给可直接照做的命令与判读标准。

8.1 进程层:确认实例存活与 JVM 参数

jps -lv | grep com.example.JobMain

实测回显(双实例期):

150 com.example.JobMain -Xms256m -Xmx512m -Dtag=node-B -Dzk=10.89.64.10:2181 -Dns=elasticjob-failover -Djob=javaSimpleJob -Dcron=0/5 * * * * ? -Dshards=3 -Ditems=0=A,1=B,2=C -Doverwrite=false
40 com.example.JobMain -Xms256m -Xmx512m -Dtag=node-A -Dzk=10.89.64.10:2181 -Dns=elasticjob-failover -Djob=javaSimpleJob -Dcron=0/5 * * * * ? -Dshards=3 -Ditems=0=A,1=B,2=C -Doverwrite=true

判读要点:每个实例一行;-Dtag 即业务给实例起的名字;堆参数应与预期一致(多实例同机时逐行核对总堆不超内存)。若 jps 列表里出现疑似死亡的 PID,用 ps -ef | grep JobMain 与 /proc/<PID> 复核进程真实状态。

8.2 日志层:触发时间线与线程名

  • 线程名 elasticjob-<作业名>-N:分片并发线程池(一轮多分片并发触发时出现多个递增编号);
  • 线程名 <作业名>_Worker-1:作业触发后的执行工作线程(含失效转移补偿队列的消费,第七章 12:27:15/12:27:30 两次补偿都在该线程串行执行);
  • 判读「分片归属变化」最快的方法是按分片项聚合触发时间轴(第六章 6.2 的 uniq -c 汇总形态),中断窗口与恢复点一目了然。

8.3 ZooKeeper 层:四个观测点与判读

# zk1 上执行 zkCli.sh 后:
ls /elasticjob-failover/javaSimpleJob/instances            # ① 存活实例清单(临时节点)
get /elasticjob-failover/javaSimpleJob/leader/election/instance   # ② 当前主实例
get /elasticjob-failover/javaSimpleJob/sharding/1/instance # ③ 分片归属
ls /elasticjob-failover/javaSimpleJob/leader/sharding      # ④ 重分片标记目录

判读表:

观测结果含义
instances 少了一个实例,sharding/*/instance 仍指向它实例刚死、会话未过期,接管尚未开始(第六章 12:21:24–12:22:05 窗口)
sharding/*/instance 全部指向同一实例该实例已独占(单实例或故障接管完成)
leader/sharding 下出现 needed 标记一次重分片被登记、尚未执行完(短暂中间态)

💡 说明:ElasticJob 3.0.4 也支持事件追踪(tracing-rdb,把触发/执行事件落库到关系数据库做轨迹审计),需要额外数据源配置,属于《ElasticJob 3.0.4 配置参数详解》中 config 节点之外的独立接线;无数据库的轻量场景,用本章三层观测即可覆盖分片故障的全部排障需求。

九、常见问题(FAQ)

Q1:为什么 kill -9 实例后,另一实例过了 40 秒左右才开始接管? ZooKeeper 会话是「服务端侧超时」机制:客户端进程被 kill -9 后无法发送任何断开指令,服务端要等会话超时(实测协商值 40 秒,见 4.1 日志 negotiated timeout = 40000)才判定会话死亡、删除临时节点;重新分片由实例删除事件触发,因此接管时延 = 会话超时 + 秒级重分片(实测 41 秒)。计划内停机用 kill -15 让关闭钩子立即摘除节点即可秒级接管。

Q2:新实例上线后,分片不会立刻重新分配吗? 会,但不是「注册瞬间」而是走重分片标记 + 作业触发/事件驱动的路径,实测 node-B 上线(12:20:40)后第一个触发轮(12:20:45)即拿到新分片;慢作业实验(第七章 slowJob,cron 30 秒)中 node-B2 于 12:24:40 前后上线、12:25:30 轮才观测到新归属,观测粒度受 cron 周期影响。作业 cron 越长,「上线后等下一轮才生效」的感觉越明显,但 ZK 的 sharding/*/instance 会先一步更新,以 ZK 为准判读。

Q3:后台 nohup 方式启动 java 进程时报 Error: Could not find or load main class com.example.FailoverJobMain? 实测踩坑记录:同一条命令前台执行正常、后台嵌套执行报类找不到(class 文件确认存在)。根因是后台执行时 -cp target/classes:target/lib/* 的通配符展开时序问题。解决:把启动命令包进启动脚本(start-b2.sh,见 7.1)再 nohup 脚本 &,或把 classpath 写成完整绝对路径逐 jar 罗列。

Q4:get /sharding/0 为什么是空值? 3.x 把分配结果存在子节点里,正确命令是 get /sharding/0/instance(见 2.2 与 4.2)。sharding/<N> 节点本身 dataLength=0,这是版本间的结构变化,不是数据丢失。

Q5:实例 ID 里 @-@ 后面的数字是什么? 实测为运行实例的进程 PID(10.89.64.11@-@40 对应 jps 里的 PID 40,两轮实验均吻合)。同机多实例部署时 PID 天然区分实例;不同主机间用 IP 区分。用实例 ID 可直接在 ZK 中把「某分片归谁」与「哪个进程」对应起来。

Q6:日志时间与本地时间差 8 小时? 实测节点系统时区为 UTC(日志 2026-10-24T12:19:25 即北京时间 20:19:25)。分析接管时延时必须用同一时钟源,跨时区对比日志会得出错误时延。

Q7:失效转移会「断点续传」吗? 不会。第七章实测补偿执行是完整重跑该分片逻辑(SLOW-START → SLOW-END 全程),业务侧需要自行保证幂等(例如按作业参数/分片参数核对处理进度)。ElasticJob 只负责「发现未完成 → 指派补跑」,不负责中断点记忆。

十、参考链接