Skip to content

事务级顾问锁(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.;事务级的关键在于它遵守事务语义:锁的生存期被严格绑定在事务边界上。

text
BEGIN;
SELECT pg_advisory_xact_lock(42);   -- 获取锁
-- ... 临界区 ...
COMMIT;                              -- 锁自动释放

为什么它是「更安全」的默认选择 ​

对比一张表就明白了:

维度会话级事务级
释放时机显式 unlock / 连接断开事务 COMMIT/ROLLBACK
忘记释放的后果永久泄漏(连接不断则锁不还)无(事务总会结束)
手动解锁支持不支持(无对应函数)
跨事务持有支持不支持
连接池友好度差(复用导致泄漏)好(随事务清理)
异常安全需手动 try/finally自动(异常中断事务即释放)
典型用途选主、长任务防重单事务内的临界区保护

它把「释放锁」从应用责任变成了数据库责任。凡是应用可能出错的地方(异常、提前 return、进程被杀),事务级锁都能自动兜住。

释放语义:三种结束方式都有效 ​

text
场景一:正常提交
  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)。

sql
BEGIN;
SELECT pg_advisory_xact_lock(42);
SELECT 1/0;
text
ERROR:  division by zero
sql
-- 此时锁仍在(事务未结束),当前事务已 aborted
SELECT 1;
text
ERROR:  current transaction is aborted, commands ignored until end of transaction block
sql
ROLLBACK;   -- 到这里锁才真正释放

不可手动解锁 ​

事务级锁没有解锁函数。pg_advisory_unlock 只认会话级锁,对事务级锁调用它返回 false:

sql
BEGIN;
SELECT pg_advisory_xact_lock(42);
SELECT pg_advisory_unlock(42);
text
 pg_advisory_unlock
--------------------
 f
(1 row)

明确这一点能避免一个常见的错误直觉:「既然不能解锁,那我多申请几次会不会把自己锁死?」——不会,事务级锁同样可重入(同一事务内重复申请只是计数 +1)。

可重入计数同样适用 ​

sql
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;
text
 count
-------
     0
(1 row)

同一事务内重复申请不会自我阻塞,且释放是「一次性归零」,不需要多次 unlock。

事务级锁仍然「看见」事务外的持有者 ​

事务级锁与会话级锁冲突域是同一套——pg_advisory_xact_lock(42) 和 pg_advisory_lock(42) 会互相阻塞。范围只决定「怎么释放」,不决定「跟谁冲突」:

sql
-- 会话 A(会话级)
SELECT pg_advisory_lock(42);

-- 会话 B
BEGIN;
SELECT pg_try_advisory_xact_lock(42);
text
 pg_try_advisory_xact_lock
---------------------------
 f
(1 row)

同一键就是同一把锁,无论你怎么获取它。

与「行锁 + FOR UPDATE」的取舍 ​

很多人会问:既然有 SELECT ... FOR UPDATE,为什么还要事务级顾问锁?

维度SELECT ... FOR UPDATEpg_advisory_xact_lock
锁的对象已存在的行任意自定义键
锁不存在的行做不到(没有行可锁)可以(锁键不依赖数据)
写入用户表需要(读行加锁)不需要
产生 WAL / 版本产生(行锁 + 可能的行更新)不产生
需先有数据是否
适合「检查-插入」勉强(需 ON CONFLICT 配合)天然适合
适合「更新已存在的行」最适合略显笨重

一句话:锁已存在的行用行锁;锁「一个还不存在的逻辑资源」用顾问锁。

语法与用法 ​

函数清单 ​

text
-- 独占锁
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 版解锁函数。

最简示例 ​

sql
BEGIN;
SELECT pg_advisory_xact_lock(9527);
-- 临界区
UPDATE counters SET value = value + 1 WHERE name = 'visits';
COMMIT;
text
BEGIN
 pg_advisory_xact_lock
-----------------------

(1 row)

UPDATE 1
COMMIT

非阻塞的 try 版 ​

sql
BEGIN;
SELECT pg_try_advisory_xact_lock(9527) AS got;
text
 got
-----
 t
(1 row)

若返回 f,业务可直接放弃(此时事务里什么锁都没拿到,随便提交或回滚):

