资讯动态

Java AI应用高并发实战:虚拟线程与异步化设计

发布时间:2026/10/8 9:14:38 来源:尧图企业网站定制
前一段时间我在做一个 AI 聚合网关类的 Java 服务核心功能是把多个大模型 API 统一包装成一套接口支持不同模型之间的路由、超时控制、流式转发还顺带做了多模型并发调用、结果择优返回这类逻辑。听起来不算复杂但真正压测的时候问题一个接一个冒出来Tomcat 线程被打满、上游模型一个慢请求拖垮整个服务、流式响应在高并发下莫名断连、数据库连接池被 AI 场景的长耗时调用间接拖死……这些问题的根源几乎都和异步化“高并发设计”有关。这篇文章我就从实际项目出发完整复盘一下 Java AI 应用在异步化和高并发方向的思考过程与落地方式先讲清楚 AI 场景为什么和传统 CRUD 服务的并发模型不一样再深入到虚拟线程、CompletableFuture、响应式编程这几个具体的技术选型然后是连接池、限流、熔断、缓存这些工程细节最后分享一次真实压测和完整排查链路。内容会比较细偏实战适合正在做 AI 应用后端、大模型网关、Agent 编排系统的 Java 工程师参考。哪怕你暂时还没接触到 AI 场景这套并发设计思路也可以直接迁移到其他高并发 IO 密集型服务上。1. 为什么 Java 做 AI 后端卡点往往不在 AI 而在并发模型1.1 大模型接口的慢才是所有问题的主线先说一个核心结论大模型应用的性能瓶颈绝大多数时候不是你的代码算得慢而是上游模型接口本身慢。一次普通业务请求访问数据库快的 1~5 毫秒慢的几十毫秒已经算夸张了。但一次大模型调用哪怕是最小的文本生成请求从发出到拿到第一个 token通常也要 1~3 秒如果是长文本生成整个请求持续 20 秒、30 秒甚至更久都很常见。这就带来一个数量级上的差异同样的吞吐目标下AI 应用持有连接的时间和等待的时间是传统应用的几十倍。举个例子。假设你的服务部署了 200 个 Tomcat 线程传统业务每个请求平均耗 50 毫秒那这 200 个线程理论上每秒钟可以处理大约 4000 个请求。同样是这 200 个线程换成大模型调用每个请求平均耗 2 秒每秒能处理的请求数就掉到了 100。这还是在理想状态下的估算实际上线程上下文切换、连接池等待、GC 停顿都会进一步压掉这个数字。如果并发请求再往上走线程很快耗尽新的请求只能排队然后用户端的超时开始生效整个服务从慢变成不可用。所以 Java AI 应用的第一课就是你必须非常清醒地认识到这是一个 IO 密集型场景而不是计算密集型场景。这决定了后续所有并发设计的基本方向。1.2 IO 密集型应用传统并发模型遭不住的三个细节Java 并发模型演进到现在其实一直围绕线程做文章。早期是每个请求一个线程靠线程池限制资源后来引入了 NIO、CompletableFuture、响应式编程本质是想在少数线程上复用 IO 等待时间。但在 AI 场景下传统做法有几个细节特别容易出问题。第一个细节是线程池大小的公式。很多团队还在用经典的CPU 核心数 1或者 Tomcat 默认 200 线程来配置容量。这对计算密集型任务是对的但 AI 应用是 IO 密集型默认线程数往往是拍脑袋定的没有按照请求耗时占比去推算。一个合理的估算方式是期望吞吐 线程数 / 单请求平均耗时。反过来算就是线程数 期望吞吐 × 单请求平均耗时。如果一个网关服务想支持每秒 50 个并发的大模型调用每个调用平均 3 秒那理论上就需要 150 个线程。但如果你有多个上游模型、多个接口混杂这个数还要加安全余量。第二个细节是上游连接的持有时间。很多 Java 服务用 RestTemplate 或 OkHttp 发起外部请求默认连接池配置里没有特别关注连接最多被占用多久。普通 HTTP 接口 200 毫秒就返回了连接可以快速归还复用大模型接口动不动十几秒连接池里的连接长时间被占用池子见底后新的请求就开始排队等连接。这时候服务本身没有任何资源问题CPU 也闲得很但接口延迟已经爆炸了。这属于典型的连接池容量与请求耗时不匹配问题。第三个细节是超时处理。大模型接口的响应时间波动非常大高峰时段同一个模型可能从 1 秒涨到 10 秒。如果你的 HTTP 客户端超时设置得太长、且没有区分连接超时和读取超时那么一个上游卡顿就可能让所有请求都顶在那里。更要命的是很多人还在异步线程里直接调同步 API线程池被阻塞之后连后续的健康检查、心跳线程都会被拖住最后整个服务被慢上游拖成雪崩。这三个细节其实是所有 Java AI 后端的通病。理解了它们后面的异步化改造才有明确的方向既然问题出在线程傻等和资源被长时间占用上那就要么让等待不占线程要么让等待本身变得更便宜。2. 虚拟线程把异步化的复杂度交给 JVM2.1 虚拟线程可以怎么替代 CompletableFutureJava 21 正式引入虚拟线程之后Java 后端处理 IO 密集型的思路发生了很大变化。以前我们要异步化主流选择是 CompletableFuture 或者 WebFlux但这两条路都有明显的学习成本和调试成本。虚拟线程的思路非常直接让每个请求在逻辑上仍然是一个同步阻塞的线程但这个线程非常轻量可以创建几万个甚至几十万个阻塞的成本低到可以忽略。我在改造 AI 聚合网关的时候最先替换的就是模型调用这一段。原来用 CompletableFuture 做了很多嵌套回调代码逻辑被拆得稀碎一旦要加超时、重试、熔断嵌套层级直接爆炸。换成虚拟线程之后代码逻辑回到了最直观的顺序执行写法不再需要暗号式的 thenCompose、thenApply 串联。核心代码大概是这样的ExecutorService virtualExecutor Executors.newVirtualThreadPerTaskExecutor(); public ChatCompletionResult callModel(ModelRequest request, String modelName) { try { var future virtualExecutor.submit(() - doCall(modelName, request)); // 直接阻塞等待结果但底层挂起的是虚拟线程不是平台线程 return future.get(15, TimeUnit.SECONDS); } catch (TimeoutException e) { // 超时后快速失败不再占住上游连接 throw new BizException(model_call_timeout); } }这里的future.get(15, TimeUnit.SECONDS)看起来是同步阻塞但实际上被阻塞的只是当前这个虚拟线程载体线程Carrier Thread在等 IO 期间会去调度其他虚拟线程。所以哪怕一个模型请求能跑 15 秒系统也能同时挂几万个这样的虚拟线程平台线程池永远保持在一个很小的量级不会被打满。这个特性是大模型后端最需要的东西因为 AI 接口的慢恰恰是常态。2.2 虚拟线程落地时的真实开发体验我实际用下来虚拟线程并不是完全没有代价有几个体验要分享。第一不要在线程内部做 CPU 密集型的计算。虚拟线程适合等待不适合运算。如果每个请求进来都要做大量 JSON 解析、向量计算、复杂业务逻辑那该用普通线程池还是用普通线程池或者专门拆出去。虚拟线程的数量虽然大但 CPU 核心就那么多你把大量计算塞进虚拟线程只会让载体线程频繁切换性能反而下降。第二虚拟线程跟 synchronized 关键字的配合有坑。虚拟线程在遇到 synchronized 时JVM 是可以通过jvmci等机制做锁优化但如果你用了非常粗粒度的 synchronized 包住了网络调用依然可能把载体线程钉住Pinning导致虚拟线程的优势发挥不出来。实际开发中我尽量把锁的粒度控制在纯内存操作范围比如更新一个状态变量、写入一个 ConcurrentHashMap网络 IO 调用全都在锁外面。如果实在避免不了可以考虑改用ReentrantLock虚拟线程在ReentrantLock下可以正常挂起而不钉住载体线程。第三线程池参数瞬间变得不重要了。我们以前调线程池参数会反复纠结 corePoolSize、maximumPoolSize、queueCapacity。用虚拟线程之后这些问题基本消失了——因为虚拟线程本身不建议被池化每次用完就销毁创建成本很低。我在代码里统一使用Executors.newVirtualThreadPerTaskExecutor()它就是为每个任务新建一个虚拟线程不用池化思路去管理。这也大大降低了团队之间的沟通成本不用再为一个接口到底要多少线程争来争去。第四线程局部变量要慎用。虚拟线程数量非常多线程局部变量会对应地创建大量实例。如果沿用老代码里的 ThreadLocal 去存用户上下文、链路追踪 ID内存压力会成倍上升。目前在虚拟线程里我一般优先把上下文信息放到入参对象里传递或者用 ScopedValue 这类更轻量的机制避免每条线程都背着厚重的 ThreadLocal。2.3 虚拟线程与 AI 流式输出怎么配合大模型场景里还有一块很特别的需求流式输出。按 Token 输出的时候服务端不是一次性拿到完整结果而是持续推送数据过来。传统异步模型处理流式输出非常痛苦因为要用回调监听数据块、用 Future 衔接状态任何一环出错都很难排查。虚拟线程处理流式输出就自然很多。可以在一个虚拟线程里同步发起大模型请求通过 HTTP 流式接口持续读取数据块每来一个 token 就转发给下游客户端如果需要按 token 粒度对客户端做推送配合 SseEmitter 或者 WebSocket 都行。代码写起来和普通同步 IO 一模一样但底层承载线程始终没有把平台线程池占住。这里有一个我踩过的坑如果在虚拟线程里做同步循环读取而每个数据块之间又做了耗时处理比如调用 Tokenizer 对内容做长度统计、敏感词过滤这些操作虽然单次耗时短但在高并发下会抢占 CPU。所以我后来的做法是流式转发链路里所有轻量处理直接做比较重的解析和处理投递到独立的消息队列或专用线程池里避免和 IO 转发竞争资源。3. 异步编排细节CompletableFuture 与虚拟线程怎么选3.1 核心取舍什么时候用 CompletableFuture什么时候直接上虚拟线程可能会有读者问既然虚拟线程这么好是不是可以全面替代 CompletableFuture我在项目里的经验是不能一概而论要分清场景。CompletableFuture 的核心优势是组合编排能力强。它可以把多个异步任务串起来、并起来、等任意一个完成、做异常恢复还能设置自定义线程池。这种能力非常适合那种任务之间有明确依赖关系的逻辑。虚拟线程的优势则是逻辑可读性强适合把大量阻塞操作顺序地堆在一起而不是让回调嵌套地狱出现。所以我的取舍标准很简单如果一个复杂的 AI 任务由多个不同上游调用组合而成且存在先调 A 和 B等两个都回来后用结果再调 C这样的编排我优先考虑 CompletableFuture配合一个合适的平台线程池。因为用虚拟线程写也能一比一还原但编排异常恢复和超时链路时CompletableFuture 的命令式 API 表达得更清晰。如果是大量平行的、耗时较长的模型调用比如一次请求要同时询问多个模型然后聚合答案我优先考虑虚拟线程。这种场景本质是一堆没有相互依赖的阻塞调用虚拟线程的写法最自然性能和可维护性都很好。3.2 一个多 AI 协作的编排案例我在做多模型竞速功能时正好把两种方式都用了。所谓多模型竞速就是同时把同一个问题发给 3~4 个大模型谁先返回完整结果就把谁的结果返回给用户剩余请求直接掐断。这在高延迟场景下能显著提升用户体验。这个功能我用 CompletableFuture 做的编排向每个模型发一个请求用anyOf等待第一个完成然后立刻取消其余 Future并关闭对应的上游连接。核心逻辑大致如下ExecutorService modelPool Executors.newFixedThreadPool(8); public ModelResult race(ModelRequest request, ListString models) { ListCompletableFutureModelResult futures models.stream() .map(model - CompletableFuture.supplyAsync( () - callModel(model, request), modelPool)) .collect(Collectors.toList()); CompletableFutureModelResult firstDone new CompletableFuture(); for (CompletableFutureModelResult f : futures) { f.whenComplete((r, ex) - { if (r ! null) { firstDone.complete(r); } else if (futures.stream().allMatch(CompletableFuture::isDone)) { firstDone.completeExceptionally(new BizException(all_models_failed)); } }); } ModelResult result; try { result firstDone.get(10, TimeUnit.SECONDS); } catch (Exception e) { result ModelResult.timeout(); } // 拿到结果后主动取消还没完成的请求 for (CompletableFutureModelResult f : futures) { f.cancel(true); } return result; }这段逻辑如果用虚拟线程写也可以实现但取消其余请求这块还是得借助 Future 句柄本质上没比 CompletableFuture 简单多少。而且模型调用本身走的是独立线程池可以通过interrupt()中断线程配合 HTTP 客户端的可中断机制把上游请求断掉效果很直接。3.3 响应式 WebFlux 还该不该用聊到异步化肯定绕不开 WebFlux。在虚拟线程出现之前WebFlux 是 Java 做高并发 IO 应用的最强方案之一响应式编程能把线程数压到极低CPU 利用率往往很漂亮。不可否认它在网关层面有真实价值尤其是需要处理海量连接、长连接推送的场景。但我在 AI 后端项目里最终没有选择 WebFlux原因比较现实第一AI 链路里大量用到第三方 SDK 和阻塞式客户端。很多大模型官方 SDK 底层还是同步 HTTP 调用要把它们接进响应式链路就得用Mono.fromCallable包一层本质还是在线程池里阻塞执行。这个转化做完后响应式带来的线程优势被削弱了代码复杂度却增加不少。第二响应式调试成本极高。一个 AI 聚合服务里涉及模型路由、鉴权、限流、重试、熔断、流式转发责任链很长。如果全部走响应式一个异常堆栈打印出来的可能是一长串 operator 嵌套调用。对于大多数团队来说这个心智负担是巨大的。第三虚拟线程已经能覆盖绝大多数场景。既然我们可以用同步代码实现接近响应式的线程利用率那为什么不选更简单的方案我在生产环境的数据时虚拟线程方案在 300~500 并发下的延迟表现和资源使用并没有比 WebFlux 差多少而代码可维护性、对普通 Java 工程师的上手难度明显优于 WebFlux。当然如果你的团队本来就深谙响应式编程或者构建的是纯网关型服务、几乎不写业务逻辑那 WebFlux 依然是好选择。但对大多数做 AI 应用后端的团队我的建议是优先虚拟线程同步代码只有在明确需求支撑下才上响应式。技术选型从来不是追新而是选那个让团队整体交付更稳的方案。4. 高并发下的整套工程策略连接池、超时、限流与降级异步化解决的是线程等待问题但在 AI 高并发场景光有异步还不够外部依赖的连接管理、流量控制和隔离策略同样决定了系统的天花板。4.1 连接池参数为什么不能沿用纯数据库项目的那套我在改造过程中发现很多 AI 服务最开始的 HTTP 连接池配置就是从老项目里抄过来的——maxConnections 设成 50readTimeout 设成 5 秒。这套参数对普通 API 没问题对大模型接口就翻车了假设有 50 个连接每个请求平均占用 3 秒那每秒钟最多只能发出 50/3≈16 个请求。一旦业务方有 20 个并发连接池就开始排队延迟迅速上升。正确做法是把大模型调用单独拆出来配一个独立的 HTTP 连接池参数按照大模型请求的特征来定。比如你希望模型调用并发上限是 200单请求平均耗时 5 秒那连接数必须大于 200否则连接池本身会成为瓶颈。我通常会预留 30%~50% 的余量比如把 maxConnections 配到 300。同时连接超时和读取超时一定要分开。连接超时可以短一些比如 3 秒读取超时要根据模型接口的响应特点灵活配置流式接口还要区分 TTFB首个 token 时间和整体完成时间。为了防止某些慢请求长期霸占连接我还会为每类上游大模型单独设置最长读取时间并在超时后主动断开连接让连接池尽快回收资源。这里有一个小技巧在 HTTP 客户端层面用evictIdleConnections定期清理空闲连接并搭配connectionTimeToLive防止对方服务端主动关闭连接后本地还留着半死连接。4.2 等待队列、舱壁隔离与信号量高并发设计里除了线程和连接还有一个重要的概念是舱壁隔离Bulkhead。传统做法是为每个下游服务单独分配线程池防止一个下游拖垮整体。在 AI 场景里我还加了一层信号量隔离。为什么要单独说信号量因为虚拟线程虽然便宜但每个上游模型连接还是有上限的。假设你有 3 个模型A 模型响应快、B 模型响应慢、C 模型老不稳定。如果所有流量都往同一个连接池里挤A 模型的请求可能会被 B 模型的慢请求挤到超时。所以我给每个模型单独做了 Semaphore 限流申请到信号量才允许发起请求否则直接拒绝或排队。Semaphore modelASemaphore new Semaphore(100); Semaphore modelBSemaphore new Semaphore(50); public ModelResult callWithLimit(String model, ModelRequest request) { Semaphore semaphore model.equals(modelA) ? modelASemaphore : modelBSemaphore; if (!semaphore.tryAcquire(500, TimeUnit.MILLISECONDS)) { // 500ms 内没有拿到信号量快速失败返回降级提示 return ModelResult.degraded(too many concurrent calls for model); } try { return doCallInternal(model, request); } finally { semaphore.release(); } }等待队列的设置也很讲究。不少团队会给 Semaphore 配一个无限等待结果上游抖动时所有请求都积压在信号量上跟线程阻塞也没本质区别。我个人的习惯是信号量获取超时设得很短比如 300~500 毫秒。拿不到就返回 429 或降级内容让客户端尽快感知压力而不是无限等下去。配合限流器比如 Guava RateLimiter 或 Resilience4j把过大流量拦在业务逻辑执行之前。4.3 缓存与语义缓存减少一次外部调用就是提升一倍并发高并发设计里最爽的优化是少做一次慢操作。对 AI 应用来说最慢的操作就是调用大模型。哪怕你的异步、限流做得再好每秒钟最多能支撑的模型调用次数是固定的。减少调用量等于直接提升并发上限。缓存分成两层。第一层是常规缓存很多用户请求是短时间内重复的比如同一个问题、同一套参数。直接用 Caffeine 做本地缓存TTL 设 5 到 10 分钟能挡掉不少重复流量。第二层是语义缓存这一层更高级不是缓存完全相同的问题而是缓存意思相同的问题。这需要把用户问题做向量化去向量数据库里做相似度检索如果命中相似度超过阈值的缓存结果就直接返回历史答案。这种机制能覆盖很多换一种问法但本质相同的请求效果非常明显。但是语义缓存有个隐患大模型输出是生成式的同一个问题在不同时间、不同参数下会给出不同的答案直接缓存全文可能导致用户拿到过时信息。我在生产环境里做的是部分语义缓存只缓存一些相对稳定的回答比如代码解释、知识问答、翻译内容。凡是涉及最新资讯、个性化推荐、可验证事实的动态场景缓存策略不同比如只对请求参数完全一致模型参数确定的结果做短 TTL 缓存。这里需要产品层配合去约束用途技术只是提供一个可靠的缓存框架。5. 一次真实的高并发压测与完整排查链路理论讲完我把一次真实的压测过程完整还原出来这条排查链路对同样做 AI 后端的人应该很有参考价值。5.1 问题复现线程没爆CPU 没占满延迟却崩了压测场景很简单模拟 200 个并发用户向我们的 AI 聚合网关发请求网关再调用一个模拟的大模型上游接口该接口固定延迟 2 秒返回。压测刚开始的 10 秒一切正常接口平均 RT 在 2.2 秒左右。但从第 15 秒开始RT 开始飙升30 秒之后平均 RT 到了 7 秒以上报错率也明显上升。第一次排查时我看监控面板Tomcat 线程数才用了 80%CPU 没超过 30%内存也很健康。看起来哪哪都没有爆但接口就是慢了。这是 AI 应用排查时最迷惑人的一点——表面上资源全部宽裕实际上链路某处的队列已经满了。5.2 排查过程顺着调用链逐层找最后定位到一个连接池我没有直接改代码而是按照调用链一层层看先看最外层 HTTP 入口线程池。Tomcat 总共 200 线程压测 200 并发每个请求还要等待上游 2 秒所以线程池很快被占满——这其实是最先暴露的瓶颈。理论上要提高 Tomcat 的 maxThreads 或直接换虚拟线程。但我不想先动入口因为我知道内核问题不完全在入口。就算 Tomcat 线程不爆下游调用层的连接池也会爆。再看网关到上游模型的 HTTP Client 连接池。当时配的是 max 50 连接每个请求占用 2 秒最大吞吐只有 25 QPS。压测 200 并发意味着大部分请求都在等连接。这时候 RT 已经是 4 秒以上了。再往深处看连接池的等待队列是默认的无界队列导致大量请求堆积在 HttpClient 内部既不超时也不失败就是干等。等到前面的请求结束后后面堆积的请求一个个缓慢执行系统进入延迟螺旋。另外我还在监控里看到了一个隐蔽问题触发了大量连接重建。因为连接被长时间占用池子又不够线程频繁地创建新连接、销毁旧连接每次 TCP 握手在高压下也需要几十毫秒进一步拖慢了响应。整个链路梳理下来问题本质有两个连接池容量和请求耗时不匹配等待队列无界导致资源被无限占用。5.3 修复与效果对比修复动作分三步落地第一引入虚拟线程处理外层入口和上游调用把平台线程数从 200 降到 64 左右同时让虚拟线程排队不再让平台线程池成为吞吐量的天花板。第二升级连接池配置maxConnections 从 50 调到 300并设置等待获取连接的超时时间超过 1 秒直接快速失败读取超时根据模型接口需求设为 10 秒连接超时设为 3 秒。加上空闲连接定期清理防止半死连接累积。第三加信号量限流和舱壁隔离每个模型独立信号量把并发数控制在真实容量以内超出部分返回明确的 429 降级响应而不是让它们排队等死。修复后同一轮压测200 并发下平均 RT 稳定在 2.3 秒左右报错率降到 0.5% 以下线程利用率反而比之前更低。原因是虚拟线程本来就不占多少平台线程连接池容量也够用请求不再互相等待。这个对比让我非常确信AI 后端性能问题很多时候不是算力问题而是并发资源的调度问题。6. 留给后来者的一些深入建议从整体项目经验看Java AI 应用的异步化与高并发设计其实没有一个万能答案。每条技术路线都有自己适用的边界但有几个判断方向是可以借鉴的。先说说技术栈的优先级。如果团队从零开始做一个 AI 后端我的选择顺序是优先 Java 21 虚拟线程把异步化和高并发的工作大量下沉到 JVM 层面如果存在复杂的多任务编排依赖再在局部引入 CompletableFuture只有当你明确需要极致的连接数控制和事件驱动模型时才考虑 WebFlux。这个顺序不是贬低响应式而是基于多数团队的可维护性 极限性能这个考量。再说说并发模型的边界。虚拟线程的确大幅简化了 Java 高并发开发但它的定位是让阻塞 IO 变得更便宜不代表你可以不看资源上限。连接数量、信号量、限流策略、超时时间这些决定系统真实承载能力的因素一个都不能少。否则就算有十万个虚拟线程连接池只配了 50 个连接照样会被卡死。最后关于 AI 场景本身无论异步化方案选得多好不要忽略业务层面的降级设计。大模型 API 的不稳定性是常态你的架构里必须包含模型调用失败时该返回什么兜底数据所有模型都超时时用户体验如何保障这些预案。在做技术选型和代码评审时多问问当一个上游模型出现抖动我的服务是优雅降级还是跟着一起崩溃这个问题思考清楚了高并发的设计才算真正闭环。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