☰
Java AI应用高并发实战:异步化、线程池隔离与背压设计
2026/10/7 6:26:25 网站建设 项目流程

1. 从一次线上事故说起:为什么Java AI应用不能照搬传统Web那套

去年底我接手了一个Java AI应用的重构项目,背景很简单:团队用Spring Boot搭了一套AI推理服务,上线初期用户量不大,响应也还算稳定。但接入方从3个涨到20多个之后,问题开始集中爆发——高峰期接口平均响应从800毫秒飙到12秒,线程池被打满,Tomcat的连接队列直接顶到上限,最后整个服务雪崩。运维那边重启了三次,每次撑不过十分钟又挂。

排查下来根因并不复杂:AI推理是典型的长耗时、高资源占用操作,一次大模型调用动辄3到30秒,而团队用的是最传统的同步阻塞写法——请求进来,Controller直接调Service,Service里同步等模型返回,返回后再写库、再响应。这套写法在普通CRUD业务里没问题,但放到AI场景下,每个请求都死死占着一个Tomcat工作线程,200个线程池容量意味着最多只能同时处理200个请求,第201个就得排队。更糟的是,模型推理本身还吃CPU和内存,线程堆得越多,单次推理反而越慢,形成恶性循环。

这就是我想聊的核心问题:Java AI应用的异步化与高并发设计,本质上不是"把接口改成异步"这么简单,而是要重新设计整条请求链路的资源模型。传统Web应用的瓶颈通常在数据库IO,而AI应用的瓶颈在计算资源和模型调用的不确定性上。前者可以用连接池、缓存缓解,后者必须靠异步编排、背压控制和资源隔离来解决。

这篇文章适合三类人看:一是正在用Spring Boot做AI应用、已经遇到或预感到并发问题的后端工程师;二是准备把AI能力集成进现有Java系统的架构师;三是对Java多线程、高并发有基础、想了解AI场景特殊性的开发者。我会从同步阻塞的真实瓶颈讲起,一路拆到异步编排、线程池隔离、流式响应、背压与降级,最后给出一套可以直接参考的落地结构。全程用大白话加真实代码,不堆概念。

2. 同步阻塞到底卡在哪:把AI请求链路拆开看

2.1 一个请求从进来到返回,线程都经历了什么

先别急着上异步,得先搞清楚同步模式下线程到底在干什么。假设你有一个/api/chat接口,用户发一句话,后端调大模型,拿到结果返回。用Spring Boot默认的Tomcat线程模型,这条链路是这样的:

  1. Tomcat的Acceptor线程接收到TCP连接,交给Poller线程;
  2. Poller把请求包装成任务,丢进Tomcat的工作线程池(默认max-threads=200);
  3. 某个工作线程(叫它T1)拿到请求,执行DispatcherServlet,进入你的Controller;
  4. Controller调Service,Service里发起对模型服务的HTTP调用或本地推理;
  5. T1在这里阻塞等待,短则几百毫秒,长则几十秒;
  6. 模型返回后,T1继续执行,写数据库、组装响应;
  7. T1把响应写回客户端,释放回线程池。

问题就出在第5步。T1在等待模型返回的这段时间里,什么也干不了,但它占着线程池的一个名额。如果同时有200个请求都在等模型,线程池就满了,第201个请求连Controller都进不来,只能在Tomcat的accept队列里排队,队列满了就直接拒绝连接。

这里有个很多人忽略的细节:Tomcat的accept队列默认长度是100(acceptCount参数),也就是说线程池满了之后,还能再缓冲100个连接,超过就拒绝。所以真实的服务容量是max-threads + acceptCount = 300,但第201到300个请求的等待时间会非常长,用户体验极差。

2.2 为什么加线程数不是解法

第一反应通常是"那把max-threads调大不就行了"。我试过,从200调到800,结果是灾难性的。原因有三:

第一,线程不是免费的。每个Java线程默认栈大小1MB(-Xss控制),800个线程光栈内存就吃掉800MB。而且线程上下文切换是有成本的,CPU核数有限的情况下,线程越多,切换越频繁,真正用于计算的时间比例反而下降。

