被线程池记住的用户:一次线程上下文数据错乱事故复盘
一次看似只涉及 traceId 的异步改造,如何因为共享线程池与 InheritableThreadLocal 的组合,演变成跨请求的数据错乱。
电话响起时,我刚睡着。
屏幕上的时间已经越过午夜。电话那头没有寒暄,只有一句足以让人立刻清醒的话:
有用户投诉,在自己的付款方式列表里看到了不属于自己的银行账户。线上包已经回滚,需要马上查清原因。
这类问题和普通的接口报错完全不同。超时可以重试,异常可以降级,但跨用户的数据错乱触碰的是系统最基本的隔离边界。哪怕只出现一次,也必须先按最高风险处理。
我打开电脑,先确认了两件事:回滚后的版本已经稳定,异常窗口没有继续扩大;投诉描述中的“其他人的银行账户”确实来自服务端响应,不是前端缓存或页面残留。
然后,疑问出现了。
这次上线,按变更说明看,主要是在修复异步任务里 Sleuth traceId 丢失的问题。没有修改银行账户查询条件,也没有改权限校验,更没有碰用户和账户的关联关系。
一个链路追踪改动,为什么会把甲用户的数据送给乙用户?
先从最不可能的地方开始
最初的怀疑很自然:
- 查询是不是少了用户条件;
- 缓存键是不是漏了用户维度;
- 多线程汇总结果时是不是共用了集合;
- 前端是不是复用了上一个登录用户的状态;
- 数据库里是不是已经存在错误关联。
这些方向都值得检查,但它们没有解释一个关键事实:回滚之后,问题立刻停止了。
这意味着事故与新旧版本之间的差异高度相关。与其继续在整个调用链里漫游,不如先回答一个更直接的问题:这两个版本到底改了什么?
我把版本差异按功能、日志、依赖和并发模型重新分类。大量日志删除、脱敏调整和模型字段变化掩盖了一个很小、却改变了系统运行方式的修改:
ExecutorService executorA = Executors.newFixedThreadPool(4);
try {
submitTasksA(executorA);
} finally {
executorA.shutdown();
}
变成了:
@Bean
ThreadPoolTaskExecutor sharedExecutorA() {
return createExecutorA();
}
我盯着这个差异看了几秒。
然后立刻去找用户上下文的实现。
final class ContextA {
private static final InheritableThreadLocal<Map<String, Object>> DATA_A =
new InheritableThreadLocal<>();
}
至此,事故的轮廓已经出现了。
应用级共享线程池和 InheritableThreadLocal 被放在了一起。前者希望线程长期存活、反复处理不同请求;后者却只在线程创建时继承一次父线程的值。
这两个设计对线程生命周期的假设,从一开始就是冲突的。
InheritableThreadLocal 到底继承了什么
普通 ThreadLocal 的值只属于当前线程。InheritableThreadLocal 在此基础上增加了一项能力:创建子线程时,子线程可以从父线程得到一个初始值。
关键不是“可以继承”,而是 JDK 注释里反复限定的两个词:创建和初始值。

