等待锁可能是正常协调,也可能让连接和请求持续排队。PostgreSQL 的 lock_timeout 可以让调用方停止等待,但它不会解释持锁者为什么慢,更不会自动决定业务是否可以重试。应从操作能够接受的延迟出发设定预算,同时找到实际阻塞者;单纯延长超时或增加尝试次数,可能只是扩大同一条等待队列。

坏掉的样子

锁超时分别作用于每一次锁获取尝试,包括语句隐式申请的锁。它测量的是等待当前锁的时间,而不是整条语句的总运行时间。查询已经执行了一段时间,随后又申请一把锁,仍然可能触发这个限制。反过来,多次较短的等待也可能累计超过一个配置间隔。把锁超时理解成“只有语句刚开始时检查一次”,或把它当成整次查询的总截止时间,都会漏掉真实的等待范围。

statement_timeout 限制整条语句执行,其中也包括等锁。如果它更短,可能先于锁超时取消语句。空闲事务会话超时处理的则是另一种状态:事务还开着,但服务器正在等待客户端发下一条命令。卡在更新内部等待锁的后端通常仍是活动状态,等待事件属于锁;不能看到连接排队,就直接把所有等待者叫作空闲事务。真正闲着不提交的持锁者和正在等待的受阻者,需要分别定位。

快速验证

准备同一个 PostgreSQL 18 可丢弃数据库,开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话。实验使用默认的零值锁超时,且没有其他会提前干预的超时设置。下面的毫秒参数只用于快速复现,不是生产建议值。甲先建立独立模式和计数表:

CREATE SCHEMA timeout_demo;
CREATE TABLE timeout_demo.counter (id integer PRIMARY KEY,n integer);
INSERT INTO timeout_demo.counter VALUES (1,0);

甲在显式事务中把数字改成十,暂时不提交,也不回滚,以便继续持有目标行的锁:

BEGIN;
UPDATE timeout_demo.counter SET n=10 WHERE id=1;

乙开启事务,对同一行加一,并给取锁等待一百毫秒预算。为展示预期错误及后续事务状态,只在这一小段临时关闭遇错停止:

BEGIN;
SET LOCAL lock_timeout='100ms';
SET LOCAL statement_timeout='1s';
\set ON_ERROR_STOP off
UPDATE timeout_demo.counter SET n=n+1 WHERE id=1;
\echo lock_state :SQLSTATE
SELECT n FROM timeout_demo.counter WHERE id=1;
\echo transaction_state :SQLSTATE
\set ON_ERROR_STOP on
ROLLBACK;

先得到 55P03,随后查询得到 25P02。前者取消乙的更新,并使这个显式事务进入失败状态;后者说明原事务不能继续正常执行。乙的回滚只清理自己的失败事务,不会释放甲的锁,也不会取消甲尚未提交的十。若忽略回滚,直接在原事务里重发更新,只会继续收到事务状态错误。

甲仍保持事务打开。乙重新开始,这次把整条语句预算设成五十毫秒,比取锁的一百毫秒更短:

BEGIN;
SET LOCAL lock_timeout='100ms';
SET LOCAL statement_timeout='50ms';
\set ON_ERROR_STOP off
UPDATE timeout_demo.counter SET n=n+1 WHERE id=1;
\echo statement_state :SQLSTATE
\set ON_ERROR_STOP on
ROLLBACK;

此时得到 57014,说明语句取消先发生。等待的依然是同一行,改变的只是哪个计时器先到期。错误码需要结合上下文解释:不等待的取锁请求也可能返回 55P03,其他查询取消也可能返回 57014。单看这些类别,既不能认定已经检测到死锁,也不能直接知道阻塞者属于哪个应用操作。

甲现在回滚,丢弃尚未提交的十并释放行锁:

ROLLBACK;

乙开启一个新的有界事务,再次更新,这次返回一并成功提交:

BEGIN;
SET LOCAL lock_timeout='100ms';
SET LOCAL statement_timeout='1s';
UPDATE timeout_demo.counter SET n=n+1 WHERE id=1 RETURNING n;
COMMIT;

结果是一,因为乙先前的失败尝试都没有提交增量,甲的十也已回滚。乙继续验证普通执行时间与取锁等待时间的区别:

BEGIN;
SET LOCAL lock_timeout='50ms';
SET LOCAL statement_timeout='1s';
SELECT pg_sleep(0.2);
\echo sleep_state :SQLSTATE
COMMIT;
SHOW lock_timeout;

睡眠持续二百毫秒,超过锁等待预算五十毫秒,但正常返回成功状态。没有等待锁,不能仅凭耗时长就触发锁超时。提交之后,显示的锁超时恢复为会话原来的零,因为局部设置只持续到当前事务结束。最后由甲核对数据仍为一,并清理实验:

SELECT n FROM timeout_demo.counter WHERE id=1;
DROP SCHEMA timeout_demo CASCADE;

恢复时不要扩大竞争

是否重试应由具体操作决定。锁超时后先回滚,只有剩余请求预算足够、并且重放符合业务语义时,才开始新事务。少量尝试、随机退避和总截止时间可以限制额外负载,却不能让永久占用的资源凭空可用。耗尽预算时应保留最终错误和尝试次数,而不是静默丢弃请求,或把它报告成成功。

真正的死锁是依赖关系形成了环,数据库检测后会返回死锁错误。锁超时也可能抢在死锁检测之前到期,所以“这次只看见超时”不能证明没有循环等待。应结合阻塞进程、事务持续时间与取锁顺序检查原因。让持锁事务早点结束,特别是移走其中等待用户输入或外部服务的环节,往往比整体调大超时更有效。生产参数必须参考正常锁持有时间和业务延迟预算,不能照抄这个实验的毫秒数。

来源