第二,AI推理本身是资源密集型的。如果模型跑在同一个JVM里(比如用DJL加载本地模型),那它吃的是CPU和堆内存。你开800个线程去抢有限的CPU核,只会让每次推理都变慢,吞吐量不升反降。这跟IO密集型任务完全不同——IO等待时线程是闲着的,多开线程有意义;计算密集时线程都在抢CPU,多开就是互相拖累。

第三,下游模型服务可能扛不住。如果你的模型是远程服务(比如独立的推理集群),800个并发打过去,对方直接被打挂。这时候你的线程再多也没用,只是把压力转嫁了。

所以结论很明确:同步阻塞模型下,单纯加线程是死路。必须让等待模型返回的线程去干别的事,或者干脆不占用宝贵的请求处理线程。

2.3 异步化的本质:把"等待"从线程里剥离出去

异步化的核心思想一句话就能说清:不要让线程在等待IO或计算时闲着,而是把它还给系统去处理别的请求,等结果就绪了再通过回调或事件通知的方式继续处理。

打个比方。同步模式就像你去餐厅点餐,点完站在柜台前一直等到菜做好,期间服务员没法接待下一个人。异步模式是你点完餐拿个号,找个座位坐下,菜好了叫号你去取。柜台(线程)在等菜的时间里可以接待更多人。

在Java里实现这个"拿号等叫号"的机制,主流有几套方案:

  • CompletableFuture:JDK8引入的异步编排工具,适合组合多个异步任务;
  • Spring的@Async:基于AOP的异步方法调用,配置简单;
  • WebFlux+Reactor:响应式编程,从Servlet容器到业务逻辑全链路非阻塞;
  • Servlet 3.0+的异步Servlet:在传统Servlet栈上做异步,改动相对小;
  • 虚拟线程(JDK21+):用轻量级线程承载阻塞操作,写法接近同步但资源开销极低。

这几套方案不是互斥的,实际项目里经常混用。选哪套取决于你的技术栈、团队熟悉度和改造范围。下一节我会详细对比,并给出选型逻辑。

3. 异步方案选型:CompletableFuture、WebFlux还是虚拟线程

3.1 四套方案的适用边界对比

先上一张表,把关键维度列清楚,后面再逐条展开。

方案编程模型改造范围学习成本适合场景主要坑点
CompletableFuture链式回调局部中已有同步栈,只想异步化模型调用回调地狱、异常传播复杂
Spring@Async注解局部低简单异步任务、通知类线程池默认配置差、事务失效
WebFlux+Reactor响应式流全链路高高并发网关、流式AI输出阻塞代码会毒化整个链路
虚拟线程同步写法局部到全局低JDK21+新项目、IO密集需注意pin住、同步块问题

3.2 CompletableFuture:改造范围最小的务实选择

如果你的系统已经是一套成熟的Spring MVC应用,不想大动干戈,那CompletableFuture是最务实的切入点。它的思路是:Controller层还是同步的,但把耗时的模型调用包装成异步任务,提交到独立线程池,然后join等待结果。

