Java AI 应用的异步化与高并发设计
把大模型接口接入 Java 后端这件事,看起来只是加一个 HTTP 调用,但你只要在真实场景里跑过一轮就会明白:AI 应用的并发模型和传统 CRUD 完全是两码事。前几天有个朋友跟我抱怨,他们的 AI 问答功能一上线,Tomcat 默认线程池直接被打满,数据库连接倒是空闲得很,但接口 P99 延迟已经飙到十几秒。CPU 没满、内存没爆、数据库没锁,系统却卡死了——这就是典型的 AI 应用同步调用模型带来的“假死”问题。
类似的情况我在好几个项目里都碰到过:要么是调用三方大模型接口耗时太长,要么是流式输出占住了线程不释放,要么是突发流量直接把上游打爆。传统 Web 开发的那套“一个请求一个线程”的老思路,在 AI 场景下基本不够用。这篇文章我就结合自己的实际项目经历,把 Java 技术栈下 AI 应用做异步化改造和高并发设计的思路、方案、参数取舍、踩坑记录整理出来,给正在做 AI 应用落地的同学一个可以直接抄作业的参考。
这套设计主要解决三个问题:第一,如何让长时间占用资源的 AI 调用不阻塞 Web 容器线程;第二,如何在突发流量下保证系统的吞吐和稳定性;第三,如何在异步化之后仍然让调用方有良好的交互体验。适合正在做智能问答、AI 内容生成、Agent 服务、多模型编排等场景的后端工程师参考。
1. 内容整体设计与思路拆解
1.1 AI 应用与传统 Web 应用在并发模型上的本质差异
传统 Web 应用的核心特征是短、平、快。一个请求进来,查个数据库、做点计算、返回 JSON,整个生命周期通常几十毫秒到几百毫秒。在这种模型下,“一个请求占用一个线程”的同步模型是成立的,因为线程很快就会被释放,线程池不需要很大就能支撑高并发。
但 AI 应用的请求特征完全不同。一次大模型调用的耗时通常在 1 到 10 秒之间,如果涉及多轮工具调用、多模型编排或者长文档处理,单次请求跑到 30 秒以上也很正常。再加上现在主流大模型接口都是流式返回,数据是一个 token 一个 token 往外吐的,这意味着连接会长时间保持打开。如果你用同步模型,一个请求进来就占住一个容器线程,从发起到收完所有 token 为止,短则几秒、长则数分钟。假设 Tomcat 默认线程池是 200,每个请求平均耗时 5 秒,那这个系统的极限吞吐就是每秒 40 个请求。想再往上加并发,请求就开始排队,用户侧感受到的延迟会迅速恶化。
这还不是最要命的。AI 应用往往还要聚合多个下游依赖:模型网关、向量数据库、知识库检索、外部工具 API。任何一个下游抖动,都会把慢请求传导到你的应用层。同步模型下,一个下游变慢,线程池很快被慢请求占满,最终拖垮整个应用。这就是为什么 AI 应用的异步化不是“优化选项”,而是“必选项”。
我见过不少团队把 AI 功能直接写在 Controller 里同步调用,上线前压测看着没问题,一上生产遇到营销活动流量就直接雪崩。核心原因就是没有理解这个差异:AI 应用并发瓶颈往往不在数据库,而在线程生命周期与下游延迟的乘积。
1.2 异步化设计的核心目标:让线程和耗时解耦
异步化的本质,是把“线程占用时间”和“请求处理时间”解耦。同步模型里,容器线程既要负责接收请求,又要等待下游返回,线程生命周期和一次完整的业务处理强绑定。异步化之后,容器线程只需要做两件事:把请求丢给后台处理,然后立即返回。真正耗时的 AI 调用放到独立的线程池、消息队列或者虚拟线程里去执行。
这样一来,Web 容器线程的占用时间从“秒级”降到了“毫秒级”,同样的线程池规模可以支撑的并发量会提升一到两个数量级。更重要的是,后台处理池可以根据 AI 调用的特征单独调优,和 Web 容器的线程配置解耦。某个下游变慢时,影响的只是处理池中的部分线程,而不是整个应用的接收能力。
异步化还有一个容易被忽略的好处:它天然把系统分成了“接收层”和“处理层”两个部分。接收层可以快速响应用户,给出“请求已受理”的反馈;处理层在后台慢慢执行。这种分层为后续的限流、排队、优先级调度、失败重试都留出了空间。你可以把处理池的负载当作一个天然的背压信号,负载高了就拒绝新请求,而不是让用户无限等待。
不过这里要提醒一点:异步化不等于简单的把方法变成 async 就完了。Java 里的异步化有很多层次,从 FutureTask 到 CompletableFuture,从响应式编程到虚拟线程,从线程池到消息队列,每个方案适用的场景和复杂度都不一样。选错方案反而会引入额外的复杂度,后面我会逐个展开讲。
1.3 高并发设计在 AI 场景下的特殊考量
高并发设计本身有一套成熟的通用方法论:缓存、异步、削峰、限流、降级、隔离。但在 AI 场景下,这套方法论有几个特殊的地方需要单独说。
第一个特殊点是流量模型。AI 应用的流量往往不是均匀分布的,而是带有明显的“热点效应”。比如某个营销活动上线、某条内容突然爆了,流量可能在几秒内飙升数倍。加上 AI 调用本身成本高(API 费用、GPU 资源),你不能像传统应用那样狂加机器硬扛,必须在架构层面做好流量的平滑和拦截。
第二个特殊点是成本约束。一次大模型调用可能是几分钱到几块钱不等,如果系统被恶意刷量或者突发流量打到失控,费用会高到离谱。所以 AI 应用的高并发设计里,限流不仅是稳定性问题,也是成本控制问题。你必须有手段在流量异常时快速熔断,保护的不只是你的服务器,还有你的钱包。
第三个特殊点是下游不确定性。模型提供方的服务能力不在你的控制范围内,他们也有自己的限流策略。当你从 100 TPS 涨到 500 TPS 时,上游不一定扛得住,也不一定愿意让你打上去。所以在 AI 场景下,高并发设计必须包含对下游的保护机制——调用方要主动做客户端限流、重试退避、降级开关。
还有一个容易被忽视的点是流式响应对资源模型的影响。SSE(Server-Sent Events)流式输出会让连接保持长时间打开,这意味着即使你做了异步化,也要考虑连接资源的管理。单个连接占用的内存虽然不大,但上万条连接同时在传输内容时,对网关、负载均衡、应用服务器的压力都和普通 HTTP 请求完全不同。
2. 核心细节解析与实操要点
2.1 选择合适的异步化方案:线程池、CompletableFuture 还是虚拟线程
Java 生态里做异步化,现在主要有三条技术路线:传统线程池 + Future、CompletableFuture 异步编排、JDK 21 引入的虚拟线程。我在不同项目里都试过,说下实际感受。
传统线程池 + Future 是最基础的做法,适合单次异步任务的场景。你定义一个独立的线程池来执行 AI 调用,主线程提交任务后可以选择阻塞等待(Future.get)或者立即返回。这个方案简单直接,但处理复杂流程编排时比较笨拙。如果一次业务要连续调用两个模型、再做一次工具调用,用 Future 串起来代码会非常难看,嵌套的 get() 写多了自己都容易绕晕。
CompletableFuture 解决了编排的问题。你可以用 thenApply、thenCompose、allOf 这些方法把多个异步任务组成一条流水线,任务之间可以有依赖关系也可以并行执行,代码写出来还是比较清晰的。我在做多模型对比(比如同一个问题让两个模型分别回答再汇总)的场景时,就是用 CompletableFuture.allOf 把多个模型调用并行发出去,等全部返回后再做汇总。如果串行调用,总耗时是两个模型耗时的和;改成并行之后,总耗时约等于最慢那个模型的耗时,用户体验提升非常明显。CompletableFuture 还有一个好处是异常传播机制比较完善,每个环节的异常都可以汇入 CompletableFuture 内部,统一处理,不会像 Future 那样因为异常处理不当导致线程卡死或异常丢失。
虚拟线程是 JDK 21 正式发布的新特性,也是我目前最推荐的方案。虚拟线程和传统线程池的思路完全不同——它不需要复用线程,而是创建一个数量级更大的轻量线程,平台线程在 IO 阻塞时自动让出执行权。用虚拟线程,你可以继续写同步风格的代码,但底层已经完成了异步化。这就把“代码可读性”和“并发性能”两个好处同时拿到了。我在几个 AI 网关项目中已经全面切换到虚拟线程,代码改动量非常小,主要就是把 Executors.newFixedThreadPool(200) 换成 Executors.newVirtualThreadPerTaskExecutor(),再把容器线程池配置调整一下。线上压测的结果是,同样硬件条件下吞吐提升了约 4 倍,P99 延迟也明显更稳定。
不过虚拟线程也不是银弹。它适合 IO 密集型任务,如果你的处理逻辑中有大量的 CPU 密集计算(比如本地跑模型推理),虚拟线程的优势就不明显,甚至会因为线程数量过多导致 CPU 上下文切换开销上升。另外要注意的是,虚拟线程不能和 synchronized 混用(会出现阻塞平台线程的情况,JDK 官方建议改用 ReentrantLock),池化虚拟线程也是反模式。这些细节在实际落地时都要注意。
2.2 线程池参数设计:核心线程数、队列容量、拒绝策略怎么定
不管选哪种异步方案,只要还在用线程池,参数设计都是躲不开的。线程池参数没有一个放之四海皆准的公式,但基于我对 AI 应用调用特征的观察,有几个实际经验供你参考。
核心线程数的估算公式,长期以来有个经典的参考:CPU 密集型任务设为 CPU 核数 + 1,IO 密集型任务设为 CPU 核数 × 2。但这个公式的前提假设是任务在等待 IO 时不占 CPU,现代场景下这个公式只能作为下限参考。对于 AI 应用来说,我觉得更好的思路是:先估算你预期的并发请求数和单次请求的平均耗时,然后用利特尔法则(Little‘s Law)去推算需要的线程数。比如你预期同时有 200 个请求在处理中,每个请求的 AI 调用平均耗时是 3 秒,那么线程池中同一时刻至少需要有 200 个线程在执行任务(按 L = λW 的变体理解即可)。换句话说,线程数要能覆盖“在途并发请求数”,而不是简单看 CPU 核数。
队列容量怎么设?我见过很多默认配置,队列满就直接拒绝,导致用户明明已经提交了请求却收到 500 报错。在 AI 场景里,我通常把队列容量设成“核心线程数 × 2 到 × 4”之间,因为 AI 调用天然就是慢任务,队列需要一定的缓冲空间来吸收瞬时流量尖峰。但队列也不能太长,否则请求在队列里等太久,用户侧感受到的延迟等价于超时,并且队列中的请求还占着内存和下游的调用配额。一个比较实用的做法是设置“最大等待时间”,比如队列中的任务超过 5 秒还没被调度就视为超时,直接走降级逻辑。
拒绝策略是第三个关键点。默认的 AbortPolicy 直接抛异常,CallerRunsPolicy 让提交线程自己去执行任务,DiscardPolicy 静默丢弃,DiscardOldestPolicy 丢最老的任务。在 AI 场景下,我强烈建议不要直接用默认策略,而是定义一套自定义拒绝逻辑:记录拒绝指标,把请求接入一个排队系统(比如 Redis 延时队列或者消息队列的延迟队列),告诉用户“任务已排队,稍后通知结果”。这样用户体验比直接报错要好得多,也符合 AI 应用异步化之后的交互模型。
2.3 连接池与 HTTP 客户端调优:AI 网关调用的隐形瓶颈
大家在设计 AI 应用高并发时,很容易只盯着线程池,却忽略了下游 HTTP 调用的连接管理。线程池只是让你有能力处理更多请求,但如果到模型网关的连接不够用,请求还是会在连接池等待获取连接,效果等于没做异步化。
我遇到过一个真实案例:服务里用的 Apache HttpClient 连接池没配置,默认每个路由只有 2 个并发连接。线上 AI 调用一多就大量出现 ConnectionPoolTimeoutException,日志里全是“Timeout waiting for connection from pool”。其实不是服务本身性能问题,而是连接池太小,连接都在排队。后来把连接池调到每路由 200 个,情况立刻缓解。
HTTP 客户端调优有四个参数值得专门说:最大连接数、单路由最大连接数、连接空闲存活时间、响应超时时间。最大连接数决定整个客户端可以从并发打开多少连接,通常设为 200 到 500 之间。单路由最大连接数在多下游场景下更关键,因为模型网关往往有 QPS 限制,如果你对所有路由共用一个大连接池,一个慢下游会占用大量连接,拖垮其他下游的调用。更好的做法是按下游拆分成多个独立的 HTTP 客户端实例,每个实例有自己的连接池和超时配置,实现故障隔离。
响应超时时间在 AI 场景下要特别谨慎。传统接口把超时设为 1 到 3 秒,但 AI 接口的正常响应时间就可能超过这个数,设得太短会导致大量请求被误杀。我的建议是区分“连接超时”和“读取超时”:连接超时设为 3 到 5 秒就够了,因为建立 TCP 连接本身不应该慢;读取超时则要根据模型的 P95 响应时间去设定,通常设为 30 到 60 秒,给流式响应留出足够的时间。如果你用的是流式读取,读取超时指的是“两个数据块之间的最大间隔”,而不是整个响应的总耗时,这点不要搞混。
2.4 结果缓存与语义缓存:降低上游压力,提升响应速度
AI 应用的缓存设计和传统应用有个很大的不同:传统应用缓存的是数据库查询结果,key 是用户 ID、商品 ID 这类稳定的标识;而 AI 应用缓存的往往是“问题 - 答案”对,key 是用户输入的自然语言。直接拿原始文本做 key 有一个问题——用户表达同一个意思可能有无数种说法,字面上完全不同的问法,语义上也许是同一个问题。这时候就需要语义缓存:把用户输入先走一次 embedding,拿向量去向量数据库里做相似度检索,如果找到相似度超过阈值的历史问答,直接返回缓存结果,不再调用大模型。
语义缓存的时效性要重点设计。对于知识库问答这类内容相对稳定的场景,语义缓存可以开得比较大,匹配阈值设到 0.92 以上(具体看 embedding 模型的距离度量)。但对新闻摘要、时事评论这类时效性很强的场景,缓存时间就要缩得很短,甚至干脆不开缓存,否则用户会拿到过时信息。我一般的做法是:根据业务类型配置不同的缓存 TTL,同时在缓存条目里存时间戳,由业务代码自己判断是否过期。
语义缓存的成本细节也要注意到。国内各家大模型服务商的 embedding 接口和文本生成接口是分开计费的,而且 embedding 费用要低得多。所以用语义缓存做一次向量检索来避免一次高成本的文本生成,这笔账是算得过来的。不过你要额外承担向量数据库的存储和查询成本。如果接入的向量数据库本来就用于知识库检索,那么语义缓存可以复用同一套基础设施,几乎不增加额外负担。如果系统里没有向量数据库,先用 Redis 存精确匹配的缓存(原始文本 MD5 作为 key)会更务实——虽然命中率低一些,但它零额外成本。
精确匹配缓存在高并发场景下也有价值,就是那些用户反复点击刷新、或者多个用户上传同一份文档做分析的情况。这类请求如果每次都重新调用模型,既浪费钱又拖慢响应。加了精确缓存之后,这类重复请求直接命中,响应时间从秒级降到毫秒级。两种缓存配合使用:精确匹配兜底高频重复点击,语义匹配覆盖改述表达,实测下来缓存命中率能到 30% 以上,上游压力和响应速度都改善不少。
3. 实操过程与核心环节实现
3.1 一个 AI 问答服务的异步化改造实例
为了把前面说的思路落到具体代码上,我拿一个实际做过的 AI 问答服务来演示改造流程。这个服务最初是同步模型:用户 POST 一个问题,Controller 直接调用大模型接口,同步返回答案。压测数据是 200 并发下 P95 延迟 8 秒,线程池频繁被打满,CPU 才用到 30% 就出现了大量超时。
改造目标很简单:让 Web 层响应变快,让模型调用在后台异步执行,同时保留流式输出的体验。最终的方案是三层结构:
第一层是接入层。Controller 接收请求后只做参数校验和身份认证,然后把任务提交到异步处理模块,立即返回 202 Accepted + taskId。用户端拿到 taskId 后,通过轮询或者 SSE 连接获取任务状态和最终结果。
第二层是处理层。用一个独立配置的线程池执行真正的 AI 调用。这里我用的核心线程数是 50,最大线程数 100,队列容量 500,拒绝策略是自定义的“排队上报”。线程池满时不会直接抛异常,而是把请求信息写进 Redis 的延迟队列,由独立的调度器稍后重新提交。同时把“排队中”的状态挂在 taskId 下,前端轮询时能明确告知用户当前处于排队状态。
第三层是结果层。AI 调用完成后把结果写入 Redis 缓存,key 是 taskId,TTL 设为一个小时。用户通过 result 接口拉取结果,拉取成功就从缓存中删除,防止重复读取。对于流式输出场景,我改造了实现方式:处理层生成内容时按 chunk 写入 Redis Stream,接入层的 SSE 端点订阅对应的消息,把内容逐段推送给前端。
线程池参数我单独说明一下为什么这么设。核心线程数 50 的依据是:预期常规流量下同时处理的请求约 200 个,每个请求从开始处理到完成约 3 秒,核心线程数只需要维持这些请求运转就能保证 100 TPS 左右的吞吐。最大线程数 100 用于吸收流量尖峰,队列容量 500 用于吸收偶发的大流量冲击。当队列也满时,说明系统确实到了瓶颈,自定义拒绝策略把新请求转入 Redis 延时队列,相当于在系统前面加了一个软缓冲带。这套设计在压测下表现很好:1000 并发持续压测 5 分钟,P95 延迟从改造前的 8 秒降到 1.2 秒,同时系统没有出现一次用户可见的 5xx 错误,最多只是状态变为“排队中”。
3.2 流式输出场景下的异步与背压处理
流式输出(SSE)的场景和一次性返回不同,它要求数据边生成边推送,用户体验更好,但对异步化设计要求更高。直接的做法是在 Controller 里同步循环读模型输出流然后写回给前端。这个做法的毛病是:读流的过程中容器线程一直被占用,一个慢模型就能占着一个线程数分钟。100 个并发流式请求就能占满 Tomcat 默认线程池,其他接口全部遭殃。
我在做流式输出改造时用的方案是发布订阅模式。具体实现是:接入层收到请求后,为这个任务创建唯一的 channel ID,返回给前端一个 SSE 连接端点(形如 /events/{taskId})。同时把请求提交到异步处理线程池,处理逻辑负责调用模型接口,拿到返回的流式数据后按块发布到 Redis Stream 或者内存中的发布订阅通道。SSE 端点作为订阅者消费这些数据块,转发给客户端。
这个方案有三个明显的工程优势。第一是容器线程不阻塞,SSE 连接虽然保持打开,但它是异步 I/O,不占线程资源;模型调用在独立的处理线程池执行,两者互不干扰。第二是天然支持背压,处理线程池满了之后,新的任务进入队列等待,不新增并发。第三是适合多实例部署,因为发布订阅通道是 Redis,应用多开几个实例也可以正常工作。
实现时的关键细节是心跳机制。SSE 连接如果长时间没有数据推送,中间的网络节点(Nginx、负载均衡)可能会把连接判定为超时空闲而断开。所以需要在接入层增加一个定时心跳发送器,每隔 15 到 30 秒向 SSE 输出一个注释行(以“:”开头的内容,前端不会渲染),保证连接存活。另外前端也要处理断线重连,EventSource 原生支持自动重连,但要控制好重连间隔,避免断线风暴把服务端打垮。
3.3 限流、熔断与降级:保护自己和保护下游
高并发设计里有一句话我一直很认同:没有限流的系统,是在赌运气。AI 应用又尤其需要限流,因为它既要防外部流量冲击,又要防内部调用泄露导致费用失控。我一般会在三个层面做限流:接入层、服务层、下游调用层。
接入层限流最简单直接,按用户维度做配额控制。比如每个用户 1 分钟内最多调用 10 次 AI 接口,超出的请求返回 429 + Retry-After 头。这个层级的限流用于防止单用户刷接口,尤其是在做公开测试或者开放 API 时,必须有这一层保护。实现上用 Redis + 滑动窗口或者令牌桶都很成熟。
服务层限流按整个服务的总体 QPS 或者并发数做控制。这一层保护的是系统自身资源,防止突发流量把线程池和连接池打爆。根据我对多个 AI 应用的测算,比较合理的限制值是:系统最大并发处理数不超过线程池最大线程数 + 队列容量的一半,这样即使流量突然飙升到极限,系统也能有足够的余量来优雅拒绝,而不是直接雪崩。
下游调用层限流是最容易被忽略的一层。模型中转方或者模型网关自己都有限流保护,但你的应用必须主动配合——否则上游触发限流后会拒绝你的请求,表现为是你的应用出故障,排查起来很难受。我一般会在 AI 客户端封装一层内置的令牌桶限流器,按上游文档里公布的 QPS 或 RPM 限制去做配置,并且在代码里实现退避重试逻辑。重试策略采用指数退避加抖动:第一次重试等待 1 秒,第二次 2 秒,第三次 4 秒,最多重试 3 次,每次加重试前都在等待时间上加入 20% 到 50% 的随机抖动。抖动重试的作用是避免多个客户端在同一个重试窗口内同时对上游发起请求,形成重试风暴。
熔断降级和限流是配套的。熔断触发条件用“错误率”比固定阈值更合理。比如最近 10 秒内调用下游的错误率如果超过 50%,打开熔断开关,接下来的请求直接返回降级内容,不再调用下游。每隔 10 秒放少量试探流量过去,成功率达到阈值就把熔断关闭。“缓慢放量恢复”是熔断逻辑最核心的部分,否则大量请求瞬间打过去,很可能又把刚恢复的下游压垮。
3.4 异步场景下的数据一致性保障
异步化带来的一个副作用是:操作结果不再是即时可见的,这对数据一致性设计提出了新要求。很多团队做异步化只关注性能和吞吐,把一致性问题放在后面,直到线上出现“任务显示失败但后台其实成功了”“用户收到了成功通知但数据库没写入”这类问题,才意识到这里面的坑。
以一次 AI 内容生成后再入库的场景为例:用户请求生成一篇文章,生成成功后要写入数据库。同步模型下,生成成功和写入数据库是同一个事务里完成的,要么都成、要么都败。异步化之后,这两个动作被拆开了:后台线程生成内容,生成完成后再写库。如果写库成功但 Redis 里的任务状态没更新,用户就会看到“处理中”卡住。如果 Redis 状态显示成功但数据库写入失败,用户会认为内容生成了,实际库里却没有。
我在项目里设计了一套状态机配合重试的补偿机制。任务状态定义成 Created、Processing、Completed、Failed、Compensating 五种状态,每次状态变更都写到 Redis 和数据库两处,形成一个可审计的状态流。处理线程执行完核心逻辑后,先更新数据库业务数据,再更新 Redis 缓存任务状态,当两者出现不一致时,由定时扫描任务把状态校准,触发补偿逻辑(重新推送消息或者执行补写)。这套方案确保了最终一致性,同时业务侧还能接受一定时间内短暂的状态不一致延迟。
还要提一个策略:幂等设计。消息队列异步处理场景下,消费者可能因为网络抖动而重复消费同一条消息。处理逻辑必须是有幂等性的——重复执行和只执行一次的结果完全一样。实现方式是每个请求分配全局唯一的 requestId,处理前先到 Redis 用 setnx 检查这个 ID 是否处理过。如果已经处理过就直接返回,避免重复调用模型接口,既省了钱又避免脏数据。
3.5 消息队列在 AI 异步场景中的定位与取舍
消息队列在异步化改造中是一个高频出现的组件,但在 AI 场景下使用它要更谨慎一些。我在多个项目中尝试过用 MQ 承载 AI 调用任务,总结下来它的定位是:适合削峰填谷和大规模任务分发,不适合低延迟交互场景。
想象一下用户请求进来,你把任务丢进 RocketMQ 或者 Kafka,消费者拉取消息再调用大模型。整个链路的时序大概是:提交消息(毫秒级)、Broker 存储、消费者拉取(可能几十到几百毫秒的延迟)、执行业务逻辑、返回结果,前端再轮询拉取结果。对于智能对话这种交互场景,用户等待超过一两秒就会明显感觉到卡顿,MQ 引入的额外延迟和轮询带来的复杂度完全不划算。所以在智能问答环节,我更推荐用线程池 + Redis Stream 的组合方式替代 MQ。Redis Stream 有消息队列的消费组能力,同时因为基于内存,延迟更低,实现也更轻量。
那 MQ 用在什么场景合适呢?批量离线处理。比如每天定时跑一次知识库文档的向量化处理,几千个文档需要切分、Embedding、写入向量库,这种任务耗时以分钟甚至小时计,对实时性毫无要求,用 MQ 天然合适。消费者可以随便扩缩容,消费进度有持久化,失败重试也方便。还有一个场景是异步通知回调,用户的 AI 分析任务在后台处理完后,通过 MQ 通知用户的 Webhook 地址。这种场景实时性要求低,但要求可靠投递,MQ 的持久化和重试机制正好合适,整体收益也是正向的。
选不选 MQ,我建议先问自己三个问题:这个任务的延迟要求是秒级以下还是分钟级往上?任务量是波浪式突增还是平滑稳定?是否需要可靠消费和失败重试?如果答案是低延迟、波浪式、不需要严格可靠投递,直接用线程池 + Redis 更务实;如果答案是高延迟容忍、高并发、要可靠投递保证,再上 MQ。
4. 常见问题与排查技巧实录
4.1 线程池满了之后的连锁故障与处理方法
线程池满时的表现,大部分新一代开发者的第一反应是“加线程数”。我踩过这个坑,加的线程数反而让系统更不稳定。原因是线程数增加导致 CPU 上下文切换开销上升,而且每个额外的线程都会建立对下游的新连接,下游收到更多并发请求后触发自己的限流,反过来导致更多失败,形成恶性循环。所以发现线程池满时,第一件事不是加线程,而是确认这个线程池承载的是不是合理流量——有没有被大流量打进来?是不是慢调用占着线程不放?有没有死锁或者异常导致线程泄漏?
线程池满时优先处理的顺序我是这样排的:先看有没有线程泄漏。用 jstack 抓线程快照,如果发现大量线程卡在某个第三方 SDK 的调用上且长时间不返回,说明下游有问题,应该先做熔断降级,保护线程池里的线程及时退出,等下游恢复后再逐步放开。其次看有没有流量异常。如果某个接口的调用量突然翻了几十倍,可能是被刷量了,也可能是缓存失效引起的缓存穿透,处理手段不同:被刷量走限流和黑名单,缓存穿透走空值缓存或增加布隆过滤器。最后才是考虑扩线程池或者加机器。
还有一个我之前忽略的细节:线程池里跑的 AI 调用如果超时了,只是任务线程被释放了,但底层 HTTP 连接可能还占着。如果超时后没有正确关闭连接,连接池就会被半开连接占满,新请求无法获取连接,表现为“线程池有容量但请求还是超时”。排查这种问题可以用 netstat 看连接状态,大量 TIME_WAIT 或者 ESTABLISHED 但无数据流动,基本可以确认是连接泄漏。解决方案是在超时逻辑里显式关闭 Response 或者使用带连接回收机制的高版本 HTTP 客户端。
4.2 CompletableFuture 误用引发的问题
CompletableFuture 用起来方便,但使用方式不正确会引入一些问题,而且是那种运行时才暴露的隐蔽问题。最常见的是没有正确设置线程池,直接用了默认的 ForkJoinPool.commonPool()。这个公共线程池的并行度默认是 CPU 核数减一,在 AI 应用场景里根本不够用。如果所有异步任务都往这里丢,任务之间会互相抢线程,大量任务排队等待,CPU 核数少一点的机器表现尤其明显。我自己在使用 CompletableFuture 时,基本都会显式传入一个独立的线程池,避免异步任务踢给公用的 ForkJoinPool。
还有一个常见的问题是对 exceptionally 函数的用法理解不到位。exceptionally 只能捕获前一个 stage 的异常,如果你在某一步用了 thenApply 但内部没有捕获异常,异常会传播到下游。如果你的业务希望“某个模型调用失败就用备用模型”,那么正确的方式是在模型调用这个 stage 的 exceptionally 里做降级替换,而不是在外层统一 catch。否则外层拿到异常后,整个 CompletableFuture 链就断了,后续编排全被跳过。
阻塞调用要单独提醒一下:CompletableFuture 链中的某个环节如果调用了一个同步阻塞的 SDK(比如某些老的 HTTP 客户端、某些 JDBC 操作),那么整个“异步”实际上已经被这一个环节拖回了同步。写到线上的代码如果阻塞在一个虚拟线程或者公共池线程上,可能会有局部资源消耗异常的情况。我的排查经验是:在 CompletableFuture 的任务里做好监控埋点,记录每个 stage 的线程切换情况和耗时,一旦发现耗时异常就有日志可查,不用靠猜。没有监控,你根本不知道异步链卡在哪个环节。
4.3 响应超时与半开连接的排查思路
AI 应用另一个高发问题就是响应超时。症状就是用户反馈“答不上来”或者“等了很久才出结果”,服务端日志能看到大量 TimeoutException。这个问题表面上是超时配置的问题,但根因往往很复杂,需要一层一层拆。
排查顺序我会从外到内:先确认是连接超时还是读取超时。连接超时说明 TCP 握手都没完成,可能是下游 IP 不通、网关承受不住、或者服务所在网络和下游之间有防火墙拦截。读取超时说明连接已建立,但数据迟迟不来,可能是下游处理真的很慢、我们的请求没发出去(比如请求头有问题)、或者返回的数据流被某个中间件掐断了。区分好这两种超时,排查范围基本就缩小了一半。
第二步是看超时时间段有没有共性。比如只在流量高峰时超时,可能是连接池不够用导致排队;只在特定 prompt 或模型参数下超时,可能是模型在生成长文本或调用工具时耗时超过了预设的读取超时。遇到后者,不要一味加长超时,而是要结合业务特征看是增加超时合理,还是调整请求参数合理(比如限制 max_token 长度、改用更快的模型)。
第三步是检查是不是中间链路有问题。有时候你发现应用调用 AI 服务超时,但自己在本地用 curl 模拟同样的请求却能正常返回,说明问题大概率出在应用和下游之间的网络链路上。重点排查负载均衡的超时设置、Nginx 的 proxy_read_timeout、网关的转发策略以及所在虚拟机的网络配置。线上真实案例中,最常见的根源其实是网关把 SSE 响应缓冲住了,导致数据积压到超时阈值——解决方法是关闭代理对 SSE 的缓冲。
4.4 流量尖峰下的限流与降级策略回放
最后整理一下我在处理流量尖峰时的限流与降级回放经验,这一套组合拳在几个真实活动场景中都经受住了考验,可以作为参考。
限流策略在选择上,固定窗口限流简单但存在边界问题——两个窗口的临界处流量会翻倍打进来,我之前在活动期间就吃到过这个亏,瞬时 QPS 比预期高了一倍。之后换成了令牌桶算法,令牌桶的好处是允许一定量的突发流量,同时限制长期平均速率。滑动窗口限流也可以做,但计数器粒度太细时 Redis 内存开销会变大。令牌桶是我在 AI 网关场景里综合下来用得最顺手的一个选择。要注意的是,AI 调用中流量尖峰往往来自“同一批热点内容”,这时候单纯限流不够,还得靠缓存和预热才能扛住。
降级策略要按功能分级。我的习惯是把所有 AI 相关能力按重要性分三个级别:核心链路必须保障,比如付费用户的智能问答;增强链路能降则降,比如文档分析里的情绪分析这类附加功能;非核心链路直接砍掉,比如智能文案的敏感词检测预处理。降级触发条件要有明确的指标定义,不要靠人工判断。一般用三个信号:服务层线程池使用率超过 80% 持续 10 秒、下游调用错误率超过 50% 持续 10 秒、RT 超过预设阈值持续 10 秒。任一信号触发就让非核心链路先降级,给核心链路腾出资源和余量。
除了防御性设计,还要有快速观察线上状况的手段。每次活动流量上来时,我主要观察四个指标:当前在途请求数(比 QPS 更本质,因为它直接对应系统承载量)、线程池活跃线程数和队列深度、下游调用的 P95 耗时(上游变慢是系统出问题的第一个信号)、熔断器当前状态。这四个指标配合预警规则,基本能让团队在用户明显感知体验恶化之前就做出反应。
另外要说一下重试的设计底线。AI 接口的重试和普通接口不同,它的每一次调用都产生真金白银的费用,并且部分模型接口不是幂等的(比如多轮对话重复调用会产生额外轮次)。所以重试必须带着限制:限制次数(一般最多 2 到 3 次)、限制范围(只对连接失败和限流错误做重试,对业务错误不重试)、限制频率(退避间隔逐渐加长)。宁可让单个请求失败,也不要让重试风暴拖垮整个下游。
我个人在实际项目里的体会是,异步化和高并发设计最忌讳一步到位、大而全。多数团队并不是第一天就需要承载十万级并发,但第一天就需要保证“系统在突发流量下不会崩”。从这个角度看,你不需要一开始就把所有 AI 调用都改造成消息队列驱动,更不需要在一开始就上 Service Mesh。先把容器线程从 AI 慢调用中释放出来,把连接池和 HTTP 客户端的参数调优到一个合理范围,把限流、熔断和降级这“三板斧”配上监控,系统就已经比绝大多数 AI 应用稳得多了。等到业务量真的上来了,再逐步引入更复杂的编排、更精细的队列和更完善的治理体系。技术选型从来不是选最先进的,而是选最能解决当下瓶颈、同时给未来留好演进空间的。这套基于 Java 生态的异步化高并发设计方法,我在多个 AI 项目里反复验证过,希望也能帮你在自己的场景里少踩几个坑。