连接池让许多应用请求在不同时间借用较少的 PostgreSQL 会话。复用能够摊薄建立连接的成本,也能限制同时借出的数量,但不会让已经过载的数据库自动变快。池本身又增加了一条队列,它的容量、等待时间和取消行为,都属于请求预算的一部分。应把这条队列作为服务行为来设计,而不是把它隐藏在一个“复用更快”的标签后面。

坏掉的样子

数据库后端处于 ClientRead,表示正在等待客户端发数据,并不表示应用请求正在等待借用连接。真正的池内等待者可能还没有数据库连接,因此服务器根本没有对应的后端可供观察。获取连接的等待应在池里测量,查询执行和锁等待应在服务器测量;把它们混成一个所谓数据库池等待事件,会掩盖实际瓶颈的位置。

归还连接也不保证抹掉所有会话状态。Psycopg 的连接上下文在正常退出时提交,在异常离开上下文时回滚;额外重置取决于配置。已经提交的会话级设置,可以继续留给下一个借用者,即使没有泄漏未结束的事务。临时对象和其他会话资源同样需要明确策略,不能因为使用了连接池,就假定所有内容都会自动恢复到初始状态。

快速验证

准备 Python 3.10 或更新版本、PostgreSQL 18 可丢弃数据库,以及允许创建实验模式的角色。原演练使用以下依赖;本轮审阅没有重新执行并发数据库实验。安装方式为:python3 -m pip install "psycopg[binary]==3.3.5" "psycopg-pool==3.3.1"。使用已有认证配置,将 PG_DSN 设置为该测试数据库的连接串。把程序保存为 pool-demo.py,在安装依赖的同一个 Python 环境中运行:

import os
import time
from concurrent.futures import ThreadPoolExecutor

import psycopg
from psycopg_pool import ConnectionPool, PoolTimeout, TooManyRequests

dsn = os.environ['PG_DSN']
with psycopg.connect(dsn, autocommit=True) as admin:
    admin.execute('CREATE SCHEMA pool_demo')
    try:
        admin.execute('CREATE TABLE pool_demo.counter (n integer)')
        admin.execute('INSERT INTO pool_demo.counter VALUES (0)')
        with ConnectionPool(
            dsn, min_size=1, max_size=1, max_waiting=1,
            timeout=3, open=True,
            kwargs={'application_name': 'pool_example'},
        ) as pool:
            pool.wait(timeout=3)

            def queued_request():
                try:
                    with pool.connection(timeout=1):
                        raise AssertionError('holder should still own the only connection')
                except PoolTimeout:
                    return 'PoolTimeout'

            with ThreadPoolExecutor(max_workers=1) as workers:
                with pool.connection() as held:
                    original_pid = held.info.backend_pid
                    waiting = workers.submit(queued_request)
                    deadline = time.monotonic() + 0.5
                    while pool.get_stats()['requests_waiting'] != 1:
                        if time.monotonic() >= deadline:
                            raise TimeoutError('request did not enter the pool queue')
                        time.sleep(0.005)
                    state = admin.execute(
                        'SELECT state,wait_event_type,wait_event '
                        'FROM pg_stat_activity WHERE pid=%s', (original_pid,)
                    ).fetchone()
                    assert state == ('idle', 'Client', 'ClientRead'), state
                    print('waiting_in_pool', 1, 'server', state)
                    try:
                        with pool.connection(timeout=0.1):
                            raise AssertionError('queue limit was not applied')
                    except TooManyRequests:
                        print('queue_limit', 'TooManyRequests')
                    assert waiting.result(timeout=2) == 'PoolTimeout'
                    print('queued_request', 'PoolTimeout')

            with pool.connection() as conn:
                assert conn.info.backend_pid == original_pid
                conn.execute("SET application_name='previous_borrower'")
            try:
                with pool.connection() as conn:
                    conn.execute('UPDATE pool_demo.counter SET n=99')
                    raise ValueError('simulated request failure')
            except ValueError as error:
                assert str(error) == 'simulated request failure'

            with pool.connection() as conn:
                value = conn.execute('SELECT n FROM pool_demo.counter').fetchone()[0]
                setting = conn.execute('SHOW application_name').fetchone()[0]
                assert value == 0 and setting == 'previous_borrower'
                assert conn.info.backend_pid == original_pid
                print('same_backend', True, 'rollback_value', value, 'retained_setting', setting)
                conn.execute('RESET application_name')
    finally:
        admin.execute('DROP SCHEMA pool_demo CASCADE')

池里只有一条连接,最多允许一个请求排队。主线程借走连接但不执行查询,工作线程尝试借用它。池报告一个等待者以后,独立观察连接看到被借走的数据库后端正空闲等待客户端读取事件。两种状态可以同时存在:应用已经排队,数据库会话却没有正在执行的查询。这正好说明为什么不能用服务器的空闲状态代替池的等待指标。

第三个获取请求因为队列已满,被拒绝并产生 TooManyRequests。原本排队的工作线程则在一秒获取预算耗尽后产生 PoolTimeout。持有者没有提前归还连接,因此实验清楚区分了队列溢出与等待超时。这两种错误都不是 PostgreSQL 的行锁超时,也不能据此认为某条应用 SQL 已经执行过。

主线程归还之后,下一个借用者得到同一个后端标识。它设置会话级应用名字并正常结束,随后另一位借用者把计数器更新为九十九,却抛出异常离开连接上下文。上下文管理器回滚这次更新。最后一次借用看到计数器仍为零,之前提交的会话设置却仍然存在,后端标识也没有改变。

因此,最终输出应同时报告后端复用为真、回滚后的值为零,以及保留的应用名字。事务清理生效了,会话状态却没有被一概清空。程序显式恢复自己的设置,并删除自己创建的模式。如果同名模式原本就存在,初始化会在取得清理所有权之前失败,不应把别人的对象当成实验残留删除。

按完整部署计算容量

池大小限制的是一个池实例,不是整个服务集群。四个进程各自保持八条连接,就可能占用三十二个服务器槽位,还没有计算其他应用、迁移、监控和管理余量。应把这些数量合计后,与数据库可用连接预算及有效执行并发比较。给每个进程都增加连接,可能只是把等待移到服务器里,同时提高内存压力,而不是减少整个请求的耗时。

连接应只在保持正确事务边界所需的完整数据库操作期间借出,所有退出路径都应归还。与数据库无关的网络调用或用户交互,不宜长期占着租用。可以使用事务局部设置来限制影响范围,或者为确实需要清理的状态配置明确重置回调。Psycopg 要求回调结束时连接处于事务外的空闲状态;清理查询如果开启了事务,也必须完成它,否则连接会被丢弃。应同时观察获取延迟、队列拒绝、持有时长和数据库执行,再决定是否扩容;否则,连接泄漏或过长的持有周期很容易被误当成池太小。

来源