sql
BEGIN;
SELECT pg_try_advisory_xact_lock(9527) AS got;
-- got = f
ROLLBACK;

事务外调用它的后果 ​

事务级锁可以在「隐式事务」中获取——即一条独立语句也会自动开启并立即提交一个事务,等于锁就地获取又就地释放,几乎没有意义:

sql
SELECT pg_advisory_xact_lock(9527);
text
 pg_advisory_xact_lock
-----------------------

(1 row)

这条语句自己构成一个隐式事务,结束后锁立即释放。所以事务级锁几乎总是要写在显式的 BEGIN 块里。

详细说明 ​

检查-插入:事务级锁最经典的应用 ​

「若不存在则插入」看起来简单,实则容易出问题。考虑「给用户分配唯一用户名」:

sql
-- 天真写法(无锁):两个并发请求都可能查不到,然后都插入
BEGIN;
SELECT 1 FROM users WHERE username = 'alice';   -- 都没有
INSERT INTO users(username) VALUES ('alice');   -- 都会尝试插入
COMMIT;

如果 username 上有唯一约束,第二个会失败(好)。但如果这个「唯一性」是业务规则(比如「一个租户只能有一个 primary 联系人」,且约束不好建),就会出脏数据。

事务级顾问锁把「检查 + 插入」变成原子序列:

sql
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 完全消除竞态:

sql
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 隔离级别应用能处理序列化失败重试是
事务级顾问锁任意业务键是(显式串行)

顾问锁的优势是不需要数据库层能表达那条业务规则。

事务级共享锁 ​

共享版允许多个事务同时持有同一键的共享锁,独占版互斥:

sql
-- 事务 A
BEGIN;
SELECT pg_advisory_xact_lock_shared(7000);
-- 读操作
COMMIT;

-- 事务 B(可同时进行)
BEGIN;
SELECT pg_advisory_xact_lock_shared(7000);
COMMIT;
text
-- 事务 C:独占,必须等 A、B 都结束
BEGIN;
SELECT pg_advisory_xact_lock(7000);   -- 阻塞直到 A、B 释放
COMMIT;

典型用途:「配置读取用共享锁、配置变更用独占锁」。

多把锁的获取顺序与死锁 ​

事务级顾问锁会参与死锁检测。两个事务以相反顺序申请两把锁,就会死锁:

sql
-- 事务 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;
text
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.

唯一可靠的解法是全局统一加锁顺序——比如永远按锁号升序申请:

sql
-- 无论业务需要什么顺序,都按从小到大申请
SELECT pg_advisory_xact_lock(1);
SELECT pg_advisory_xact_lock(2);

锁等待受 lock_timeout 与死锁检测约束 ​

sql
BEGIN;
SET LOCAL lock_timeout = '2s';
SELECT pg_advisory_xact_lock(8801);
text
ERROR:  canceling statement due to lock timeout

与 SET lock_timeout 不同,这里推荐 SET LOCAL——它只在当前事务内生效,事务结束自动恢复,不会污染连接后续的语句(尤其重要,因为连接会被池复用)。

lock_timeout 触发会让语句报错、事务进入 aborted 状态,最终 ROLLBACK 时锁释放,不存在会话级锁的那个「报错但已持有」竞态——这是事务级锁的又一优势。

事务结束的边界细节:SAVEPOINT 不释放锁 ​

SAVEPOINT / ROLLBACK TO SAVEPOINT 是事务内部的子事务标记,不会释放事务级顾问锁:

sql
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;
text
 count
-------
     1
(1 row)

这点容易误解:以为「回滚到保存点就等于撤销了锁」。实际上锁的释放只认顶层事务的结束。

sql
COMMIT;   -- 到这里才释放

同理,PL/pgSQL 中 BEGIN ... EXCEPTION ... END 块内部的 EXCEPTION 会隐式创建子事务,其中的锁同样不会因异常而释放,要等外层事务结束。

观察事务级锁的持有者 ​

sql
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;
text
  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)且持有顾问锁,说明是一个正在进行的事务级锁,而非泄漏的会话级锁——这就是区分两者的实用判据。

