连接上限约束的是某个资源所有者的需求,不会自动约束共享数据库的所有应用。真正有用的预算,是每台后端可能接收到的物理连接总和,其中也包括连接池里暂时空闲的连接。先确认后端可供普通业务使用的名额,再分配到所有调用方与可能重叠的部署状态,而不是给每个服务随手填一个相同数字。

先数清有多少池,再决定每个池多大

对有限配置的 SQLAlchemy QueuePool,同时连接数上限是 pool_size + max_overflow,但这个数字只属于一个池。各自创建独立 QueuePool 的 Engine 才有独立上限;多个工作进程、多个副本,还会继续放大总数。应按真实池计数,派生的 Engine 视图可能仍然共用原池。如果配置相同,可用副本数乘每副本进程数,再乘每进程池数和单池上限;不同调用方的配置不同,则应逐项相加,不能假设它们共享同一份限额。

部署预算应使用最大重叠数量,而不是滚动更新结束后的就绪副本数。新副本已经建连时,旧副本可能仍在排空业务,也可能还保留空闲连接。后台任务、迁移工具、监控连接和其他服务也必须进入清单。不同数据库服务器需要各自计算预算;把主库、其他目标的连接数合成一个看起来宽裕的总量,会掩盖真正受限的后端。

PostgreSQL 的 max_connections 是服务器总上限,并不全部属于某个应用。没有预留名额权限的普通角色,应当为 superuser_reserved_connections 和 reserved_connections 都留出空间;后者提供给具有 pg_use_reserved_connections 权限的角色。还要扣除其他普通客户端和额外运维余量,不应让日常流量依赖紧急预留容量才能运行。这里区分的是权限与预算,不能仅凭连接名字叫“运维”就认定它可以使用某种预留名额。

坏掉的样子

服务完全遵守每个本地池的上限,仍然可能把数据库用满,因为实际池数量超过了预算表里的假设。若允许无限溢出,有限预算更是直接失去依据。反过来,上限过小也可能让应用在后端仍有余量时排队;看到这种等待,并不能直接断定必须增大数据库服务器的总上限。

更多连接不会创造额外 CPU、加速存储,也不会消除事务之间的锁冲突。PostgreSQL 的部分资源分配还直接取决于配置的连接上限,调高它也有资源代价。应同时观察获取连接的等待、实际物理连接数、活跃工作量和事务时长。连接数量受到约束,不代表吞吐一定提高,也不保证服务之间公平,更不能保证锁等待变短。

外部连接池代理会再增加一层计数边界。PgBouncer 的会话池化让服务器连接随客户端会话保留;事务池化则在事务结束后归还服务器连接。因此客户端连接数不能直接当成后端会话数。预算清单需要包含代理的服务器侧分配与池化模式。事务模式会改变部分会话特性的使用前提,引入它不是只修改一个容量数字,还需要核对应用是否依赖那些特性。

快速验证

在 macOS 或 Linux 的空目录执行 python3 -m venv .venv,然后执行 .venv/bin/python -m pip install SQLAlchemy==2.0.52。把代码保存为 pool_probe.py,运行 .venv/bin/python pool_probe.py。本次验证使用 Python 3.14.4 和 SQLite 3.53.0。这里的三个真实连接池位于同一个进程,共用临时本地数据库文件,并没有创建三个生产副本。

from pathlib import Path
from tempfile import TemporaryDirectory

import sqlalchemy
from sqlalchemy import create_engine, event
from sqlalchemy.exc import TimeoutError as PoolTimeout
from sqlalchemy.pool import QueuePool


