1. 问题
目前我们有一个系统需要执行大量数据的重算,我们会依据指定条件从数据库中提取入参数据,并使用这些数据进行业务计算。
由于数据量巨大,每次都会有将几千万级别的数据,单台机器执行耗时较长,不能满足业务的需求。为了提升执行的速度,我们引入xxljob,将庞大数据量分摊到多台机器执行来缩减总体执行时间。大致流程图如下:
整体任务的执行逻辑如下:
- 通过 xxl-job 向多台机器分发任务,携带总的数据 ID 区间。
- 每台机器收到任务后,使用统一算法对总区间进行划分,仅处理其负责的子区间。
例如:
机器编号 | 数据区间 |
---|---|
机器 A | 0 ~ 20w |
机器 B | 20w ~ 40w |
... | ... |
但是最近一段时间,系统开始偶发出现以下异常情况:
- 实际执行完成的数据量少于预期
- 刚刚发布的几天内不会失败,执行一段时间后会失败,重启后能执行成功,再执行一段时间后还是会失败
1.1 排查思路
针对这一问题,我们按照常规思路进行了逐一排查
- 基础设施/部署环境排查
CPU 负载、内存是否过高 ,IO读写是否报错,网络是否抖动等。
- 系统层排查
缓存是否污染,线程池是否阻塞,JVM相关指标是否正常
- 调度层排查
xxljob轮询是否均衡,是否有调度失败的情况,机器是否频繁上下线或心跳丢失,调度参数是否有误,
xxl-job 日志是否有异常等
- 应用层排查
日志是否有异常,错误链路追踪等
1.2 问题分析
使用上述手段排查之后,仍然无法定位原因,只能在执行过程中加入大量日志,几乎覆盖过程的每一步。最终在日志中发现一些端倪:
结合我们的业务代码
@HllXxlJob(value = "DivideTaskJob")
public void DivideTaskJob() {
log.info("originParam:{}", HllXxlJobManager.getJobParam());
handleExecutor.execute(() -> {
try {
String command = HllXxlJobManager.getJobParam() ;
log.info("divideParam:{}", HllXxlJobManager.getJobParam());
......
ParamDTO param = JSONUtils.parseJson(command, ParamDTO.class);
Long taskId = param.getTaskId();
Long minId = param.getMinId();
Long maxId = param.getMaxId();
......
} catch (Exception e) {
log.error("IndependentDemandJobHandler.executeIndependentDemandDivideTaskJob error", e);
}
});
}
根据日志可以看出,部分机器(如图pod1和pod8)执行过程中,第3行获取的参数是
{"taskId":4111, "minId":20000001, "maxId":"41000000"}
,
但是第7行获取的参数却是
{"taskId":4110, "minId":0, "maxId":"20000000"}
我们需要执行的taskId是4111,而最终在执行前拿到的taskId却是4110,那执行出来的结果总数肯定对不上。
那为什么会发生这样的问题呢? 我们深入xxljob提供的HllXxlJobManager.getJobParam()一探究竟
2. 源码分析
2.1 xxljob消费端参数获取
看看这个方法具体的执行过程
目前逻辑还比较简单,我们继续深挖参数是如何写入的
2.2 xxljob消费端参数写入
这里可以看出参数被写入了一个InheritableThreadLocal, 那xxljob为什么会使用它呢?他有什么特性呢?
2.3 InheritableThreadLocal特性
InheritableThreadLocal是ThreadLocal的一个子类,它在ThreadLocal的基础上增加了线程间变量继承的功能。当一个线程创建子线程时,子线程会自动继承父线程中所有可继承的InheritableThreadLocal变量的初始值。
继承性:
- 子线程在创建时,会自动继承父线程中inheritableThreadLocals 中保存的 ThreadLocal 变量的副本。
初始值:
- 子线程的InheritableThreadLocal变量初始值与父线程相同,但可以在子线程中修改,且不会影响到父线程。
介于对xxljob源码分分析,大致调用的结构
父线程(Job执行线程)
│
├── 设置 InheritableThreadLocal<XxlJobContext>
│
└── 创建子线程(new Thread())
└── 自动继承 XxlJobContext
└── 子线程中仍可调用 XxlJobHelper.getJobParam()
所以xxljob的子线程为了获取到job执行线程(父线程)的参数,xxljob设计使用了InheritableThreadLocal
2.4 InheritableThreadLocal源码及特性解析
InheritableThreadLocal源码:
public class InheritableThreadLocal<T> extends ThreadLocal<T> {
protected T childValue(T parentValue) {
return parentValue;
}
ThreadLocalMap getMap(Thread t) {
return t.inheritableThreadLocals;
}
void createMap(Thread t, T firstValue) {
t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
}
}
我们看到InheritableThreadLocal 继承了 ThreadLocal 类。并且重写了父类的 createMap,getMap ,childValue三个方法。在createMap 和getMap 方法中我们可以看到,将ThreadLocal 方法中的线程threadLocals 属性换成了 inheritableThreadLocals 属性。我们可以看下Thread类中的这成员定义。
public class Thread implements Runnable {
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
}
我们接着去看设置这些变量的代码
再看下ThreadLocalMap(parentMap)构造函数
private ThreadLocalMap(ThreadLocalMap parentMap) {
Entry[] parentTable = parentMap.table;
int len = parentTable.length;
setThreshold(len);
table = new Entry[len];
for (int j = 0; j < len; j++) {
Entry e = parentTable[j];
if (e != null) {
@SuppressWarnings("unchecked")
ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
if (key != null) {
Object value = key.childValue(e.value);
Entry c = new Entry(key, value);
int h = key.threadLocalHashCode & (len - 1);
while (table[h] != null)
h = nextIndex(h, len);
table[h] = c;
size++;
}
}
}
}
我们会发现父子的InheritableThreadLocal是通过拷贝而不是共享的方式进行的继承,所以父线程的数据变了,并不会影响子线程的数据
举个例子:
InheritableThreadLocal<String> local = new InheritableThreadLocal<>();
local.set("parent");
Thread child = new Thread(() -> {
System.out.println(local.get());
System.out.println("开始等待3s");
Thread.sleep(3000);
System.out.println(local.get());
});
child.start();
local.set("parent2");
执行结果
parent
开始等待3s
parent
3. 问题总结
了解源码之后,再结合我们出现的异常状况进行分析
由于需要实现多task任务并发执行,在接收到xxljob的调用请求之后,使用了一个线程池执行重算任务
一开始handleExecutor线程池为每个任务创建新线程,HllXxlJobManager.getJobParam()会正常继承job执行线程里的变量;
随着任务增多,线程池核心线程数达到上限,任务线程开始复用旧线程。
但是这些复用线程中上次任务的参数并没有清除,我们通过HllXxlJobManager.getJobParam()获取参数时,xxljob执行线程入参的修改并不会影响我们在handleExecutor在旧线程中获取InheritableThreadLocal的数据,也就是说他用的还是以前未清除的入参,最终导致任务失败。
失败场景示意图
问题根因
本次问题表面上是 任务执行缺失或不完整,实际深层原因是由于 线程复用场景下上下文污染 所导致的“历史任务数据误执行”。通过对调度框架 xxl-job 的执行链路深入分析,我们识别出其任务参数是通过 InheritableThreadLocal
进行线程内传递的,而在业务层采用线程池执行任务逻辑时,没有及时清理或覆盖上下文,从而导致部分任务在线程复用后读取到了旧的任务上下文。
4. 本地线程问题延伸
4.1 InheritableThreadLocal与ThreadLocal的区别
特性 | ThreadLocal | InheritableThreadLocal |
---|---|---|
是否为每个线程维护独立变量 | 是 | 是 |
子线程是否能访问父线程的值 | 否 | 能 |
子线程中获取值的来源 | 无(默认null) | 父线程值的副本 |
值是否自动传播 | 否 | 是(线程创建时) |
在线程池中能否继承 | 否 | 否(线程复用) |
使用场景 | 当前线程私有变量,如上下文、计数器 | 上下文需要在子线程中使用,如日志追踪、任务参数 |
继承的是引用还是值? | - | 值的浅拷贝(对象引用) |
值的修改是否会影响父线程 | - | 不会(副本机制) |
4.2 使用场景建议
ThreadLocal | InheritableThreadLocal | |
---|---|---|
适用场景 | 当前线程内维护变量,不需要跨线程访问 | 子线程需要访问父线程设置的变量 |
常见用途 | 全局上下文(如 userId、token)、线程级缓存等 | 日志追踪(traceId 传递),定时任务参数传递,埋点标记传递等 |
5. xxljob使用建议
- 明确 任务 参数的获取与传递边界
任务参数应统一在调度线程中获取并解析,作为业务方法的显式入参传入,不应在业务线程池中再次调用 XxlJobHelper.getJobParam()
,避免依赖 InheritableThreadLocal 进行隐式传递。
- 严格管理线程上下文的生命周期
所有上下文变量(包括 ThreadLocal、InheritableThreadLocal)必须在任务执行完成后及时清理,防止线程复用导致的数据残留和上下文污染。可以建立统一的任务上下文封装与清理机制,避免开发人员在业务代码中直接操作线程变量,降低出错概率。
- 增强日志与监控体系,及时发现线程上下文异常
建议在每次任务执行中打印关键上下文信息(如任务 ID、参数摘要、线程名称),并监控异常上下文读取场景,及时发现线程复用引起的参数错乱或任务漏执行问题。