Java 虚拟线程实战:订单聚合接口的超时、限流与降级写法

2026-10-28 0 157

订单详情页这种接口,后端通常要拼好几个下游:用户中心、订单中心、库存、优惠券,可能还有物流。串行调一遍,本地测 120ms,上了生产遇到一个下游抖动就变成 800ms 往上。这类“自身没什么计算、全在等网络”的接口,正是虚拟线程最舒服的场景。

但很多人把 CompletableFuture 换成虚拟线程之后就收工了,结果压测一跑,下游被打挂两个。虚拟线程解决的是“线程不够用”,不是“下游扛得住”。下面用一个能直接跑的订单聚合案例,把开启方式、限流、超时、降级,以及几个必须知道的坑一次讲完。

为什么不是把线程池调大一点

平台线程的成本摆在那里。要撑住 5000 个同时在途的请求,每个请求都要等下游 200ms,按需求算就是几百上千个线程。线程池调到 500 也行,但这 500 个线程里有 99% 的时间在等 socket 返回,真正干活的时间可以忽略。这时候如果某个下游从 200ms 变成 2s,线程全被占住,新请求直接排队,连超时判断都轮不上。

虚拟线程把这层关系拆开了:等待不再占用宝贵资源,写代码的方式却仍然是“一行一行的同步调用”,不用把逻辑拆成回调或者 thenCompose 链。调试堆栈也是完整的一条,这点在线上排查时差别非常大。

先把串行改成并发

最直接的写法是每个下游起一个任务,然后用 newThreadPerTaskExecutor 拿到虚拟线程池。注意不是 newFixedThreadPool(200) 里塞虚拟线程,那样虚拟线程数被池子限制住,等于白用。

public OrderDetail load(long orderId) {
    try (ExecutorService pool = Executors.newThreadPerTaskExecutor(factory)) {
        Future<String> fUser   = pool.submit(() -> fetch(USER_URL + orderId, userPermits));
        Future<String> fOrder  = pool.submit(() -> fetch(ORDER_URL + orderId, orderPermits));
        Future<String> fCoupon = pool.submit(() -> fetch(COUPON_URL + orderId, miscPermits));
        Future<String> fStock  = pool.submit(() -> fetch(STOCK_URL + orderId, miscPermits));

        return new OrderDetail(
                orElse(fUser,   "用户信息加载失败"),
                orElse(fOrder,  "订单信息加载失败"),
                orElse(fCoupon, "无可用优惠券"),
                orElse(fStock,  "库存加载失败"));
    }
}

这里的 try-with-resources 不是装饰。它在方法返回时关闭 executor,并等待内部任务结束,相当于给这一批任务划了一个作用域。好处是任务不会泄漏到请求之外,坏处也在这里:如果某个任务自己没设超时,close() 会一直等下去,把整个接口拖死。所以下面每个任务都必须带上自己的时间上限。

限流:虚拟线程不限制并发,限制并发的是你

这是最容易出事的地方。原来线程池大小是 50,往下游最多也就打 50 个并发;换成虚拟线程之后,压测 5000 QPS 就是 5000 个并发请求同时打向下游,下游的连接池、数据库、慢查询会一起爆掉。

所以限流要显式做,而且应该按下游分别做,不能用一个全局名额。用户中心和优惠券服务的容量完全不是一个量级。

private final Semaphore userPermits  = new Semaphore(60);
private final Semaphore orderPermits = new Semaphore(60);
private final Semaphore miscPermits  = new Semaphore(30);

private String fetch(String url, Semaphore permits) throws Exception {
    // 拿不到名额就直接失败,宁可降级也不让请求在内存里排队
    if (!permits.tryAcquire(200, TimeUnit.MILLISECONDS)) {
        return null;
    }
    try {
        HttpRequest req = HttpRequest.newBuilder(URI.create(url))
                .timeout(Duration.ofMillis(800))
                .header("X-Trace-Id", traceId())
                .GET()
                .build();
        HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
        return resp.statusCode() == 200 ? resp.body() : null;
    } finally {
        permits.release();
    }
}

名额怎么定?用下游能承受的并发倒推,别拍脑袋。假设某个服务是 8 核、单实例连接池上限 200、该接口 P99 是 200ms,那单实例大概能扛 200 / 0.2 = 1000 QPS,本地有多少个实例、分配给订单页多少流量,除一下就能得到单机允许的并发数。我上面写的 60 和 30 只是示例,真实数值必须按自己的压测数据来。

