承接的问题
13.1 把三路通知扇出到线程池,13.2 给每一路加了超时和降级。到这里代码是「对」的,但排查问题时会发现一个尴尬的事实:push 的日志里没有 requestId。
原因是执行线程换了。HTTP 请求进来时,过滤器把 requestId、traceId、会员 ID 放进 ThreadLocal;同步代码里任何一层都能读到。一旦 supplyAsync 把任务交给另一个线程,读到的就是那个线程自己的 ThreadLocalMap——空的。
这不是 CompletableFuture 的缺陷,而是 ThreadLocal 的定义:它绑定线程,不绑定逻辑调用链。这一节解决两件事:上下文怎么跨线程传过去,以及为什么每个下游要用自己的池。
默认执行器与上下文丢失
先把「丢了」量出来。上下文体是一个 record,存在 ThreadLocal 里:
record Ctx(String requestId, String traceId) {}
static final ThreadLocal<Ctx> CTX = new ThreadLocal<>();
static String current() {
Ctx c = CTX.get();
return c == null ? "null" : c.requestId() + "/" + c.traceId();
}
在 main 里设置之后,分别用三种方式执行:
== 提交线程 ==
main -> REQ-1001/TRACE-7
== 默认 commonPool ==
ForkJoinPool.commonPool-worker-1 -> null
== 自定义池,无包装 ==
bare-worker -> null
两种「换线程」都丢,原因不同但结果一样:
- 默认执行器是
ForkJoinPool.commonPool()(13.1 实测 parallel=9)。commonPool 的 worker 是全局共享的,不可能带着某个请求的上下文。 - 自定义池的线程是预先创建的,创建时
main还没设置上下文。所以InheritableThreadLocal也救不了:它只在线程创建时复制一次父线程的值,池线程早就创建好了(7.2 已经完整复现过这个断裂)。
ScopedValue 对普通线程和线程池任务同样不继承——只有 StructuredTaskScope.fork(JDK 25 preview)派生的子任务会自动继承(7.3 实测过)。没有任何一种上下文机制能自动跟随「任务」走,因为「任务」这个概念对 JVM 不可见。
装饰器:提交时捕获,执行时安装
正确的位置只有一个:在提交任务的线程上捕获,在执行任务的线程上安装。
这和 7.1 的 MemberScopedTask 是同一个思路:一个逐任务包装,一个装饰执行器。后者覆盖面更全,因为 CompletableFuture 的提交入口不经过你的任务对象。
/** Executor 装饰器:在 execute 时捕获提交线程的上下文。 */
static final class ContextExecutor implements Executor {
private final Executor delegate;
ContextExecutor(Executor delegate) {
this.delegate = delegate;
}
@Override
public void execute(Runnable command) {
Ctx captured = CTX.get(); // 必须在提交线程上读
delegate.execute(() -> {
Ctx previous = CTX.get();
CTX.set(captured);
try {
command.run();
} finally {
if (previous == null) {
CTX.remove(); // 不留给下一个任务
} else {
CTX.set(previous); // 嵌套时恢复外层
}
}
});
}
}
四个细节,每一个都对应一类线上 bug:
- 捕获必须在
execute里、在delegate.execute之前。如果把CTX.get()写进 lambda 里,它会在工作线程执行,读到null。 finally里要恢复而不是无脑remove。工作线程可能被嵌套包装(比如两层装饰器),直接remove会把外层的上下文也弄丢。- 没有旧值时必须
remove。池线程是复用的,留着上下文会让下一个任务读到上一个请求的requestId——这就是「串值」。 - 装饰
Executor而不是逐个任务包装。CompletableFuture.supplyAsync(s, executor)、execute、submit最终都走execute,装饰一层就全覆盖;逐个包任务总会漏掉某个入口。
实测(同一个 main 线程设置 REQ-1001/TRACE-7,任务提交两次):
== 自定义池,提交时捕获 ==
task1: ctx-worker-1 -> REQ-1001/TRACE-7
task2: ctx-worker-1 -> REQ-1001/TRACE-7
== 不清理造成的串值 ==
第一个任务 set 后读到 REQ-A/TRACE-A(未 remove)
第二个任务没 set,读到 REQ-A/TRACE-A
== 包装后池内残留检查 ==
包装任务跑完后,同一池线程读到 null(裸提交,不经过包装)
第三段专门验证「清理」:包装任务跑完后再用裸提交同一个池线程,读到的是 null,说明 finally 里的 remove 生效了。第二段是反例——任务里自己 set 却不 remove,下一个任务直接读到了别人的请求 ID。
非 Async 回调带的是谁的上下文
这里有一个比「丢失」更隐蔽的问题:thenApply 这类非 Async 回调由完成者线程执行(future 已完成时除外,那一支在注册线程上跑,13.1 实测),它带的是完成者线程的上下文,而不是注册回调那个线程的。
在按请求独立提交的场景里,这两个上下文通常是同一个,看不出问题。但只要有跨请求共享的 future(缓存、批量聚合、全局单例),回调就可能带着 A 请求的 requestId 去写 B 请求的日志。无法确认时,跨请求的回调一律用 thenApplyAsync(fn, contextExecutor)。
实测两种情况:主线程装的是 REQ-B,另起一个线程装 REQ-A 去完成 future。
已完成 future,注册者 REQ-B 上的 thenApply 看到 = REQ-B
未完成 future,完成者 REQ-A 触发的回调看到 = sms:ok@REQ-A
第一条符合直觉:future 已完成,回调就在注册者线程上同步跑。第二条是坑:注册者是 REQ-B,回调却打印了 REQ-A——它跑在完成者的线程上。共享 future 时,日志里的 requestId 会指向另一个请求。
上下文里该放什么
不是所有东西都适合塞进 ThreadLocal。判断标准是「它是不是横切关注点」:
| 字段 | 放不放 | 理由 |
|---|---|---|
requestId / traceId | 放 | 排查与链路追踪的索引 |
tenantId | 放 | 鉴权与数据隔离,每层都要用 |
| 剩余预算(绝对时刻) | 放 | 13.2 的超时需要逐层递减 |
memberId 等业务参数 | 不放 | 应该显式传参,隐式依赖难以测试 |
| 可变对象(集合、计数器) | 不放 | 池线程复用会串,且难以清理 |
「剩余预算」值得单独说。超时不该在每一层重新给一个常量,而应该由最外层给一个绝对截止时刻,子任务只算剩余。给 Ctx 加上第三个字段:
record Ctx(String requestId, String traceId, long deadlineNanos) {}
static long remainingMillis(Ctx ctx) {
return Math.max(0, (ctx.deadlineNanos() - System.nanoTime()) / 1_000_000);
}
// 提交侧:用上下文里的剩余预算,而不是新的 200ms
Ctx ctx = CTX.get();
CompletableFuture<String> f = CompletableFuture
.supplyAsync(() -> gateway.send("push", memberId, message), pushExec)
.orTimeout(remainingMillis(ctx), TimeUnit.MILLISECONDS);
System.nanoTime() 是 JVM 内的单调时钟,跨线程比较是安全的(只在同一 JVM 内有效),所以传绝对时刻比传「还剩多少毫秒」更准——后者在排队时就已经开始计时了。这样上层给了 300ms,排队花掉 120ms,下游只剩 180ms,而不是又拿到 300ms。
池隔离:一个慢下游不该拖垮另外两个
上下文解决的是「日志对不对」,池隔离解决的是「一个下游挂了,另外两个还活不活」。
先用一个最小实验看清共享池的代价:池只有 1 个线程,先提交一个 400ms 的 push,10ms 后提交一个 20ms 的 mail,量 mail 自己的延迟。
共享 1 线程池:mail 自身延迟 = 418ms / 421ms / 425ms
隔离 1+1 线程池:mail 自身延迟 = 20ms / 23ms / 22ms
三组数据几乎一致:共享池里 mail 要等 push 占着的 400ms,自己的 20ms 完全被埋掉;隔离之后它只花自己的 20ms。
真实系统里这个数字会放大:push 卡 30 秒,共享池的 20 个线程会在 30 秒内被 20 个 push 请求占满,mail 和 sms 一起饿死。这就是**线程池作为舱壁(bulkhead)**的意义——把故障限制在一个下游的池子里。
隔离的粒度按「故障域」划分,不按「代码模块」:
| 隔离方式 | 一个池 | 每个下游一个池 |
|---|---|---|
| 故障传播 | 慢下游拖垮所有下游 | 只影响自己 |
| 线程总量 | 一个上限 | 各自上限,总和是新的成本 |
| 上下文 | 串值风险高,必须严格清理 | 每池独立,仍需清理 |
| 排障 | 看线程名分不清是谁 | 线程名直接标出下游 |
代价是线程变多。三个下游各 8 个线程就是 24 个,还要另外算排队队列的内存。所以池大小不是越大越好:每个池按该下游的并发度设定,超过就排队,排队比无限扩线程便宜得多(10.2 的拒绝策略在这里同样适用)。
在 libraryd-conc 里落地
NotificationDispatcher 把两件事合起来:三个下游三个池,每个池外面套一层上下文装饰器。
public final class NotificationDispatcher {
private final Executor mailExec;
private final Executor smsExec;
private final Executor pushExec;
private final NotificationGateway gateway;
public NotificationDispatcher(ExecutorService mailPool, ExecutorService smsPool,
ExecutorService pushPool, NotificationGateway gateway) {
this.mailExec = new ContextExecutor(mailPool);
this.smsExec = new ContextExecutor(smsPool);
this.pushExec = new ContextExecutor(pushPool);
this.gateway = gateway;
}
public NotifyReport dispatch(String memberId, String message) {
Map<String, CompletableFuture<ChannelResult>> futures = new LinkedHashMap<>();
futures.put("mail", submit("mail", mailExec, memberId, message));
futures.put("sms", submit("sms", smsExec, memberId, message));
futures.put("push", submit("push", pushExec, memberId, message));
CompletableFuture.allOf(futures.values().toArray(new CompletableFuture[0])).join();
return new NotifyReport(memberId, futures.values().stream()
.map(CompletableFuture::join).toList());
}
private CompletableFuture<ChannelResult> submit(String channel, Executor pool,
String memberId, String message) {
return CompletableFuture.supplyAsync(() -> {
Ctx ctx = CTX.get(); // 由 ContextExecutor 安装
String receipt = gateway.send(channel, memberId, message);
return ChannelResult.ok(channel, receipt, ctx);
}, pool).handle((r, e) -> e == null
? r
: ChannelResult.failed(channel, e));
}
}
三点说明:
ContextExecutor是字段,不是每次调用 new 一个。它无状态,复用没有额外开销;每次新建只是浪费对象。- 池的创建与关闭在装配层(
conc-core的启动代码),NotificationDispatcher只接收ExecutorService。这样测试时能塞同步执行器,也能在关闭时按 10.3 的两段式依次 shutdown。 ChannelResult里带上了ctx,异步结果回到调用线程后,日志、指标、审计都还能拿到原始requestId。只靠线程上下文是不够的——结果一旦离开工作线程,上下文就又断了。
上下文真正的起点在入口,那里也必须成对地设置与清理:
public final class RequestContextFilter {
public void around(String requestId, String traceId, long budgetMs, Runnable chain) {
Ctx previous = CTX.get();
CTX.set(new Ctx(requestId, traceId,
System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(budgetMs)));
try {
chain.run();
} finally {
if (previous == null) {
CTX.remove();
} else {
CTX.set(previous);
}
}
}
}
入口线程本身就是复用的(Web 容器的工作线程、事件循环线程都是池),不清理同样会串值。异步侧用装饰器、同步侧用 try/finally,两端都做对,上下文才真正完整。
Ctx、ChannelResult、NotifyReport、NotificationGateway 是支撑类型与端口,不在 COMMON 的领域契约内;LoanService.reserve 返回的 Reservation 仍然是同步结果,通知编排不改变领域签名。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
在 lambda 里读 ThreadLocal | 读到 null,因为已经在工作线程 | 在提交线程捕获后闭包传入 |
finally 里只 remove 不恢复 | 嵌套装饰时丢掉外层上下文 | 有旧值就恢复,没有才 remove |
| 包装任务却不清理 | 池线程串值,日志张冠李戴 | finally 保证清理 |
用 InheritableThreadLocal 传池任务 | 池线程创建于设置之前,永远为 null | 显式装饰器传递 |
| 所有下游共用一个池 | 一个慢下游拖垮全部 | 按故障域分池 |
| 每个请求新建一个池 | 线程数爆炸,创建开销吃掉收益 | 池在装配层创建并复用 |
只传 requestId 不传 traceId | 跨服务链路断掉 | 上下文整体传递,别挑字段 |
| 结果离开工作线程后还想读上下文 | 回到调用线程后又是 null | 把上下文放进结果对象 |
什么时候不要用线程上下文传递。 如果一段逻辑的执行线程是确定的(同步调用、单线程事件循环),显式参数比 ThreadLocal 清楚得多,也更难出错。ThreadLocal 适合「横切关注点」——日志、鉴权、租户、超时预算——不适合传业务参数。另外,不要用上下文传递可变状态:一个线程安装的 Ctx 如果被下游修改,会影响同一个池线程上的后续任务;上下文应该是不可变的 record,这也是上面用 record 的原因。虚拟线程场景下这条更严格:线程数量大、生命周期短,ThreadLocal 的清理成本会被放大(14.3 展开)。
小结
- 异步换线程,
ThreadLocal必丢:实测 commonPool 与裸线程池读到的都是null;InheritableThreadLocal对池线程无效,因为线程早已创建(7.2)。 - 传递的唯一正确位置是「提交时捕获、执行时安装」。
ContextExecutor装饰Executor,覆盖supplyAsync、execute、submit所有入口。 - 三个必须做对的细节:捕获在提交线程、
finally恢复旧值、无旧值时remove。实测不清理会让下一个任务读到REQ-A。 - 非 Async 回调由完成者线程执行,带的是完成者上下文;跨请求共享 future 时必须用 Async 变体。
- 池隔离按故障域划分。1 线程共享池里
mail的延迟是 418–425ms,隔离后是 20–23ms;代价是线程总数上升,池大小要按各下游并发度设定。
第 13 章到此结束。CompletableFuture 解决了「结果怎么拼」,但也暴露了它的天花板:任务与线程没有从属关系,取消不能传播,上下文要手工搬。第 14 章换一个执行模型——虚拟线程;第 15 章再往上一步,用结构化并发把「任务属于谁」写进代码。
阅读导航:上一节 13.2 异常、超时与取消 | 下一节 14.1 虚拟线程的调度模型
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。