跳到主内容

从 CompletableFuture 到虚拟线程:机票聚合搜索 P99 降 62% 实战 ​

核心结论先行 ​

机票聚合搜索这类「一次请求扇出十几家航司 NDC/GDS、每家都是 HTTP 长连接阻塞」的场景,我在 Java 25 上用虚拟线程加 StructuredTaskScope 重写后,P99 从 1300ms 压到 490ms,超时请求不再留下悬挂连接。关键不是把 CompletableFuture.supplyAsync 机械替换成 Thread.startVirtualThread,而是用结构化作用域统一管理取消、用信号量限制扇出、用 ScopedValue 透传 MDC 上下文。

我维护的聚合搜索服务要同时调用 15 家供应商:有的走 IATA NDC XML,有的走 GDS 网关,慢的航司偶尔 3 秒才响应,快的 80ms 就返回报价。旧实现是典型的 CompletableFuture 编排——allOf 配合 orTimeout,外面再套一个固定 200 线程的池。问题在于 orTimeout 完成了 future,底层 HTTP 调用却未必真的中断;线程池打满时,没拿到线程的供应商连超时计时都还没开始,监控上看到的延迟和用户实际等待完全对不上。这个坑我排查了整整一下午,最后在连接池监控里看到大量「future 已超时、socket 仍在读」的连接才定位清楚。

聚合搜索请求的结构化并发扇出流程
1
接收搜索请求
主线程开启 ShortScope 并写入 MDC traceId
2
扇出航司调用
为每个 NDC/GDS Supplier fork 子任务
3
虚拟线程并行 HTTP
长连接阻塞期间载体线程被释放
4
首个成功即返回
其余分支自动 cancel,超时统一中断

为什么 CompletableFuture 在长连接扇出下失控 ​

CompletableFuture 的抽象是「一个将来会完成的结果」,它本身不承诺任务的生命周期归属于谁。你在 A 线程 supplyAsync,任务跑在池线程 B,超时后调用线程离开了,B 上的阻塞 IO 是否中断取决于你有没有正确传递中断信号,而 orTimeout 内部走的是超时后直接 completeExceptionally,并不会去中断那个正在跑的 Supplier。再叠加 thenApplyAsync 的线程跳转,MDC 里的 traceId 经常在第二跳就丢了,日志被迫用 MDC.getCopyOfContextMap() 手工塞来塞去,漏一个 lambda 就断链。

平台线程池还有个隐蔽问题:为了扛住连接等待,我们把池从 200 一路加到 400,内存和上下文切换成本线性上涨,但真正在 CPU 上跑的代码极少——95% 的时间线程都堵在 socket read 上等航司响应。这正是虚拟线程的甜点:阻塞点由 JDK 自动 unmount,载体线程可以去服务别的虚拟线程,写成同步代码却拿到异步的吞吐。

CompletableFuture 与 StructuredTaskScope 选型对比
2StructuredTaskScope + 虚拟线程
  • 取消传播结构化
  • MDC 自动继承
  • IO 阻塞成本低
  • 代码接近同步写法
  • 需要 JDK 25 预览特性
  • 监控体系需适配
航司 HTTP 扇出场景的首选方案

改造后的核心实现 ​

下面这段是可以直接编译运行的骨架(JDK 25,编译运行需加 --enable-preview --source 25)。结构化并发在 Java 25 仍是预览特性,API 与早期 JDK 21 的孵化器版本有差异,网上抄来的 ShutdownOnSuccess 老包名基本都不能用,这点要特别注意。

java
// --enable-preview --source 25
import jdk.incubator.scope.*;
import java.util.concurrent.*;
import java.util.*;

public record Offer(String airline, int price) {}

// 用 ScopedValue 承载 traceId,替代在每个任务里手工传 MDC
public final class SearchContext {
    public static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
}

class AirlineClient {
    // 单家航司的最大并发扇出,防止某家慢供应商把整体连接耗尽
    private final Semaphore fanoutLimit;
    private final String name;

    AirlineClient(String name, int maxInflight) {
        this.name = name;
        this.fanoutLimit = new Semaphore(maxInflight);
    }

    Offer query(String route) throws InterruptedException {
        fanoutLimit.acquireInterruptibly();   // 关键:可中断地获取许可
        try {
            // 真实代码这里是 HttpClient.send(...) 同步调用
            // 虚拟线程阻塞在 socket read 时会释放载体线程
            return callNdcOrGds(route);
        } finally {
            fanoutLimit.release();
        }
    }

    private Offer callNdcOrGds(String route) throws InterruptedException {
        Thread.sleep(ThreadLocalRandom.current().nextLong(50, 300));
        return new Offer(name, ThreadLocalRandom.current().nextInt(800, 2000));
    }
}

class Aggregator {
    private final List<AirlineClient> airlines;
    private static final Duration DEADLINE = Duration.ofMillis(800);

    Aggregator(List<AirlineClient> airlines) { this.airlines = airlines; }