tryAcquire 带超时这一步也有讲究。用 acquire() 无限等,请求会在信号量上堆积,游客看到的是一堆慢请求;给 200ms 上限,超过就返回 null 走降级,页面还能出来,只是少了模块。这一类接口的正确目标从来不是“所有数据都拿到”,而是“快速给出一个能看的页面”。

超时和降级:每个任务都要有自己的截止时间

private String orElse(Future<String> future, String fallback) {
    try {
        String value = future.get(900, TimeUnit.MILLISECONDS);
        return value == null ? fallback : value;
    } catch (TimeoutException e) {
        future.cancel(true);
        return fallback;
    } catch (ExecutionException e) {
        return fallback;
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        future.cancel(true);
        return fallback;
    }
}

这里有两层超时,作用不一样。HttpClient 上的 timeout(800ms) 是网络层超时,管的是“连接建立之后多久没响应”;Future.get(900ms) 是业务层兜底,管的是“整个任务从提交到完成最多花多久”,包括抢信号量那 200ms。两层的数字要错开,网络层略小于业务层,这样正常情况下网络超时先触发,能拿到更具体的异常信息。

future.cancel(true) 会中断对应的虚拟线程,HttpClient.send 响应中断后会放弃这次调用。不 cancel 直接返回,任务还在后台跑,等到 executor 关闭时才发现一堆没结束的线程,那时候排查就麻烦了。

请求上下文:把 ThreadLocal 换成 ScopedValue

老的代码喜欢用 ThreadLocal 存 traceId、租户号、登录用户。到了虚拟线程这里要小心:每个虚拟线程都会有自己的一份副本,一个请求开四五个虚拟线程,就是四五份拷贝。量大之后这些小的对象会成为 GC 的常客,而且手动清理漏一处就会串数据。

JDK 25 里 ScopedValue 已经是正式 API(JEP 506),语义正好匹配这种“一次请求期间有效、出作用域自动失效”的数据:

static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();

// 入口处绑定,出作用域自动解绑
String traceId = UUID.randomUUID().toString();
OrderDetail detail = ScopedValue.where(TRACE_ID, traceId)
        .call(() -> aggregator.load(42L));

// 任何调用链深处都可以直接读,不需要层层传参
private String traceId() {
    return TRACE_ID.isBound() ? TRACE_ID.get() : "-";
}

有一点需要留意:ScopedValue 的绑定是不可见的跨线程传递。在 pool.submit() 里新起的虚拟线程默认看不到父线程绑定的值。如果确实需要传,就在任务内部重新绑定一次 ScopedValue.where(...).call(...),或者干脆把 traceId 作为参数传进 fetch。前者写法更整齐,后者更直白,看团队习惯。

踩过的几个坑

1. 把虚拟线程放进固定大小的池。 Executors.newFixedThreadPool(64, Thread.ofVirtual().factory()) 这种写法只会得到 64 个虚拟线程,排队行为和平台线程池一样,收益接近于零。要么用 newThreadPerTaskExecutor,要么直接 Thread.ofVirtual().start(...)。

2. 在 synchronized 里阻塞。 JDK 21 到 23 上,虚拟线程在 synchronized 块里阻塞会固定住载体线程(pinning),并发高的时候载体线程池被打满,效果比平台线程还差。JDK 24 的 JEP 491 已经解决了这个限制,如果你的服务还在 21/23,可以用 -Djdk.tracePinnedThreads=full 排查一下有没有热点。老代码里那些包着 HTTP 调用的 synchronized 方法,能换 ReentrantLock 就换掉。

3. 在虚拟线程里跑 CPU 密集逻辑。 虚拟线程的优势在等待,不在计算。一个大循环放在虚拟线程里,照样占着载体线程,还多了调度开销。这类任务还是用固定大小的平台线程池,或者扔进并行流外的独立线程池。

4. MDC 日志丢失。 主流日志框架的 MDC 底层是 ThreadLocal。任务被提交到新的虚拟线程后,日志里就没有 traceId 了。解决办法是在任务开头重新 MDC.put,或者改用结构化日志把 traceId 作为显式字段输出。这一点如果不提前处理,线上排查会很难受。

5. 忘了 executor 的关闭代价。 close() 会等待所有任务结束,所以任务里任何一个“没有超时的阻塞”都会变成接口的尾延迟。上线前可以故意把某个下游 mock 成永不返回,看接口是不是能在预期时间内返回降级结果。

怎么验证效果

