数据库不忙,Agent 为何耗尽连接池
文章分析智能体等待外部调用时持续占用连接,导致数据库 CPU 很低却出现请求失败的故障模式。核心在于有限资源的占用时长,并将分析扩展至 HTTP 客户端、Redis 等资源池。
AI 深度解读
数据库 CPU 只有 1.8%,登录和结账却接连超时,问题可能出在连接被谁占着。原文讨论了一类 AI Agent 服务故障:请求拿到数据库连接后,又握着连接等待 LLM 或远程 API,最终让整个连接池耗尽。
作者用排队公式和虚拟时间模拟解释故障,再提出短租约、请求幂等和版本校验的修复思路。对写 Node 服务、接入模型调用的团队很实用,但文章中的性能数字、修复统计和「15ms CI 门禁」需要分开看。
为什么数据库很闲,连接池却已经满了
数据库连接池是一组数量有限、可以重复借用的连接。借出之后,即使业务代码只是等待网络响应,这个名额也无法交给其他请求。
原文的反例把一次数据库读取、一次模型调用和一次数据库更新包在同一段连接租期里:
async function runAgentStep(db, agent, userId) {
const conn = await db.getConnection(); // t=0, lease acquired
const user = await conn.query("SELECT * FROM users WHERE id = ?", [userId]); // t=0->2ms
const decision = await agent.generateDecisionStream(user); // t=2->28,002ms (WAN hold)
await conn.query("UPDATE accounts SET state = ? WHERE id = ?", [decision.state, userId]); // t=28,002->28,004ms
conn.release(); // t=28,004ms, lease released
}
要盯住 await agent.generateDecisionStream(user):数据库只工作了前后各 2ms,连接却被占了 28,004ms。代码也没有用 finally 释放连接,异常时还有泄漏风险。
作者用 Little 定律 L = λW 说明容量关系:平均占用量等于请求到达速率乘以平均占用时间。连接池大小为 N 时,λW 接近 N,就接近饱和。
按原文的理想化计算,20 个连接、每次占用 0.002s,对应 20 / 0.002 = 10,000 请求/秒;把模型等待纳入租期后,变成 20 / 28.004 = 0.714 请求/秒。若每秒到达 20 个请求,所需连接约为 560 个,远超容量。
连接池容量取决于连接被占用多久,CPU 利用率无法替代这个指标。
所以,从 db.m6g.large 升级到 db.r6g.4xlarge 未必解决问题。作者说这可能增加每月数千美元开支,但没有给出实际账单或价格条件。
这个问题也不限于数据库。作者列举了 JDBC/Hikari、PgBouncer、Redis 连接、HTTP 客户端、Kafka 分区、Salesforce API 配额窗口和 LLM 流式回调。它们的共同点是容量有限,不过具体如何计算占用时间,仍要按各自机制分析。
为什么测试通过,上线才开始排队
单用户测试通常没有资源争用,内存 mock 又很快返回,模型调用的漫长等待就消失了。预发布环境只有两名内部测试者,也很难把 20 个连接占满。
原文把根因称为「V4 Channel 的 C1 Timing 被包在 V3 Lease 内」:外部通信延迟进入了连接租期。C4 Saturation,也就是容量耗尽,是后续症状。它还列出 C2 Omission、C3 Duplication、C5 Crash、C6 Corruption,分别对应遗漏、重复、崩溃和损坏。
这些标签来自作者的建模方式,不能直接当成行业统一标准。
验证部分也有明确边界:作者披露,遥测和轨迹来自基准场景,由离散事件内核在虚拟时间里模拟,使用固定种子 42817 和文中名为 mullberry32 的伪随机数生成器。开头的 1.8% CPU,以及 CRM 示例的 2.4% CPU,都没有附上生产监控证据。
模拟设置为 N = 20、外部等待 W = 28s。开始时同时到达 50 个请求,此后保持 λ = 20/s:20 个拿到连接,30 个排队。获取连接的超时为 5000ms,因此 5 秒时首批 30 个请求失败,10 秒时累计失败 130 个。
原文分别提到 HTTP 500、503 和 504;实际返回哪个状态码,还取决于应用、网关和负载均衡器如何处理超时。
引擎通过 6 轮二分搜索,在不到 200ms 内把饱和边界缩到 ±16ms。报告显示,980ms 租期仍可安全运行,1,012ms 时进入饥饿区间,与理论边界 W_max = N / λ = 1.0s 接近。不过,标题里的「15ms CI Gate」没有对应的测量过程,正文并未证明这个耗时。
释放连接之后,还要处理重试和旧数据
修复的第一步是晚借早还:读取完成就归还连接,等待模型时不占连接,写入时重新获取。
原文用这个辅助函数确保连接最终释放:
async function withQuickConnection(db, fn) {
const conn = await db.getConnection();
try {
return await fn(conn);
} finally {
conn.release();
}
}
关键是 finally 中的 conn.release()。但辅助函数不会自动缩短租期,如果传入的 fn 仍然等待外部网络,原来的问题依然存在。
第二步是请求幂等。作者模拟了数据库提交成功、返回 ACK 却丢失的情况:客户端在 96ms 后检测到 RST,以相同 requestId 重试。没有保护时,会再花 28 秒调用模型,并重复修改数据。
修复方案在任何读取或模型调用之前,以 sha256(workflowId:requestId:stepIndex) 生成幂等键。入口先查询 agent_mutations,命中就返回已保存的状态;最终提交时,通过 ON CONFLICT (idempotency_key) DO NOTHING 拦截重复写入,并把幂等记录和账户更新放进同一事务。
第三步是乐观并发控制。模型等待期间,账户可能已经被其他请求修改,因此更新使用 WHERE id = $2 AND version = $3,成功后执行 version = version + 1。示例轨迹把版本从 5 更新到 6。
版本冲突由 runWithRetries 处理,MAX_VERSION_RETRIES = 3,等待时间为 50 * attempts 毫秒。但代码也把「记录不存在」归入同一种重试错误,这类失败未必能靠重试恢复。
入口查询还有一个边界:两个并发请求可能同时查不到幂等记录,随后都调用模型。最终事务可以拦住重复落库,却不能保证没有重复推理。原文展示的是提交完成后的重试快速返回。
我的判断
我认同作者把连接租期放到排查中心。对于「CPU 很低、接口很慢、连接等待很多」的服务,它比直接扩容更接近代码层面的原因。增加池容量可以暂时缓解排队,却也可能把压力继续传给数据库。
不过,原文修复后的统计有明显矛盾。文字声称每个请求只有两次 2ms 租约,共 4ms,所以 L = 20 × 0.004 = 0.08,占 20 个连接的 0.4%。
代码和轨迹实际包含三次借还:幂等查询 2ms、读取快照 2ms、提交事务 4ms,总计 8ms。按该轨迹重算,平均占用应为 0.16 个连接,即 0.8%。这不推翻修复方向,但说明报告数字不能直接照搬。
示例也需要适配才能运行:SQL 混用了 ? 和 $1 占位符,查询结果既按 .rows 处理,又直接读取 user.version;该版本来自 users 查询,更新条件却落在 accounts,两者关系没有说明。正常提交路径也没有返回与缓存命中路径一致的结果。
这是一套有解释力的故障模型,尚不足以证明工具能覆盖真实服务的全部行为。
作者推荐的 DISTRIB 能否准确复现实际连接池、驱动和重试机制,需要进一步验证。原文没有给出它与真实压测或其他故障注入方案的对照数据;虚拟时间测试适合提前筛查结构问题,实际吞吐仍要结合服务测试判断。
能拿来做什么
- 检查 Node Agent 接口里从
db.getConnection()到conn.release()的所有await,把 LLM、支付或 CRM 等远程调用移出连接持有范围,并用finally保证释放。 - 为关键接口记录连接获取等待时间、租期、池内占用和排队数量,再观察模型延迟上升时这些指标如何变化,避免仅凭 CPU 判断数据库是否健康。
- 让同一次业务操作的传输重试保留相同
requestId,在昂贵模型调用前检查已提交结果,并用同一事务提交幂等记录和业务修改。 - 在 CI 或预发布测试里同时加入突发并发、慢外部响应、提交后响应丢失三种条件,检查是否出现连接耗尽、重复写入和重复模型调用。