本节目标:让「一个请求内的多个并发查询」拥有明确的父子关系、失败传播和超时边界。
适用版本:JDK 25(Temurin 25.0.5);StructuredTaskScope仍是预览 API,编译与运行都需要--enable-preview。
承接的问题
6.1 的最后留了一个尾巴。用 ExecutorService 扇出三个查询,成功路径很好,失败路径是这样:
failed after 408ms: IllegalStateException, siblingCancelled=false
一个任务 50ms 就抛了异常,另一个任务要跑 400ms,invokeAll 仍然等到 400ms 才返回,慢的那个没有被取消,siblingCancelled 是 false。放到 libraryd 的会员面板上,这意味着:
- 会员不存在时,页面要等 400ms 才报错,而不是 50ms。
- 请求线程被占住 400ms,虚拟线程再便宜也是浪费。
- 如果慢任务里有数据库查询,连接也被多占 350ms。
要修,得自己写一套「一个失败就取消其他」的逻辑:保存 Future 列表、捕获第一个异常、遍历 cancel(true)、再处理 CancellationException。这套逻辑容易写漏,尤其是嵌套扇出的时候——孙子任务谁来取消?
结构化并发把这件事变成语言结构:任务必须在 try-with-resources 的作用域里 fork,作用域结束前所有子任务必须结束;一个子任务失败,作用域可以取消其余子任务。
API 形态与编译开关
JDK 25 的 StructuredTaskScope 是 JEP 505,第五次预览。形态和 JDK 21 预览期完全不同:没有 ShutdownOnFailure 子类,改成「打开作用域时传一个 Joiner」。
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<Object>allSuccessfulOrThrow())) {
var a = scope.fork(() -> loadActiveLoans(memberId));
var b = scope.fork(() -> loadOverdueCount(memberId));
var c = scope.fork(() -> loadFineCents(memberId));
scope.join();
return combine(a.get(), b.get(), c.get());
}
三个关键对象:
StructuredTaskScope.open(Joiner)创建作用域。open()不是构造器,作用域是AutoCloseable的。fork(Callable)返回Subtask<T>,任务立刻开始执行。Subtask实现了Supplier<T>,get()取结果。join()等待所有子任务结束(或 joiner 决定提前结束),返回 joiner 的结果。
不写 --enable-preview 直接编译,会得到:
错误: StructuredTaskScope 是预览 API,默认情况下处于禁用状态。
(请使用 --enable-preview 以启用预览 API)
编译和运行都要加开关,而且 --release 25 和 --enable-preview 必须同时出现:
javac --release 25 --enable-preview -d out DashboardService.java
java --enable-preview -cp out com.plume.library.app.Main
这个约束会传导到 CI、Docker 镜像、IDE 配置。如果团队不能接受「运行参数里必须带一个预览开关」,就不该用这套 API——卷二的 9.2 因此选了 CompletableFuture。本卷把两种都写出来,是为了让取舍有依据。
失败传播与取消
Joiner.allSuccessfulOrThrow() 的语义是:所有子任务都成功才返回,任何一个失败就取消其余子任务,并抛 FailedException。
用一个会失败的任务和一个 300ms 的慢任务验证:
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<Object>allSuccessfulOrThrow(),
cfg -> cfg.withName("dashboard-" + memberId))) {
var loans = scope.fork(() -> List.of(new Loan(1, "978000000001", ...)));
var overdue = scope.fork(() -> {
if (failOverdue) {
Thread.sleep(50);
throw new IllegalStateException("overdue query failed");
}
return 2L;
});
var fines = scope.fork(() -> {
try {
Thread.sleep(300);
} catch (InterruptedException e) {
siblingCancelled.set(true);
throw e;
}
return 200L;
});
scope.join();
return new MemberDashboard(memberId, loans.get(), overdue.get(), fines.get());
}
本机输出:
-- happy path
MemberDashboard[memberId=M-1, activeLoans=[Loan[id=1, ...]], overdueCount=2, totalFineCents=200]
-- one fork fails
join threw java.util.concurrent.StructuredTaskScope$FailedException: java.lang.IllegalStateException: overdue query failed
siblingCancelled=true
- 失败被包装成
FailedException,原始异常在getCause()里。 - 慢任务收到中断,
siblingCancelled=true。整个load在 44ms 左右返回(失败任务睡 50ms 后抛),而不是等 300ms。 - 取消是通过
InterruptException送达的,所以任务里的阻塞调用必须响应中断。如果任务在synchronized里做不可中断的等待,取消会被延迟——这也是为什么 libraryd 的查询都带statement_timeout。
FailedException 和 TimeoutException 都是 StructuredTaskScope 的嵌套类,都是 RuntimeException,可以直接 catch。
超时:作用域级别的 deadline
比「一个任务失败」更常见的是「一个任务太慢」。open 的第二个参数是 Configuration,可以设名字、线程工厂和超时:
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<Object>allSuccessfulOrThrow(),
cfg -> cfg.withName("dashboard-timeout").withTimeout(Duration.ofMillis(100)))) {
var slow = scope.fork(() -> {
Thread.sleep(500);
return "slow";
});
scope.join();
System.out.println("result=" + slow.get());
}
本机输出:
-- timeout 100ms, slow fork 500ms
timed out after 101ms: java.util.concurrent.StructuredTaskScope$TimeoutException: null
101ms 就抛了 TimeoutException,慢任务被取消。注意超时是整个作用域的,不是每个任务各自 100ms:三个任务并发跑,总预算 100ms。
超时值和下游的超时要对齐。libraryd 的 statement_timeout 设 2s,HTTP 层设 3s,那 dashboard 的作用域超时应该设在 2.5s 左右:比 SQL 短,比 HTTP 长没有意义;太短会把本来能成功的查询砍掉。
Joiner 的几种语义
Joiner 决定了「什么时候算结束」和「结果怎么给」:
| Joiner | 结束条件 | join() 返回 |
|---|---|---|
allSuccessfulOrThrow() | 全部成功,或任一失败 | Stream<Subtask<T>> |
anySuccessfulResultOrThrow() | 任一成功 | 那个成功的结果 T |
awaitAllSuccessfulOrThrow() | 全部结束,任一失败则抛 | Void |
awaitAll() | 全部结束,忽略失败 | Void |
allUntil(predicate) | 谓词满足 | Stream<Subtask<T>> |
libraryd 的面板用第一种:三个数字必须来自同一时刻的成功查询,缺一个就没法展示。anySuccessfulResultOrThrow() 适合「多副本取最快」——比如同一个 ISBN 的馆藏同时查主库和只读副本,谁先回用谁。awaitAll() 适合「发通知,失败无所谓」的旁路任务。
allUntil 用得少但有意思:可以传入「已经拿到 2 个成功结果就停」的谓词,用来做「至少 N 个可用」的降级查询。
在 libraryd 里落地
DashboardService 把三路查询放进一个作用域,并把异常翻译成领域错误:
package com.plume.library.web;
import com.plume.library.domain.ErrorCode;
import com.plume.library.domain.LibraryException;
import com.plume.library.domain.LoanRepository;
import com.plume.library.domain.MemberDashboard;
import java.time.Duration;
import java.time.LocalDate;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.FailedException;
import java.util.concurrent.StructuredTaskScope.TimeoutException;
public final class DashboardService {
private final LoanRepository loans;
private final Duration timeout;
public DashboardService(LoanRepository loans, Duration timeout) {
this.loans = loans;
this.timeout = timeout;
}
public MemberDashboard load(String memberId, LocalDate today) {
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<Object>allSuccessfulOrThrow(),
cfg -> cfg.withName("dashboard-" + memberId).withTimeout(timeout))) {
var active = scope.fork(() -> loans.findActiveByMember(memberId));
var overdue = scope.fork(() -> loans.countOverdue(memberId, today));
var fines = scope.fork(() -> loans.sumFineCents(memberId, today));
scope.join();
return new MemberDashboard(memberId, active.get(), overdue.get(), fines.get());
} catch (TimeoutException e) {
throw new LibraryException(ErrorCode.DB_UNAVAILABLE,
"dashboard 聚合超过 " + timeout.toMillis() + "ms", e);
} catch (FailedException e) {
Throwable cause = e.getCause();
if (cause instanceof LibraryException library) {
throw library;
}
throw new LibraryException(ErrorCode.INTERNAL, "dashboard 聚合失败", cause);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LibraryException(ErrorCode.INTERNAL, "dashboard 聚合被中断", e);
}
}
}
四个设计点:
- 泛型用
Joiner.<Object>。三个子任务返回List<Loan>、Long、Long,共同上界是Object。如果强行让它们同类型,就得包一层没有意义的容器。 - 超时放在
Configuration,而不是在任务里各自Thread.sleep计时。作用域超时会取消所有子任务,不需要手写看门狗。 FailedException的 cause 如果是LibraryException,原样抛出。MEMBER_NOT_FOUND这类领域错误不应该被包装成INTERNAL,否则 Web 层会返回 500 而不是 404。InterruptedException单独处理并恢复中断标志。作用域被外部中断时,不能吞掉中断状态。
MemberDashboard 是不可变 record,构造时 List.copyOf:
package com.plume.library.domain;
import java.util.List;
public record MemberDashboard(String memberId, List<Loan> activeLoans,
long overdueCount, long totalFineCents) {
public MemberDashboard {
activeLoans = List.copyOf(activeLoans);
if (overdueCount < 0 || totalFineCents < 0) {
throw new IllegalArgumentException("overdueCount 与 totalFineCents 不能为负");
}
}
}
用假仓储跑三条路径,本机输出:
-- happy path
elapsedMs=64 MemberDashboard[memberId=M-1, activeLoans=[...], overdueCount=1, totalFineCents=300]
-- overdue query fails
elapsedMs=44 code=INTERNAL message=dashboard 聚合失败 cause=IllegalStateException
-- slow overdue query, timeout 100ms
elapsedMs=106 code=DB_UNAVAILABLE message=dashboard 聚合超过 100ms
- 成功路径 64ms:三个查询分别 20ms、40ms、30ms,并发后取最慢的 40ms 加启动开销。
- 失败路径 44ms:失败任务 40ms 抛异常,慢任务被取消,没有等满 300ms。
- 超时路径 106ms:作用域 100ms 到期,抛
TimeoutException并翻译成DB_UNAVAILABLE。
编译与运行:
javac --release 25 --enable-preview -d out -cp out \
com/plume/library/web/DashboardService.java
java --enable-preview -cp out DashboardDemo
常见错误与边界
错误一:照抄 JDK 21 的 new StructuredTaskScope.ShutdownOnFailure()。
// 反例:这个类在 JDK 25 不存在
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { ... }
编译会直接报「找不到符号」。JDK 25 的形态是 open(Joiner),joiner 是参数不是子类。升级 JDK 时旧博客的示例基本都要改。
错误二:在 fork 里 catch 掉异常。
// 反例:异常被吞掉,作用域认为任务成功
var task = scope.fork(() -> {
try {
return repository.load(id);
} catch (Exception e) {
return null; // 作用域看不到失败
}
});
allSuccessfulOrThrow 依赖子任务抛出异常来触发取消。吞掉异常会让 join() 正常返回,后续 get() 拿到 null,错误被推迟到更难排查的地方。要记录日志可以,但记录完必须重新抛出。
错误三:在 fork 里写共享可变状态。 三个子任务同时往一个 HashMap 里放结果,既没有顺序保证也不线程安全。子任务应该只返回自己的结果,由 join() 之后的父线程合并。
错误四:把作用域当线程池。 StructuredTaskScope 是「一次请求内的扇出」,作用域关闭后不能复用。需要常驻并发就用 ExecutorService,两者不要混。
边界:预览 API 的部署成本。 运行参数必须带 --enable-preview,且 class 文件版本与 JDK 严格匹配。用 java --enable-preview 跑一个用 JDK 25 编译的 class 没问题,但用 JDK 26 跑就会因为预览版本不匹配而拒绝启动。容器基础镜像升级时这是常见的翻车点。
边界:取消不等于立刻停止。 取消是发中断,任务要自己响应。如果任务在不可中断的本地调用里,作用域关闭会被拖住。所有阻塞调用都应该有超时。
小结
ExecutorService.invokeAll不会 fail-fast:一个任务 50ms 失败,另一个 400ms 的任务仍会跑完,本机总耗时 408ms。- JDK 25 的
StructuredTaskScope是预览 API,形态为open(Joiner),编译运行都要--enable-preview。 allSuccessfulOrThrow在任一子任务失败时取消其余子任务,本机siblingCancelled=true,整体 44ms 返回。- 作用域级别的
withTimeout是总预算,不是每个任务的预算;100ms 超时在 101ms 触发。 Joiner有五种语义,libraryd 的面板用「全成功」,多副本取最快用「任一成功」。FailedException的 cause 要按领域错误翻译,不要把MEMBER_NOT_FOUND变成 500。
机制讲完了,接下来是落地时最容易踩的三件事:连接池、ThreadLocal、限流。虚拟线程让等待变便宜,但不会让下游容量变大。
阅读导航:上一节:6.1 虚拟线程原理:载体线程、挂起与 pinning · 下一节:6.3 虚拟线程落地:连接池、ThreadLocal 与限流 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。