类注释的第一段可以拆成四层意思:
InheritableThreadLocal扩展了ThreadLocal,增加父线程到子线程的值继承;- 触发继承的时机是 a child thread is created,也就是子线程被创建时;
- 子线程拿到的是 initial values,也就是自己那份线程本地变量的初始值;
- 默认情况下,子线程的值与父线程的值相同,也可以重写
childValue()改变这个结果。
这段话没有说“每次任务提交时传播”,也没有说“父线程的值变化后,子线程会自动同步”。继承只是线程出生时的一次初始化动作。初始化完成后,父子线程分别从自己的线程本地存储中取值。
childValue() 的方法注释把时间点说得更具体:它使用 the parent's value at the time the child thread is created 计算子线程的初始值,而且是在父线程内部、子线程启动之前调用。
默认实现没有复制逻辑,只是把入参原样返回:
protected T childValue(T parentValue) {
return parentValue;
}
如果保存的是字符串、数字这类不可变值,父子线程初始内容相同;如果保存的是一个可变 Map,父子线程最初拿到的甚至是同一个对象引用,而不是自动生成的副本。重写 childValue() 可以制作副本,却仍然不能改变“只在线程创建时初始化一次”这个根本时机。
这里还有一个容易被线程池 API 掩盖的概念:提交任务的请求线程,不等于 worker 的“父线程”。JDK 文档里的父子关系来自 new Thread(...) 的创建过程,而不是来自 executor.execute(taskA) 这次调用。任务只是进入队列,随后被某个已经存在的 worker 取走;任务从哪个线程提交,不会重新定义 worker 的父线程。
这套机制用于生命周期明确的临时子线程时,看起来很方便:
请求线程:用户 A
└── 创建子线程
└── 继承用户 A
└── 执行完成并退出
旧代码恰好就是这种模型。每个请求创建一组新线程,任务结束后关闭线程池。即使设计中存在隐式上下文依赖,线程的生命也基本被限制在当前请求内。
改成共享线程池后,时间关系完全不同:
请求 A 到达
└── 创建 worker-1
└── worker-1 继承用户 A
请求 A 结束
└── 只清理请求线程的上下文
请求 B 到达
└── 复用 worker-1
└── worker-1 仍然持有用户 A
把过程展开到 childValue() 的调用级别,问题会更直观:
T0 请求 A 的线程设置上下文 A
T1 线程池发现 worker 不足,创建 worker-1
T2 创建过程中调用 childValue(A),worker-1 得到初始值 A
T3 请求 A 结束,只清理请求线程自己的上下文
T4 请求 B 的线程设置上下文 B,并向线程池提交任务
T5 worker-1 已经存在,因此不会再次创建线程,也不会再次调用 childValue(B)
T6 worker-1 执行任务,从自己的线程本地存储中仍然读到 A
请求线程调用 remove(),只能删除请求线程自己的 ThreadLocal 条目。线程本地存储归线程所有,它无法隔空清理另一个长期存活的 worker。
而线程池提交的是任务,不是新线程。任务 B 被放进队列,并不会触发一次新的 InheritableThreadLocal 继承。
因此,真正出错的不是 InheritableThreadLocal 没有完成继承,而是它完全按照文档完成了继承:worker 在第一次创建时继承了 A,此后长期复用;业务代码却误以为每次提交任务时,它都会继承当前请求的 B、C、D。
如果线程池预先在线程上下文为空的启动线程中创建全部 worker,现象可能变成“异步任务读不到身份”,而不是“读到其他身份”。这不会让设计变得安全,只说明错误表现取决于 worker 在何时、由哪个线程创建。
错误用户是怎样进入查询的
只理解线程上下文还不够。要形成真实的数据错乱,业务代码必须在异步任务中读取这个旧值。
问题代码可以抽象成下面这样:
Map<String, Object> getDataA(Long idA) {
List<DataA> listA = repositoryA.findDataA(idA);
for (DataA dataA : listA) {
sharedExecutorA.execute(new TaskA(dataA));
}
return collectDataA();
}
外层看起来没有问题。idA 来自当前请求,listA 也属于用户 B。
真正的转折发生在子任务里:
final class TaskA implements Callable<ResultA> {
private final DataA dataA;
@Override
public ResultA call() {
Long idA = ContextA.getIdA();
List<DataB> listB = repositoryB.findDataB(idA);
return ResultA.of(dataA, listB);
}
}
于是,同一个结果对象中出现了两个来源不同的身份维度:
dataA → 当前请求的用户 B
idA → worker 记住的用户 A
listB → 用户 A 的银行账户
这也解释了为什么问题集中出现在银行账户列表,而不是整个付款方式接口完全变成另一个用户的数据。调用链的一部分使用显式参数,另一部分依赖线程上下文,最终拼出了一份逻辑上不可能存在的混合结果。
为什么它不是每次都发生
如果每个请求都稳定读到同一个错误用户,问题反而容易定位。真正让事故难以复现的,是线程池按需创建和调度任务的方式。
ThreadPoolExecutor 默认会随着任务到来逐步创建 core worker。假设核心线程数是 4:
worker-1 可能继承用户 A
worker-2 可能继承用户 A
worker-3 可能继承用户 C
worker-4 可能继承用户 D
具体由谁“初始化”某个 worker,取决于服务启动后的请求顺序和并发情况。等核心线程创建完毕,后续任务再随机落到这些长期存活的线程上。
于是线上现象会显得毫无规律:
- 同一个用户刷新两次,结果可能不同;
- 服务重启之后,错误关联对象可能变化;
- 多账户请求的多个子任务可能落到不同 worker;
- 压测环境未必复现,因为初始化线程池的请求顺序不同。
它看起来像随机的数据污染,实际上只是被线程调度隐藏起来的确定性行为。
开发者当时在想什么
把提交历史按时间重新排列后,这次修改的动机并不难理解。
原实现每次请求都会创建并关闭一个固定线程池。请求量上来后,这意味着持续创建线程、分配资源、切换上下文,再销毁线程。把它改成 Spring 管理的单例线程池,是很常见的优化方向。
与此同时,异步任务里的 Sleuth traceId 无法稳定延续。为了让日志仍然属于原请求,代码又增加了任务包装器,大致做了这些事:
Runnable wrapA(Runnable taskA) {
Map<String, String> mapA = MDC.getCopyOfContextMap();
Span spanA = tracerA.currentSpan();
return () -> {
restoreMdcA(mapA);
try (Scope scopeA = tracerA.withSpan(spanA)) {
taskA.run();
} finally {
clearMdcA();
}
};
}
这个方向本身没有错。traceId 属于一次任务,需要在提交时捕获,在执行时恢复,并在结束后清理。
问题在于,代码只处理了 Sleuth span 和 MDC,却漏掉了应用自己的用户上下文。开发者解决了“这条日志属于哪个请求”,却没有同步解决“这个任务代表哪个用户”。
从日志观察,trace 链路甚至可能是完全正确的:请求 B 的日志拥有 B 的 traceId,但同一个任务从 InheritableThreadLocal 里读取到的却是用户 A。
这正是这类事故最危险的地方:可观测性告诉你任务属于 B,业务代码实际使用的身份却属于 A。
回滚之后,真正漫长的工作才开始
定位根因并没有花太久。真正耗时的是之后的数据核查。
代码可以在几分钟内回滚,已经发生过的请求却不能。修复工作需要围绕事故窗口逐层确认:
- 哪些实例运行过问题版本;
- 每个实例的共享 worker 可能继承过哪些用户上下文;
- 哪些请求调用过受影响的异步路径;
- 错误数据是否只出现在响应中;
- 用户是否基于错误列表执行过后续操作;
- 是否存在需要纠正的持久化记录或业务状态。
线程池没有提供一份“这个 worker 最初继承了谁”的历史清单。普通日志记录的是任务执行时的 traceId,也未必记录 worker 内真实读取到的用户 ID。很多时候,只能把访问日志、线程名、请求轨迹和数据库记录重新拼在一起,缩小可能受影响的范围。
这是一种令人疲惫的修复:代码中的错误已经消失,团队却还要继续追赶它留下的每一个可能性。
也正是在这个阶段,我开始重新理解事故修复的含义。修复从来不只是让新请求恢复正确,还包括证明旧请求究竟发生过什么。
正确修复不是换一种 ThreadLocal
回滚到每请求线程池可以快速止血,但它只是恢复了过去碰巧安全的线程生命周期,也重新带回了线程频繁创建的问题。
更直接的修复,是消除业务身份对隐式线程上下文的依赖。外层流程本来就已经拥有可信的用户标识,应当把它作为任务输入继续传递:
for (DataA dataA : listA) {
sharedExecutorA.execute(new TaskA(idA, dataA));
}
final class TaskA implements Callable<ResultA> {
private final Long idA;
private final DataA dataA;
@Override
public ResultA call() {
List<DataB> listB = repositoryB.findDataB(idA);
return ResultA.of(dataA, listB);
}
}
用户 ID 是业务查询条件,不是日志装饰信息。显式传参让依赖可以被代码审查、类型系统和单元测试直接看见,也不再依赖任务恰好运行在哪个线程。
如果某些基础设施确实必须传播上下文,则要在任务提交时捕获一份防御性副本,在 worker 执行前设置,并在 finally 中恢复或清理:
ContextData dataA = contextA.copy();
return () -> {
ContextData oldDataA = contextA.get();
try {
contextA.set(dataA);
taskA.run();
} finally {
contextA.restore(oldDataA);
}
};
但即便如此,支付、授权、租户和账户归属等关键业务条件仍应优先显式传递。上下文传播适合解决横切关注点,不应该成为核心权限条件的唯一来源。
那个必须补上的测试
传统单元测试通常只验证“一个用户的一次调用”,而这个问题必须通过“多个用户复用同一个线程”才能出现。
最小回归测试反而很简单:使用只有一个 worker 的共享线程池,先执行用户 A,再执行用户 B。
@Test
void workerAUsesCurrentData() {
ExecutorService executorA = Executors.newFixedThreadPool(1);
invokeA("A", executorA);
ResultA resultA = invokeA("B", executorA);
assertThat(resultA.ownerA()).isEqualTo("B");
}
如果实现仍然依赖 InheritableThreadLocal,第二次调用会稳定暴露问题。与其在线上等待线程调度替我们抽奖,不如在测试中主动控制线程生命周期。
线程池记住的,不应该是用户
这次事故表面上始于一次 Sleuth traceId 修复,真正改变的却不是日志,而是线程的生命周期。
原来的代码隐含着一个脆弱前提:子线程与当前请求共同出生,也共同结束。共享线程池打破了这个前提,让曾经只活几百毫秒的用户上下文,住进了可以存活数天的 worker。
所以问题不只是“不要在线程池里使用 InheritableThreadLocal”。更值得记住的是:
上下文的生命周期必须与它代表的业务作用域一致。
请求级身份不应该依附于应用级线程;任务级 trace 不应该靠线程创建时碰巧继承;关键授权条件也不应该藏在调用者看不见的静态上下文中。
深夜电话之后,事故最终被止住,错乱数据的核查仍在继续。而那个最初看起来毫无攻击性的改动,也留下了一条足够清晰的教训:
当我们把线程从“一次请求专用”改成“整个应用共享”时,不能只问性能变好了多少。
还必须问一句:
这个线程,究竟还记得谁?