Postgres 连接风暴:97 个可用槽位、三条 FATAL 与四个默认为 0 的超时
凌晨应用全站 500,日志全是 timeout,docker stats 里 Postgres 的 CPU 却几乎贴地。数据库不是忙,是满了:连接槽位耗尽,应用在门外排队十分钟。成因通常不是单个 bug,而是三件事叠加——pool_size × 进程数 × 副本数 的算术、重启让所有容器在同一瞬间要连接、无节制重试把慢库拖成死库。
一、算术:97 个槽位,180 个想连的人
Postgres 默认 max_connections = 100(initdb 在某些内核上可能取更低)。superuser_reserved_connections 默认 3,留给超级用户,应用实际能用约 97 个——还要和迁移工具、备份任务、Grafana 数据源、你周二忘了关的 psql 共享。
应用侧最坏情况:容器数 × 进程数 × (pool_size + max_overflow)。SQLAlchemy create_engine 默认 pool_size=5、max_overflow=10,每个 worker 最多 15 条连接。
3 × 4 × (5 + 10) = 180 # 默认值
3 × 4 × (10 + 10) = 240 # “把 pool 调大点”之后
而 Postgres 只有 97 可用。
最容易漏算的一点:pool 是每进程一份。gunicorn fork 之后,每个 worker 自己建 engine。10 个容器每个 2 worker 就是 20 个池,不是 10 个。
二、看现场
SELECT count(*), state FROM pg_stat_activity GROUP BY state ORDER BY count(*) DESC;
健康时大部分是 idle,少量 active。风暴时 active 顶高且后面压着长查询,或者一堆 idle in transaction——最麻烦的一种:开着事务的连接持有锁、阻塞 vacuum,还白占池位。
对照上限:
SHOW max_connections;
SHOW superuser_reserved_connections;
SELECT count(*) AS in_use FROM pg_stat_activity WHERE backend_type = 'client backend';
撞墙时客户端看到的三条 FATAL(作者对照 PG 16 与 18 源码核实):
FATAL: sorry, too many clients already
FATAL: remaining connection slots are reserved for roles with the SUPERUSER attribute
FATAL: remaining connection slots are reserved for roles with privileges of the "pg_use_reserved_connections" role
第一条是槽位全满;后两条是普通用户被挡、只剩预留槽。措辞在 PG 16 变了:15 及以前是 reserved for non-replication superuser connections。PG 16 新增 reserved_connections 设置(默认 0)。
三、重启惊群
数据库抖 30 秒(重启、备份卡顿、磁盘抖动)之后,所有池里的连接都死了。库回来那一刻,每个 worker 同时重建池,正在请求中的还叠加重试。刚醒来的库在一秒内吃到最坏情况的全量连接。docker compose up 同理:所有容器一起起,第一波请求同时填满每个池。SQLAlchemy 和 node-postgres 是懒建连接,惊群随第一波流量到达;HikariCP、pgx 的 MinConns 这类带最小连接数的池在启动时后台就开了。
两个便宜防御:
等库健康再起。 compose 里 depends_on + condition: service_healthy,db 加 healthcheck:
services:
db:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
app:
depends_on:
db:
condition: service_healthy
这只解决冷启动;运行中重启它管不了,所以还要重试。
打散。 三个副本同一秒启动就是惊群,第一次查询前随机睡一下(STARTUP_JITTER_MAX)。
四、重试放大
库从 20ms 变 2s,请求超时,代码 catch 后立刻重试:1 个失败变 2 个、3 个,库从 100% 变成 300%。
正确做法是指数退避 + jitter。AWS 那篇 Exponential Backoff And Jitter 的结论是纯指数退避仍会让客户端同步成波,加 jitter 才能打散,100 客户端模拟里总调用数减少一半以上。full jitter 取 0 到上限之间的随机值:
sleep = random_between(0, min(cap, base * 2 ** attempt))
Python 示例(重试仅限连接级错误 + 幂等操作):
import random, time
from sqlalchemy.exc import OperationalError, TimeoutError as PoolTimeout
def with_retry(fn, attempts=5, base=0.1, cap=5.0):
for attempt in range(attempts):
try:
return fn()
except (OperationalError, PoolTimeout):
if attempt == attempts - 1:
raise
time.sleep(random.uniform(0, min(cap, base * 2 ** attempt)))
几个边界:次数封顶,别无限循环;睡眠从 0 开始随机;只重试连接级错误(唯一约束冲突重试五次也一样失败);只重试幂等操作,否则超时后重试可能把写执行两遍。
五、四个必须设的超时
- 池获取超时:SQLAlchemy 的
pool_timeout默认 30 秒——一个 HTTP 请求等 30 秒太久。设到负载均衡超时以下,快速失败。 - 连接超时:node-postgres 的
connectionTimeoutMillis默认 0,即不超时,库黑洞化时请求无限挂。 - statement_timeout:默认 0(禁用)。不设的话一条失控查询会一直占着槽位直到人工 kill。
- idle_in_transaction_session_timeout:默认 0(禁用)。这类会话持锁并阻塞 vacuum 清理死行。
后两个按角色设置,能扛住客户端代码的 bug:
ALTER ROLE app_user SET statement_timeout = '30s';
ALTER ROLE app_user SET idle_in_transaction_session_timeout = '60s';
文档明确不建议在 postgresql.conf 全局设 statement_timeout,因为迁移和备份也会被打断。数值从最慢的合法查询推,不要凭感觉。
六、池化器只是把墙挪走
如果算术怎么都凑不上——很多小服务、serverless、一堆应用共用一个库——服务端池化器可以在中间接住成千上万客户端连接,复用几十条真实连接。PgBouncer 是常用选择。但应用侧的上限和超时仍然要留:池化器只是把墙挪走,没有拆掉。
检查清单
- 乘一遍:容器 × 进程 × (
pool_size + max_overflow),注意 fork 后每个 worker 一份池;结果压在max_connections减去预留槽和人工余量之下。 - 池获取超时设短,请求快速失败。
- 连接超时必须设,0 就是永远。
- 按角色设
statement_timeout与idle_in_transaction_session_timeout。 - 重试用封顶指数退避 + full jitter,只作用于幂等操作。
- 启动等 healthcheck,加 startup jitter。
- 盯着
pg_stat_activity按 state 分组,撞顶前告警。