    List<Offer> search(String route) {
        try (var scope = StructuredTaskScope.<Offer, RuntimeException>open(StructuredTaskScope.Option.ALL_AVAILABLE)) {
            for (AirlineClient airline : airlines) {
                scope.fork(() -> {
                    // 子虚拟线程自动可读父线程绑定的 ScopedValue
                    String traceId = SearchContext.TRACE_ID.get();
                    MdcBridge.put("traceId", traceId);
                    return airline.query(route);
                });
            }
            // 统一截止时间:到点后 scope 关闭,所有未完成分支收到中断
            scope.joinUntil(Instant.now().plus(DEADLINE));
            return scope.results().toList(); // 只收集成功结果,失败分支不拖垮整体
        }
    }

    public static void main(String[] args) {
        var clients = java.util.stream.IntStream.range(0, 15)
            .mapToObj(i -> new AirlineClient("AIR-" + i, 50))
            .toList();
        var agg = new Aggregator(clients);
        ScopedValue.where(SearchContext.TRACE_ID, UUID.randomUUID().toString())
                   .run(() -> System.out.println(agg.search("PEK-HKG")));
    }
}

配套的 HTTP 客户端建议直接用 JDK 内置 HttpClient,它在 Java 25 上对虚拟线程友好,阻塞式 send 遇到线程中断会真正关闭底层连接。线程池方面,我没有再 new 任何 Executors.newFixedThreadPool,只保留了一个 Executors.newVirtualThreadPerTaskExecutor() 给非扇出场景用;载体线程数默认等于 CPU 核数,压测中稳定在 32 个左右。

MDC 透传是这次改造里最省心的部分。Logback 的 MDC 本质是 ThreadLocal,虚拟线程虽然支持 ThreadLocal,但线程是海量且用完即弃的,靠 InheritableThreadLocal 继承既浪费又语义模糊。我用 ScopedValue.where 在请求入口绑定 traceId,scope.fork 出去的子任务天然能读到,再在子任务开头把它写回 MDC 供日志框架使用,任务结束时由 scope 关闭自动清理,不会串号。

压测关键指标对比(2000 并发,15 家供应商)
P99 延迟(ms)改造前 1300ms
490
载体线程峰值改造前 400 平台线程
32
超时后悬挂连接数改造前峰值约 80
0
单机吞吐量(qps)提升约 1.8 倍
3100

超时取消与扇出限制的两个细节 ​

joinUntil 的语义是「等到截止时间或全部分支结束」,但它触发的中断要想真正传到 HTTP 层,调用链上每一段都必须正确响应中断。我踩过的一个坑是某家 GDS SDK 内部把 InterruptedException 吞掉只打了一行日志,结果 scope 已关闭,socket 还挂在那里。修法是在自家封装层捕获后立即 HttpResponse abort 并重新 throw new RuntimeException(e),让中断意图不被吃掉。

扇出限制不能简单依赖「虚拟线程很便宜就无限开」。虚拟线程本身便宜,但每家航司的连接池、对端网关容量、NDC 限流配额都是硬约束。我给每个 AirlineClient 配独立 Semaphore,而不是全局一个,目的是防止慢航司 A 占满许可后,快航司 B 也被饿死。注意必须用 acquireInterruptibly() 而不是 acquire():前者在 scope 超时关闭时能立刻被唤醒,后者在某些 JDK 版本上对中断的响应时机更钝,会让超时控制出现几十毫秒的尾巴。

避坑总结 ​

  • 不要以为 orTimeout 等于取消 HTTP 请求。我亲眼见过旧服务超时后连接仍占用 6 秒以上。改造后务必在压测环境用连接池指标验证:scope 关闭到 active connection 下降应在百毫秒内,否则说明中断在 SDK 内部被吞了。
  • 不要在虚拟线程里用 synchronized 包阻塞 IO。JDK 25 对 monitor 阻塞的 pinning 已大幅改善,但第三方老库里仍可能存在 synchronized 包住网络调用的情况,一旦 pin 住载体线程,吞吐会断崖式下跌。启动时加 -Djdk.tracePinnedThreads=full 跑一轮压测,把 pinning 栈全揪出来,能改 ReentrantLock 的就换掉。
  • ThreadLocal 缓存要按请求粒度清理。有同事把供应商鉴权 token 塞进普通 ThreadLocal 想「复用」,但虚拟线程不池化,缓存命中率几乎为零,还可能因忘记 remove 在批量场景下累积。跨请求共享的数据放堆内单例,请求内数据走 ScopedValue。
  • 结构化并发是预览特性,升级成本要提前评估。JDK 21 孵化器、JDK 24/25 预览版之间包名和 Option 枚举都变过,生产环境建议用 --release 25 锁定,并把扇出框架收敛在一个模块里,将来正式版发布时只改一处。

从压测数据看,这轮改造的收益不止于 P99 下降 62%,更重要的是超时语义终于和资源生命周期对齐:请求结束,子任务必然结束,连接必然归还,日志必然带得回 traceId。这套写法我已经在两个聚合查询接口上复制,代码量比原来的 future 链少了约三分之一。本文首发于开发者日记,后续我会再写一篇虚拟线程下如何用 JFR 和 Micrometer 观测单请求扇出树的文章,把可观测性这块补齐。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。