编程 Postgres 连接风暴:97 个可用槽位、三条 FATAL 与四个默认为 0 的超时

2026-10-21 21:31:38

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 开始随机;只重试连接级错误(唯一约束冲突重试五次也一样失败);只重试幂等操作,否则超时后重试可能把写执行两遍。

五、四个必须设的超时

  1. 池获取超时:SQLAlchemy 的 pool_timeout 默认 30 秒——一个 HTTP 请求等 30 秒太久。设到负载均衡超时以下,快速失败。
  2. 连接超时:node-postgres 的 connectionTimeoutMillis 默认 0,即不超时,库黑洞化时请求无限挂。
  3. statement_timeout:默认 0(禁用)。不设的话一条失控查询会一直占着槽位直到人工 kill。
  4. 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 是常用选择。但应用侧的上限和超时仍然要留:池化器只是把墙挪走,没有拆掉。

检查清单

  1. 乘一遍:容器 × 进程 × (pool_size + max_overflow),注意 fork 后每个 worker 一份池;结果压在 max_connections 减去预留槽和人工余量之下。
  2. 池获取超时设短,请求快速失败。
  3. 连接超时必须设,0 就是永远。
  4. 按角色设 statement_timeout 与 idle_in_transaction_session_timeout。
  5. 重试用封顶指数退避 + full jitter,只作用于幂等操作。
  6. 启动等 healthcheck,加 startup jitter。
  7. 盯着 pg_stat_activity 按 state 分组,撞顶前告警。

原文:Database Connection Storms, Explained

复制全文 生成海报 PostgreSQL 连接池 运维 PgBouncer SQLAlchemy

推荐文章

程序员茄子在线接单