压测时重点看三个数:载体线程数、虚拟线程总数、下游的实际并发。第一项用 jcmd <pid> Thread.dumpToFile 导出线程转储就能看到,正常情况下载体线程数应该稳定在 CPU 核数附近,而不是一路涨上去。如果涨上去了,说明有阻塞没有让出载体线程。

下游的实际并发可以直接在下游服务的监控里看,和你设置的信号量名额对一下,偏差大就说明有名额泄漏或者限流没生效。

还有一个容易被忽略的点:HttpClient 自身也有连接复用和内部线程,连接池默认大小可能成为新的瓶颈。并发打高之后如果发现耗时集中在建连上,把 HttpClient 做成单例复用,并且确认版本相关的连接池参数,这一步经常能再省下几十毫秒。

完整可运行示例

把下面这段存成 OrderAggregator.java,用 JDK 25 直接 java OrderAggregator.java 就能跑(需要先把四个 URL 换成你自己的,或者用任何能返回内容的本地服务代替)。

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public class OrderAggregator {

    record OrderDetail(String user, String order, String coupon, String stock) {}

    static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();

    private static final String USER_URL   = "http://127.0.0.1:8081/users/";
    private static final String ORDER_URL  = "http://127.0.0.1:8081/orders/";
    private static final String COUPON_URL = "http://127.0.0.1:8081/coupons/";
    private static final String STOCK_URL  = "http://127.0.0.1:8081/stocks/";

    private final HttpClient http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofMillis(300))
            .build();

    private final Semaphore userPermits   = new Semaphore(60);
    private final Semaphore orderPermits  = new Semaphore(60);
    private final Semaphore miscPermits   = new Semaphore(30);

    private final ThreadFactory factory =
            Thread.ofVirtual().name("order-agg-", 0).factory();

    public OrderDetail load(long orderId) {
        try (ExecutorService pool = Executors.newThreadPerTaskExecutor(factory)) {
            Future<String> fUser   = pool.submit(() -> fetch(USER_URL + orderId, userPermits));
            Future<String> fOrder  = pool.submit(() -> fetch(ORDER_URL + orderId, orderPermits));
            Future<String> fCoupon = pool.submit(() -> fetch(COUPON_URL + orderId, miscPermits));
            Future<String> fStock  = pool.submit(() -> fetch(STOCK_URL + orderId, miscPermits));

            return new OrderDetail(
                    orElse(fUser,   "用户信息加载失败"),
                    orElse(fOrder,  "订单信息加载失败"),
                    orElse(fCoupon, "无可用优惠券"),
                    orElse(fStock,  "库存加载失败"));
        }
    }

    private String fetch(String url, Semaphore permits) throws Exception {
        if (!permits.tryAcquire(200, TimeUnit.MILLISECONDS)) {
            return null;
        }
        try {
            HttpRequest req = HttpRequest.newBuilder(URI.create(url))
                    .timeout(Duration.ofMillis(800))
                    .header("X-Trace-Id", traceId())
                    .GET()
                    .build();
            HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
            return resp.statusCode() == 200 ? resp.body() : null;
        } finally {
            permits.release();
        }
    }

    private String orElse(Future<String> future, String fallback) {
        try {
            String value = future.get(900, TimeUnit.MILLISECONDS);
            return value == null ? fallback : value;
        } catch (TimeoutException e) {
            future.cancel(true);
            return fallback;
        } catch (ExecutionException e) {
            return fallback;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            future.cancel(true);
            return fallback;
        }
    }

    private String traceId() {
        return TRACE_ID.isBound() ? TRACE_ID.get() : "-";
    }

    public static void main(String[] args) throws Exception {
        var aggregator = new OrderAggregator();
        String traceId = UUID.randomUUID().toString();

        long start = System.currentTimeMillis();
        OrderDetail detail = ScopedValue.where(TRACE_ID, traceId)
                .call(() -> aggregator.load(42L));
        long cost = System.currentTimeMillis() - start;

        System.out.printf("traceId=%s cost=%dms%n%s%n", traceId, cost, detail);
    }
}

跑通之后建议做个对照实验:把 miscPermits 改成 1,再调一次,你会看到优惠券和库存两个模块依次降级,接口总耗时被压在 1.5s 以内。这个实验能让你直观感受到限流名额和降级策略之间的关系——名额越少,越依赖降级兜底;名额越多,下游压力越大。线上要的就是这两者之间那个合适的点,而它只能靠压测数据找出来,不能靠猜。

Java 虚拟线程实战:订单聚合接口的超时、限流与降级写法
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 java Java 虚拟线程实战:订单聚合接口的超时、限流与降级写法 https://www.taomawang.com/server/java/2975.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务