@RestController public class ChatController { private final ExecutorService modelExecutor; private final ChatService chatService; public ChatController(ChatService chatService) { this.chatService = chatService; // 专门给模型调用用的线程池,与Tomcat线程池隔离 this.modelExecutor = new ThreadPoolExecutor( 32, 64, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(200), new ThreadFactoryBuilder().setNameFormat("model-call-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy() ); } @PostMapping("/api/chat") public CompletableFuture<ResponseEntity<ChatResponse>> chat(@RequestBody ChatRequest req) { return CompletableFuture .supplyAsync(() -> chatService.callModel(req), modelExecutor) .thenApply(ResponseEntity::ok) .exceptionally(ex -> ResponseEntity.status(500).build()); } }

这段代码有几个关键点值得说:

第一,线程池隔离。模型调用用的是独立的modelExecutor,不是Tomcat的工作线程池。这样即使模型调用慢,也不会把Tomcat的线程占满,其他接口(比如健康检查、静态资源)还能正常响应。

第二,队列容量和拒绝策略。队列设了200,拒绝策略用CallerRunsPolicy——队列满了之后,由提交任务的线程(也就是Tomcat工作线程)自己执行。这是一种天然的背压:当模型线程池扛不住时,压力会传导回Tomcat线程,让请求变慢而不是直接失败。当然,如果你希望快速失败,可以换成AbortPolicy配合降级逻辑。

第三,返回CompletableFuture。Spring MVC从4.0开始支持Controller方法返回CompletableFuture,它会自动异步处理,不阻塞Tomcat线程。这是很多人不知道的一个点——返回CompletableFuture比在方法里join再返回结果要高效得多,因为前者真正释放了Tomcat线程。

但CompletableFuture也有明显的坑。多个异步任务组合时,thenCompose、thenCombine嵌套几层之后代码可读性急剧下降,异常处理也容易漏。而且它没有背压机制,任务提交速度超过处理速度时,队列会无限增长(除非你设了有界队列)。所以它适合"单次模型调用异步化"这种简单场景,不适合复杂的多步编排。

3.3 WebFlux:全链路非阻塞,但代价不小

WebFlux是Spring的响应式栈,底层用Netty或Undertow,从容器到业务逻辑全程非阻塞。它的优势在于:用极少的线程就能支撑极高的并发连接数。Netty默认的工作线程数是CPU核数乘以2,比如8核机器就是16个线程,理论上能扛住成千上万的并发连接。

但WebFlux的代价也很明显。首先,整条链路都必须是响应式的,任何一处阻塞调用(比如JDBC、同步的HTTP客户端)都会毒化整个链路,让响应式的优势荡然无存。其次,Reactor的操作符(flatMap、concatMap、zipWith等)学习曲线陡峭,团队不熟悉的话很容易写出bug。最后,调试困难,响应式流的调用栈跟传统同步代码完全不同,出问题时排查成本高。

如果你的AI应用需要支持流式输出(比如ChatGPT那种逐字返回的效果),WebFlux配合Server-Sent Events或WebSocket是很自然的选择。但如果是普通的请求-响应模式,且团队没有响应式经验,我不建议为了异步而强行上WebFlux。

3.4 虚拟线程:JDK21之后最值得关注的新选项

JDK21正式引入了虚拟线程(Virtual Threads),这是Java并发模型的一次重大变革。虚拟线程由JVM调度,不直接映射到操作系统线程,创建成本极低,可以轻松创建百万个。它的最大好处是:你可以用同步的写法,获得接近异步的性能。

// 用虚拟线程执行器,写法跟同步完全一样 ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); @PostMapping("/api/chat") public ChatResponse chat(@RequestBody ChatRequest req) { // 每个请求一个虚拟线程,阻塞等待模型返回也不怕 return chatService.callModel(req); }

配合Spring Boot 3.2+,只需要在配置里加一行spring.threads.virtual.enabled=true,Tomcat就会用虚拟线程处理请求。这意味着你几乎不用改代码,就能让每个请求的阻塞等待不再占用宝贵的平台线程。

但虚拟线程不是银弹。有两个坑必须注意:一是synchronized块会pin住虚拟线程(JDK21中,进入synchronized块时虚拟线程会被固定到平台线程上,失去轻量优势;JDK24已改善此问题),所以要用ReentrantLock替代;二是CPU密集型任务用虚拟线程没意义,因为计算本身就要占CPU,虚拟线程解决的是IO等待的线程占用问题。

对于AI应用,如果模型调用是远程HTTP请求(IO密集),虚拟线程非常合适;如果是本地推理(CPU密集),虚拟线程帮助有限,还是得靠线程池隔离加限流。

3.5 我的选型建议

综合下来,我的建议是分场景:

  • 新项目、JDK21+:优先用虚拟线程,写法简单,收益直接;
  • 存量Spring MVC项目、改造范围有限:用CompletableFuture+ 独立线程池,局部异步化;
  • 需要流式输出、超高并发连接:上WebFlux,但要有响应式经验的团队;
  • 简单异步任务(发通知、写日志):@Async够用,但记得自定义线程池。

4. 线程池隔离与背压:别让一个慢接口拖垮整个服务

4.1 为什么必须做线程池隔离

我见过太多项目,所有异步任务共用一个默认的ThreadPoolTaskExecutor,结果一个慢的AI推理任务把线程池占满,连发短信验证码这种轻量任务都执行不了。这就是典型的故障扩散。

线程池隔离的核心思想是:按业务重要性或资源类型划分独立的线程池,互不影响。在AI应用里,至少应该划分出这几类:

