请求 B 的日志为什么出现请求 A 的标识

线程池复用线程,也复用尚未清理的 ThreadLocal 值。请求 A 在工作线程写入追踪标识,任务结束时没有移除;请求 B 恰好复用这条线程,读到的仍可能是 A。这个错误并不要求两个请求同时执行,连续请求就足以触发。

另一端的错误是完全没有传播。请求线程里能读到标识,提交到线程池后却变成 null。自动复制所有线程变量又会产生更危险的问题:请求对象、数据库连接和事务同步状态有各自的生命周期,不能因为都叫“上下文”就一起搬到另一个线程。

blog.spring.Chapter34 使用 JDK 21、Spring Framework 6.2.11、真实 PostgreSQL 18.0,分别观察普通 ThreadLocal、Spring 请求 holder 和 JDBC 事务绑定。HTTP 部分使用 JDK HttpServer 与真实 HttpClient 回环请求,手动绑定 RequestAttributes 测试夹具;它不是 Spring MVC DispatcherServlet 实验,也没有使用日志框架 MDC。

提交时捕获、工作线程安装与 finally 恢复

ThreadLocal 的键存在,不等于当前线程有值

一个静态 ThreadLocal 对象可以被所有线程引用,但每条线程保存自己的关联值。调用线程设置 caller-A 后,未装饰的工作线程读到 null。随后工作线程主动设置 leaked-A,没有清理;同一线程池的下一项任务读到 leaked-A。

1
2
CHECK PASS 34 plain ThreadLocal not copied
CHECK PASS 34 reused worker leaks uncleaned context

实验把线程池大小固定为1,使连续任务必定复用同一条工作线程。它不依赖调度概率,也不需要高并发压测来撞出污染。Future 的 get 等待前一个任务完成,保证检查发生在写入之后。

修复有两个方向:需要传播的值应在明确边界捕获,不应传播的值不能残留在复用线程上。只完成前一半,容易让空上下文的后续任务继承上一个请求;只完成后一半,异步日志可能始终缺少关联标识。

InheritableThreadLocal 也不能替代线程池任务协议。继承发生在线程创建的边界,线程池中的既有线程不会为每个新任务重新继承提交者状态。向线程池请求 holder 开启 inheritable,还可能使请求生命周期之外的线程持有过期对象。RequestContextHolder API

TaskDecorator 的两个线程时点

ThreadPoolTaskExecutor 接收任务时可以调用 TaskDecorator,得到包装后的 Runnable。装饰函数运行在任务提交路径,可以捕获提交者的当前值;返回的 Runnable 稍后在执行线程运行,再把捕获值安装到执行线程。

实验的装饰器只处理一个不可变 String 标识。包装器先保存工作线程之前的值,在 try 中安装捕获值并运行任务,在 finally 中恢复之前的值;如果之前没有值,就调用 remove。TaskDecorator 6.2.11 API

恢复旧值比无条件 remove 更完整。装饰器可能被嵌套调用,也可能遇到会在提交线程执行任务的执行器策略;外层线程原本就有合法上下文时,任务退出后应恢复它。当前实验使用正常线程池工作线程,旧值为空;更广的嵌套场景属于这个实现选择的设计依据,不冒充本次已测用例。

捕获时间也决定传播的内容。提交以后调用线程把自己的 TRACE 从 A 改为 B,已经生成的包装任务仍保存提交时的 A。若传播的是可变 Map 的引用而不是快照,后续修改又可能泄漏进任务;因此“捕获了对象”与“固定了对象内容”必须区分。

TaskDecorator 不保证接收到原始业务 Runnable。submit 路径可能先把业务封装成 FutureTask,装饰器实际包装的是该执行回调。只在装饰器里 catch Throwable 不足以观察所有业务失败,因为 Future 包装器可能把异常保存为结果状态。

请求标识、请求对象和事务是三个范围

实验创建一个 RequestAttributes 夹具,绑定到调用线程的 RequestContextHolder,同时设置自有 TRACE 为 request-A。真实 JDBC 事务在调用线程启动并绑定连接 holder。

装饰线程池的任务返回四项观察:追踪标识、请求 holder 是否为空、是否绑定同一个 DataSource 资源、事务是否激活。结果依次为 request-A、true、false、false。

1
2
3
CHECK PASS 34 caller JDBC connection bound
CHECK PASS 34 trace copied without request or JDBC transaction
CHECK PASS 34 worker leaves caller context intact

这里复制了用于关联日志的普通数据,没有复制请求对象和物理事务资源。工作线程仍能关联业务请求,但不因此拥有请求作用域 Bean,也没有继承原事务。对应的资源使用若确实需要发生,应在工作线程重新建立明确的请求无关输入与事务范围。

把 ConnectionHolder 从一个线程绑定到另一个线程并不是传播协议。哪条线程提交、哪条线程清理、谁拥有借出的连接以及同步回调何时执行,都会变得不明确。读写同一个连接还可能违反驱动的并发使用约束。追踪标识可以复制,连接租约必须按资源所有权管理。

