温热的连接池能够减少反复建立 PostgreSQL 连接,但空闲连接在池里等待期间也可能消失。服务器结束会话、网络故障或中间设备的空闲策略,都可能让客户端仍握着一个已经不能使用的套接字。复用方案必须包含失效识别和替换;对象还在池里,只说明应用保存了它,不能证明下一条查询一定成功。
坏掉的样子
TCP 保活能够识别原本空闲连接上的部分故障,却不会替 SQL 设置执行截止时间,也不证明数据库能够完成某个具体请求,更不会处理结果未知的提交。客户端连接参数只作用于 TCP,使用 Unix 域套接字时相关选项会被忽略;服务器端保活设置控制的是另一端。具体操作系统是否支持某个选项,也会影响最终行为,不能只看参数字符串存在就宣称故障检测已经发生。
借出前健康检查,可以在把连接交给调用方之前识别已经失效的连接。Psycopg 默认不执行这种检查,但提供回调让应用选择启用。多一次探测意味着额外往返,也仍然无法阻止连接在探测成功后立刻出故障。因此,建立连接、等待租用和执行语句,都需要各自的预算与错误处理,不能用一个“保活开启”覆盖全部阶段。
快速验证
准备 Python 3.10 或更新版本,通过 TCP 连接使用默认零值语句超时的 PostgreSQL 18,并安装原演练使用的版本:python3 -m pip install "psycopg[binary]==3.3.5" "psycopg-pool==3.3.1"。把 PG_DSN 设置为可丢弃数据库,使用允许结束自己后台连接的角色。保存为 liveness-demo.py 后运行。程序先记录每个测试池刚创建的连接标识,然后只结束那条已经归还、处于空闲状态的连接:
import os
import psycopg
from psycopg.errors import QueryCanceled
from psycopg_pool import ConnectionPool
dsn = os.environ['PG_DSN']
with psycopg.connect(dsn, autocommit=True) as admin:
for checked in (False, True):
with ConnectionPool(
dsn, min_size=1, max_size=1, timeout=3, open=True,
check=ConnectionPool.check_connection if checked else None,
kwargs={
'connect_timeout': 2,
'keepalives': 1,
'keepalives_idle': 30,
'keepalives_interval': 10,
'keepalives_count': 3,
},
) as pool:
pool.wait(timeout=3)
with pool.connection() as conn:
assert conn.execute('SELECT inet_server_addr() IS NOT NULL').fetchone()[0]
original_pid = conn.info.backend_pid
parameters = conn.info.get_parameters()
assert [parameters[k] for k in (
'keepalives', 'keepalives_idle', 'keepalives_interval', 'keepalives_count'
)] == ['1', '30', '10', '3']
# Terminate only the idle connection just created by this fixture.
assert admin.execute('SELECT pg_terminate_backend(%s,2000)', (original_pid,)).fetchone()[0]
if not checked:
try:
with pool.connection() as conn:
conn.execute('SELECT 1')
except psycopg.OperationalError as error:
print('unchecked_first_use', error.sqlstate)
else:
raise AssertionError('terminated connection unexpectedly worked')
with pool.connection() as conn:
replacement_pid = conn.info.backend_pid
assert replacement_pid != original_pid
assert conn.execute('SELECT 1').fetchone()[0] == 1
print('checked', checked, 'replacement', True)
try:
with pool.connection() as conn:
conn.execute("SET LOCAL statement_timeout='50ms'")
conn.execute('SELECT pg_sleep(0.2)')
except QueryCanceled as error:
assert error.sqlstate == '57014'
print('statement_timeout', error.sqlstate)
else:
raise AssertionError('long statement did not time out')
with pool.connection() as conn:
assert conn.info.backend_pid == replacement_pid
assert conn.execute('SHOW statement_timeout').fetchone()[0] == '0'
assert conn.execute('SELECT 1').fetchone()[0] == 1
print('healthy_after_rollback', True)
两种情况都配置了客户端保活参数,并核对有效值,不打印整个连接串。第一种没有借出前检查。空闲后端被服务器结束后,下次读取遭遇操作错误;本次这种明确的服务器关闭返回 57P01。池随后丢弃坏连接,下一次借用得到不同的后端标识,并成功返回一。
第二种启用内置的连接检查回调。经过同样的受控终止,获取阶段发现旧连接不能使用,再把正常的新连接交给借用者。两种情况最后都报告完成替换,但只有未检查的那一种,把首次使用旧连接的错误直接暴露给业务代码。原实验验证的是显式服务器终止,不是静默网络分区,也没有等待 TCP 保活探测计时器真正触发。本轮审阅未重新执行 TCP 实验。
每种情况还会在五十毫秒的事务局部语句预算下,执行二百毫秒睡眠,预期得到 57014。此时连接仍然活着,TCP 保活也已启用,查询却依旧需要自己的截止时间。异常离开连接上下文后,失败事务被回滚;后续借用继续使用同一个替代后端,看到语句超时恢复为原来的零,并能正常读取一。
这些检查区分了两件事:死连接需要替换,活连接上的语句取消则可以通过正确事务清理恢复。实验还确认局部超时设置没有泄漏给下一位借用者。不能把所有 SQL 错误都当成必须销毁连接的证据,也不能因为健康探测成功,就删除执行阶段的预算。判断应跟随错误发生的阶段,以及对应连接和事务的真实状态。
在明确的操作边界重新连接
池的最大空闲时间和最大寿命,有助于管理连接轮换,但都不能保证套接字一定活到设置的年龄。在 Psycopg 中,max_idle 只回收超过 min_size 的闲置连接,不会把最低预热数量中的每条空闲连接都定时关闭。服务器或中间设备可能更早关闭连接。应协调这些策略,观察丢弃、重连次数和故障期间的获取延迟;健康检查过于频繁,也会给大量短查询增加不可忽略的往返成本。策略需要结合请求长度和故障特征,而不是越积极越可靠。
示例是在应用写入开始之前替换空闲连接。提交期间断连则不同:数据库可能已经提交,只是确认丢失。建立新连接不会自动告诉调用方上一笔究竟成功没有,直接重放写入就可能重复副作用。应保留稳定操作标识,并查询持久化结果,在结果得到核实后再决定是否重发非幂等工作。
获取连接的多次尝试,还应受调用方取消和请求总截止时间控制,避免请求已经结束,后台重连仍然不停消耗资源。保活、健康检查、获取预算和语句预算可以共同工作,但每一项都只回答自己的问题;把它们对应到具体操作边界,才能知道错误之后应该替换连接、回滚事务,还是先核对已经提交的结果。
来源
- PostgreSQL 18 libpq 连接参数,客户端保活、套接字适用范围与连接选项,已核验 2026-10-15。
- Psycopg 连接池文档源码,借出前检查与失效连接替换,已核验 2026-10-15。
- Psycopg 连接池 API 文档源码,可选检查回调与连接生命周期控制,已核验 2026-10-15。
- PostgreSQL 18 客户端连接默认值,语句超时与连接存活的不同边界,已核验 2026-10-15。