数据库连接池为什么会突然耗尽?聊聊 5 个常见泄漏点

服务压力并不高,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
  • 定时任务和消息消费者是否有明确生命周期
  • 是否监控活跃、空闲、等待和等待时长
  • 连接池上限是否低于数据库总连接预算

连接池耗尽看起来像基础设施问题,实际上经常是业务代码的生命周期问题。

池子可以扩大,但如果慢查询、外部调用和未释放连接始终存在,扩容只会把故障推迟一点,并把压力继续传给数据库。

你们线上遇到过连接池耗尽吗?最后定位到的原因是什么?欢迎在评论区聊聊。

如果你也在排查连接池或数据库稳定性问题,也欢迎在微信公众号搜索「长安米粒贵」继续交流。

相关推荐
盛世宏博智慧档案1 小时前
恒湿净化一体机联动开发:档案馆温湿度阈值判断与启停控制实践
java·后端·struts·恒湿机·加湿除湿消毒净化一体机
兔子零10241 小时前
FastAPI 零基础入门教程:写过 Node.js,就能轻松学会 Python 后端开发(一)
前端·后端·python
vipxieliang1 小时前
ValidX 应用于 HR系统员工信息验证:入职、离职、社保数据
java·spring boot·后端
huaweichenai1 小时前
spring boot实现base64上传
java·spring boot·后端
ThinkerQAQ_1 小时前
并发编程(九):BlockingQueue 与 Channel——语言层的原子性、可见性与有序性
后端
花间相见2 小时前
【计算基础|网络16】【工程实战】Nginx 部署与反向代理
后端
点纭2 小时前
spring ai1.0实战:ChatClient(2)
java·后端·spring
Gopher_HBo2 小时前
Resolver 与 Balancer —— 连接管理三层联动
后端
yunwei372 小时前
eBPF 实例教程:BPF Arena 零拷贝共享内存
linux·后端·性能优化