
最近在做一个 Java AI 应用的后端架构改造主业务是对话机器人、Agent 任务和批量内容生成。上线后第一个“惊喜”来自并发——高峰期线程池被打爆、接口超时、用户不断重试整个服务像被掐住脖子一样喘不过气。排查到最后发现问题不在代码逻辑而在整个架构还停留在传统 Web 接口的同步阻塞思维里。这篇文章把我这段时间验证过的异步化思路、可直接落地的代码骨架以及踩过的坑整理成体系重点讲清楚三件事为什么 AI 应用的高并发和传统 Web 高并发完全不是一回事异步化到底要解决哪些核心矛盾以及流式响应、任务编排、虚拟线程、批处理削峰这些手段分别怎么用。适合正在做 Java AI 后端、准备 Java 面试中“多线程与高并发”部分或者正在被线程池问题折磨的工程师参考。1. AI应用为什么绕不开异步化先算清慢接口这笔账1.1 AI接口比CRUD慢了一个数量级资源黑洞怎么算传统 Web 接口比如用户查询订单、刷新列表一次数据库查询加一点计算几十毫秒就返回了。Tomcat 默认 200 个线程每个请求占一个线程的时间极短系统就能扛住很高的 QPS。哪怕业务线程池稍微小一点只要单个请求处理时间够短吞吐量依然可观。但 AI 接口完全是另一个物种。一次大模型调用哪怕是轻量模型也要两三秒起步复杂一点的推理、长文本生成、多步 Agent 链路七八秒甚至半分钟都很正常。我打个比方同样是 200 个线程的线程池处理 50ms 的请求理论上每秒最多能处理 4000 个请求处理 20 秒的 AI 请求理论上每秒最多只能处理 10 个。这中间差了整整两个数量级而且这还没算用户等待时的重试流量。更麻烦的是AI 接口的慢不是代码 bug而是业务属性。你不能让大模型“快一点”把结果算完只能从架构上想办法让慢请求不要一直占着宝贵的线程资源。很多团队刚开始做 AI 应用时习惯性地把大模型 HTTP 调用写在 Controller 里同步执行结果一压测就发现线程池被打满其实不是并发量真的有多大而是每个请求占用的时间太长了。理解这一点才算迈进了 AI 高并发设计的大门。1.2 同步阻塞的三宗罪线程占着、CPU空转、超时连坐同步阻塞写起来最顺手但在 AI 场景下会带来三个连锁问题。第一是线程占用。一个请求进入 Tomcat从进入 Controller 到拿到大模型完整响应这一个线程全程被占住。用户绝不愿意在页面等 20 秒于是会反复刷新、重发请求这些新增请求继续抢占线程很快线程池就满了后面进来的请求只能排队或者直接被拒绝。第二是 CPU 空转与资源浪费。线程在等待网络响应时处于阻塞状态本身不消耗大量 CPU但 JVM 要为每个线程维护独立的栈空间和调度信息。Tomcat 默认 200 个线程每个线程默认栈大小 1MB 左右光栈内存就是几百 MB。而且线程上下文切换也是开销大量线程卡在等待上系统资源并没有被用在真正有价值的地方。第三是超时连坐。客户端等不到响应会超时超时后重试重试又占用新的线程。一旦上游 AI 服务变慢所有调用方请求翻倍涌进来你的服务线程池被占满紧接着你的下游也没法正常工作。这不只是性能问题是故障传播问题。我习惯用一个生活场景来理解餐厅有 200 个座位每桌客人坐下后平均要等 30 分钟才上菜等不及的客人不断催单、换桌导致所有座位被人占满真正想好好吃饭的人反而进不来。同步阻塞在 AI 场景下的核心问题就是让“等待”这件事无序地占满了所有座位。1.3 异步化的两条主线接入层让位与任务级编排既然同步阻塞不可取异步化就成了必然选择但异步化不是一个“new Thread()”那么简单。我习惯把异步化拆成两条主线来看。第一条主线是接入层让位。核心目标是把 HTTP 线程尽可能早地释放回容器线程池让 Tomcat 的 200 个线程只负责接收请求、快速返回实际的 AI 调用放到另一个线程池、虚拟线程或者异步事件驱动中去执行。典型手段包括 Servlet 异步化、SSE 流式响应、WebFlux 响应式编程以及 JDK 21 的虚拟线程。第二条主线是任务级编排。单个 AI 请求里可能包含多次模型调用比如意图识别、工具调用、最终生成这些调用之间有的有依赖有的能并行。用 CompletableFuture 这类工具把任务串成流水线能并行的并行能提前返回的提前返回能流式的流式。整体等待时间取决于最慢的那个环节而不是所有环节加起来的总和。我把这两条主线浓缩成三句口诀能立即返回的就立即返回不能立即返回的就流式返回大批量任务就排队异步处理。后面的实操内容全部围绕这三句话展开。2. 三个核心抓手流式响应、任务编排与异步批处理2.1 流式响应SSE把长时间请求切成一段段小响应先说说 AI 应用里最典型、收益也最明显的改造流式响应。早期我做 AI 对话接口习惯把大模型的完整输出拼好一次性返回给前端。前端接收后展示出来体验上有个致命问题用户点完发送后要干等好几秒页面上一个字都没有。后来改成 SSEServer-Sent Events大模型每生成一段内容就推送一次前端逐字输出打字机效果让等待感大幅降低。从高并发角度看流式响应的意义不只是体验。传统一次性返回接口HTTP 线程必须等到全量内容生成完毕才能响应如果生成需要 30 秒线程就被占住 30 秒。使用 SseEmitter 之后请求线程把 SseEmitter 对象交给业务线程池随后立即返回Tomcat 线程被释放。业务线程不断地把生成片段推给客户端容器线程池的占用时间被压缩到毫秒级。这里要澄清一个容易误解的点SSE 只是改变了占用线程的位置并没有减少总的连接数。连接依然是长连接只是不再占用 Tomcat 业务线程池里的少数线程而是由专门的异步线程或虚拟线程来维护。配合虚拟线程连接占用的成本极其低廉扛几万路并发推送也没有问题。前端接入也很简单浏览器原生 EventSource 就能接收不需要额外引入 WebSocket 库。选择 SSE 还是 WebSocket我的经验是纯对话、文本生成类场景SSE 够用需要服务端主动推送消息给客户端比如 Agent 在执行多步任务时不断上报进度WebSocket 更合适。很多团队一上来就上 WebSocket其实对话场景用 SSE 省事得多。2.2 CompletableFuture编排Agent多个AI调用像流水线AI Agent 类应用比单纯对话复杂得多。一个典型的 Agent 流程可能是先做意图识别判断用户想干什么接着调用多个工具获取信息比如查天气、查库存、查价格最后把工具结果汇总给大模型生成最终回答。如果完全同步写整个链路是串行的耗时会非常夸张。以“查天气、查库存、查价格”三个工具调用为例如果每个调用要 5 秒串行就是 15 秒对用户来说已经不可接受了。但这三个调用之间并没有依赖关系完全可以并行并行后总耗时只取决于最慢的那个也就是 5 秒左右。这就是 CompletableFuture 最擅长的场景把无依赖的任务提交到不同线程并行执行再用编排方法把结果聚合起来。我常用的编排方法就那么几个supplyAsync 负责提交异步任务thenCompose 用于串联有依赖关系的任务allOf 用于等待所有并行任务完成orTimeout 用于给整条链路设置超时exceptionally 用于统一异常兜底。用一个流水线来类比多个窗口同时出餐主窗口并不等每个窗口全部完成而是等最慢的那个窗口完成后把菜品统一打包交给复核窗口。整体出餐时间取决于最慢环节而不是所有环节相加。编排时有个大坑默认的 ForkJoinPool.commonPool() 不能乱用。AI 调用普遍带着网络 IO一旦高并发公共池会被阻塞任务占满影响整个 JVM 里其他使用 CompletableFuture 的地方。我建议所有 AI 相关异步任务都走独立的业务线程池或者直接用虚拟线程线程隔离是高并发设计里最基本的一条红线。2.3 异步批处理与削峰把峰值任务排队而不是硬扛对话、Agent 是典型的实时场景还有一大类 AI 应用是批量场景给电商商品批量生成描述、给历史内容批量补审、给短剧脚本批量打分。这类任务的特点是单个任务不一定需要立即返回结果但量级大、峰值明显、对线程池冲击最强。以前团队习惯用定时任务同步拉取一批数据循环调用大模型一个批次几百条任务全部同步等待定时任务一跑就是几十分钟期间整个服务的线程池跟着遭殃。后来改成异步批处理请求进来后先把任务状态落库业务表或任务表里插一条记录再把任务 ID 写入有界队列接口立刻返回 taskId。后台消费者线程按可控的并发度从队列取任务逐个调用 AI 接口完成后更新数据库状态。前端通过轮询任务接口拿到最终结果。这个模式的本质是削峰填谷高峰期把任务暂存在队列里低谷期慢慢消费业务的峰值流量不再直接冲击 AI 服务和线程池。队列容量需要精心设计我的经验是按峰值任务量放大 1.2 倍左右设置上限避免内存无限膨胀。单机实例用内存队列就够多实例部署时需要换成 Redis Stream 或消息队列但思路一致。任务状态表是整个批处理链路的关键。我会给每个任务设计状态机待处理、处理中、成功、失败、补偿中。消费者执行前先 CAS 更新状态防止多个消费者重复消费同一任务。数据库落库和队列写入要放在同一个事务边界里避免任务丢失。这一步不做后续排查会非常痛苦。3. 实操落地从零搭建Java AI异步高并发骨架3.1 版本选型与工程结构调整先说选型。新项目我建议直接用 Spring Boot 3.2 和 JDK 21最大的理由是虚拟线程可以在接入层自动生效代码改动极小。如果你还在 Spring Boot 2.x 的老项目里CompletableFuture 和 SseEmitter 也都能用异步改造不受版本限制但虚拟线程带来的红利暂时吃不到长期还是建议升级。一个常见的误区是既然要异步化是不是就要上 WebFlux 全家桶我的看法是不要盲目切换。WebFlux 的响应式编程模型对团队心智负担很大排障链路也更复杂。对 90% 的 Java AI 应用来说Spring MVC 虚拟线程 CompletableFuture SSE 的组合已经能解决绝大部分高并发问题代码写起来还像同步代码一样直观。工程模块上我习惯分成四层Controller 层只负责接收请求和返回结果Application 层负责异步编排也就是把 AI 调用、工具调用、状态更新组织成流水线Domain 层封装大模型客户端适配Infra 层管线程池、队列、数据库操作。这样分层的核心价值是异步逻辑集中在 Application 层排查问题时不用在 Controller 里翻半天异步代码。3.2 虚拟线程接入层配置替换Tomcat线程池的心智负担JDK 21 虚拟线程最大的好处是阻塞不再浪费载体线程一个平台线程可以承载成千上万个虚拟线程。Spring Boot 3.2 开始配置虚拟线程变得非常简单在 application.yml 里加一行配置即可spring: threads: virtual: enabled: true开启之后Tomcat 的请求处理线程就切换成了虚拟线程。每个请求进来创建一个虚拟线程阻塞时虚拟线程自动从载体线程上卸载载体线程继续服务其他请求。我实测下来同样的 AI 同步调用代码开启虚拟线程后线程池不再被打满连接数可以承受比原来高一个数量级的规模。代码一行没改性能发生了质变这是 JDK 21 给 Java AI 应用带来的最大惊喜。如果你的项目因为各种原因用不了虚拟线程那就需要自定义业务线程池。推荐参数可以参考下面这个表但一定要根据实际压测数据调整参数建议值说明corePoolSize等于 CPU 核心数避免线程过多导致上下文切换maxPoolSizeCPU 核心数 * 2 到 4AI 调用以 IO 为主可适当放宽queueCapacity200~1000有界队列防止内存无限增长keepAliveSeconds60空闲线程回收时间拒绝策略CallerRunsPolicy / 自定义熔断不能简单抛出异常线程池的核心计算逻辑是IO 密集型任务线程数可以多于 CPU 核心数CPU 密集型任务线程数等于 CPU 核心数附近。AI 调用本质是昂贵的外部 IO所以 maxPoolSize 可以放宽但 queueCapacity 一定要有界否则高并发下队列撑爆内存比线程池满更致命。3.3 用CompletableFuture实现对话接口的非阻塞编排先给一个完整的对话接口异步化示例。假设有一个 AiChatService内部封装了对大模型的 HTTP 调用返回完整文本。异步化改造后的 Controller 可以这样写RestController RequestMapping(/api/ai) public class ChatController { private final ExecutorService bizExecutor Executors.newFixedThreadPool(32); private final AiChatService aiChatService; public ChatController(AiChatService aiChatService) { this.aiChatService aiChatService; } PostMapping(/chat) public CompletableFutureChatResponse chat(RequestBody ChatRequest request) { long start System.currentTimeMillis(); return CompletableFuture .supplyAsync(() - aiChatService.chat(request.getPrompt()), bizExecutor) .orTimeout(20, TimeUnit.SECONDS) .exceptionally(ex - buildFallbackResponse(ex, request)); } }这里几个细节值得展开。supplyAsync 指定了 bizExecutor而不是使用默认的 commonPool目的是隔离 AI 长时间调用可能带来的线程池污染。orTimeout 是 JDK 9 之后的好东西给整个链路设置 20 秒硬超时避免一个异常缓慢的大模型调用无限期占着线程。exceptionally 里返回了一个兜底响应即使大模型超时或报错用户也不会看到 500。这里要特别注意返回类型是 CompletableFutureController 方法本身是异步返回但 Spring MVC 对 CompletableFuture 有原生支持它会自动把业务线程池的结果转发到响应线程调用方无感知。这个模式下Tomcat 请求线程很快被释放真正的耗时发生在 bizExecutor 里。异步化不是不加线程而是把线程从容器池挪到了可控的业务池。如果后续要把这个对话接口改成流式输出CompletableFuture 这种整段返回的方式就不够了需要切换到 SseEmitter。这两种方式并不冲突实时的问答接口用流式后台处理类的接口用 CompletableFuture 更直观。3.4 用SseEmitter实现流式对话接口体验和线程释放双赢流式对话接口用 SseEmitter 实现核心思路是接口先创建一个 SseEmitter 返回给前端同时在业务线程池里执行大模型流式调用每收到一段生成结果就调用 emitter.send() 推送。完整示例RestController RequestMapping(/api/ai) public class StreamChatController { private final ExecutorService streamExecutor Executors.newVirtualThreadPerTaskExecutor(); PostMapping(/chat/stream) public SseEmitter streamChat(RequestBody ChatRequest request) { SseEmitter emitter new SseEmitter(60_000L); streamExecutor.submit(() - { try { aiChatService.streamChat(request.getPrompt(), chunk - { emitter.send(SseEmitter.event() .name(message) .data(chunk)); }); emitter.send(SseEmitter.event().name(done).data([DONE])); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); emitter.onTimeout(() - { emitter.complete(); }); emitter.onCompletion(() - { // 清理资源、记录日志 }); return emitter; } }这段代码有两个关键点。第一SseEmitter 的构造参数是超时时间这里设 60 秒但仅靠超时时间还不够AI 长文本生成可能超过 60 秒所以一定要定期发送心跳否则连接会被中间代理切断。心跳就是每隔几十秒发一个注释事件或空事件。第二流式调用一定不要在一个线程里同步等待完成后才返回而是要借助 onCompletion 和 onTimeout 回调做好资源清理。实测下来SSE 改造对线程池压力的缓解非常明显。同样 1000 个并发对话请求一次性返回接口会让 1000 个线程同时被占住SSE 模式下 Tomcat 请求线程很快释放真正维持推送的线程如果走虚拟线程单个载体线程就能支撑几百上千个连接。这也是我强烈建议 AI 对话类接口第一天就上 SSE 的原因。3.5 用内存队列定时任务实现批量AI任务削峰批量任务的骨架可以用一个有界阻塞队列加一组消费者线程实现核心是状态驱动。先定义一个任务状态表字段大致包括task_id、biz_data、status、result、error_msg、create_time、update_time。请求进来时在事务里完成两件事插入任务记录将 task_id 放入队列。消费者线程拿到 task_id 后先查库确认状态是待处理再 CAS 更新为处理中然后调用 AI 接口。给出一个简化版的队列消费者实现Component public class BatchAiTaskConsumer { private final BlockingQueueLong queue new ArrayBlockingQueue(2000); private final ExecutorService consumerPool Executors.newFixedThreadPool(8); private final TaskMapper taskMapper; private final AiGenerateService aiGenerateService; public BatchAiTaskConsumer(TaskMapper taskMapper, AiGenerateService aiGenerateService) { this.taskMapper taskMapper; this.aiGenerateService aiGenerateService; startConsumers(); } public void submitTask(Long taskId) { queue.offer(taskId); } private void startConsumers() { for (int i 0; i 8; i) { consumerPool.submit(() - { while (true) { try { Long taskId queue.take(); process(taskId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }); } } private void process(Long taskId) { TaskRecord task taskMapper.selectForUpdate(taskId); if (task null || !PENDING.equals(task.getStatus())) { return; } taskMapper.updateStatus(taskId, PROCESSING); try { String result aiGenerateService.generate(task.getBizData()); taskMapper.updateResult(taskId, result, SUCCESS); } catch (Exception e) { taskMapper.updateError(taskId, e.getMessage(), FAILED); } } }这里 selectForUpdate 加锁是为了防止多实例重复消费但要注意锁粒度要小只锁到单条任务不能锁整表。有界队列是 2000这是按峰值任务量估算出来的后面我会单独讲怎么定。消费者线程数量 8对应下游 AI 服务的并发限制不能为了吞吐无限加大否则会把大模型服务的限流直接打爆。前端通过 taskId 轮询查询任务状态拿到 SUCCESS 后展示结果。这个模型虽然简单但能覆盖绝大多数批量 AI 任务的削峰需求。等单机队列不够用了再迁移到 Redis Stream 或者 RocketMQ思路完全一致只是存储从内存换成分布式组件。4. 常见问题与排查技巧实录4.1 一张速查表解决异步高并发90%的坑异步化改造过程中我总结了一个问题速查表覆盖了最常见的几个故障模式先看现象再对照根因能省掉一大半排查时间现象根因解决方案RejectedExecutionException线程池队列满了拒绝策略抛异常改自定义拒绝策略配合熔断降级异步任务报错但接口返回成功future.get() 没被校验统一用 exceptionally / handle 兜底日志 traceId 在异步任务里丢失ThreadLocal 没有跨线程传递用 TaskDecorator 包装线程任务流式接口中途断连不再推送超过中间代理空闲超时定时发送心跳事件批量任务积压内存飙升队列容量没有上限用有界队列超过水位触发限流线程池满但 CPU 占用率很低大量线程在等待 IO换虚拟线程或放大 IO 线程数这张表基本涵盖了我在 AI 异步化改造中遇到的所有常见问题每一条背后都对应一次线上故障或压测翻车。值得特别说的是 RejectedExecutionException很多团队直接交给默认的 AbortPolicy结果高并发时异常满天飞。我的建议是自定义策略如果队列满了把任务转交给一个降级 MQ 或者直接返回“稍后再试”的提示而不是让用户看到一个含糊的 500。4.2 实际排障记录线程状态全WAITING问题出在阻塞调用分享一次真实的排障经历。某个 AI 内容生成服务在某次活动上线后突然接口大面积超时用户侧不断提示“服务繁忙”。我看了一下监控CPU 并不高但 Tomcat 线程池迅速被打满。现场线程 dump 之后发现几乎所有线程都卡在 SocketInputStream.read 上也就是说无线路程都在等着大模型 HTTP 调用的响应。这个现象非常典型线程处于 WAITING 状态网络 IO 阻塞。CPU 占用率低恰好证明系统并没有在做有意义的计算资源全耗在线程管理和等待上了。问题根因也很清晰老代码里Controller 直接同步调用大模型 HTTP 接口活动流量一冲几百个线程全部挂在读响应上容器线程池自然被占满。修复分两步走。第一步把所有大模型调用从 Controller 同步代码中拆出去改用独立线程池 CompletableFuture 显式超时第二步把对话类接口从一次性返回改成 SseEmitter 流式返回既提升体验又把容器线程池的压力彻底降下来。改完之后同样流量下线程池使用率从接近 100% 降到了 10% 出头。面试时讲“Java 多线程和高并发”如果能带上这种一线排障案例会生动很多。4.3 超时、重试与兜底AI应用异步设计的最后一道防线做 AI 高并发设计超时和重试策略是整个链路里最容易忽略、也最值得花心思的地方。我见过很多团队给 AI 接口配置了连接超时但没配读取超时结果大模型服务返回很慢时请求一直挂在读取阶段把线程池占光。我的经验是设置三层超时连接超时 3 秒读取超时 15 秒整体链路超时 30 秒。每一层超时都比下一层短确保任何一环卡住都能快速暴露。重试策略要格外谨慎。传统接口重试是常规操作但 AI 接口重试有特殊性生成类任务不是天然幂等的。比如批量生成商品描述你调一次可能生成“白色衬衫”超时后重试可能生成“白色T恤”两次结果不一致任务状态表里到底记哪一条所以我的建议是写操作和生成操作默认不重试除非接口协议里明确携带幂等键读操作重试最多两次每次间隔递增退避。兜底设计也不能少。AI 调用失败后用户看到的不应该是一段冷冰冰的错误堆栈。我一般会设计一个降级文案比如“当前服务繁忙请稍后再试”同时记录失败原因到日志。更精细一点的方案是分级降级主模型超时切换到备用模型备用模型也失败再返回兜底文案。这套逻辑放到 CompletableFuture 的 exceptionally 回调里代码很简洁但对用户体验的保障作用非常明显。说实话Java AI 应用的异步化不是把代码里 new Thread() 换掉就完事而是要把“慢”当成系统设计的一部分来对待。我踩过的最大的坑就是一开始把大模型接口当成普通 HTTP 接口同步调用结果被真实流量教育了一次。如果让我从零再搭一遍我会先做两件事第一所有 AI 调用全部加上超时和隔离线程池第二对话类接口第一天就上 SSE。解决了这两个基础问题之后再慢慢引入任务编排、批处理和队列。异步化方案也需要保持克制能用线程池解决的就别急着上消息队列和分布式框架压测出来的数据比框架的名头更可靠。最后再分享一个小技巧无论方案设计得多完美上线前一定模拟一次 AI 服务变慢的场景把超时、降级、队列积压都真实跑一遍看看系统会不会雪崩——这一跑通常能发现比代码 review 多一倍的隐患。