事务级顾问锁(Transaction-Level Advisory Locks)
如果你想在 PostgreSQL 里实现一把「分布式互斥锁」,又不想担心忘记解锁,事务级顾问锁(Transaction-Level Advisory Lock) 就是标准答案。它的规则简单到只有一条:事务一结束(COMMIT 或 ROLLBACK),锁自动消失。没有 unlock 函数,也不需要——释放由事务边界兜底。
代价是它不能跨事务持有。理解这枚硬币的两面,就能在「防泄漏」与「跨事务互斥」之间做出正确取舍。本页并给出事务级锁最经典的应用:串行化「检查-插入」。
基础概念
一句话定义
事务级顾问锁在当前事务结束时自动释放——无论是提交还是回滚。
官方原文对两种范围的描述是 There are two ways to acquire an advisory lock in PostgreSQL: at session level or at transaction level.;事务级的关键在于它遵守事务语义:锁的生存期被严格绑定在事务边界上。
BEGIN;
SELECT pg_advisory_xact_lock(42); -- 获取锁
-- ... 临界区 ...
COMMIT; -- 锁自动释放为什么它是「更安全」的默认选择
对比一张表就明白了:
| 维度 | 会话级 | 事务级 |
|---|---|---|
| 释放时机 | 显式 unlock / 连接断开 | 事务 COMMIT/ROLLBACK |
| 忘记释放的后果 | 永久泄漏(连接不断则锁不还) | 无(事务总会结束) |
| 手动解锁 | 支持 | 不支持(无对应函数) |
| 跨事务持有 | 支持 | 不支持 |
| 连接池友好度 | 差(复用导致泄漏) | 好(随事务清理) |
| 异常安全 | 需手动 try/finally | 自动(异常中断事务即释放) |
| 典型用途 | 选主、长任务防重 | 单事务内的临界区保护 |
它把「释放锁」从应用责任变成了数据库责任。凡是应用可能出错的地方(异常、提前 return、进程被杀),事务级锁都能自动兜住。
释放语义:三种结束方式都有效
场景一:正常提交
BEGIN; SELECT pg_advisory_xact_lock(42); COMMIT;
→ 锁在 COMMIT 时释放
场景二:显式回滚
BEGIN; SELECT pg_advisory_xact_lock(42); ROLLBACK;
→ 锁在 ROLLBACK 时释放
场景三:语句报错导致事务异常
BEGIN; SELECT pg_advisory_xact_lock(42); SELECT 1/0;
→ 事务进入 aborted 状态,后续任何命令都失败;
必须 ROLLBACK(或 COMMIT 实际等价 ROLLBACK),此时锁释放第三种情况很重要:一条语句报错不会立刻释放锁,事务会变成 aborted 状态。锁要等到事务真正终止(ROLLBACK / COMMIT)才释放。所以应用层必须确保异常后一定会结束事务,否则锁会一直挂到连接层处理(如池的 rollback-on-return)。
BEGIN;
SELECT pg_advisory_xact_lock(42);
SELECT 1/0;ERROR: division by zero-- 此时锁仍在(事务未结束),当前事务已 aborted
SELECT 1;ERROR: current transaction is aborted, commands ignored until end of transaction blockROLLBACK; -- 到这里锁才真正释放不可手动解锁
事务级锁没有解锁函数。pg_advisory_unlock 只认会话级锁,对事务级锁调用它返回 false:
BEGIN;
SELECT pg_advisory_xact_lock(42);
SELECT pg_advisory_unlock(42); pg_advisory_unlock
--------------------
f
(1 row)明确这一点能避免一个常见的错误直觉:「既然不能解锁,那我多申请几次会不会把自己锁死?」——不会,事务级锁同样可重入(同一事务内重复申请只是计数 +1)。
可重入计数同样适用
BEGIN;
SELECT pg_advisory_xact_lock(42); -- 计数 1
SELECT pg_advisory_xact_lock(42); -- 计数 2
COMMIT; -- 一次性全部释放
SELECT count(*) FROM pg_locks
WHERE locktype='advisory' AND ((classid::bigint<<32)|objid::bigint)=42; count
-------
0
(1 row)同一事务内重复申请不会自我阻塞,且释放是「一次性归零」,不需要多次 unlock。
事务级锁仍然「看见」事务外的持有者
事务级锁与会话级锁冲突域是同一套——pg_advisory_xact_lock(42) 和 pg_advisory_lock(42) 会互相阻塞。范围只决定「怎么释放」,不决定「跟谁冲突」:
-- 会话 A(会话级)
SELECT pg_advisory_lock(42);
-- 会话 B
BEGIN;
SELECT pg_try_advisory_xact_lock(42); pg_try_advisory_xact_lock
---------------------------
f
(1 row)同一键就是同一把锁,无论你怎么获取它。
与「行锁 + FOR UPDATE」的取舍
很多人会问:既然有 SELECT ... FOR UPDATE,为什么还要事务级顾问锁?
| 维度 | SELECT ... FOR UPDATE | pg_advisory_xact_lock |
|---|---|---|
| 锁的对象 | 已存在的行 | 任意自定义键 |
| 锁不存在的行 | 做不到(没有行可锁) | 可以(锁键不依赖数据) |
| 写入用户表 | 需要(读行加锁) | 不需要 |
| 产生 WAL / 版本 | 产生(行锁 + 可能的行更新) | 不产生 |
| 需先有数据 | 是 | 否 |
| 适合「检查-插入」 | 勉强(需 ON CONFLICT 配合) | 天然适合 |
| 适合「更新已存在的行」 | 最适合 | 略显笨重 |
一句话:锁已存在的行用行锁;锁「一个还不存在的逻辑资源」用顾问锁。
语法与用法
函数清单
-- 独占锁
pg_advisory_xact_lock(key bigint) → void
pg_advisory_xact_lock(key1 int, key2 int) → void
pg_try_advisory_xact_lock(key bigint) → boolean
pg_try_advisory_xact_lock(key1 int, key2 int) → boolean
-- 共享锁
pg_advisory_xact_lock_shared(key bigint) → void
pg_advisory_xact_lock_shared(key1 int, key2 int) → void
pg_try_advisory_xact_lock_shared(key bigint) → boolean
pg_try_advisory_xact_lock_shared(key1 int, key2 int) → boolean所有函数返回 void(阻塞版)或 boolean(try 版)。没有 xact 版解锁函数。
最简示例
BEGIN;
SELECT pg_advisory_xact_lock(9527);
-- 临界区
UPDATE counters SET value = value + 1 WHERE name = 'visits';
COMMIT;BEGIN
pg_advisory_xact_lock
-----------------------
(1 row)
UPDATE 1
COMMIT非阻塞的 try 版
BEGIN;
SELECT pg_try_advisory_xact_lock(9527) AS got; got
-----
t
(1 row)若返回 f,业务可直接放弃(此时事务里什么锁都没拿到,随便提交或回滚):
BEGIN;
SELECT pg_try_advisory_xact_lock(9527) AS got;
-- got = f
ROLLBACK;事务外调用它的后果
事务级锁可以在「隐式事务」中获取——即一条独立语句也会自动开启并立即提交一个事务,等于锁就地获取又就地释放,几乎没有意义:
SELECT pg_advisory_xact_lock(9527); pg_advisory_xact_lock
-----------------------
(1 row)这条语句自己构成一个隐式事务,结束后锁立即释放。所以事务级锁几乎总是要写在显式的 BEGIN 块里。
详细说明
检查-插入:事务级锁最经典的应用
「若不存在则插入」看起来简单,实则容易出问题。考虑「给用户分配唯一用户名」:
-- 天真写法(无锁):两个并发请求都可能查不到,然后都插入
BEGIN;
SELECT 1 FROM users WHERE username = 'alice'; -- 都没有
INSERT INTO users(username) VALUES ('alice'); -- 都会尝试插入
COMMIT;如果 username 上有唯一约束,第二个会失败(好)。但如果这个「唯一性」是业务规则(比如「一个租户只能有一个 primary 联系人」,且约束不好建),就会出脏数据。
事务级顾问锁把「检查 + 插入」变成原子序列:
BEGIN;
-- 用业务键作为锁号,保证同一 username 串行
SELECT pg_advisory_xact_lock(hashtextextended('username:alice', 0));
-- 现在只有我能同时执行检查与插入
SELECT count(*) FROM users WHERE lower(username) = 'alice';
-- 若为 0,则插入
INSERT INTO users(username) VALUES ('alice');
COMMIT; -- 锁在这里释放,下一个请求才能进入配合 WHERE NOT EXISTS 完全消除竞态:
BEGIN;
SELECT pg_advisory_xact_lock(hashtextextended('reserve:sku-8891', 0));
INSERT INTO inventory_reservations (sku, order_id, qty)
SELECT 'sku-8891', 10023, 1
WHERE NOT EXISTS (
SELECT 1 FROM inventory_reservations
WHERE sku = 'sku-8891' AND order_id = 10023
);
COMMIT;为什么不用 ON CONFLICT
INSERT ... ON CONFLICT 解决的是「有唯一约束的冲突」,它非常高效,但只在有约束时成立。当唯一性条件是「软规则」(跨多列的业务判断、需要调用外部服务)时,ON CONFLICT 无能为力,而顾问锁可以:
| 方法 | 适用前提 | 并发安全 |
|---|---|---|
唯一索引 + ON CONFLICT | 能建唯一约束 | 是 |
SERIALIZABLE 隔离级别 | 应用能处理序列化失败重试 | 是 |
| 事务级顾问锁 | 任意业务键 | 是(显式串行) |
顾问锁的优势是不需要数据库层能表达那条业务规则。
事务级共享锁
共享版允许多个事务同时持有同一键的共享锁,独占版互斥:
-- 事务 A
BEGIN;
SELECT pg_advisory_xact_lock_shared(7000);
-- 读操作
COMMIT;
-- 事务 B(可同时进行)
BEGIN;
SELECT pg_advisory_xact_lock_shared(7000);
COMMIT;-- 事务 C:独占,必须等 A、B 都结束
BEGIN;
SELECT pg_advisory_xact_lock(7000); -- 阻塞直到 A、B 释放
COMMIT;典型用途:「配置读取用共享锁、配置变更用独占锁」。
多把锁的获取顺序与死锁
事务级顾问锁会参与死锁检测。两个事务以相反顺序申请两把锁,就会死锁:
-- 事务 A
BEGIN;
SELECT pg_advisory_xact_lock(1);
SELECT pg_advisory_xact_lock(2); -- 等 B
COMMIT;
-- 事务 B
BEGIN;
SELECT pg_advisory_xact_lock(2);
SELECT pg_advisory_xact_lock(1); -- 等 A → 死锁
COMMIT;ERROR: deadlock detected
DETAIL: Process 21456 waits for ExclusiveLock on advisory lock [16384,0,2,1]; blocked by process 21457.
Process 21457 waits for ExclusiveLock on advisory lock [16384,0,1,1]; blocked by process 21456.
HINT: See server log for query details.唯一可靠的解法是全局统一加锁顺序——比如永远按锁号升序申请:
-- 无论业务需要什么顺序,都按从小到大申请
SELECT pg_advisory_xact_lock(1);
SELECT pg_advisory_xact_lock(2);锁等待受 lock_timeout 与死锁检测约束
BEGIN;
SET LOCAL lock_timeout = '2s';
SELECT pg_advisory_xact_lock(8801);ERROR: canceling statement due to lock timeout与 SET lock_timeout 不同,这里推荐 SET LOCAL——它只在当前事务内生效,事务结束自动恢复,不会污染连接后续的语句(尤其重要,因为连接会被池复用)。
lock_timeout 触发会让语句报错、事务进入 aborted 状态,最终 ROLLBACK 时锁释放,不存在会话级锁的那个「报错但已持有」竞态——这是事务级锁的又一优势。
事务结束的边界细节:SAVEPOINT 不释放锁
SAVEPOINT / ROLLBACK TO SAVEPOINT 是事务内部的子事务标记,不会释放事务级顾问锁:
BEGIN;
SAVEPOINT sp1;
SELECT pg_advisory_xact_lock(9000);
ROLLBACK TO SAVEPOINT sp1;
-- 锁仍然持有!因为整个事务还没结束
SELECT count(*) FROM pg_locks
WHERE locktype='advisory' AND ((classid::bigint<<32)|objid::bigint)=9000; count
-------
1
(1 row)这点容易误解:以为「回滚到保存点就等于撤销了锁」。实际上锁的释放只认顶层事务的结束。
COMMIT; -- 到这里才释放同理,PL/pgSQL 中 BEGIN ... EXCEPTION ... END 块内部的 EXCEPTION 会隐式创建子事务,其中的锁同样不会因异常而释放,要等外层事务结束。
观察事务级锁的持有者
SELECT
l.pid,
a.state,
a.xact_start,
now() - a.xact_start AS xact_age,
CASE l.objsubid
WHEN 1 THEN ((l.classid::bigint << 32) | l.objid::bigint)::text
WHEN 2 THEN format('(%s, %s)', l.classid, l.objid)
END AS key_readable,
l.granted
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.locktype = 'advisory'
ORDER BY xact_age DESC NULLS LAST; pid | state | xact_start | xact_age | key_readable | granted
-------+--------+-------------------------------+--------------+--------------+---------
21456 | active | 2026-09-28 11:20:01.221+08 | 00:00:02.884 | 9527 | t
21457 | active | 2026-09-28 11:20:01.230+08 | 00:00:02.875 | 9527 | f
(2 rows)state = active(而不是 idle)且持有顾问锁,说明是一个正在进行的事务级锁,而非泄漏的会话级锁——这就是区分两者的实用判据。
在连接池下的表现
因为锁随事务释放,事务级锁与连接池天然兼容:
请求 → 借连接 → BEGIN → xact_lock → 工作 → COMMIT(锁释放)→ 归还连接只要应用确保事务被正确结束,归还的连接上不会残留任何顾问锁。唯一需要警惕的是「事务被长时间挂起」(如代码里 BEGIN 后忘了 COMMIT 就去调外部 HTTP),此时锁会一直占着——但这不是锁机制的问题,而是事务管理问题,且同样会被 idle_in_transaction_session_timeout 兜住:
SET idle_in_transaction_session_timeout = '60s';事务级锁与两阶段提交
如果事务被 PREPARE TRANSACTION 变成 prepared 状态,它持有的顾问锁继续保留,直到 COMMIT PREPARED 或 ROLLBACK PREPARED:
BEGIN;
SELECT pg_advisory_xact_lock(42);
PREPARE TRANSACTION 'gid_lock_42';
-- 此时锁仍在,且 pg_locks 中该锁的 pid 为 NULL(属于 prepared 事务)SELECT locktype, pid, granted
FROM pg_locks
WHERE locktype = 'advisory' AND ((classid::bigint<<32)|objid::bigint) = 42; locktype | pid | granted
----------+------+---------
advisory | | t
(1 row)这是一个隐蔽的风险点:prepared 事务被遗忘,顾问锁会一直被持有。相关内容见两阶段提交原理。
实战示例
场景:串行化「检查-插入」完整演示
CREATE TABLE IF NOT EXISTS usernames (
username text PRIMARY KEY,
created_at timestamptz DEFAULT now()
);
DO $$
DECLARE
want text := 'alice';
got boolean;
BEGIN
got := pg_try_advisory_xact_lock(hashtextextended('username:' || want, 0));
IF NOT got THEN
RAISE NOTICE 'another session is registering %, retry later', want;
RETURN;
END IF;
IF EXISTS (SELECT 1 FROM usernames WHERE username = want) THEN
RAISE NOTICE 'username % already taken', want;
RETURN;
END IF;
INSERT INTO usernames(username) VALUES (want);
RAISE NOTICE 'username % registered', want;
END
$$;NOTICE: username alice registered
DO注意:这个 DO 块本身就是一个事务,pg_advisory_xact_lock 在块结束时释放。
场景:库存预留(sku 级串行)
BEGIN;
-- 按 sku 串行,避免超卖
SELECT pg_advisory_xact_lock(hashtextextended('sku:' || 'SKU-8891', 0));
WITH stock AS (
SELECT qty FROM inventory WHERE sku = 'SKU-8891' FOR UPDATE
), reserved AS (
SELECT coalesce(sum(qty), 0) AS q
FROM inventory_reservations
WHERE sku = 'SKU-8891' AND status = 'held'
)
SELECT s.qty - r.q AS available
FROM stock s, reserved r; available
-----------
12
(1 row)-- 若 available >= 需要的数量,则插入预留
INSERT INTO inventory_reservations(sku, order_id, qty, status)
VALUES ('SKU-8891', 10023, 2, 'held');
COMMIT; -- 锁在这里释放场景:多锁按固定顺序申请(防死锁)
BEGIN;
SET LOCAL lock_timeout = '3s';
-- 把需要保护的资源 ID 排序后统一升序加锁
DO $$
DECLARE
k bigint;
BEGIN
FOR k IN SELECT unnest(ARRAY[1002, 1001, 1003]) ORDER BY 1 LOOP
PERFORM pg_advisory_xact_lock(k);
END LOOP;
END
$$;
-- 临界区(同时操作 1001/1002/1003)
UPDATE accounts SET balance = balance - 10 WHERE id = 1001;
UPDATE accounts SET balance = balance + 10 WHERE id = 1002;
COMMIT;场景:配合 SAVEPOINT 的错误处理(注意锁不释放)
BEGIN;
SELECT pg_advisory_xact_lock(9500);
SAVEPOINT sp;
-- 可能在业务上失败的操作
INSERT INTO audit_log(msg) VALUES (repeat('x', 1000000));
ROLLBACK TO SAVEPOINT sp; -- 撤销了插入,但锁仍在
-- 继续使用同一把锁做别的事
UPDATE counters SET value = value + 1 WHERE name = 'audit_fallback';
COMMIT; -- 到这里锁才释放场景:超时 + 重试(应用侧模式)
-- 尝试获取,失败快速返回(供应用层做退避重试)
SELECT pg_try_advisory_xact_lock(hashtext('report:2026-09-28')) AS got; got
-----
f
(1 row)应用层逻辑对应:
for attempt in 1..3:
BEGIN
if pg_try_advisory_xact_lock(k): → 干活 → COMMIT,成功退出
else: → ROLLBACK,sleep(backoff)场景:对比两种范围在同一键上的阻塞行为
-- 终端 1:事务级持锁,暂不提交
BEGIN;
SELECT pg_advisory_xact_lock(9600);-- 终端 2:会话级申请同一键 → 阻塞
SET lock_timeout = '3s';
SELECT pg_advisory_lock(9600);ERROR: canceling statement due to lock timeout-- 终端 3:确认是同一个键上的冲突
SELECT pid, granted, mode FROM pg_locks
WHERE locktype = 'advisory'
AND ((classid::bigint << 32) | objid::bigint) = 9600; pid | granted | mode
-------+---------+---------------
22110 | t | ExclusiveLock
22113 | f | ExclusiveLock
(2 rows)场景:确认事务级锁已随 COMMIT 释放
BEGIN;
SELECT pg_advisory_xact_lock(9700);
COMMIT;
SELECT count(*) AS still_locked FROM pg_locks
WHERE locktype='advisory'
AND ((classid::bigint << 32) | objid::bigint) = 9700; still_locked
--------------
0
(1 row)对照会话级:
SELECT pg_advisory_lock(9701);
COMMIT; -- 甚至没有事务
SELECT count(*) AS still_locked FROM pg_locks
WHERE locktype='advisory'
AND ((classid::bigint << 32) | objid::bigint) = 9701; still_locked
--------------
1
(1 row)注意事项
- 错误!对事务级锁调用
pg_advisory_unlock。 它只对会话级锁有效,会返回f;事务级锁的释放只能靠事务结束。 - 错误!以为
LEFT JOIN pg_locks里pid为空就代表锁已释放。pid为空表示它属于prepared 事务,锁仍被持有。 - 错误!以为
ROLLBACK TO SAVEPOINT会释放事务级锁。 它只回滚子事务内的数据变更,锁要等顶层事务结束才释放。 - 错误!在
BEGIN之后调用但是忘记结束事务。 锁会一直占着,并被idle_in_transaction_session_timeout杀掉连接前一直持有。 - 不推荐在事务外部(无
BEGIN)调用pg_advisory_xact_lock,那等于「加了就立刻放」,毫无保护作用。 - 错误!多个资源不按统一顺序加锁。 事务级锁参与死锁检测,乱序加锁会直接触发
deadlock detected。 - 不推荐用无超时的阻塞版事务级锁,长事务会长时间占用槽位;优先
try版或配SET LOCAL lock_timeout。 - 错误!以为事务级锁能跨事务保护后续逻辑。 事务一提交锁就没了,跨事务需求必须用会话级锁。
最佳实践
- 默认首选事务级:只要临界区能放进一个事务,就用
pg_advisory_xact_lock。 - 统一加锁顺序:所有涉及多把锁的代码,均按锁号升序申请,从根上消除死锁。
- 用
try版 + 快速失败:pg_try_advisory_xact_lock搭配应用层退避重试,比阻塞等待更可控。 SET LOCAL设置超时:在事务内用SET LOCAL lock_timeout,避免污染连接会话状态。- 锁号绑定业务键:优先用整数业务 ID 或
hashtextextended(业务前缀 || id, 0),并集中登记前缀。 - 缩短事务:不要在持锁期间做网络调用、大文件 IO;锁的持有时间 = 事务的持续时间。
- 监控事务年龄:对
xact_start超过阈值且持有顾问锁的事务告警,区分「长事务」与「泄漏」。 - 牢记两阶段提交影响:prepared 事务会继续持有顾问锁,必须监控
pg_prepared_xacts。
下一节
继续学习:顾问锁应用场景——定时任务防重、租户级串行、缓存防击穿、消息顺序消费、迁移互斥与限额发放的完整落地模式。