Rust in Everything
基于 sqlx 0.9.0

sorry, too many clients already

主线位置:连接池主线里 acquire 没有空闲连接、去 connect 新建的那一步。connect 的重试和退避见获取连接;本页讲服务端连接数用满时你实际看到什么,以及池的上限怎么定。

flowchart TB
  acq["acquire 要新建"] --> conn["connect"]
  conn --> srv{"服务端还有槽位"}
  srv -->|有| ok["拿到连接"]
  srv -->|"没有 53300"| retry["退避后重试"]
  retry --> dl{"到 acquire_timeout"}
  dl -->|没到| conn
  dl -->|到了| pto["PoolTimedOut"]

本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,依赖 sqlx = "=0.9.0"。服务端是一个单独的实验容器,启动参数 max_connections=8,只给这一页用。

现象

Postgres 的连接数到了 max_connections,新连接在启动阶段被拒,错误码 53300(too_many_connections):

sorry, too many clients already

直接 PgConnection::connect 会立刻拿到这个错误。经连接池时却看不到它:acquire 卡满 acquire_timeout,然后报 pool timed out while waiting for an open connection,看起来像是池太小或者连接泄漏了。

最小复现

src/bin/too_many.rs:两个池 A、B,各 max_connections(5)、acquire_timeout(2s),加起来 10,超过服务端的 8。A 先并发借满 5 条并一直拿着,B 再并发借 5 条;然后直连一条;最后 A 关掉一条,B 再借:

let a = PgPoolOptions::new().max_connections(5).acquire_timeout(Duration::from_secs(2)).connect_lazy_with(opts("pit3-a")?);
let b = PgPoolOptions::new().max_connections(5).acquire_timeout(Duration::from_secs(2)).connect_lazy_with(b_opts);
let held_a = grab("A", &a, 5).await;
let held_b = grab("B", &b, 5).await;
match PgConnection::connect_with(&opts("pit3-direct")?).await {
  // 打印 Database(code, message) 或其他错误
}

grab 用 join_all 同时发出 n 个 acquire,统计成功和失败;client backend 是 pg_stat_activity 里客户端后端的个数,含一条观察用的连接:

max_connections=8 superuser_reserved_connections=3 reserved_connections=0 lab 是超级用户: true
开始时 client backend = 1(含观察连接)
--- 两个池,max_connections 5 + 5 ---
A: 借 5 条,拿到 5,用时 0.1s
client backend = 6
B: 借 5 条,拿到 2,用时 2.0s
B:   3 条失败: pool timed out while waiting for an open connection
client backend = 8,B.size() = 2
--- 这时直接连一条 ---
直连: Database(Some("53300"), sorry, too many clients already)
--- A 还回一条之后,B 再借 ---
B 拿到一条,用时 0.03s

B 抢到了最后两个槽位,另外三个 acquire 各等满 2 秒,报 PoolTimedOut,没有一处提到 53300。同一时刻直连,错误原文立刻就有。A 关掉一条(close_on_drop)之后,B 下一次 acquire 几十毫秒就拿到了。

B 的连接经过一个打印协议消息的代理(TRACE=1)。代理记下 B 在这 2 秒里一共发起了 32 次连接,每次都发了 StartupMessage,其中 30 次收到的是 ErrorResponse:

  [conn 1] C->S StartupMessage(v3.0)
  [conn 4] C->S StartupMessage(v3.0)
  [conn 2] C->S StartupMessage(v3.0)
  [conn 3] C->S StartupMessage(v3.0)
  [conn 5] C->S StartupMessage(v3.0)
  [conn 2] S->C ErrorResponse
  [conn 4] S->C Authentication
  [conn 1] S->C Authentication
  [conn 5] S->C ErrorResponse
...

原因

PoolInner::connect 在截止时间之内循环建连接,遇到 is_transient_in_connect_phase 为真的数据库错误就退避重试,Postgres 认的是 53300 和 57P03。退避从 10 毫秒翻倍,上限是剩余时间的五分之一,所以 2 秒里每个等待者大约试 10 次,三个等待者合计 30 次,正好对上代理的计数。截止时间一到,循环返回的是 Error::PoolTimedOut,最后一次的 53300 被丢掉。PoolTimedOut 是没有字段的变体,带不出原因。

PoolOptions::connect_with 建池时也走 acquire,所以服务端已满时建池同样是等满 acquire_timeout 后报 PoolTimedOut(这一条是读源码得出的,本页没有单独跑)。

📝NOTE
没有蠢问题

问: 服务端都明确说满了,为什么还要重试,而不是把 53300 直接交给我?

答: 源码注释给的理由是:This may be returned if we just un-gracefully closed a connection, give the database a chance to notice it and clean it up. 池刚刚 close_hard 掉一条连接时,服务端可能还没注意到,槽位还占着,稍等一下就会空出来。对这种短暂的满,重试是对的;对「几个池的上限加起来本来就超了」这种长期的满,重试只是把错误换成了超时。

修法:按服务端上限倒推池的上限

能给应用用的连接数是:

可用 = max_connections - superuser_reserved_connections - reserved_connections

后两项是给超级用户和 pg_use_reserved_connections 角色留的,普通角色用不到;本页实验的 lab 恰好是超级用户,所以 8 条都用上了(1 + 5 + 2)。生产里应用一般不是超级用户,Postgres 16 默认 max_connections=100、superuser_reserved_connections=3,可用是 97。

然后把所有会连这个库的东西加起来,保证不超过「可用」:

Σ(实例数 × 该实例上所有池的 max_connections) + 迁移、定时任务、psql、监控等 ≤ 可用
  • 「实例数」按扩容的上限算,滚动发布时新旧实例会同时在线,也要算进去。
  • 一个进程里有几个池(读写分开、PgListener 自己的池、测试里每个用例一个池)就加几份。
  • PoolConnection::detach 出来的连接不再占池的计数,池会再开一条补上,见归还、Drop 与维护任务。

把实验里两个池都改成 3,加上观察连接一共 7 条,不超过 8:

--- 按上限算好:3 + 3 + 观察连接 1 ---
A: 借 3 条,拿到 3,用时 0.1s
B: 借 3 条,拿到 3,用时 0.1s
client backend = 7

实例数多到这样算下来每个池只剩一两条时,该在前面加连接池代理(pgbouncer 一类),而不是调大服务端的 max_connections:每条后端连接是一个进程,调大有内存代价。

怎么确认自己没中招

  • 看到 PoolTimedOut,先在同一台机器上 psql 或直连一次:报 sorry, too many clients already 就是服务端满了,不是池的问题。
  • 在库里看是谁占着连接。给每个池设 PgConnectOptions::application_name,就能按池分开数:
select application_name, usename, count(*)
from pg_stat_activity
where backend_type = 'client backend'
group by 1, 2
order by 3 desc;
  • 用上面的式子算一遍最坏情况,留出余量。

小结

  • 服务端连接数满了,直连报 53300 sorry, too many clients already;经 sqlx 连接池只报 PoolTimedOut,因为 connect 把 53300 当成暂时错误,退避重试到 acquire_timeout,最后的原因被丢掉。
  • 两个 max_connections(5) 的池连一个上限 8 的服务端,后来的池只拿到剩下的 2 条,其余 acquire 各等满超时;代理看到 2 秒内 32 次连接尝试。
  • 池的上限按「实例数 × 每实例各池上限 + 其他客户端 ≤ max_connections − 保留连接」倒推;不够分时加连接池代理。

💬 关联讨论

发起讨论
加载中...