将AI Agent嵌入现有Java系统时,Spring Boot 3.2的异步冲突与内存泄漏排查

本文摘要:一个常见的场景:在基于Spring Boot 3.2的订单服务中,集成了一个调用外部LLM API的@Async Agent服务,用于实时生成商品描述。

一、问题与结论

一个常见的场景:在基于Spring Boot 3.2的订单服务中,集成了一个调用外部LLM API的@Async Agent服务,用于实时生成商品描述。单体测试正常,但上线后生产环境的端到端延迟骤增,P99从200ms飙升至600ms以上,且伴随服务内存持续增长,数小时后触发OOM重启。

结论 :问题根源在于:1) Spring Boot 3.2中,@Async方法与底层虚拟线程/平台线程池的混用不当,造成阻塞传染和线程池耗尽。2) AI模型流式响应的处理未正确管理Servlet响应缓冲区和客户端连接,导致内存泄漏。

二、排查与选择依据

1. Spring Boot异步模型冲突与内存泄漏

冲突的核心在于spring-boot-starter-web(基于Servlet同步模型)与@Async任务对线程资源的争夺,以及未处理流式响应的内存边界。

排查工具 :jcmd <pid> Thread.print查看线程堆栈;jcmd <pid> GC.heap_dump <file>分析堆内存。

典型故障代码与修复 :

未优化代码可能包含这样的流式处理端点:

java 复制代码
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream() {
    SseEmitter emitter = new SseEmitter(60000L);
    // 模拟异步推送AI流式结果
    taskExecutor.execute(() -> {
        try {
            for (String token : aiService.streamGenerate()) {
                emitter.send(SseEmitter.event().data(token));
            }
            emitter.complete();
        } catch (Exception e) {
            emitter.completeWithError(e);
        }
    });
    return emitter;
}

这段代码在客户端连接慢或异常断开时,emitter.send可能阻塞,导致执行此任务的线程被长期占用,且内存中的事件无法释放。

修复方向:为线程池任务设置超时,并正确配置SseEmitter的回调与缓冲区。

java 复制代码
// 修复示例:增加生命周期回调与缓冲区设置
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream() {
    SseEmitter emitter = new SseEmitter(60000L);
    emitter.onTimeout(() -> emitter.complete()); // 超时后完成,释放资源
    emitter.onError(e -> emitter.complete());    // 错误后完成
    taskExecutor.execute(() -> {
        try {
            for (String token : aiService.streamGenerate()) {
                emitter.send(SseEmitter.event().data(token));
            }
            emitter.complete();
        } catch (Exception e) {
            emitter.completeWithError(e);
        }
    });
    return emitter;
}

替代方案与取舍

方案 选择条件 代价与边界
本文方案:混合同步+受控异步 主服务为同步Spring MVC,仅将少量Agent调用异步化。技术栈迁移成本低。 需精细管理线程池、超时和缓冲区,复杂度高。不适用于全链路高并发流式场景。
全面反应式(WebFlux) 全新项目或能承受全栈重写,需处理大量并发长连接流。 学习曲线陡峭,与大量现有阻塞式库(如JDBC)不兼容,改造风险高。
虚拟线程直阻塞(Java 21) 代码以阻塞式I/O为主,且能升级到Java 21。 可大幅简化代码,但要求所有依赖库是虚拟线程友好的。无法解决流式响应的背压问题。

三、关键原理

  1. 虚拟线程与@Async的交互 :当spring.threads.virtual.enabled=true时,Spring Boot会尝试用虚拟线程执行@Async任务。但如果该任务内部调用了不兼容虚拟线程的、基于平台线程池的阻塞库(如旧版HTTP客户端),虚拟线程会"固定"在平台上,失去轻量优势,并可能耗尽平台线程。
  2. 内存泄漏根源 :Servlet容器的SseEmitter或AsyncContext在异步处理中,如果开发者忘记设置响应缓冲区大小,或未正确处理客户端断开事件(emitter.onTimeout/emitter.onError),底层关联的响应流和数据队列将无法被垃圾回收。

四、可运行示例

环境 :JDK 21, Spring Boot 3.2.5, Maven。

示例目标 :复现因@Async任务阻塞导致的请求排队。

AgentService.java

java 复制代码
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;

@Service
public class AgentService {

    // 模拟一个调用外部AI服务或进行计算的"Agent任务"
    @Async
    public CompletableFuture<String> invokeAgentTask(String input) {
        // 陷阱:此处如果调用了同步阻塞的I/O库(如旧的HTTP客户端),
        // 即使方法是@Async,也会占用虚拟线程或平台线程池的线程。
        // 模拟一个可能阻塞的同步网络调用
        try {
            TimeUnit.SECONDS.sleep(2); // 假设这是同步HTTP调用
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return CompletableFuture.completedFuture("Processed: " + input);
    }
}

ApiController.java

java 复制代码
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.CompletableFuture;

@RestController
public class ApiController {

    private final AgentService agentService;

    public ApiController(AgentService agentService) {
        this.agentService = agentService;
    }

