服务压力并不高,CPU 和内存也很正常,接口却突然开始卡住。
日志最后往往只留下一句:
text
TimeoutError: timeout exceeded when trying to connect
数据库本身没有宕机,SQL 也没有大面积慢查询,问题却出现在连接池上:
- 活跃连接一直降不下来;
- 空闲连接越来越少;
- 请求开始排队等待;
- 等待超过阈值后集中超时;
- 重启应用后又短暂恢复。
连接池耗尽通常不是"池子太小"这么简单。它更像是连接生命周期管理出了问题。
下面聊聊最常见的 5 个泄漏点。
连接池不是吞吐量放大器
先明确一个前提:
text
连接池大小 ≠ 系统最大吞吐量
连接池真正做的是复用有限的数据库连接,并在请求和数据库之间建立一层并发边界。
如果每个请求都长时间占用连接,那么:
text
并发请求数增加
-> 活跃连接增加
-> 空闲连接减少
-> 新请求开始等待
-> 等待时间超过客户端超时
-> 请求失败并释放连接
-> 下一批请求继续等待
此时把连接池从 20 调到 100,不一定能解决问题,反而可能把压力传导给数据库:
- 数据库上下文切换增加;
- 锁竞争加剧;
- 慢查询占用更多连接;
- 内存和文件句柄压力上升;
- 最终数据库先出现饱和。
连接池问题的第一原则不是"扩容",而是确认连接为什么没有及时归还。
泄漏点一:异常路径没有释放连接
最经典的泄漏写法是:
ts
const client = await pool.connect();
const result = await client.query("SELECT * FROM orders WHERE id = $1", [id]);
return result.rows[0];
如果中间的查询抛出了异常,代码会直接退出,client.release() 根本不会执行。
正确做法是把释放动作放进 finally:
ts
const client = await pool.connect();
try {
const result = await client.query(
"SELECT * FROM orders WHERE id = $1",
[id],
);
return result.rows[0];
} finally {
client.release();
}
更稳妥的方式是封装成 withConnection:
ts
async function withConnection<T>(
pool: Pool,
handler: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await pool.connect();
try {
return await handler(client);
} finally {
client.release();
}
}
调用方就不需要每次都记得释放:
ts
const order = await withConnection(pool, async client => {
const result = await client.query(
"SELECT * FROM orders WHERE id = $1",
[id],
);
return result.rows[0];
});
数据库连接应该是短生命周期资源,谁获取,谁负责在 finally 中释放。
泄漏点二:在事务中调用外部服务
这是连接池耗尽后最难发现的一类问题。
下面这段代码从"逻辑正确性"看没有问题:
ts
await withTransaction(async tx => {
const order = await tx.orders.findById(orderId);
if (order.status !== "pending") {
throw new Error("order cannot be paid");
}
const payment = await paymentClient.createPayment({
orderId,
amount: order.amount,
});
await tx.orders.update({
where: { id: orderId },
data: { status: "paid" },
});
return payment;
});
但 paymentClient.createPayment 是一个网络调用。
如果支付服务变慢:
text
支付调用等待 3 秒
-> 数据库连接被占用 3 秒
-> 所有支付请求都占住连接
-> 连接池很快耗尽
-> 其他无关接口也开始超时
事务应该尽量只包含数据库操作。
可以调整流程:
text
1. 短事务读取并校验订单
2. 调用支付服务
3. 使用幂等键和状态条件更新订单
伪代码:
ts
const order = await orderRepo.findById(orderId);
if (order.status !== "pending") {
throw new ConflictError("order cannot be paid");
}
const payment = await paymentClient.createPayment({
orderId,
amount: order.amount,
idempotencyKey: paymentIdempotencyKey,
});
await db.transaction(async tx => {
await tx.orders.updateMany({
where: {
id: orderId,
status: "pending",
},
data: {
status: "paid",
paymentId: payment.id,
},
});
});
这样做的代价是需要处理"支付成功但本地更新失败"的补偿,但它避免了把数据库连接绑在慢网络请求上。
原则很简单:
事务中不要等待不可控的外部服务,尤其是在高并发路径上。
泄漏点三:流式查询没有消费完
有些数据库驱动在流式读取结果时,会持续占用连接,直到结果集被消费完、关闭或释放。
例如:
ts
const cursor = client.query(
new Cursor("SELECT * FROM large_table"),
);
for await (const row of cursor) {
if (shouldStop(row)) {
break;
}
await process(row);
}
如果提前 break,但驱动没有自动关闭游标,底层连接可能仍然处于占用状态。
更安全的写法是显式关闭:
ts
const cursor = client.query(
new Cursor("SELECT * FROM large_table"),
);
try {
for await (const row of cursor) {
if (shouldStop(row)) break;
await process(row);
}
} finally {
await cursor.close();
}
不同数据库和驱动的行为不同,必须以对应驱动的文档为准。
排查时可以关注:
- 是否有长时间运行的
idle in transaction; - 连接是否处于等待结果状态;
- 是否存在未关闭的游标;
- 流式导出是否被上游取消;
- 超时中断后是否执行了清理逻辑。
"请求结束了"不等于"底层连接自动释放了",特别是流式接口和导出任务。
泄漏点四:嵌套事务和异常回滚破坏连接状态
业务代码经常会出现这种调用:
ts
await withTransaction(async tx => {
await updateOrder(tx);
await createAuditLog(); // 内部又开启了一次事务
});
底层可能发生:
- 两个事务使用不同连接;
- 内层异常被吞掉,外层继续提交;
- 连接被标记为失败但未回滚;
- 释放回池后仍处于异常状态;
- 下一次取出时复用失败连接。
因此,事务边界必须清晰。
一个常用原则是:
text
Controller / Use Case 层负责开启事务
Repository / Service 接收事务对象
内部不再偷偷创建新事务
示例:
ts
async function cancelOrder(
tx: Transaction,
orderId: string,
) {
await tx.orders.update({
where: { id: orderId },
data: { status: "cancelled" },
});
await tx.auditLogs.create({
data: {
action: "order.cancelled",
targetId: orderId,
},
});
}
如果框架支持 Savepoint,也要明确什么时候使用 Savepoint,什么时候应该整体回滚。
连接池中的连接不是"用完就恢复原样"的黑盒。事务失败后,如果没有正确 ROLLBACK,连接状态可能被污染。
泄漏点五:后台任务和异步上下文忘记归还
后台任务通常没有 HTTP 请求那样的明确生命周期,更容易泄漏。
例如:
ts
setInterval(async () => {
const client = await pool.connect();
if (!shouldRun()) {
return;
}
try {
await syncData(client);
} finally {
client.release();
}
}, 1000);
如果提前 return,连接同样不会释放。
定时任务、消息消费者、WebSocket 连接和后台导出都容易出现类似问题。尤其是使用了异步上下文或并发任务时,连接可能被传递到另一个没有正确结束的执行链。
可以给任务增加统一的生命周期:
ts
async function runJob(job: Job) {
return withConnection(pool, async client => {
const lock = await acquireLock(job.id);
try {
await job.execute(client);
} finally {
await releaseLock(lock);
}
});
}
需要特别注意的是,锁释放和连接释放不是同一个概念。锁等待或锁续期逻辑即使正常结束,也不代表事务连接已经归还。
连接池耗尽时应该看什么
仅仅看到"活跃连接 = 最大值"还不够,至少应该同时观察:
text
totalConnections
idleConnections
activeConnections
waitingRequests
acquireLatencyP95
queryLatencyP95
transactionDurationP95
databaseMaxConnections
不同库的指标名称不同,但核心问题是一样的:
- 是连接不够,还是请求用得太久?
- 等待发生在获取连接,还是查询执行?
- 慢的是数据库,还是事务中的外部调用?
- 是否存在异常后没有释放的请求?
- 重启后多久再次耗尽?
- 单个接口是否会把池子占满?
如果只监控"等待次数",很容易误判为"需要加大池子";但如果同时看 active、idle 和事务持续时间,就能判断是资源不足还是生命周期泄漏。
如何估算连接池大小
可以先做一个粗略估算:
text
所需并发连接数
≈ 请求到达速率 × 单个请求占用连接的平均时间
例如:
text
每秒 200 个请求
每个请求平均占用连接 50ms
所需平均并发连接约为 200 × 0.05 = 10
但这不是最终答案,因为:
- P95 和平均耗时差异很大;
- 不同接口占用连接的时间不同;
- 外部服务会放大事务时长;
- 数据库本身有最大连接限制;
- 多个服务共享同一个数据库。
更稳妥的方式是先控制事务时长和慢查询,再通过压测找到:
text
等待时间开始明显上升的并发点
连接池大小应该服务于这个边界,而不是简单设成一个很大的数字。
最后检查这 8 项
- 所有
connect()是否都有对应的finally release() - 事务中是否避免调用不受控的外部服务
- 流式查询提前终止时是否关闭游标
- 内部函数是否避免偷偷创建嵌套事务
- 异常后是否确保执行
ROLLBACK - 定时任务和消息消费者是否有明确生命周期
- 是否监控活跃、空闲、等待和等待时长
- 连接池上限是否低于数据库总连接预算
连接池耗尽看起来像基础设施问题,实际上经常是业务代码的生命周期问题。
池子可以扩大,但如果慢查询、外部调用和未释放连接始终存在,扩容只会把故障推迟一点,并把压力继续传给数据库。
你们线上遇到过连接池耗尽吗?最后定位到的原因是什么?欢迎在评论区聊聊。
如果你也在排查连接池或数据库稳定性问题,也欢迎在微信公众号搜索「长安米粒贵」继续交流。