assert sqlalchemy.__version__ == "2.0.52"
with TemporaryDirectory(prefix="pool-caps-") as directory:
    engines, held, opened, closed = [], [], [0, 0, 0], [0, 0, 0]
    try:
        for owner in range(3):
            engine = create_engine(
                "sqlite:///" + str(Path(directory) / "probe.db"),
                poolclass=QueuePool, pool_size=2, max_overflow=1, pool_timeout=0.05,
            )
            engines.append(engine)

            def on_connect(dbapi_connection, entry, index=owner):
                opened[index] += 1

            def on_close(dbapi_connection, entry, index=owner):
                closed[index] += 1

            event.listen(engine, "connect", on_connect)
            event.listen(engine, "close", on_close)
            for _ in range(3):
                held.append(engine.connect())
            try:
                unexpected = engine.connect()
            except PoolTimeout:
                pass
            else:
                unexpected.close()
                raise AssertionError("each pool must reject a fourth simultaneous lease")
        assert len({id(engine.pool) for engine in engines}) == 3
        view = engines[0].execution_options(logging_token="same-pool")
        assert view.pool is engines[0].pool
        print("an Engine execution-options view shares its parent pool; it adds no connection slots")
        assert opened == [3, 3, 3]
        assert [engine.pool.checkedout() for engine in engines] == [3, 3, 3]
        print("three independent pools each cap at 3, yet together hold 9 physical SQLite connections")

        for connection in held:
            connection.close()
        assert [engine.pool.checkedin() for engine in engines] == [2, 2, 2]
        assert closed == [1, 1, 1]
        print("after return, 6 connections remain idle; the 3 overflow connections are closed")
    finally:
        for connection in held:
            connection.close()
        for engine in engines:
            engine.dispose()
    assert opened == closed == [3, 3, 3]


def demand(replicas, processes, pools_per_process, size, overflow):
    return replicas * processes * pools_per_process * (size + overflow)


# Illustrative PostgreSQL worksheet, not a live server configuration or benchmark.
maximum, superuser_reserve, role_reserve = 100, 3, 5
other_clients, operating_margin = 24, 8
budget = maximum - superuser_reserve - role_reserve - other_clients - operating_margin
assert budget == 60
assert demand(6, 1, 1, 8, 2) == 60
assert demand(8, 1, 1, 8, 2) == 80 > budget  # Two extra replicas during rollout.
assert demand(8, 1, 1, 6, 1) == 56 <= budget
assert demand(8, 2, 1, 6, 1) == 112 > budget  # A second process creates another pool.
assert demand(8, 1, 2, 6, 1) == 112 > budget  # A second independent pool per process.
print("ordinary-client allocation: 60; steady demand 60; rollout demand 80; revised rollout demand 56")
print("doubling either processes or pools raises that revised demand to 112; no PostgreSQL server was run")

每个池实际持有三条 SQLite 物理连接,第四次获取分别抛出 PoolTimeout。三个池合起来仍然有九条连接,证明“单池上限为三”不等于“整个数据库只能收到三条”。通过 execution_options 派生的 Engine 视图共享第一个池,并未新增容量。归还后共留下六条空闲连接,关闭三条超出保留容量的连接。所有借用归还以后再 dispose,各池观察到的 connect 与 close 事件数量相等。这是本地真实池行为,不是模拟器直接打印出来的预设答案。

脚本后半段则明确是算术模型,而不是运行中的 PostgreSQL 配置。示例总额一百,扣掉三个超级用户预留、五个角色预留、二十四个其他普通客户端名额,以及八个运维余量,得到本应用可分配的六十。五个角色预留是本例自主选择的配置值;该设置的 PostgreSQL 文档默认值为零,不能把示例误当成安装默认参数。

六个副本、每池上限十,恰好用掉六十;滚动更新时若重叠八个副本,就需要八十。把每池改为保留六条、溢出一条,重叠期间需要五十六。但是每副本增加第二个进程,或者每进程再建第二个独立池,需求都会翻倍至一百一十二,仍然超过原预算。单池参数没有变化,不代表部署总需求没有变化。

这组检查证明了本地池行为与预算算术,并未给出生产吞吐结论或通用安全尺寸。本次没有运行 PostgreSQL、PgBouncer 或实际滚动发布。在生产修改限额之前,需要核实真实池清单和最大重叠数量,再用目标工作负载观察排队与后端饱和;不能把通过这些断言当成已经完成生产容量验证。

来源