查询超时限制的是数据库工作中的某个阶段,不会自动覆盖连接池排队,也不保证远端已经取消执行或事务清理已经结束。要保护共享容量,必须说清由哪个组件计时、它到点会中断什么,以及错误返回之后还剩下什么状态。只有一个名字叫 timeout 的配置,不能构成完整的成本边界。

说清这只钟从何时开始,管哪段工作

PostgreSQL 的 statement_timeout 从命令到达服务器开始计时,直到服务器完成处理。它不是调用方整个请求的期限:此前获取连接池借用的等待,以及服务器完成之后客户端本地处理结果的时间,不在同一计时范围内。锁等待也可能消耗语句时间,因此“还没有真正开始做有用的查询工作”,不代表语句预算尚未开始使用。

lock_timeout 的含义更窄,分别约束每次尝试获取锁的等待,而语句超时也可以中断不在等锁的执行工作。事务中空闲会话的超时又是另一条边界:它针对已经打开事务、却正在等待客户端下一条查询的会话,并不是当前仍在执行的语句。把这些数值设成一样,并不会让它们获得相同作用;应先理解它们观察的是哪一种状态。

PostgreSQL 18 还提供 transaction_timeout,在显式或隐式事务超过预算时终止会话,可以约束由许多短语句组成的长事务。预备事务不受这个超时限制。它仍然是服务端的一条边界,不能代替调用方完整期限;增加一个配置项,也不能省略连接恢复与事务结果检查。

驱动参数也需要同样认真地阅读。Python 的 sqlite3.connect(timeout=...) 设置的是遇到数据库锁时最多等多久,不是所有 SQL 的执行总期限。不同驱动里都叫“超时”的参数,可能分别针对建连、锁等待或读取;只复制名称和数值,可能让真正昂贵的查询仍然完全没有执行时间限制。

坏掉的样子

调用方停止等待时,数据库仍可能继续执行。即便 PostgreSQL 的取消请求已经成功发出,也不能保证它实际改变了什么,因为原命令可能早已处理完成。需要观察命令结果与事务结果,不能把“捕获到超时异常”或“发出了 cancel”直接当成所有远端工作均已停止的证明,更不能据此断定是否可以安全重放有副作用的操作。

语句时间限制也不会退还已经花掉的资源。清理、事务恢复仍可能耗时,先前的操作也可能已有各自结果。连接归还池中之前,应完成所需恢复并核对状态,而不是在异常被捕获后立刻当作干净连接继续借出。如果报表契约要求完整结果,半份结果就不能因为“总比没有好”而自动变成成功,除非产品本来就明确允许这种部分结果。

阈值应从工作负载和请求预算出发,同时给连接获取、响应传输与恢复留下空间。把 p99 简单乘固定倍数,不是普遍成立的安全规则:百分位描述的是已经观察到的时长,并不能定义哪些业务请求本来就合理。PostgreSQL 文档也提醒,不应不加区分地在全局配置语句超时,因为那会影响所有会话;交互查询与明确安排的长时分析任务可能需要不同策略。

快速验证

把代码保存为 query_probe.py,运行 python3 query_probe.py。示例只使用标准库中的 SQLite,本次环境是 Python 3.12.14 和 SQLite 3.53.1。两条连接共享脚本自己的临时数据库文件,不连接 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 的含义,不是直接抛出一个模拟驱动异常,再声称数据库已经按期终止了查询。

接着在显式事务中插入编号二,但暂不提交。一个规模很大的递归查询开始执行,进度处理器每隔一千条虚拟机指令检查单调时钟截止点。回调返回非零值时,SQL 被中断,错误码必须是 SQLITE_INTERRUPT。这个查询预算比连接的锁等待超时更长,展示了两个控制项互不等价:连接上的 timeout 并没有先把这条不在等锁的计算截断。

finally 先移除进度处理器,再显式回滚。最后表内只剩早已提交的编号一,编号二与编号三都不存在;后续 SELECT 仍可成功,连接也已经不在事务中。这里既检查了持久化结果,也检查了恢复后的可用状态,比仅仅捕获异常并打印“查询超时”更能说明资源是否可以继续使用。物理连接最终由 closing 作用域关闭,之后才清理临时文件。

进度回调只能在 SQLite 调用它时运行,并不是能在任意墙钟时刻抢占阻塞操作或应用代码的通用计时器。这个实验验证的是本地查询中断和清理,没有验证 PostgreSQL 的取消传输,也不保证所有平台上的精确时序。对于真实后端,仍需沿对应错误、事务状态和归还连接的路径执行验证,才能判断这条期限是否真正限制住了查询的代价。

来源