这两年我接触到的Java AI项目,几乎每一个都在跟同样的烦恼较劲:大模型调用太慢、用户并发一上来系统就顶不住、异步化听着简单做起来全是细节。很多朋友一听到“Java AI应用”,第一反应是“Python才是搞AI的”,但真到了把大模型能力接入业务系统的那一刻,Java反而是大多数企业后端最稳的那道闸门。这篇文章不聊算法,也不聊模型训练,就聊Java AI 应用落地时最难绕过去的两个坎——异步化与高并发设计,适合正在做AI网关、AI Agent、智能客服或知识库问答这类工程的同学,也适合准备Java高并发面试时想把“异步化”讲出深度的人。
1. 为什么Java AI应用必须重新思考“并发模型”
1.1 AI请求与传统Web请求的负载差异
我最初做Java后端的时候,接口的RT(响应时间)普遍在20毫秒到50毫秒之间。那时候的并发设计非常直白:Tomcat默认200个线程,一个请求来了占用一个线程,数据库查询、Redis读取、返回结果,线程很快就释放了,200个线程足够撑起几千QPS。
但AI应用完全是另一套节奏。一个智能问答接口的典型链路是什么?用户发一句话,系统先去向量库检索知识片段,再拿着上下文调大模型接口,大模型可能还要思考、生成、流式吐字,整个过程2秒到10秒非常正常。甚至AI Agent场景更夸张,一个任务要拆成多个子任务,每个子任务都要调用一次模型,整体耗时一分钟都不稀奇。
这意味着什么?传统思维里,线程是用来“干活”的;AI场景里,线程大部分时间是在“等人”。一个线程等模型API返回的时候,它不能服务其他请求,但内存、CPU上下文、文件描述符全都被它占着。同样的200个Tomcat线程,在20ms接口下能扛1万QPS,放到5秒RT的AI接口上,满打满算也就40QPS。这不是服务器性能的问题,这是并发模型选错了的问题。
1.2 异步化的本质:把“等待”从线程里剥离
我用一个生活化的例子来理解异步化。餐厅服务员就好比线程,厨师就是大模型API。传统同步模式下,客人点完菜,服务员就站在厨房门口死死盯着厨师做菜,做完了才端走,这个服务员在等菜期间一个客人也服务不了。异步模式是,服务员记下单子交给厨师,然后继续去接待别的客人,菜做好了厨房喊一声,服务员再过来端。
放到Java里,就是不让调用线程一直阻塞等待外部响应,而是把任务拆成“提交”和“回调”两个阶段:提交时快速返回,底层通过事件机制在结果准备好之后再触发后续逻辑。这样省下的不是计算时间,而是线程资源。线程是最贵的资源之一,默认栈空间就有1MB,你创建1000个线程,光栈就吃掉1GB内存,更不用说上下文切换带来的CPU损耗。
理解了这一层,你再看网上那些“异步化提升性能”的文章就不会觉得玄了。异步化不是让单次请求变快,而是让同样的机器能扛住更多并发,让等待不再白白占住宝贵的线程。
1.3 Java并发工具箱的演进与选型
Java生态里能实现异步化的工具很多,我按演进顺序排了一下:
| 方案 | 编程模型 | 适用场景 | 心智负担 |
|---|---|---|---|
| Thread + ThreadPoolExecutor | 同步阻塞 | 传统IO、任务简单 | 低 |
| CompletableFuture | 异步编排 | 多阶段依赖、并行调用 | 中 |
| WebFlux / Reactor | 响应式流式 | 高吞吐、流式推送 | 高 |
| Virtual Threads | 同步风格、异步执行 | 大量阻塞式调用 | 低 |
我的选型原则很朴素:能用同步代码解决的就别硬上响应式,异步化是为了解决线程闲置问题,不是为了炫技。团队对WebFlux不熟的时候,硬上响应式编程,代码review都过不去,出问题还没人会排查。CompletableFuture和虚拟线程是我目前在Java AI项目里用得最多的两个方案,下面重点拆。
2. 异步化的落地方式与选型
2.1 四种方案对比:线程池、消息队列、CompletableFuture、虚拟线程
先说线程池加Future。这是最基础的异步化,把耗时任务丢进线程池,主线程用future.get()拿结果。这个方案在小任务量下没问题,但一旦任务之间有依赖关系,比如“先查知识库,再调大模型,同时做内容审核”,你就会掉进回调地狱:一个Future里套一个Future,逻辑乱成一团。我见过不止一个项目把异步代码写成了金字塔,维护起来想哭。
消息队列是另一个维度。你的AI任务如果不需要实时返回结果,比如批量内容审核、离线文档解析,直接丢进Kafka或RabbitMQ,消费者慢慢处理。这种方案的核心价值是削峰填谷,把瞬时流量摊平到整个时间轴上。但注意,用户问答这种强交互场景不适合纯消息队列,用户不可能等十秒再收到一个HTTP响应,该用SSE流式返回还是得用。
CompletableFuture解决的是“多个异步任务怎么编排”的问题,它把并发、串行、合并、异常处理都封装成一个个方法,代码读起来像流水线。虚拟线程则是Java 21之后的杀手锏,它允许你继续写同步代码,但底层自动把阻塞让出来,让线程利用率提升一个量级。下面两个小节我分别给实战代码。
2.2 用CompletableFuture编排AI任务链路
举个真实的AI问答场景。用户提问之后,我需要并行做三件事:从向量知识库检索相关文档、调用内容安全服务审核问题、拿到结果后拼Prompt再调大模型。代码长这样:
public CompletableFuture<Answer> buildAnswer(String question, int topK) { // 并行执行:检索 + 审核 CompletableFuture<List<Document>> retrievalFuture = CompletableFuture .supplyAsync(() -> vectorStore.search(question, topK), aiExecutor); CompletableFuture<Boolean> auditFuture = CompletableFuture .supplyAsync(() -> auditClient.pass(question), aiExecutor); return retrievalFuture .thenCombineAsync(auditFuture, (docs, pass) -> { if (!pass) { return Answer.blocked("内容不合规"); } return Answer.prompt(buildPrompt(docs, question)); }, aiExecutor) .thenApplyAsync(prompt -> llmClient.complete(prompt.promptText()), aiExecutor) .orTimeout(8, TimeUnit.SECONDS) .exceptionally(ex -> { log.error("AI链路异常", ex); return Answer.fallback("服务暂时繁忙,请稍后再试"); }); }这段代码里几个关键点值得说。supplyAsync是异步提交任务,aiExecutor是我自定义的线程池,没用默认的ForkJoinPool,因为默认池和业务线程池混用,一旦一个慢任务拖住公共池,整个JVM里所有并行流和异步调用都会遭殃。thenCombineAsync把检索和审核两个结果合并,注意这里有个隐藏细节:如果审核不通过,这个分支会返回一个“不合规”的Answer,后续thenApplyAsync依然会执行,所以我在实际项目里会在Answer里加一个阻断标记,LLM调用前再判断一次。
异常处理和超时是AI链路里最容易被忽略的。调用大模型的接口,经常因为网络抖动或模型负载高而卡住,不设超时的话,用户的请求会一直挂着,线程池会被慢慢占满。Java的CompletableFuture默认没有超时机制,需要手动加orTimeout,超时后会抛TimeoutException,再被exceptionally接住,走兜底方案。另外我强烈建议在exceptionally里打日志,CompletableFuture的异常默认是“吞”掉的,你不主动记录,出了问题根本无从查起。
2.3 虚拟线程:把同步代码当作异步来用
如果项目已经升级到JDK 21和Spring Boot 3.2,我非常推荐试试虚拟线程。它最大的价值,是让你不需要重构业务代码就能获得异步化的好处。以前你写的同步阻塞调用,比如HTTP调用大模型接口,阻塞的是整个平台线程;换成虚拟线程之后,阻塞的只是虚拟线程本身,底层载体线程会自动让出来执行其他虚拟线程。
配置起来非常干净:
@Configuration public class ThreadConfig { @Bean public AsyncTaskExecutor applicationTaskExecutor() { return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()); } @Bean("aiExecutor") public ExecutorService aiExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); } }Spring Boot侧只需要在配置文件里加一行:
spring.threads.virtual.enabled=trueTomcat容器就会改用虚拟线程接收请求,原来的同步Handler代码不用动。我在实际项目里观察,线上接口RT没有明显变化,但并发吞吐量上了一个台阶,因为虚拟线程的创建成本几乎可以忽略,几万个虚拟线程同时挂在那里等模型返回也不会有内存压力。
但虚拟线程不是银弹。我踩过两个坑:第一,不要试图把虚拟线程池化,虚拟线程的设计初衷就是“创建即用、用完即弃”,你拿Executors.newFixedThreadPool去包虚拟线程,等于给跑车装了低档变速器;第二,CPU密集型任务在虚拟线程上没有任何优势,它解决的是阻塞等待问题,不是计算能力问题。AI应用里大部分阻塞都发生在外部API调用、数据库查询这类IO等待上,正好是虚拟线程的主场。
3. 高并发场景下AI应用的关键设计
3.1 LLM并发控制与批量合并
异步化把线程从等待中解放出来了,但另一个瓶颈马上浮出水面:大模型API的配额限制。几乎所有云厂商的模型接口都有每分钟请求数(RPM)和每分钟Token数(TPM)限制。你用100个线程同时去打一个模型接口,还没等你的服务扛不住,对方先给你甩一堆429限流错误。
我的标准做法是信号量Semaphore控制并发数,同时配合超时排队:
@Service public class AiInvoker { private static final int MAX_CONCURRENT_LLM_CALLS = 20; private final Semaphore llmPermits = new Semaphore(MAX_CONCURRENT_LLM_CALLS); private final ExecutorService llmExecutor = Executors.newFixedThreadPool(40, new ThreadFactoryBuilder().setNameFormat("llm-call-%d").build()); public CompletableFuture<String> call(String prompt) { return CompletableFuture.supplyAsync(() -> { try { if (!llmPermits.tryAcquire(3, TimeUnit.SECONDS)) { throw new TooManyRequestsException("模型调用排队超时"); } return doCallLlm(prompt); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } finally { llmPermits.release(); } }, llmExecutor); } }这里有几个细节容易翻车。tryAcquire而不是acquire,是避免请求在信号量队列里无限等待,排队超过3秒直接快速失败,把压力反馈给上游,而不是把请求全挂死在内存里。Semaphore的许可证数量不是拍脑袋定的,是根据模型API的RPM和单次请求平均耗时反推的。比如模型API每分钟允许600次调用,单次平均耗时1.5秒,那么并发上限大约就是600除以60再乘1.5,等于15,留点余量设成20。
批量合并是高并发场景的进阶技巧。很多模型API提供了Batch接口,可以把多个Prompt合并成一次调用。这个策略特别适合客服系统里大量相似问题:多个用户同时问“怎么退款”,与其每个用户独立打一次模型接口,不如把这批请求聚合成一条Batch请求,显著降低API调用次数。聚合窗口一般设在200到500毫秒,等一个极短的窗口期把请求攒起来,既不影响用户体验,又能把RPM消耗降到原来的几分之一。
3.2 SSE流式响应与断连处理
AI应用和高并发设计绑得最紧的,就是流式输出。现在的对话产品都是打字机效果,模型生成一个token推送一个token,这种场景用传统HTTP长轮询或者JSON一次性返回都别扭。业界标准方案是SSE,也就是Server-Sent Events,服务端单向推送。
Spring Boot里用SseEmitter实现流式返回非常方便:
@GetMapping("/chat/stream") public SseEmitter chatStream(@RequestParam String question) { SseEmitter emitter = new SseEmitter(60_000L); aiService.streamAnswer(question, new StreamCallback() { @Override public void onToken(String token) { try { emitter.send(token); } catch (IOException e) { // 客户端断开,取消底层模型调用 aiService.cancelCurrentCall(); } } @Override public void onComplete() { emitter.complete(); } @Override public void onError(Throwable t) { emitter.completeWithError(t); } }); return emitter; }流式场景的高并发设计有讲究。第一,限流必须在流开始之前做,模型已经吐了二十个token你才告诉用户“系统繁忙”,体验极其糟糕。第二,客户端断连是流式接口最大的隐形杀手,很多同学只实现了onComplete,忽略了onError,用户中途关掉页面,底层模型还在疯狂生成,Token费用照扣不误。实测下来,用户等得不耐烦关页面的比例比你想象的高得多,断连处理做不好,一个月浪费的钱够吃好几顿好的。
还有Nginx网关的超时问题。SSE连接是长连接,如果你在Nginx层设了60秒超时,模型生成超过60秒就会被网关硬切,表现就是前端收到一半内容突然断开。我通常会把代理层的proxy_read_timeout调整到300秒以上,或者至少大于模型最大生成时间。
3.3 语义缓存与热点请求优化
高并发下,很多AI应用的请求其实是重复的。同一个商品的介绍、同一个政策条文的解释、同一个热门事件的评论,大量用户问的是高度相似的问题。把这些请求全部打给大模型,既烧钱又拖垮吞吐。
最简单的优化是加一层语义缓存:请求进来先做文本向量化,拿向量去缓存里做相似度检索,相似度超过阈值就直接返回缓存答案。完全相同的请求可以用哈希去重,一个Map就能搞定;语义相似的请求则依赖向量检索,小规模场景用内存向量库,规模大了用Redis的向量检索能力。
我项目里的具体做法是分级缓存:
| 级别 | 匹配规则 | 存储 | 失效策略 |
|---|---|---|---|
| 第一级 | 请求完全一样 | Caffeine本地缓存 | 5分钟TTL |
| 第二级 | 文本向量相似度>0.92 | Redis Vector | 30分钟TTL |
| 第三级 | 算不过来的 | 直接调模型 | 无 |
热点问题还有一个细节:固定TTL会导致缓存同时过期,然后所有请求一起打到模型API上,形成缓存雪崩。我一般对热点问题做“渐进式过期”,比如TTL设置为10到15分钟之间的随机值,把过期时间打散。
3.4 限流、熔断、降级三件套
AI应用比传统交易系统更依赖这三件套,因为外部模型API是不可控的。你以为模型服务很稳定,结果高峰期对方队列一满,响应时间从1秒飙到10秒,你的线程池立刻被打爆。我把三件套的落地方式整理成了下表:
| 关注点 | 工具 | 策略 |
|---|---|---|
| 限流 | Bucket4j / Sentinel | 按用户维度限流,公平分配模型配额 |
| 熔断 | Resilience4j CircuitBreaker | 连续失败率超过50%时打开断路器,快速失败 |
| 降级 | 自定义Fallback | 模型不可用时返回模板话术或纯知识库拼接结果 |
熔断这块我单独提醒一句:断路器的滑动窗口大小要和模型API的实际情况对齐。有些模型接口会周期性抖动,如果窗口设置太短,可能误判一次瞬时故障就熔断了,导致大面积降级,反而影响体验。我用Resilience4j时,滑动窗口一般设置在20次以上,失败率阈值设在40%,等待半开状态的时间设在30秒左右。这个参数不是固定的,要结合压测和线上观察持续调。
降级策略一定要提前设计好,不能等到线上炸了再临时想。最省事的降级方案是:当大模型超时或者熔断时,直接把向量检索最相关的几段文档拼在一起返回给用户,附一句“基于已有资料生成,若需更精确请稍后再试”。体验谈不上完美,但至少用户拿到的是有用的信息流,而不是一句冰冷的“系统错误”。
4. 一个实战:AI Agent异步编排系统
4.1 业务场景与任务表设计
理论讲多了容易飘,我用一个做过的AI Agent系统来串一遍。需求是一个多模型协作的Agent助手:用户提一个问题,系统并行调用一个通用大模型、一个垂直领域专用模型,同时检索知识库,最后把所有结果交给一个聚合器,生成最终答案。因为整个流程耗时长,不能同步等,我们采用“任务提交 + 异步编排 + 结果回传”的架构。
任务状态需要持久化,我用的是Spring Boot加MyBatis,数据库表设计如下:
CREATE TABLE agent_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id VARCHAR(64) NOT NULL, session_id VARCHAR(64), user_id VARCHAR(64), status TINYINT NOT NULL DEFAULT 0, question TEXT, answer TEXT, tokens INT, cost DECIMAL(10, 6), created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, KEY idx_task (task_id), KEY idx_session (session_id) );状态流转是PENDING到RUNNING再到SUCCESS或FAILED。任务一来先插入一条PENDING记录,立即返回taskId给前端,前端拿着taskId轮询或等SSE推送。这里高并发的核心点在于:任务表不要做成频繁更新的热表,每个任务的写次数控制在两到三次,查询带上taskId索引,否则高并发下数据库会先成为瓶颈。
4.2 核心编排代码实现
核心编排逻辑用CompletableFuture组合多个异步结果:
public void handleAgentRequest(Long taskId, String question) { CompletableFuture<ModelResult> generalFuture = gateway.callGeneralModel(question).orTimeout(10, TimeUnit.SECONDS); CompletableFuture<ModelResult> specialFuture = gateway.callSpecialModel(question).orTimeout(10, TimeUnit.SECONDS); CompletableFuture<List<Document>> docFuture = knowledge.search(question).orTimeout(5, TimeUnit.SECONDS); CompletableFuture<String> merged = CompletableFuture .allOf(generalFuture, specialFuture, docFuture) .thenApplyAsync(v -> aggregator.merge( generalFuture.join(), specialFuture.join(), docFuture.join()), agentExecutor) .orTimeout(15, TimeUnit.SECONDS) .handle((result, ex) -> { if (ex != null) { log.error("Agent编排失败, taskId={}", taskId, ex); taskMapper.updateTask(taskId, Status.FAILED, null); return buildFallbackAnswer(ex); } taskMapper.updateTask(taskId, Status.SUCCESS, result); return result; }); merged.whenComplete((result, ex) -> notifyFrontend(taskId, result)); }代码逻辑很清楚,但我必须强调一个新人容易踩的坑:join()是阻塞方法。这段代码之所以敢用join,是因为它跑在agentExecutor线程池里,而我们这里的agentExecutor是虚拟线程执行器,阻塞一个虚拟线程几乎无成本。如果你用的是普通线程池,join会把一个业务线程白白卡住,三个future排队join,异步效果大打折扣。普通线程池环境下,建议用thenCombine一层层嵌套下去,虽然代码难看一点,但不会出现线程阻塞。这也是我之前说的,虚拟线程最大的价值就是让你在编排复杂链路时不用束手束脚,可以保留同步代码的直观性。
4.3 并发参数估算与压测实录
做压测之前,先做了一份参数估算:
| 参数 | 数值 | 说明 |
|---|---|---|
| 单次模型平均时延 | 1.5秒 | 实测 |
| 模型并发配额上限 | 20 | API Permit限制 |
| 单请求模型调用数 | 2 | 通用模型+专用模型并行 |
| 理论QPS | 20 / 1.5 ≈ 13 | 受并发配额约束 |
| 若并发配额放宽到200 | 约133 QPS | 需确认API配额支持 |
这个估算表暴露了一个关键洞察:在这个系统里,真正的瓶颈不是服务器线程,也不是数据库,而是外部模型API的并发配额。你的异步化做得再漂亮,线程再多,外部只能同时处理20个请求,你的吞吐就锁死在20除以时延这个公式里。高并发设计到这里,重点已经从“服务端调优”转移到了“合理利用外部配额”上。
压测采用的是100并发持续10分钟,对比同步版和异步编排版的数据。同步版在50并发时已经出现大面积超时,p99涨到12秒,报错率接近8%;异步编排版在100并发下p99稳定在6秒左右,报错率降到1.2%。系统本身资源占用并不高,CPU只有30%,说明计算不是瓶颈。值得注意的是,异步版的报错全部来自模型API的429限流,这进一步印证了上面的估算:模型配额才是真正的天花板。
4.4 线程池隔离与参数调优
我发现很多项目犯的一个错误是“一个线程池走天下”。AI应用里有多种不同特征的调用:检索知识库是毫秒级延迟,调大模型是秒级延迟,写任务表是数据库操作。把它们混在同一个线程池,一次模型调用超时打满队列,知识库检索也跟着受影响,这个故障扩散是非常要命的。
我的做法是拆分三个线程池:
| 线程池 | 核心线程数 | 用途 |
|---|---|---|
| retrievalExecutor | CPU核数*2 | 知识库检索、本地计算 |
| llmExecutor | 40 | 大模型调用(受Semaphore控制) |
| taskExecutor | 20 | 任务状态写入、前端通知 |
参数调整方面,虚拟线程环境下,platform线程池大小已经不那么敏感,但仍然建议给模型调用单独加信号量控制,防止排队请求无限积压。数据库线程池也要注意,MyBatis操作任务表这种高频写操作,连接池大小可以控制在10到20,因为单次写操作只要几毫秒,池子太大反而浪费连接资源。
5. 常见问题与排查技巧实录
5.1 线程池拒绝策略导致请求丢失
现象:高峰期日志里出现大量RejectedExecutionException,用户反馈请求没有结果。
原因:线程池满了,任务队列也满了,默认的AbortPolicy选择直接抛异常,这个异常如果没被捕获,请求就被静默丢弃了。
解决:自定义饱和策略,比如改用CallerRunsPolicy,让提交线程自己跑这个任务,虽然会阻塞调用方,但至少不会丢请求;更精细的做法是,在饱和时返回一个“系统繁忙”响应,让上层去决定是重试还是降级。排查时可以先用Arthas的thread命令查看哪个线程池队列快要打满,观察线程名,避免猜着调。
我给CompletableFuture编排异步任务时,额外强调线程池命名:通过ThreadFactory设置线程名,比如llm-call-1、task-writer-2。不要小看这个习惯,线上出问题的时候,没有命名你是看不出每个线程在干什么的,那才是真的无从下手。
5.2 SSE断连导致模型调用未取消
现象:用户量不大,但每天模型消耗的Token费用高得离谱,远超正常估算。
原因:用户点击停止加载或者直接关闭浏览器,服务端SseEmitter发送失败,但底层模型调用还在继续,直到整个Prompt生成完。
解决:实现SseEmitter的onError回调,在断连时调用底层模型调用的cancel方法,把流中断信号传下去。还有一个细节:SseEmitter超时时间要设置合理,我一般设成60秒到120秒,如果超时了自动触发completeWithError,同时取消模型调用。成本问题最直接的提现就是,加了断连取消之后,每月Token账单能下降20%到30%。
5.3 CPU不高但吞吐上不去
现象:压测时服务器CPU只有40%,但TPS一直上不去,响应时间还在不断上涨。
原因:这个现象十有八九是线程数太多导致的上下文切换。你把线程数量堆到800、1000,每个线程都在等待或者切换,真正干活的CPU时间被切稀碎了。
解决:线程数不是越多越好。平台线程池大小一般设置为CPU核数的两倍左右,多出来的并发等待交给信号量控制。如果你用了虚拟线程,这个问题会小很多,因为虚拟线程的切换成本极低,不受平台线程数量限制。但不管哪种方式,都要先明确瓶颈在哪:用压测工具看线程waiting时间和运行时间比例,waiting比例过高,就是等得太多了,适当降低线程数反而可能提升吞吐。
5.4 面试中讲异步化与高并发的正确姿势
这个话题经常在Java面试里被问到。面试官问“你怎么设计一个高并发的AI应用”,最容易翻车的回答是“我用线程池,再多加几台机器”。这句话没有错误,但没有深度。
我建议回答时按这三层递进:
第一层先讲瓶颈分析。AI应用的RT比传统接口高两个数量级,线程平均利用率不到10%,这是并发设计的起点。第二层讲异步化层次:任务编排用CompletableFuture,流式输出用SSE,如果项目升级到JDK21可以引入虚拟线程,把同步代码和异步性能结合起来。第三层讲外部依赖保护:模型API的并发配额、超时、熔断和降级。最后用你压测的真实数据收尾,比如“同步版100并发p99是12秒,异步编排后p99降到6秒,瓶颈转移到模型API配额”。
不要一上来就堆WebFlux、Reactor、响应式编程这些词。面试官想听到的是你对线程模型的理解、对瓶颈的定位、对故障的预防,而不是名词的罗列。
6. 我踩过坑之后留下的一些习惯
最后分享几条我现在做Java AI项目一定会遵守的习惯。
第一,不管异步还是同步,日志里必须带上taskId和traceId。没有链路追踪的异步系统,排查问题就像在黑屋子里找掉在地上的针,你会疯掉的。第二,Semaphore的初始值一定往保守里设。我见过一个项目上线第二天就把外部模型API给打限流了,只因为配置里并发值拍脑袋填了个200。从20开始压测,慢慢往上调,比调下来容易得多。第三,CompletableFuture的每个分支都要有超时和异常日志。异步代码的异常默认是不会打出来的,你不主动记录,等于给自己埋雷。第四,别把异步化当成银弹。消息队列、异步任务加多了,实时性下降,产品体验会出问题;异步化要服务于业务场景,该同步的地方同步,该异步的地方异步,不要为了技术而技术。
踩过几次坑之后,我现在接任何一个Java AI项目,第一件事不是写代码,而是先画一张“耗时与依赖图”:哪些调用是同步必须的,哪些可以异步化,外部依赖的配额是多少,超时设多少。这张图画完之后,系统的并发模型基本就定下来了。高并发不是靠事后调优调出来的,是靠一开始把模型选对、把参数想清楚建出来的。希望这篇东西能让你少走一些我走过的弯路。