同样,请求对象可能在原 HTTP 请求结束后失效或被容器回收。后台任务通常只需要不可变的用户标识、语言设置、业务ID等最小输入,而不需要保留整个 ServletRequest。权限相关信息还必须有明确的验证来源和失效策略,不能把外部传入字符串直接当成授权结果。

正常、失败和空上下文都要验证

第一项装饰任务读取 request-A,下一项设置为 request-B 后读取 request-B。随后提交一个抛出 IllegalStateException 的任务,要求 Future 以 ExecutionException 包装该异常。

主线程再移除自身 TRACE,提交一个读取任务,结果应为 null。最后测试直接向底层线程池提交一次探测,绕过装饰器读取工作线程的原始 TRACE,也必须为 null。最后这个探测很重要:如果每次装饰都会重新覆盖值,仅检查装饰后的值,可能掩盖前一次任务留下的污染。

1
2
3
4
CHECK PASS 34 next request sees its own trace
CHECK PASS 34 Future retains task error
CHECK PASS 34 absent next context remains absent
CHECK PASS 34 finally restores clean worker after failure

异常发生后仍执行 finally,所以任务失败不能成为跳过清理的理由。探测通过也不证明所有 ThreadLocal 都已清理;它只验证本文装饰器拥有的 TRACE。数据库和请求 holder 由各自的 finally 或事务管理器清理,程序另有相应断言。

两次真实 HTTP 请求经过同一组线程

为了验证请求进入线程池再返回的路径,实验在回环地址随机端口启动 JDK HttpServer。HTTP 处理器有一条专用线程,业务执行器也只有一条线程。真实 HttpClient 连续发送 X-Trace: http-A 和 X-Trace: http-B。

处理器手动把请求标识放入 RequestAttributes 夹具和 TRACE,提交业务任务,等待其结果并写回 HTTP 响应。业务任务读取 TRACE,并检查自己的 RequestContextHolder 为空。两个响应分别要求 HTTP 200 且正文为 http-A:true、http-B:true。

1
2
3
CHECK PASS 34 real HTTP http-A propagates trace without request object
CHECK PASS 34 real HTTP http-B propagates trace without request object
CHECK PASS 34 reused HTTP thread cleaned

第二个响应同时检查了复用业务线程时没有保留 A 的标识。两个请求结束后,再向 HTTP 处理线程提交探测,要求 TRACE 与请求 holder 都为空。这样验证入口线程与工作线程两端的清理,而不只是验证响应看起来正确。

这个 HTTP 实验刻意手动控制绑定与解除绑定,因此能够隔离上下文传播机制。它不能证明 Spring MVC 的过滤器顺序、异步 dispatch 重新绑定或 Servlet 请求销毁回调;这些属于 MVC 运行时的独立验证范围。

Reactor Context 为什么不能等同于 ThreadLocal

响应式链可能跨多个执行线程,同一条线程也可能交错处理多个订阅。Reactor Context 关联的是订阅链中的 Subscriber,contextWrite 的作用范围与位置相关;它不是一个换了名字的全局线程变量。需要在响应式计算中读取时,应使用订阅上下文读取接口。Reactor Context 文档

从 ThreadLocal 到 Reactor Context 的桥接需要显式的集成能力、访问器注册与明确的捕获时点。注册一个 TaskDecorator 只覆盖该执行器收到的任务,不能自动覆盖所有 Reactor scheduler,也不能把 JDBC 的线程绑定事务变成响应式事务。

本篇没有添加 Reactor 依赖或运行响应式上下文桥接,相关段落是机制边界说明,实验状态为 NOT_RUN。实际订阅、线程切换及响应式事务资源的验收应使用后续响应式章节的独立版本栈,不能复用这里的 JDBC 结果作证明。

复现与改动练习

JDK 21 与 PostgreSQL 启动方法见实验工程 db/README.md。完整源码包含 HttpServer、HttpClient、RequestAttributes 夹具、装饰器以及清理路径:

1
./mvnw -q compile exec:java -Dexec.mainClass=blog.spring.Chapter34

本次17条断言通过,退出码0,日志为 evidence/34/local-20261002/run.txt。程序停止 HTTP server、关闭 HttpClient、等待 HTTP 执行器和两组业务线程池终止,检查数据库活跃租约回到0。

预测题:删除装饰器 finally 中的恢复动作,但保留每次提交时覆盖 TRACE,连续 A、B 两个响应是否可能仍正确?可能。每次新任务都覆盖标识会掩盖残留;绕过装饰器的原始线程探测才能直接暴露污染。

改动练习:保留现有完整实验,另建一个故意不清理的装饰器对照。让任务抛异常后直接检查底层线程值,要求观察到残留,再恢复 finally 并要求同一断言改为 null。不要把请求对象或 ConnectionHolder 加入复制集合来修复缺失标识。

参考资料

系列入口与完整实验工程