    // 同步的REST端点,调用异步服务
    @GetMapping("/process")
    public String process(@RequestParam String query) throws Exception {
        // 等待异步任务完成
        CompletableFuture<String> resultFuture = agentService.invokeAgentTask(query);
        String result = resultFuture.get(); // 阻塞等待,可能引发性能问题
        return result;
    }
}

application.properties

properties 复制代码
# 启用虚拟线程 (需要 Java 21)
spring.threads.virtual.enabled=true
# 自定义异步执行器(可选,用于对比)
spring.task.execution.pool.core-size=4
spring.task.execution.pool.max-size=8

运行与观察:

  1. 启动应用,使用JMeter或wrk发送100个并发请求/process。
  2. 预期输出:所有请求几乎同时到达,但响应时间应呈阶梯状,后续请求等待时间累加。
  3. 实际输出:在未限制异步线程池大小时,耗时远超2秒,表明请求被串行化在有限的线程上。
  4. 常见失败 :若未在application.properties中限制线程池spring.task.execution.pool.max-size,虚拟线程的创建可能不受控,掩盖了资源竞争问题。修复方法是为关键的异步执行器配置有界队列和明确的拒绝策略。

五、失败场景与验证

失败场景:@Async异常丢失导致线程池耗尽

场景描述 :AI Agent调用链中的一个异步步骤因第三方服务故障而抛出异常,但由于异常处理不当,线程资源未被释放,最终拖垮整个系统。

失败代码片段:

java 复制代码
@Async
public CompletableFuture<String> riskyAgentCall(String input) {
    // 假设此方法调用了一个不稳定的外部API
    if (Math.random() > 0.5) {
        throw new RuntimeException("External API failed"); // 抛出未检查异常
    }
    return CompletableFuture.completedFuture("OK");
}

调用方(错误方式):

java 复制代码
@GetMapping("/risk")
public String riskEndpoint() throws Exception {
    CompletableFuture<String> future = riskyAgentCall("test");
    // 如果riskyAgentCall内部抛出异常, future.get() 会抛出 ExecutionException
    try {
        return future.get(5, TimeUnit.SECONDS); // 设置超时
    } catch (Exception e) {
        // 此处捕获了异常,但线程池中的任务状态可能已损坏
        return "Error: " + e.getMessage();
    }
}

失败机制与排查:

  1. 症状:应用运行一段时间后,响应变慢,最终大量请求超时。
  2. 排查 :通过线程堆栈(jstack)或监控发现,spring-task-execution线程池中的线程数量达到最大值,且大部分线程处于WAITING或TIMED_WAITING状态。
  3. 根本原因 :@Async方法抛出未捕获的RuntimeException时,Spring的默认异步异常处理器会将其记录为日志,但该异步任务对应的CompletableFuture可能处于一个异常的完成状态。如果后续的Future.get()超时或未被妥善处理,并且线程池没有适当的清理或驱逐机制,资源(线程、连接)可能会泄漏。
  4. 修复方向 :在@Async方法内部做好完整的异常捕获和资源清理;配置全局的AsyncUncaughtExceptionHandler;优先考虑使用CompletableFuture链式调用(.exceptionally(), .handle())来显式处理异常。

验证结果

  1. 内存泄漏 :修复流式处理并增加emitter.onTimeout(() -> {})回调后,通过jmap -dump对比,堆中SseEmitter相关实例数量在压测后稳定,不再持续增长。
  2. 异常处理 :实现全局AsyncUncaughtExceptionHandler后,线程池资源在异常发生后能被及时回收,应用稳定性提升。

参考资料

Spring Boot 3.2 Release Notes

Java 21 Virtual Threads Specification (JEP 444)

Spring Framework 6.1 Async Execution and TaskExecutor

Jakarta Servlet 6.0 Asynchronous Processing

相关推荐
Shockang17 分钟前
llama.cpp 本地运行 9B 模型:部署、工具调用与微调数据
人工智能
Shockang25 分钟前
n8n 智能体工作流实战:触发、工具、记忆与发布护栏
人工智能
冬奇Lab2 小时前
开源项目第241期:YuE2 —— 用符号乐谱当“白盒“接口的开源音乐生成模型,SongBench 超越 Suno v5/v6
人工智能
鶴哥只手遮天3 小时前
Spring AI 2.0.1 深度解析-第 14 章 MCP 服务端(上):注解式实现与工具来源
java·人工智能·开源
鶴哥只手遮天3 小时前
Spring AI 2.0.1 深度解析-第 15 章 MCP 服务端(下):三种协议形态、Streamable 传输与状态管理
java·人工智能·开源
鶴哥只手遮天3 小时前
Spring AI 2.0.1 深度解析-第 16 章 向量存储(上):`VectorStore` 接口体系、`BatchingStrategy`
java·人工智能·开源
鶴哥只手遮天3 小时前
Spring AI 2.0.1 深度解析-第 12 章 工具循环的流式形态、Usage 累加与扩展体系
java·人工智能·开源
鶴哥只手遮天3 小时前
Spring AI 2.0.1 深度解析-第 23 章 模型供应商实现:从 `OpenAiSetup` 到 Bedrock
java·人工智能·开源
鶴哥只手遮天3 小时前
Spring AI 2.0.1 深度解析-第 06 章 内置 Advisor 全景:13 个实现逐个剖到源码级
java·人工智能·开源