  • 模型调用池:专门跑模型推理,容量根据模型服务的承载能力设定;
  • IO任务池:处理数据库、缓存、文件等IO操作;
  • 轻量任务池:发通知、记日志、埋点等,要求快速执行;
  • 兜底池:处理降级逻辑、补偿任务。

每个池独立配置核心线程数、最大线程数、队列容量和拒绝策略。这样即使模型调用池被打满,轻量任务池依然能正常工作,保证核心业务不中断。

4.2 线程池参数怎么算,别拍脑袋

线程池参数不是拍脑袋定的,得根据任务类型算。公式分两种:

IO密集型任务(比如调远程模型API,大部分时间在等网络):

核心线程数 = CPU核数 × (1 + 平均等待时间 / 平均计算时间)

假设8核CPU,模型调用平均等待2秒,本地处理耗时200毫秒,那核心线程数 ≈ 8 × (1 + 2000/200) = 8 × 11 = 88。当然这是理论上限,实际还要考虑下游模型服务的承载能力,不能真开88个并发去打。

CPU密集型任务(比如本地模型推理):

核心线程数 = CPU核数 + 1

8核就是9个线程,多了反而因为上下文切换降低效率。

队列容量也要想清楚。用无界队列(LinkedBlockingQueue不设容量)是危险的,任务堆积会导致内存暴涨,最后OOM。建议用有界队列,容量根据可接受的最大等待时间反推。比如你希望请求最多等30秒,单任务平均耗时3秒,那队列容量大概设10左右(30/3),超过就触发拒绝策略。

4.3 背压:让压力有地方传导,而不是直接崩

背压(Backpressure)是个听起来很玄的词,其实道理很简单:当消费速度跟不上生产速度时,要让生产方感知到压力并主动降速,而不是无限堆积。

在AI应用里,背压有几个落地点:

第一,线程池的拒绝策略。CallerRunsPolicy就是一种背压——队列满了,提交任务的线程自己执行,这样Tomcat线程被占用,新的请求进不来,自然就限流了。AbortPolicy则是快速失败,配合降级返回兜底结果。

第二,信号量限流。用Semaphore控制同时进行的模型调用数量,超过就等待或拒绝。

private final Semaphore modelSemaphore = new Semaphore(50); public ChatResponse callModelWithLimit(ChatRequest req) { if (!modelSemaphore.tryAcquire(3, TimeUnit.SECONDS)) { // 3秒内没拿到许可,走降级 return ChatResponse.degraded("当前请求较多,请稍后重试"); } try { return chatService.callModel(req); } finally { modelSemaphore.release(); } }

第三,响应式流的背压。如果用Reactor,Flux天然支持背压,订阅者可以通过request(n)控制消费速度。这是响应式编程相比传统异步的一大优势。

第四,网关层限流。在Spring Cloud Gateway或Nginx层做限流,把压力挡在服务之外。常用算法有令牌桶、漏桶、滑动窗口,Sentinel和Resilience4j都是成熟的Java限流组件。

4.4 一个真实的参数调优过程

说个我实际调优的例子。某AI问答服务,8核16G,模型是远程调用,平均响应1.5秒,P99是5秒。初始配置是Tomcat默认200线程,模型调用用@Async默认线程池(核心8、最大Integer.MAX_VALUE、队列无界)。

压测结果:QPS到50就开始劣化,100时大量超时。排查发现两个问题:一是@Async默认队列无界,任务堆积到几千个,内存吃紧;二是模型调用没有限流,把下游打挂了。

调整方案:

  1. 模型调用独立线程池,核心32、最大64、队列100、CallerRunsPolicy;
  2. 加Semaphore限流,最多40个并发模型调用;
  3. Tomcat线程数降到100,因为大部分请求已经异步化了,不需要那么多;
  4. 加降级逻辑,模型调用超过3秒直接返回缓存或兜底话术。

调整后QPS稳定在200左右,P99控制在4秒内,内存也稳了。这个过程中最关键的不是某个参数,而是把资源边界想清楚:下游能扛多少、本机CPU能跑多少、用户能等多久,三个约束一交叉,参数范围就出来了。

5. 流式响应与AI场景的特殊处理

5.1 为什么AI应用特别需要流式输出

传统接口是"请求-等待-完整响应",用户盯着转圈等几秒甚至几十秒,体验很差。AI生成内容的特点是逐token产出,模型每生成一个词就可以返回给前端。流式输出让用户看到内容一点点"长出来",感知延迟大幅降低——虽然总耗时没变,但首字节时间(TTFB)从几秒降到几百毫秒,体验天差地别。

在Java里实现流式输出,主流方案有三种:

  • SSE(Server-Sent Events):基于HTTP长连接,服务端单向推送,实现简单,浏览器原生支持EventSource;
  • WebSocket:全双工,适合需要双向交互的场景;
  • 分块传输(Chunked Transfer):直接往HttpServletResponse的OutputStream写,手动flush。

对于AI对话场景,SSE是最常用的,因为它是单向推送,正好匹配"服务端持续输出、客户端接收"的模式。

5.2 Spring MVC下用SSE实现流式输出

在传统Spring MVC里,用SseEmitter就能实现SSE,不需要上WebFlux。

@GetMapping("/api/chat/stream") public SseEmitter streamChat(@RequestParam String question) { SseEmitter emitter = new SseEmitter(60_000L); // 60秒超时 // 提交到模型调用线程池,不阻塞Tomcat线程 modelExecutor.execute(() -> { try { chatService.streamModel(question, token -> { try { emitter.send(SseEmitter.event() .name("message") .data(token)); } catch (IOException e) { emitter.completeWithError(e); } }); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }

这里有几个实操要点:

第一,超时时间要设够。SseEmitter默认超时是30秒,AI生成长文本可能超过,要显式设置。但也不能无限长,否则连接泄漏。

第二,异常处理要完整。客户端断开连接时,emitter.send会抛IOException,必须捕获并completeWithError,否则线程会卡住。

第三,线程池要隔离。流式任务占用线程时间长,必须用独立线程池,不能和普通请求混用。

第四,注意Nginx缓冲。如果前面有Nginx,默认会缓冲响应,导致流式效果失效。需要配置proxy_buffering off;和X-Accel-Buffering: no响应头。

5.3 流式场景下的并发控制

流式输出有个特殊问题:每个连接占用时间长,并发连接数容易堆积。一个用户开着对话页面,连接可能持续几十秒。如果有1000个用户同时在线,就是1000个长连接。

这时候线程模型的选择很关键。如果用传统Tomcat线程,1000个连接就是1000个线程,直接爆。用SseEmitter的话,Tomcat线程在返回emitter后就释放了,实际占用的是模型调用线程池的线程,但那个池也不能无限大。

更优雅的方案是WebFlux + SSE,用少量事件循环线程支撑大量长连接。或者用虚拟线程,每个连接一个虚拟线程,成本极低。

另外,流式场景下要特别注意连接清理。用户关闭页面时,服务端要能感知到并停止模型生成,否则白白消耗资源。可以通过emitter.onCompletion和emitter.onTimeout注册回调,在回调里取消模型调用。

5.4 模型调用的超时与重试策略

AI模型调用有两个特点:一是延迟不确定,同样的输入,响应时间可能差好几倍;二是可能失败,网络抖动、模型服务过载都会导致失败。

超时设置要分层:

  • 连接超时:一般设1到3秒,连不上就快速失败;
  • 读超时:根据模型P99延迟设,比如P99是5秒,那读超时设8到10秒;
  • 总超时:整个请求的最大耗时,超过就降级。

重试要谨慎。AI推理通常不是幂等的(同样的输入可能生成不同结果),而且重试会加重下游负担。我的建议是:只对明确的网络错误重试,且最多重试1次,重试要加退避(比如等500毫秒再试)。对于超时,不要重试,直接降级——因为超时说明下游已经压力很大了,再重试就是雪上加霜。

RetryTemplate retryTemplate = RetryTemplate.builder() .maxAttempts(2) .fixedBackoff(500) .retryOn(ConnectException.class) // 只对连接异常重试 .build();

6. 监控与压测:异步化之后怎么确认真的稳了

6.1 异步化之后,传统监控指标不够用了

同步模式下,你看Tomcat的活跃线程数、请求响应时间就能大致判断健康状况。异步化之后,请求处理被拆成了多个阶段,Tomcat线程可能很快就释放了,但实际任务还在线程池里排队。这时候如果只看Tomcat指标,会误以为服务很健康,实际上线程池队列已经堆了几千个任务。

所以异步化之后,必须补充这几类监控:

线程池指标:活跃线程数、队列大小、已完成任务数、拒绝任务数。Spring Boot Actuator配合Micrometer可以自动采集ThreadPoolTaskExecutor的指标,但自定义的ThreadPoolExecutor需要手动注册。

@Bean public ThreadPoolTaskExecutor modelExecutor(MeterRegistry registry) { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(32); executor.setMaxPoolSize(64); executor.setQueueCapacity(100); executor.setThreadNamePrefix("model-"); executor.initialize(); // 注册监控指标 new ExecutorServiceMetrics( executor.getThreadPoolExecutor(), "modelExecutor", Collections.emptyList() ).bindTo(registry); return executor; }

模型调用指标:调用次数、成功率、P50/P95/P99延迟、超时次数。这些指标要按模型、按接口维度细分,才能定位问题。

队列等待时间:任务从提交到开始执行的时间。这个指标最能反映系统是否过载——如果等待时间持续增长,说明消费速度跟不上生产速度,该扩容或限流了。

降级触发次数:每次降级都意味着有用户没拿到完整服务,这个数字要重点关注。

6.2 压测怎么做才有效

异步系统的压测比同步系统复杂,因为瓶颈可能在多个地方:Tomcat线程、模型线程池、下游模型服务、数据库连接池。压测的目标是找到真正的瓶颈点,而不是简单看QPS数字。

我的压测步骤通常是:

第一步,单接口基准测试。用JMeter或wrk对单个AI接口施压,从低并发逐步增加,观察QPS和延迟曲线。当延迟开始非线性上升时,那个点就是当前配置的容量上限。

第二步,定位瓶颈。容量上限出现时,看各个线程池的指标:是Tomcat线程满了,还是模型线程池队列满了,还是下游模型服务响应变慢了。用Arthas的thread命令可以实时看线程状态,dashboard能看整体情况。

第三步,混合场景测试。真实系统不会只有一个接口,要把AI接口和其他普通接口混在一起压,验证线程池隔离是否生效——理想情况下,AI接口被打满时,普通接口的延迟不应该有明显变化。

第四步,故障注入。模拟下游模型服务变慢或不可用,验证降级逻辑是否按预期工作,服务是否还能保持基本可用。

有个容易忽略的点:压测时要监控GC。异步化之后,对象创建速率可能变化(比如CompletableFuture、回调对象),如果GC频繁,会拖累整体性能。用-Xlog:gc*或者VisualVM观察GC日志,必要时调整堆大小和GC器。

6.3 一个监控看板的指标清单

我习惯给每个AI服务配一个监控看板,核心指标如下:

指标类别具体指标告警阈值建议
请求层QPS、P99延迟、错误率P99 > 5s 或错误率 > 1%
Tomcat活跃线程数、队列长度活跃线程 > 80% 容量
模型线程池活跃线程、队列大小、拒绝数队列 > 80% 容量 或 拒绝数 > 0
模型调用成功率、P99延迟、超时数成功率 < 99% 或 超时数突增
降级降级触发次数每分钟 > 10次
JVMGC次数、GC耗时、堆使用率Full GC > 1次/分钟

这些指标用Prometheus + Grafana采集展示,告警接到钉钉或企业微信。关键是阈值要根据实际压测结果设定,不能照搬网上的数字。

7. 一套可以直接参考的落地结构

7.1 分层设计与职责划分

把前面讲的东西串起来,我给出一套实际项目在用的结构。核心原则是按资源类型分层,每层职责单一,层与层之间通过明确的接口交互。

Controller层 ├─ 接收请求,参数校验 ├─ 提交任务到对应的线程池 └─ 返回 CompletableFuture / SseEmitter / 同步结果 编排层(Orchestration) ├─ 组合多个异步任务 ├─ 处理超时、重试、降级 └─ 不包含业务逻辑,只做流程控制 业务层(Service) ├─ 纯业务逻辑 ├─ 调用模型客户端、数据库、缓存 └─ 不关心线程和异步 资源层(Client / Repository) ├─ 模型调用客户端(带超时、重试配置) ├─ 数据库访问 └─ 缓存访问

这样分层的好处是:异步和并发控制集中在Controller和编排层,业务层保持纯净,方便测试和复用。业务层的方法可以在同步和异步场景下都能用,不需要为了异步而改写。

7.2 线程池配置集中管理

线程池不要散落在各个类里,集中到一个配置类,方便统一调整和监控。

@Configuration public class ExecutorConfig { @Bean("modelExecutor") public ThreadPoolTaskExecutor modelExecutor(MeterRegistry registry) { return buildExecutor(registry, "model", 32, 64, 100); } @Bean("ioExecutor") public ThreadPoolTaskExecutor ioExecutor(MeterRegistry registry) { return buildExecutor(registry, "io", 16, 32, 500); } @Bean("lightExecutor") public ThreadPoolTaskExecutor lightExecutor(MeterRegistry registry) { return buildExecutor(registry, "light", 8, 16, 1000); } private ThreadPoolTaskExecutor buildExecutor( MeterRegistry registry, String name, int core, int max, int queue) { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(core); executor.setMaxPoolSize(max); executor.setQueueCapacity(queue); executor.setThreadNamePrefix(name + "-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30); executor.initialize(); new ExecutorServiceMetrics( executor.getThreadPoolExecutor(), name + "Executor", Collections.emptyList() ).bindTo(registry); return executor; } }

注意setWaitForTasksToCompleteOnShutdown(true)和setAwaitTerminationSeconds(30),这两个配置保证服务关闭时,正在执行的任务能优雅完成,不会突然中断导致数据不一致。

7.3 降级策略的落地

降级不是简单的try-catch返回默认值,要有策略。我一般分三级:

一级降级:返回缓存结果。如果之前有相同或相似请求的结果,直接返回缓存。AI场景下可以用语义缓存,相似问题命中同一答案。

二级降级:返回简化结果。比如用更小的模型、更短的max_tokens,快速生成一个简版回答。

三级降级:返回兜底话术。明确告诉用户"当前服务繁忙,请稍后重试",并记录日志用于后续分析。

降级要可配置、可开关,通过配置中心动态调整,出问题时能快速切换。

public ChatResponse callWithFallback(ChatRequest req) { try { return circuitBreaker.executeSupplier(() -> modelClient.call(req)); } catch (Exception e) { log.warn("模型调用失败,触发降级", e); // 一级:查缓存 ChatResponse cached = cache.get(req); if (cached != null) return cached; // 二级:简化调用 try { return modelClient.callSimplified(req); } catch (Exception ex) { // 三级:兜底 return ChatResponse.fallback(); } } }

7.4 几个容易踩的坑

最后分享几个我在实际项目里踩过的坑,都是文档里不会写的。

坑一:@Async方法的事务失效。@Async和@Transactional一起用时,如果异步方法内部调用了带事务的方法,事务可能不生效,因为异步执行在新线程里,事务上下文没传过去。解决办法是把事务逻辑放在被调用的同步方法里,或者手动管理事务。

坑二:CompletableFuture的异常被吞掉。如果只调thenApply不调exceptionally或handle,异常会被包装成CompletionException,如果不处理,可能悄无声息地丢失。一定要在链路末端加异常处理。

坑三:线程池的CallerRunsPolicy在特定场景下会死锁。如果提交任务的线程本身就在等待这个任务的结果,CallerRunsPolicy会让它自己执行,但它在等待,就死锁了。这种场景要用AbortPolicy配合降级。

坑四:SSE连接没清理导致内存泄漏。SseEmitter如果客户端异常断开而服务端没感知,emitter对象会一直挂在内存里。要设置合理的超时,并在onCompletion、onTimeout、onError里都做清理。

坑五:虚拟线程里用ThreadLocal要小心。虚拟线程数量巨大,如果每个都持有ThreadLocal的大对象,内存会爆。JDK21引入了ScopedValue作为替代,但还在预览阶段。现阶段建议在虚拟线程场景下避免使用重量级ThreadLocal。

异步化和高并发设计这件事,说到底是对系统资源的精细化管理。AI应用因为模型调用的长耗时和不确定性,把这个问题放大了。但只要你把请求链路拆清楚、把线程池隔离好、把背压和降级做到位,再配合监控和压测验证,稳定性是完全可以保障的。我在多个项目里用这套思路,从最初的频繁雪崩到后来的平稳运行,最大的体会是:不要指望某个框架或某个参数能一劳永逸,真正的稳定来自于对每个环节边界的清晰认知。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询