在连接池下的表现 ​

因为锁随事务释放,事务级锁与连接池天然兼容:

text
请求 → 借连接 → BEGIN → xact_lock → 工作 → COMMIT(锁释放)→ 归还连接

只要应用确保事务被正确结束,归还的连接上不会残留任何顾问锁。唯一需要警惕的是「事务被长时间挂起」(如代码里 BEGIN 后忘了 COMMIT 就去调外部 HTTP),此时锁会一直占着——但这不是锁机制的问题,而是事务管理问题,且同样会被 idle_in_transaction_session_timeout 兜住:

sql
SET idle_in_transaction_session_timeout = '60s';

事务级锁与两阶段提交 ​

如果事务被 PREPARE TRANSACTION 变成 prepared 状态,它持有的顾问锁继续保留,直到 COMMIT PREPARED 或 ROLLBACK PREPARED:

sql
BEGIN;
SELECT pg_advisory_xact_lock(42);
PREPARE TRANSACTION 'gid_lock_42';
-- 此时锁仍在,且 pg_locks 中该锁的 pid 为 NULL(属于 prepared 事务)
sql
SELECT locktype, pid, granted
FROM pg_locks
WHERE locktype = 'advisory' AND ((classid::bigint<<32)|objid::bigint) = 42;
text
 locktype | pid  | granted
----------+------+---------
 advisory |      | t
(1 row)

这是一个隐蔽的风险点:prepared 事务被遗忘,顾问锁会一直被持有。相关内容见两阶段提交原理。

实战示例 ​

场景:串行化「检查-插入」完整演示 ​

sql
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
$$;
text
NOTICE:  username alice registered
DO

注意:这个 DO 块本身就是一个事务,pg_advisory_xact_lock 在块结束时释放。

场景:库存预留(sku 级串行) ​

sql
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;
text
 available
-----------
        12
(1 row)
sql
-- 若 available >= 需要的数量,则插入预留
INSERT INTO inventory_reservations(sku, order_id, qty, status)
VALUES ('SKU-8891', 10023, 2, 'held');

COMMIT;   -- 锁在这里释放

场景:多锁按固定顺序申请(防死锁) ​

sql
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 的错误处理(注意锁不释放) ​

sql
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;                      -- 到这里锁才释放

场景:超时 + 重试(应用侧模式) ​

sql
-- 尝试获取,失败快速返回(供应用层做退避重试)
SELECT pg_try_advisory_xact_lock(hashtext('report:2026-09-28')) AS got;
text
 got
-----
 f
(1 row)

应用层逻辑对应:

text
for attempt in 1..3:
    BEGIN
      if pg_try_advisory_xact_lock(k):  → 干活 → COMMIT,成功退出
      else:                              → ROLLBACK,sleep(backoff)

场景:对比两种范围在同一键上的阻塞行为 ​

sql
-- 终端 1:事务级持锁,暂不提交
BEGIN;
SELECT pg_advisory_xact_lock(9600);
sql
-- 终端 2:会话级申请同一键 → 阻塞
SET lock_timeout = '3s';
SELECT pg_advisory_lock(9600);
text
ERROR:  canceling statement due to lock timeout
sql
-- 终端 3:确认是同一个键上的冲突
SELECT pid, granted, mode FROM pg_locks
WHERE locktype = 'advisory'
  AND ((classid::bigint << 32) | objid::bigint) = 9600;
text
  pid  | granted |     mode
-------+---------+---------------
 22110 | t       | ExclusiveLock
 22113 | f       | ExclusiveLock
(2 rows)

场景:确认事务级锁已随 COMMIT 释放 ​

sql
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;
text
 still_locked
--------------
            0
(1 row)

对照会话级:

sql
SELECT pg_advisory_lock(9701);
COMMIT;  -- 甚至没有事务
SELECT count(*) AS still_locked FROM pg_locks
WHERE locktype='advisory'
  AND ((classid::bigint << 32) | objid::bigint) = 9701;
text
 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。

下一节

继续学习:顾问锁应用场景——定时任务防重、租户级串行、缓存防击穿、消息顺序消费、迁移互斥与限额发放的完整落地模式。

参考链接 ​