查询超时限制的是数据库工作中的某个阶段,不会自动覆盖连接池排队,也不保证远端已经取消执行或事务清理已经结束。要保护共享容量,必须说清由哪个组件计时、它到点会中断什么,以及错误返回之后还剩下什么状态。只有一个名字叫 timeout 的配置,不能构成完整的成本边界。
说清这只钟从何时开始,管哪段工作 PostgreSQL 的 statement_timeout 从命令到达服务器开始计时,直到服务器完成处理。它不是调用方整个请求的期限:此前获取连接池借用的等待,以及服务器完成之后客户端本地处理结果的时间,不在同一计时范围内。锁等待也可能消耗语句时间,因此“还没有真正开始做有用的查询工作”,不代表语句预算尚未开始使用。
lock_timeout 的含义更窄,分别约束每次尝试获取锁的等待,而语句超时也可以中断不在等锁的执行工作。事务中空闲会话的超时又是另一条边界:它针对已经打开事务、却正在等待客户端下一条查询的会话,并不是当前仍在执行的语句。把这些数值设成一样,并不会让它们获得相同作用;应先理解它们观察的是哪一种状态。
驱动参数也需要同样认真地阅读。Python 的 sqlite3.connect(timeout=...) 设置的是遇到数据库锁时最多等多久,不是所有 SQL 的执行总期限。不同驱动里都叫“超时”的参数,可能分别针对建连、锁等待或读取;只复制名称和数值,可能让真正昂贵的查询仍然完全没有执行时间限制。
坏掉的样子 调用方停止等待时,数据库仍可能继续执行。即便 PostgreSQL 的取消请求已经成功发出,也不能保证它实际改变了什么,因为原命令可能早已处理完成。需要观察命令结果与事务结果,不能把“捕获到超时异常”或“发出了 cancel”直接当成所有远端工作均已停止的证明,更不能据此断定是否可以安全重放有副作用的操作。
语句时间限制也不会退还已经花掉的资源。清理、事务恢复仍可能耗时,先前的操作也可能已有各自结果。连接归还池中之前,应完成所需恢复并核对状态,而不是在异常被捕获后立刻当作干净连接继续借出。如果报表契约要求完整结果,半份结果就不能因为“总比没有好”而自动变成成功,除非产品本来就明确允许这种部分结果。
阈值应从工作负载和请求预算出发,同时给连接获取、响应传输与恢复留下空间。把 p99 简单乘固定倍数,不是普遍成立的安全规则:百分位描述的是已经观察到的时长,并不能定义哪些业务请求本来就合理。PostgreSQL 文档也提醒,不应不加区分地在全局配置语句超时,因为那会影响所有会话;交互查询与明确安排的长时分析任务可能需要不同策略。
快速验证 把代码保存为 query_probe.py,运行 python3 query_probe.py。示例只使用标准库中的 SQLite,本次环境是 Python 3.14.4 和 SQLite 3.53.0。两条连接共享脚本自己的临时数据库文件,不连接 PostgreSQL,也不使用生产凭据。短时长与回调频率用于快速展示边界,不是推荐直接上线的默认参数。
from contextlib import closing from pathlib import Path import sqlite3 from tempfile import TemporaryDirectory import time def exercise(): with TemporaryDirectory(prefix="query-budget-") as directory: database = Path(directory) / "probe.db" with ( closing(sqlite3.connect(database, timeout=0.02, isolation_level=None)) as connection, closing(sqlite3.connect(database, timeout=0.02, isolation_level=None)) as contender, ): connection.execute("CREATE TABLE ledger(id INTEGER PRIMARY KEY)") connection.execute("INSERT INTO ledger VALUES (1)") connection.execute("BEGIN IMMEDIATE") try: contender.execute("INSERT INTO ledger VALUES (3)") except sqlite3.OperationalError as error: assert error.sqlite_errorcode == sqlite3.SQLITE_BUSY else: raise AssertionError("the held writer lock must prevent this insert") finally: connection.rollback() print("busy timeout rejects a competing writer; row 3 was not inserted") connection.execute("BEGIN") connection.execute("INSERT INTO ledger VALUES (2)") callbacks = 0 started = time.monotonic() deadline = started + 0.05 def expired(): nonlocal callbacks callbacks += 1 return int(time.monotonic() >= deadline) connection.set_progress_handler(expired, 1000) try: connection.execute(""" WITH RECURSIVE numbers(n) AS ( VALUES(1) UNION ALL SELECT n + 1 FROM numbers WHERE n < 100000000 ) SELECT sum(n) FROM numbers """).fetchone() except sqlite3.OperationalError as error: assert error.sqlite_errorcode == sqlite3.SQLITE_INTERRUPT else: raise AssertionError("the expensive query must reach its progress deadline") finally: connection.set_progress_handler(None, 0) connection.rollback() elapsed = time.monotonic() - started assert callbacks > 0 and elapsed >= 0.05 rows = connection.execute("SELECT id FROM ledger ORDER BY id").fetchall() assert rows == [(1,)] assert connection.execute("SELECT 42").fetchone() == (42,) assert not connection.in_transaction print("progress deadline interrupts SQL after", round(elapsed, 3), "seconds") print("explicit rollback leaves only committed row 1; the connection remains usable") print("SQLite", sqlite3.sqlite_version) exercise() 第一条连接先持有写锁,第二条连接因此无法插入编号三的记录,在锁等待额度耗尽后得到 SQLITE_BUSY。随后回滚第一条事务,清除刻意制造的竞争。这是在真实 SQLite 锁上检查连接 timeout 的含义,不是直接抛出一个模拟驱动异常,再声称数据库已经按期终止了查询。
...