ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

Java AI应用高并发设计:异步化、虚拟线程与稳定性实战 1. 先回答那个最现实的问题AI应用为什么会被并发击垮1.1 AI调用链路比传统接口慢一个数量级我见过太多团队把普通Web应用的那套同步模型直接搬到AI应用上上线第一天就被真实流量打懵。原因其实很简单传统接口的响应时间普遍在几十毫秒到几百毫秒而AI应用的核心链路——LLM推理——动辄要2到30秒这还只是模型自身的生成时间。如果你接入的是外部大模型API还要再加一层网络往返和上游排队时间。算一笔账你就明白了。假设Tomcat默认工作线程池是200一个AI请求平均耗时5秒。在同步阻塞模型下系统的极限吞吐大约是吞吐量 ≈ 线程数 ÷ 平均响应时间 200 ÷ 5 40 QPS也就是说每秒超过40个请求线程池就会被打满后续请求全部进入队列等待。当排队时间超过前端设置的超时阈值用户看到的就是转圈、超时、报错。而这时候你去查CPU往往只有10%左右——线程全阻塞在等待AI响应的路上计算资源在空转。这就是AI应用高并发设计最核心的矛盾慢接口 同步线程模型 资源被无效占用。1.2 高并发设计不是扛流量而是让等待不再占线程很多人的第一反应是扛不住就加机器但加机器解决的是吞吐问题解决不了资源利用率的问题。AI场景下每条请求里有大量时间花在等待外部模型响应上这是纯粹的IO等待。同步模型里一个线程被一个请求独占从发起到响应结束线程全程跟着走——等待期间它什么都干不了。高并发设计的本质是识别出哪些是计算、哪些是等待然后把等待从线程里解放出来。传统同步模型就像你去银行办事一个柜员全程只服务你一个人哪怕你在旁边填表填了10分钟柜员也得干等着。异步化相当于你填表的时候柜员先去服务别的客户等你的材料好了再回来处理。对Java应用来说这涉及两条技术路线一条是把阻塞操作放到固定的线程池外用回调或CompletableFuture编排异步流程另一条是Java 21之后引入的虚拟线程让虚拟线程以极低的创建成本坐等阻塞平台线程穿梭调度。两种路线各有适用场景后面我会分别讲清楚实现细节和选择依据。2. 异步化的三条路线程池、CompletableFuture与虚拟线程怎么选2.1 线程池先行最稳妥的异步化改造先别急着上虚拟线程。对大多数存量Java应用从同步改造成业务线程池Couroutine式编排是风险最低的第一步。先说线程池的配置。很多人的配置方式是搜一个模板抄过来比如corePoolSize CPU核数 × 2这在纯计算场景下是对的但AI应用是典型的IO密集场景固化公式用不得。更合理的估算方式是线程池大小 CPU核数 × (1 平均等待时间 / 平均计算时间)假设你的服务是4核调用AI平均等待2秒本地计算平均0.05秒那合理的线程池大小大约是4 × (1 2 / 0.05) 164这个公式的核心思想是等待占比越高线程数就可以越大但线程数的增长不是为了并发执行更多计算而是为了并发发起更多等待中的调用。实际落地时我不会一次配满而是先配计算值的70%再压测逐步上调给系统留出缓冲。线程池的选择要区分两个池子一个是接收HTTP请求的容器线程池Tomcat的maxThreads另一个是执行AI调用等阻塞任务的业务线程池。很多新手直接在Controller里同步调用AI服务等于让Tomcat工作线程去承担等待一旦上游慢Tomcat线程池迅速耗尽整个应用的静态资源、健康检查接口全部跟着阻塞。正确做法是Controller层接收请求后立即提交给业务线程池快速释放容器线程。拒绝策略方面我推荐CallerRunsPolicy的变体——不是直接让提交线程去跑任务而是配合限流器先判断当前是否过载过载则直接返回503。这个后面在稳定性兜底章节细说。2.2 CompletableFuture异步编排的正确姿势线程池解决了谁来执行的问题但AI应用往往不只是单次调用。比如一个RAG应用要先做向量检索、再调LLM生成、甚至多个模型并行输出后做融合这就涉及异步任务编排。CompletableFuture是Java异步编排的基石但很多人用的时候姿势不对。先看基础用法。假设我们需要并行调用两个独立的AI模型一个做摘要一个做情感分析等两者都完成后再统一返回public CompletableFutureAnalysisResult analyze(String text) { ExecutorService aiPool AiThreadPool.getInstance(); CompletableFutureString summaryFuture CompletableFuture.supplyAsync(() - llmService.summarize(text), aiPool); CompletableFutureString sentimentFuture CompletableFuture.supplyAsync(() - llmService.sentiment(text), aiPool); return summaryFuture.thenCombine(sentimentFuture, (summary, sentiment) - new AnalysisResult(summary, sentiment)) .orTimeout(10, TimeUnit.SECONDS); }这里几个容易被忽略的点supplyAsync的第二个参数必须传业务线程池否则会使用公共的ForkJoinPool.commonPool()。在高并发下这个公共池的并行度默认是CPU核数减1一旦被阻塞任务占满其它使用commonPool的代码会被无差别拖垮。orTimeout放的位置有讲究。放finally链路的最外层是对整体耗时的兜底如果要精确控制每步耗时应该在每个关键分支上分别挂超时。异常处理不要只做exceptionally兜底还要在回调里打全链路日志。异步链路里异常传播链很容易断出问题后排查困难。我习惯在每层回调都挂一个whenComplete记录耗时与异常线上排障时这些日志是救命稻草。2.3 虚拟线程Java 21之后的异步化新解法说句实话虚拟线程是这几年Java并发领域最值得关注的变革没有之一。传统异步化再怎么写代码里到处是回调、Future、编排逻辑可读性很受影响。虚拟线程的目标简单粗暴让阻塞式代码重新变得高效平台线程不再被占用虚拟线程创建成本几乎为零最多可以创建数百万个。对AI应用来说受益最明显的场景就是大量并发调用外部模型API。代码可以保持同步风格// 虚拟线程环境下直接同步调用不需要改业务代码 public Response handle(Request req) { String r1 llmService.call(req.getPrompt()); String r2 ragService.retrieve(req.getQuery()); return merge(r1, r2); }容器层面Tomcat JDK 21可以配置用虚拟线程处理请求每来一个请求起一个虚拟线程阻塞时平台线程自动调度去服务别的虚拟线程。吞吐量在IO密集场景下提升非常显著。但虚拟线程有几个坑我必须提醒第一synchronized块内发生阻塞会导致pinning问题。虚拟线程在ReentrantLock上等待会释放载体平台线程但在synchronized代码块里阻塞时底层平台线程会被钉住而无法释放。如果依赖的SDK或老代码大量使用synchronized虚拟线程的优势会被抵消。排查方法是用JDK的jcmd Thread.dump_to_file看线程转储中是否存在pinned标记。第二虚拟线程不适合CPU密集任务。它解决的是等待资源问题计算密集任务仍然需要真正的平台线程。所以虚拟线程池可以不给核心线程数上限但需要限制整体并发量防止疯狂创建虚拟线程打爆下游连接数或内存。第三ThreadLocal的兜底调整。虚拟线程很多ThreadLocal的存活数量和系统线程数不是一个量级传统的用ThreadLocal做上下文透传在虚拟线程场景下需要谨慎评估内存回收频率。我给的方案是能用参数传递就用参数传递必须用上下文透传时考虑InheritableThreadLocal已被标记为不建议在虚拟线程使用可以自行封装轻量map传入。3. 请求削峰与批量聚合把碎片化AI调用变成可控负载3.1 排队削峰用户不感知的等待同步模型面对突发流量会直接击穿全异步模型虽然提升了吞吐但如果下游AI服务有速率限制很多外部模型API按TPM限制每分钟只让你调几十万token上游再异步也没用。这就是削峰存在的意义。我的做法是在AI调用层之前放一个有界队列配合一组固定数量的消费者线程。入口请求入队即返回一个任务ID前端通过轮询或推送获取结果。这个过程用户是无感知的因为AI应用本身响应就慢用户默认会等待。队列的好处是缓冲突发流量达到削峰填谷的效果消费者按固定速率消费天然匹配下游的速率限制队列有界超出容量直接返回系统繁忙避免无界堆积引发OOM实现上我推荐ArrayBlockingQueue而不是LinkedBlockingQueue前者有界且底层数组可以预分配不会因为链式节点累积触发频繁GC。队列容量一般设为单消费者QPS × 峰值持续秒数 × 系数比如下游每秒只能消费5个AI请求预计峰值能持续30秒容量就设为150再乘1.2留余量。3.2 批量聚合把N个相似请求合并成一次调用这是很多人没意识到的优化点。AI推理服务大多支持batch推理把多条prompt拼成一个批次输入算力是共享的单条成本的边际递减非常明显。对我的场景来说尤其适合Embedding调用和短文本分类这类请求。实现一个通用的批量聚合器并不复杂。核心思路是请求进来先投到一个暂存队列由一个定时任务每隔固定时间比如20毫秒把攒下的请求打包成批次一次性发给AI服务响应再按顺序拆分返还给各个调用方public class BatchAggregatorT, R { private final BlockingQueueRequestHolderT, R pendingQueue; private final ScheduledExecutorService scheduler; private final BatchHandlerT, R batchHandler; private final int batchSize; private final long windowMillis; public void submit(T item, CompletableFutureR future) { RequestHolderT, R holder new RequestHolder(item, future); pendingQueue.offer(holder); } private void drainBatch() { ListRequestHolderT, R batch new ArrayList(batchSize); pendingQueue.drainTo(batch, batchSize); if (batch.isEmpty()) return; // 异步执行批处理避免阻塞调度线程 executor.submit(() - { ListR results batchHandler.handleBatch( batch.stream().map(RequestHolder::getItem).toList()); for (int i 0; i batch.size(); i) { batch.get(i).complete(results.get(i)); } }); } }调度窗口的选择有讲究窗口太短攒不够批次数聚合效果差窗口太长单条请求延迟被拉高影响体验。我的经验值对Embedding这类毫秒级响应的服务窗口取10到20毫秒对LLM生成类服务窗口取100到200毫秒因为用户对首token的等待容忍度本身就在秒级。什么场景不适合批量聚合答案是流式输出场景和强个性化场景。流式输出天然希望首token尽快返回攒批次与流式诉求相悖强个性化场景每条prompt差异巨大拼批次反而会互相污染注意力计算这种就别硬聚合了。3.3 下游限速的应对实例我在实战里碰过一个很典型的案例接入某Embedding服务单账号TPM限额是100万token每分钟我们单条请求平均输入token约500换算下来每秒最多约33个请求。压测时一梭子到了80 QPS结果上游直接开始429。最后就是靠聚合限速两个手段一起解决聚合让每个批次合并成一个大请求单批token数上去但请求次数下降配合一个固定速率的数据包调度器把实际请求速率压到限额以下。改造完成后同样的业务量上游429从每分钟几十次降到了零。4. 流式响应让AI回答像打字一样从服务端吐出来4.1 为什么流式响应对并发是正向帮助很多人看到流式第一反应是HTTP连接长时间占用会不会把连接池打爆这里要区分连接和线程两个维度。传统的同步接口一个请求占一个线程直到响应完成而流式响应配合Servlet异步化或WebFlux占用的是连接资源平台线程在每次数据块写出后就释放了。更重要的是用户体验指标。同步等待30秒才出结果的接口用户大概率以为服务挂了改成流式后首token在1秒内返回用户看到字符一个个蹦出来感知到的延迟从30秒骤降到几乎即时。这不仅是体验问题还减少了用户重复刷新带来的额外请求——重复刷新在AI应用里非常常见是隐藏的并发杀手。4.2 基于Servlet异步 SSE的落地实现用Spring MVC SSE实现流式的标准姿势是SseEmitter。但要注意的是SseEmitter本身只是事件推送机制真正阻塞的环节在于调用LLM服务的IO操作。必须把这个IO调用放到单独的线程池执行容器线程才能释放Controller RequestMapping(/ai) public class AiStreamController { private final ExecutorService aiExecutor; GetMapping(value /chat, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chat(RequestParam String prompt) { SseEmitter emitter new SseEmitter(300_000L); // 5分钟超时 aiExecutor.submit(() - { try (var response llmService.streamChat(prompt)) { response.tokens().forEach(token - { emitter.send(SseEmitter.event().data(token)); }); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; } }这套实现里有几个细节直接影响稳定性心跳机制。长时间没有数据推送时代理服务器或负载均衡器会因空闲超时掐断连接。需要定时发送注释行:保持连接活跃。SseEmitter支持通过WebAsyncManager配置拦截器做心跳或者起一个定时任务周期性地send(comment)。连接超时与线程回收。SseEmitter构造参数是超时毫秒值超时后容器会回调onTimeout。很多人在这个回调里什么都不做结果下游线程池里那个还在阻塞调用LLM的任务不会被释放最终堆满线程池。所以onTimeout里必须配合future的取消逻辑把下游调用一并取消。网关的坑。Nginx默认会对响应做缓冲导致SSE的每个字符攒满4KB才发一次用户看到的就是卡顿、一块一块地输出。必须在Nginx的location中设置proxy_buffering off; proxy_cache off; proxy_read_timeout 300s;另外如果开启了Gzip压缩Nginx会等整个batch压缩完才发同样破坏流式效果。对SSE路径要单独禁用Gzip。4.3 WebFlux与虚拟线程流式的取舍Spring WebFlux是另一个流式方案全链路非阻塞。优点是从底子就为长连接高并发设计缺点是编程模型对团队要求高调试门槛高。我的建议是新项目、团队熟悉响应式范式的可以考虑WebFlux存量Spring MVC项目优先用SseEmitter 业务线程池改造收益已经足够。虚拟线程方案下代码可以保持传统的阻塞式写法GetMapping(/chat-stream) public void chatStream(RequestParam String prompt, SseEmitter emitter) { // 虚拟线程直接阻塞式调用代码简单但效果等同异步 try (var response llmService.streamChat(prompt)) { response.tokens().forEach(token - send(emitter, token)); } }关键在于配置。Spring Boot 3.2 JDK21的环境里把Tomcat的请求处理切换到虚拟线程只需要设置系统属性-Dspring.threads.virtual.enabledtrue并确认Tomcat版本支持。但别忽略前面说的pinning问题——如果模型SDK内部用了synchronized连接池流式场景下虚拟线程优势可能大幅缩水。建议上线前用压测验证虚拟线程与平台线程在具体SDK下的实际表现不要只看Demo数字就下结论。5. 高并发下的稳定性兜底限流、熔断、超时与优雅降级5.1 限流阈值不是拍脑袋定的从压测反推限流最怕的不是不设而是随便设一个数然后自我感觉良好。我见过太多团队限流阈值拍脑袋填100QPS结果真实AI调用单路只能扛30QPS上线第二天满屏503。正确做法是压测反向推导。步骤如下固定一个请求比如固定的prompt长度和模型大小压测工具从低到高逐步加压观察CPU、内存、下游错误率、响应P99找到性能拐点——即继续加压时P99显著恶化或错误率超过1%的那个QPS取拐点QPS的70%作为线上限流阈值给突发流量留缓冲我用过的一个参考配置4C8G、接外部LLM API、平均响应2秒是这样的指标值压测拐点62 QPS线上限流阈值43 QPS限流算法令牌桶令牌桶容量30每秒填充速率43这里选择令牌桶而不是固定窗口的原因是AI业务的请求耗时会剧烈波动固定窗口在窗口边界容易出现两倍流量冲击上窗口余量下窗口配额叠加令牌桶天然平滑了这种突发。限流维度建议从三层同时入手入口层按QPS限流拦全局AI调用层按并发数限流保护线程池和下游如果再细一点可以按token消耗预算限流保护费用预算。第三层在接入付费大模型API时特别有用防止失控调用把月底账单打爆。5.2 熔断与降级上游坏了不能让整个应用陪葬AI应用对上游模型的依赖极强但上游不总是可靠的。模型扩缩容、限流策略调整、网络波动随时可能让你的API调用批量失败。熔断器是这层防护的标准答案。我用的是Resilience4j CircuitBreaker配置思路resilience4j.circuitbreaker: instances: llmService: slidingWindowSize: 30 failureRateThreshold: 50 waitDurationInOpenState: 10s permittedNumberOfCallsInHalfOpenState: 5 recordExceptions: - java.io.IOException - feign.FeignException$TooManyRequests ignoreExceptions: - com.example.TooManyTokensException几个配置项的考量recordExceptions要精确。最常见的错误是把超时和连接错误都算进去但把业务侧的参数错误也记成失败熔断会被误触发。waitDurationInOpenState别设太短10秒比较稳妥。太短的话上游还没恢复半开试探一打又是批量失败。熔断后的降级方案要有层次第一层返回最近一次相似问题的缓存回答适合FAQ类场景第二层换更小的模型处理适合对质量要求不高的场景第三层明确告知用户当前AI服务繁忙请稍后重试而不是让用户无限转圈降级逻辑要提前想好并写进代码而不是等事故发生再临时想方案。事故现场临时写降级代码几乎不可避免地带bug上线。5.3 超时设计的层次感AI应用的超时设计比普通接口复杂得多因为一条用户请求背后可能是3次模型调用而每次模型调用的耗时又高度不确定。超时设计要分层次超时类型推荐值说明连接超时3秒对端建立TCP连接超时3秒足够再多就是网络问题读取超时首字节15秒请求发出后等待第一个token返回的时间模型排队太久说明已过载整体调用超时30秒单次模型调用最长容忍时间覆盖生成时间用户请求总超时45秒包括多次AI调用、后处理、网络传输的累计时间超时参数特别容易在连接池层被忽略。像Apache HttpClient或OkHttp的connection manager如果没设空闲回收策略长时间运行后大量半开连接堆在连接池里AI服务一抖动连接建立失败会集中爆发。配合evictExpiredConnections和空闲连接检测是必备操作。还有一个我踩过很多次的坑多个AI任务并行时的超时不能各自独立。假设一个编排里有3个并行子任务每个子任务超时是30秒整体超时也是30秒那么一旦整体超时触发必须同时cancel所有子任务对应的future。否则子线程池里的任务会继续执行占用下游配额浪费算力。代码上就是维护一个子任务Context整体超时后用future.cancel(true)逐个打断。6. 实测记录一次完整的高并发压测与参数调整过程6.1 压测环境与三轮测试设计理论讲完了分享一次我实际做过的压测记录你会更直观看到这些设计思路是怎么互相作用的。压测环境4核8G容器Spring Boot 3.2 JDK21模拟AI服务固定延迟2秒且支持并发。目标在200持续并发下寻找系统吞吐瓶颈并对比三种模型——同步基线、业务线程池异步、虚拟线程。压测脚本用wrk每个场景持续5分钟分别记录QPS、P99、错误率和CPU均值。6.2 基线数据同步模型是真的顶不住第一轮直接测同步模型。结果应验了我们的计算Tomcat默认200线程平均响应2秒理论吞吐极限100QPS实际测下来稳定在82QPSP99高达3.8秒CPU利用率只有28%。线程转储一抓198条线程全部处于TIMED_WAITING状态全部阻塞在模拟AI调用的Thread.sleep上。这个结果非常有说服力——系统看起来很忙其实全部线程都在睡大觉。加机器确实能线性扩吞吐但那是拿更多CPU换取更低的利用率成本高且不可持续。6.3 业务线程池异步吞吐翻倍但出现长尾延迟第二轮用业务线程池改造线程池配置为new ThreadPoolExecutor( 120, // corePoolSize 200, // maximumPoolSize 60, TimeUnit.SECONDS, // keepAliveTime new ArrayBlockingQueue(200), // 有界队列 new ThreadPoolExecutor.CallerRunsPolicy() );测试结果QPS上升到156CPU利用率提升到55%看起来合理了不少。但P99反而劣化到了5.2秒。排查后发现瓶颈在CallerRunsPolicy——队列塞满后任务直接跑回Tomcat工作线程这些线程本身还在处理新的HTTP请求一个线程里嵌套跑AI调用旧任务拖慢新任务的处理节奏产生明显的长尾。这个发现让我放弃了CallerRunsPolicy改用自己的过载保护逻辑队列满时直接抛异常给上层由入口拦截器统一返回503。长尾问题随即消失P99回到2.4秒。6.4 虚拟线程吞吐登顶但遇到pinning问题第三轮切到虚拟线程业务代码回到同步写法去掉了自定义线程池。第一轮数据非常亮眼QPS冲到210CPU利用率首次突破70%线程转储显示平台线程只有16个在忙碌虚拟线程数量超过1000但创建和销毁接近零成本。不过在压测跑到第3分钟时P99突然从1.8秒跳到6.7秒。抓线程转储发现大量虚拟线程被pinning在synchronized块上——模拟AI服务的HTTP客户端库内部连接池用了synchronized保护。这就是虚拟线程最典型的陷阱。处理方案有两个方向一是换用基于ReentrantLock的HTTP客户端比如新版OkHttp或JDK内置HttpClient二是给这部分调用单独配置一个小的平台线程池兜底回避pinning热点。我们最后采用了新HttpClientpinning问题消除P99回落到1.5秒。6.5 最终配置与数据对比三轮压测的最终结果方案QPSP99CPU均值备注同步基线823.8s28%线程全阻塞扩机器利用率仍低业务线程池 过载保护1562.4s55%适合存量项目改造量小虚拟线程 新HttpClient2101.5s74%吞吐最高需排查SDK兼容性最终线上采用虚拟线程方案同时给AI调用层加了限流阈值设为180QPS留了约15%余量、熔断失败率超50%熔断10秒、和三级降级策略。上线后高峰期从没出现过线程池耗尽告警P99稳定在2秒以内。如果你准备动手优化自己的Java AI应用我的建议是从第二套方案业务线程池起步。它改动可控、风险低、效果显著跑稳之后再评估要不要切虚拟线程。直接上虚拟线程而底层HTTP客户端不支持这坑我已经替你踩过了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进