AI智能体系统与传统Web应用有一个很本质的区别:处理一次用户请求,智能体可能要执行几十次工具调用,每次工具调用背后几乎都伴随着数据库读写——检索记忆、写入会话状态、读取知识库、更新任务进度。如果每个数据库操作都从连接池借走一个连接,而连接池的大小只有区区几十个,那么只要并发上来几个长会话,连接池就会被瞬间打满,后续所有请求排队等待,最终表现为整个服务大面积阻塞。这篇文章结合一次真实的线上故障,完整讲讲这类问题的排查思路和调优方法。

故障现象:为什么智能体一忙起来整个服务就卡死
那次故障的表现非常有迷惑性。监控大盘上,Agent服务的CPU和内存都很平稳,但接口响应时间从平均800毫秒飙升到30秒以上,大量请求以504超时结束。数据库侧的慢查询日志里却找不到对应的慢SQL,说明请求根本没有真正打到数据库,而是卡在了应用层获取连接的环节。
翻应用日志,看到了关键信息:Connection is not available, request timed out after 30000ms。这是典型的连接池等待超时。再统计活跃会话数,发现问题发生前有十几个用户同时在跑长任务,每个任务涉及多轮工具调用,而连接池配置的最大连接数只有20。粗算一下:10个并发会话乘以每个会话同时持有的3个连接(一个写会话状态、一个读记忆、一个查知识库),峰值需求就已经超过30,连接池被打满是必然结果。
更糟糕的是连接泄漏。部分代码在异常路径上没有归还连接,长轮询式的工具执行占着连接不放,连接池只会越来越紧张,最终进入死锁状态——所有线程都在等连接,而连接永远等不回来。
排查方法:定位是谁在吃掉连接
调优之前必须先搞清楚连接都去哪了。第一步看数据库侧,执行下面的SQL查看当前连接的来源和状态:
-- 查看当前所有连接及其状态 SELECT id, user, host, db, command, time, state, info FROM information_schema.processlist WHERE command != 'Sleep'; -- 统计各来源的连接数量 SELECT SUBSTRING_INDEX(host, ':', 1) AS client_ip, COUNT(*) AS conn_count FROM information_schema.processlist GROUP BY client_ip ORDER BY conn_count DESC;
如果发现某个应用实例持有的连接数正好等于连接池的最大值,而且大量连接长时间处于Sleep状态,基本可以断定应用侧存在泄漏或长期占用。第二步看应用侧,主流连接池都暴露了监控指标:HikariCP可以通过hikaricp.connections.active、hikaricp.connections.pending等Micrometer指标观察活跃连接和排队线程数;Druid则自带Web控制台,能直接看到连接持有时间排行。
连接持有时间排行是定位泄漏的利器。正常情况下单次数据库操作持连接的时间是毫秒级,如果排行榜上出现持有了几分钟甚至几小时的连接,顺着堆栈就能找到对应代码。常见的泄漏点包括:手动获取连接后异常分支没有close、在事务方法里调用了长时间运行的外部API、以及流式读取结果集时中途抛出了异常。
调优方案:参数、代码、架构三个层面
1. 科学计算连接池参数
连接池不是越大越好。数据库的连接处理能力是有限的,MySQL默认的线程池、PostgreSQL的每个进程模型,都决定了盲目调大连接数只会增加数据库负担。一个被广泛验证的经验公式是:
// 连接池大小经验公式 // maximumPoolSize = CPU核数 * 2 + 有效磁盘数 // 例如8核服务器:8 * 2 + 1 = 17,再结合实际压测微调 HikariConfig config = new HikariConfig(); config.setMaximumPoolSize(20); // 最大连接数,按压测结果设定 config.setMinimumIdle(5); // 最小空闲连接,与最大值保持接近可减少抖动 config.setConnectionTimeout(3000); // 获取连接超时,宁快失败不长时间阻塞 config.setIdleTimeout(600000); // 空闲连接回收时间 config.setMaxLifetime(1800000); // 连接最大存活时间,略小于数据库wait_timeout
这里有个关键取舍:connectionTimeout不要设得太长。很多团队默认30秒,结果一旦池满,每个请求都白白卡30秒才失败,线程被大量占住引发雪崩。设成2到3秒,让过载请求快速失败并触发熔断,反而能保住整个服务。
2. 修复代码层面的连接占用
智能体场景下最大的问题是把外部调用和数据库操作混在一个事务里。典型错误写法是:开启事务获取连接,调用LLM生成回复(耗时可能超过30秒),再写回会话状态。这期间连接一直被占用。正确做法是收缩事务边界,只在真正读写数据库时才持有连接:
// 错误示例:LLM调用被包在事务里,连接被占用30秒以上
@Transactional
public ChatResponse chat(String sessionId, String message) {
sessionRepository.save(buildMessage(sessionId, message)); // 占用连接
String reply = llmClient.generate(message); // 长耗时外部调用,连接仍在占用
sessionRepository.save(buildReply(sessionId, reply));
return new ChatResponse(reply);
}
// 正确示例:拆分事务,LLM调用期间不持有连接
public ChatResponse chat(String sessionId, String message) {
saveMessage(sessionId, message); // 独立事务,用完即还
String reply = llmClient.generate(message); // 此时连接已归还连接池
saveMessage(sessionId, reply); // 再次独立事务
return new ChatResponse(reply);
}
另外要善用超时控制。给每条SQL设置合理的语句超时(socketTimeout、MyBatis的defaultStatementTimeout),避免一条失控的慢SQL把连接占死。对于必须手动操作连接的场景,务必使用try-with-resources确保连接在任何路径下都能归还。
3. 架构层面的隔离与削峰
如果智能体的不同模块对数据库的依赖模式差异很大,建议做连接池隔离。比如把会话读写、知识库检索、任务调度分别配置独立的连接池,任何一个模块出问题都不会拖垮其他模块。以Spring多数据源的方式,为每个业务域指定独立的DataSource即可实现物理隔离。
对于高频的会话状态写入,还可以引入缓冲层:先写Redis,异步批量落库,把数据库写入压力削掉一个数量级。记忆检索这类读多写少的操作,则适合加一层本地缓存或向量库,减少对关系型数据库的直接依赖。经过这三层治理,那次故障的系统在同等并发下连接池峰值占用从打满降到不足一半,P99延迟稳定在了1.5秒以内。
总结
智能体的连接池耗尽,表面是参数问题,本质是架构问题。长耗时外部调用与数据库事务纠缠、多模块共享一个池、缺少快速失败机制,这三个因素叠加才是服务阻塞的完整链条。排查时先看数据库连接分布和应用侧监控指标定位泄漏点,调优时从参数计算、事务边界、池隔离三个层面同步入手,才能真正让系统在高并发长会话场景下稳定运行。