Skip to content

会话级顾问锁(Session-Level Advisory Locks) ​

会话级顾问锁(Session-Level Advisory Lock) 是顾问锁里「生命周期最长、也最容易出事」的一类:一旦拿到,它会一直挂在那个数据库连接上,直到你显式解锁、或连接断开。它不遵守事务语义——事务回滚不会释放它,甚至解锁语句本身也不会被回滚。

正因为「连接还在,锁就还在」,会话级顾问锁非常适合「跨多个事务的长期互斥」(如选主、定时任务防重),但也是锁泄漏事故的头号来源。本页把 pg_advisory_lock 家族的每一个函数、可重入计数、会话生命周期、以及连接池下的防范讲透。

前置知识

阅读本节前,建议先了解:顾问锁概述

基础概念 ​

会话级锁的归属单位是「连接」,不是「事务」 ​

会话级顾问锁挂在后端进程(Backend Process) 上。一个连接对应一个后端进程,一个后端进程对应一把(或若干把)顾问锁的持有记录。这条规则推出三个结论:

text
1. 事务提交 → 锁仍在
2. 事务回滚 → 锁仍在
3. 连接断开 → 锁立即释放(无论有没有 unlock)

官方描述非常清楚:

Once acquired at session level, an advisory lock is held until explicitly released or the session ends. Unlike standard lock requests, session-level advisory lock requests do not honor transaction semantics: a lock acquired during a transaction that is later rolled back will still be held following the rollback, and likewise an unlock is effective even if the calling transaction fails later.

注意最后半句同样重要:解锁也不遵守事务语义——你执行了 pg_advisory_unlock,即使后面事务失败回滚,锁也不会回来。这和「数据库操作可回滚」的直觉正好相反。

为什么要有「不遵守事务语义」这种东西 ​

看起来违反直觉,但它解决的是一个真实需求:锁的持有时间要比事务长。

text
需求:整个「生成月度报表」流程(包含多个事务、耗时 20 分钟)期间,
      不允许第二个实例同时启动。

如果用表锁/行锁:每个事务结束锁就没了,20 分钟里会有无数个空窗期。
如果用事务级顾问锁:只能覆盖单个事务,跨事务就断了。
只有会话级顾问锁:一次获取,跨任意多个事务持续持有,直到流程结束手动释放。

典型场景:

场景为什么必须是会话级
选主 / 定时任务防重任务跨多个事务,且可能需要长时间持有
在线迁移互斥迁移脚本分多个批次提交
长任务限流(如全量重建索引缓存)任务本身不构成单个事务
应用启动时的单例初始化需要在若干次短事务之间保持互斥

与事务级锁的对照 ​

维度会话级事务级
获取函数pg_advisory_lock 等pg_advisory_xact_lock 等
释放方式显式 pg_advisory_unlock / 连接断开事务 COMMIT/ROLLBACK 自动
遵守事务语义否是
能否手动解锁能不能(无对应函数)
可重入是(计数)是(计数)
跨多个事务持有可以不可以
最大风险忘记解锁 → 长期泄漏几乎没有(有事务边界兜底)

一句话选择法:锁的存活期正好等于一个事务 → 用事务级;锁要跨事务 → 用会话级,并接受「必须自己管释放」的责任。

可重入:本质是引用计数 ​

同一会话对同一把锁可以申请多次,每申请一次计数 +1,每解锁一次 -1,归零才真正释放:

text
会话 A:
  pg_advisory_lock(42)   → 计数 1(真正获得锁)
  pg_advisory_lock(42)   → 计数 2(只是 +1)
  pg_advisory_lock(42)   → 计数 3(只是 +1)

  pg_advisory_unlock(42) → 计数 2,返回 t
  pg_advisory_unlock(42) → 计数 1,返回 t
  pg_advisory_unlock(42) → 计数 0,真正释放,返回 t
  pg_advisory_unlock(42) → 无锁可解,返回 f

这带来两个后果:

  1. 同一会话不会自我阻塞——重入永远成功,不会死锁。
  2. 解锁次数必须匹配——少解一次,锁就一直挂着。
sql
SELECT pg_advisory_lock(42);
SELECT pg_advisory_lock(42);
SELECT pg_advisory_unlock(42);   -- t,但锁仍被自己持有
-- 其他会话依然拿不到 42

阻塞语义与 try 变体 ​

函数族拿不到锁时
pg_advisory_lock阻塞等待,直到拿到(或 lock_timeout 触发)
pg_try_advisory_lock立即返回 false,不等待
pg_advisory_lock_shared阻塞等待(共享锁)
pg_try_advisory_lock_shared立即返回 boolean

生产代码里「阻塞版本 + 无限等待」几乎总是错的——一个忘记解锁的旧连接就能把整条业务链路挂死。默认用 try 版本,只在明确知道锁会很快释放时才用阻塞版本,并且一定配 lock_timeout。

pg_locks 里的呈现 ​

会在 pg_locks 里生成一行(或多次申请时多行):

sql
SELECT pid, classid, objid, objsubid, mode, granted
FROM pg_locks
WHERE locktype = 'advisory' AND pid = pg_backend_pid();
text
  pid  | classid | objid | objsubid |     mode      | granted
-------+---------+-------+----------+---------------+---------
 18422 |       0 |    42 |        1 | ExclusiveLock | t
(1 row)

注意没有「会话级/事务级」的标志位——pg_locks 不区分这两者。区分它们只能靠「当前是否在事务块内」以及「持有者是否已经不在事务中」来间接判断。这是一处需要留意的监控盲区。

语法与用法 ​

函数完整清单 ​

text
-- 独占锁:阻塞版
pg_advisory_lock(key bigint)                 → void
pg_advisory_lock(key1 int, key2 int)         → void

-- 独占锁:非阻塞版
pg_try_advisory_lock(key bigint)             → boolean
pg_try_advisory_lock(key1 int, key2 int)     → boolean

-- 共享锁:阻塞版
pg_advisory_lock_shared(key bigint)          → void
pg_advisory_lock_shared(key1 int, key2 int)  → void

-- 共享锁:非阻塞版
pg_try_advisory_lock_shared(key bigint)          → boolean
pg_try_advisory_lock_shared(key1 int, key2 int)  → boolean

-- 解锁
pg_advisory_unlock(key bigint)               → boolean
pg_advisory_unlock(key1 int, key2 int)       → boolean
pg_advisory_unlock_shared(key bigint)        → boolean
pg_advisory_unlock_shared(key1 int, key2 int) → boolean

-- 批量解锁当前会话的所有顾问锁
pg_advisory_unlock_all()                     → void

最简示例 ​

sql
-- 获取一把独占的会话级锁
SELECT pg_advisory_lock(9527);
text
 pg_advisory_lock
------------------

(1 row)
sql
-- 干活(这里可以是任意多个事务)
BEGIN;
UPDATE jobs SET status = 'running' WHERE id = 9527;
COMMIT;

BEGIN;
UPDATE jobs SET status = 'done' WHERE id = 9527;
COMMIT;
-- 两个事务都结束了,但锁还在!
sql
-- 确认锁仍在
SELECT count(*) FROM pg_locks
WHERE locktype = 'advisory' AND pid = pg_backend_pid();
text
 count
-------
     1
(1 row)
sql
-- 显式释放
SELECT pg_advisory_unlock(9527);
text
 pg_advisory_unlock
--------------------
 t
(1 row)

回滚不会释放:一个必须亲手验证的例子 ​

sql
BEGIN;
SELECT pg_advisory_lock(7700);
ROLLBACK;
text
 pg_advisory_lock
------------------

(1 row)

ROLLBACK
sql
-- 锁还在
SELECT granted FROM pg_locks
WHERE locktype = 'advisory'
  AND ((classid::bigint << 32) | objid::bigint) = 7700;
text
 granted
---------
 t
(1 row)

必须:

sql
SELECT pg_advisory_unlock(7700);

非阻塞 + 显式失败处理 ​

sql
SELECT pg_try_advisory_lock(9527) AS locked;
text
 locked
--------
 f
(1 row)
sql
-- 拿不到就跳过,而不是傻等
DO $$
BEGIN
    IF pg_try_advisory_lock(9527) THEN
        BEGIN
            -- 临界区:执行真正的业务
            PERFORM pg_sleep(1);
        EXCEPTION WHEN OTHERS THEN
            PERFORM pg_advisory_unlock(9527);
            RAISE;
        END;
        PERFORM pg_advisory_unlock(9527);
    ELSE
        RAISE NOTICE 'another worker holds lock 9527, skip';
    END IF;
END
$$;
text
NOTICE:  another worker holds lock 9527, skip
DO

详细说明 ​

独占与共享的组合语义 ​

共享锁允许并发读持有者:

sql
-- 会话 A
SELECT pg_advisory_lock_shared(2000);
-- 会话 B
SELECT pg_advisory_lock_shared(2000);   -- 成功,不冲突
-- 会话 C
SELECT pg_try_advisory_lock(2000);      -- f,独占被共享锁挡住
text
-- 若 C 用阻塞版,它要等到 A、B 都释放才能拿到
SELECT pg_advisory_lock(2000);          -- 阻塞

用它实现缓存读写锁:

sql
-- 读缓存:加共享锁
SELECT pg_advisory_lock_shared(2001);
SELECT payload FROM cache_entries WHERE key = 'home:feed';
SELECT pg_advisory_unlock_shared(2001);

-- 重建缓存:加独占锁
SELECT pg_advisory_lock(2001);
-- 重建逻辑...
SELECT pg_advisory_unlock(2001);

解锁必须匹配模式 ​

独占锁只能用 pg_advisory_unlock 解,共享锁只能用 pg_advisory_unlock_shared 解:

sql
SELECT pg_advisory_lock_shared(3000);
SELECT pg_advisory_unlock(3000);          -- f,模式不匹配
SELECT pg_advisory_unlock_shared(3000);   -- t
text
 pg_advisory_unlock
--------------------
 f
(1 row)

 pg_advisory_unlock_shared
---------------------------
 t
(1 row)

同时持有独占与共享计数时,两类计数各自独立维护:

sql
SELECT pg_advisory_lock(3001);          -- 独占计数 1
SELECT pg_advisory_lock_shared(3001);   -- 共享计数 1(同会话不冲突)
SELECT count(*) FROM pg_locks
WHERE locktype='advisory' AND pid=pg_backend_pid();
-- 2

解锁次数不匹配时的返回值语义 ​

情况返回值
成功减少一次计数(含未归零)t
本会话从未持有该锁f
键空间不匹配(用 bigint 解 two-int)f
模式不匹配(用独占解锁共享锁)f
sql
SELECT pg_advisory_lock(4000);                 -- 双键空间用 (2, 4000) 才对应
SELECT pg_advisory_unlock(2, 4000);            -- f —— 键空间不匹配
SELECT pg_advisory_unlock(4000);               -- t

任何一次 f 都应被视为异常信号(说明代码里 unlock 与实际 lock 不成对),生产代码应记录日志。

会话结束是终极释放手段 ​

无论持有多少把、计数多高,连接一断,全部清零:

sql
-- 查看当前连接的 pid
SELECT pg_backend_pid();
text
 pg_backend_pid
----------------
          18422
(1 row)
sql
-- 从另一个会话强制断开它(需要超级用户或同角色)
SELECT pg_terminate_backend(18422);
text
 pg_terminate_backend
----------------------
 t
(1 row)

这是最后的兜底手段,但通常代价过高:杀掉连接会连带杀掉它正在执行的业务事务,可能留下半成品数据。优先找业务层面修复。

lock_timeout 与那个已知竞态 ​

sql
SET lock_timeout = '3s';
SELECT pg_advisory_lock(42);
text
ERROR:  canceling statement due to lock timeout

会话级顾问锁有一个官方社区确认的竞态(Race Condition):超时取消与锁授予可能交错,导致报超时但锁其实已授予本会话。Tom Lane 的原话是:

there's a fairly basic race condition there, which is that the lock might get granted to us immediately after the timeout fires. In ordinary usage this isn't very problematic because the lock would get released anyway during the transaction abort resulting from the timeout error. However, when you're asking for a session-level advisory lock, that doesn't happen.

因为会话级锁不会随错误自动释放,所以一旦报错,务必复核:

sql
SELECT count(*) FROM pg_locks
WHERE locktype = 'advisory' AND pid = pg_backend_pid()
  AND objsubid = 1
  AND ((classid::bigint << 32) | objid::bigint) = 42;
text
 count
-------
     1
(1 row)

计数为 1 说明「报错归报错,锁其实在我手上」。要么补一次 pg_advisory_unlock(42),要么让业务继续使用它——绝不能既不使用也不释放。

连接池复用导致的泄漏链条 ​

text
连接池(如 PgBouncer transaction 模式 / 应用侧 HikariCP)
   ↓
请求 1 从池中借到物理连接 C
   ↓
SELECT pg_advisory_lock(42)  ── 成功
   ↓
业务代码抛异常,跳过 unlock
   ↓
连接 C 归还池中(TCP 未断开)
   ↓
请求 2 借到同一个连接 C
   ↓
SELECT pg_advisory_lock(42)  ── 又是成功(可重入!)
   ↓
请求 2 以为自己拿到全局互斥,实际上请求 1 的逻辑从未完成
   ↓
计数 ≥ 1 永不归零,其他所有连接永远阻塞

连接池 + 会话级顾问锁 = 高危组合

排查特征:pg_locks 里某把锁长期被同一个 pid 持有,而对应会话的 state 是 idle。修复办法通常是①改用事务级锁;②在归还连接前用 DISCARD ALL 重置会话;③确保 unlock 在 finally 中。

用 DISCARD ALL 兜底 ​

DISCARD ALL 会重置会话状态,其中包含释放所有会话级顾问锁(DISCARD ALL 等价于 SET SESSION AUTHORIZATION DEFAULT; RESET ALL; DEALLOCATE ALL; CLOSE ALL; UNLISTEN *; SELECT pg_advisory_unlock_all(); DISCARD PLANS; DISCARD TEMP;):

sql
-- 归还连接到池之前执行
DISCARD ALL;
text
DISCARD ALL

验证效果:

sql
SELECT count(*) FROM pg_locks
WHERE locktype = 'advisory' AND pid = pg_backend_pid();
text
 count
-------
     0
(1 row)

代价是同时清掉预编译语句与临时表,对性能有影响。用得上的场景是「无法保证 unlock 一定执行」的遗留代码。

用 pg_advisory_unlock_all 一次性清理 ​

sql
-- 先看看自己持有什么
SELECT
    CASE objsubid
        WHEN 1 THEN ((classid::bigint << 32) | objid::bigint)::text
        WHEN 2 THEN format('(%s, %s)', classid, objid)
    END AS key_readable,
    mode
FROM pg_locks
WHERE locktype = 'advisory' AND pid = pg_backend_pid();
text
 key_readable |     mode
--------------+---------------
 42           | ExclusiveLock
 (1, 9527)    | ExclusiveLock
(2 rows)
sql
-- 全部释放(含共享锁,含所有计数)
SELECT pg_advisory_unlock_all();
text
 pg_advisory_unlock_all
------------------------

(1 row)

它只作用于当前会话

pg_advisory_unlock_all() 不接受参数,也无视任何归属——它只解锁「调用它的那个会话」持有的全部顾问锁。你无法从会话 B 清理会话 A 的锁,这一点无法绕过(除非杀掉 A)。

命名规范:让 pg_locks 可读 ​

sql
-- 把业务语义编码进键号
-- 1001xx: 定时任务防重(100100 + job_id)
SELECT pg_try_advisory_lock(100000 + 17);   -- job_id = 17

-- 2000xx: 租户级串行(第一键=域,第二键=tenant_id)
SELECT pg_try_advisory_lock(2000, 8842);    -- tenant 8842

-- 9000xx: 迁移互斥
SELECT pg_try_advisory_lock(900001);

配套维护一张映射表:

键号区间语义举例
100000 + job_id定时任务独占100017
(2000, tenant_id)租户级串行(2000, 8842)
900000 + step迁移步骤互斥900001
700000 + user_id用户级串行700123

会话级锁不写 WAL、崩溃即失 ​

text
数据库崩溃重启
   ↓
所有后端进程消失
   ↓
所有会话级顾问锁自动清空(不依赖任何持久化记录)
   ↓
恢复后应用重新竞争

这既是优点(无残留)也是缺点(无法跨重启保持「任务正在跑」的状态)。如果你需要「重启后仍认为上一轮任务未完成」,那要把状态落库到业务表,用顾问锁只做运行期的互斥:

sql
CREATE TABLE job_runs (
    job_name     text PRIMARY KEY,
    started_at   timestamptz,
    finished_at  timestamptz,
    status       text
);

顾问锁负责「同一时刻只有一个实例在跑」,业务表负责「历史可追溯」。

实战示例 ​

场景:定时任务防重(会话级 + try + finally 语义) ​

sql
-- 在任务的入口处
SELECT pg_try_advisory_lock(100017) AS acquired;
text
 acquired
----------
 t
(1 row)
sql
DO $$
DECLARE
    got boolean;
BEGIN
    got := pg_try_advisory_lock(100017);
    IF NOT got THEN
        RAISE NOTICE 'job 17 already running elsewhere, exit';
        RETURN;
    END IF;

    BEGIN
        -- 真正的任务逻辑:可以是多个事务
        INSERT INTO job_runs(job_name, started_at, status)
        VALUES ('daily_settlement', now(), 'running')
        ON CONFLICT (job_name) DO UPDATE
           SET started_at = now(), status = 'running', finished_at = NULL;

        PERFORM pg_sleep(0.5);   -- 模拟耗时工作

        UPDATE job_runs
           SET status = 'done', finished_at = now()
         WHERE job_name = 'daily_settlement';
    EXCEPTION WHEN OTHERS THEN
        UPDATE job_runs SET status = 'failed' WHERE job_name = 'daily_settlement';
        PERFORM pg_advisory_unlock(100017);   -- 异常路径也要解锁
        RAISE;
    END;

    PERFORM pg_advisory_unlock(100017);       -- 正常路径解锁
END
$$;
text
NOTICE:  job 17 already running elsewhere, exit
DO

注意这里的结构:解锁必须出现在所有出口。用 EXCEPTION WHEN OTHERS + RAISE 重抛,保证失败也不泄漏。

场景:观察锁的生命周期 ​

sql
-- 终端 1
SELECT pg_backend_pid();
SELECT pg_advisory_lock(5000);
text
 pg_backend_pid
----------------
          20112
(1 row)

 pg_advisory_lock
------------------

(1 row)
sql
-- 终端 2
BEGIN;
SELECT pg_advisory_unlock(5000);   -- f:不是我的锁
ROLLBACK;

SELECT pg_try_advisory_lock(5000); -- f:被别人占着
text
 pg_advisory_unlock
--------------------
 f
(1 row)

 pg_try_advisory_lock
----------------------
 f
(1 row)
sql
-- 终端 3:看谁在占
SELECT pid, granted, mode FROM pg_locks
WHERE locktype = 'advisory' AND objsubid = 1
  AND ((classid::bigint << 32) | objid::bigint) = 5000;
text
  pid  | granted |     mode
-------+---------+---------------
 20112 | t       | ExclusiveLock
(1 row)

场景:泄漏检测查询 ​

找出「持有会话级顾问锁但长时间空闲」的嫌疑连接:

sql
SELECT
    l.pid,
    a.usename,
    a.datname,
    a.state,
    a.state_change,
    now() - a.state_change AS idle_for,
    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
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.locktype = 'advisory'
  AND a.state = 'idle'
  AND now() - a.state_change > interval '5 minutes'
ORDER BY idle_for DESC;
text
  pid  | usename | datname | state |        idle_for        | key_readable
-------+---------+---------+-------+------------------------+--------------
 18422 | app     | shop    | idle  | 00:42:11.228331        | 100017
(1 row)

场景:阻塞与等待的可视化 ​

sql
-- 终端 1:持锁不放
SELECT pg_advisory_lock(6000);

-- 终端 2:阻塞申请
SET lock_timeout = '10s';
SELECT pg_advisory_lock(6000);

-- 终端 3
SELECT
    l.pid,
    l.granted,
    l.waitstart,
    now() - l.waitstart AS waiting_for,
    a.query
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.locktype = 'advisory' AND NOT l.granted;
text
  pid  | granted |          waitstart          |   waiting_for   |        query
-------+---------+-----------------------------+-----------------+---------------------
 21456 | f       | 2026-09-28 11:03:22.918+08  | 00:00:04.551237 | SELECT pg_advisory_lock(6000)
(1 row)

场景:安全的 try-lock 模式(应用侧伪代码对应 SQL) ​

sql
-- 期望的应用逻辑:
--   if (try_lock) { try { work(); } finally { unlock(); } }
-- 用 SQL 表达:
BEGIN;
SELECT pg_try_advisory_lock(6100) AS got;   -- 若 f,业务层直接放弃
-- 拿到后立刻 COMMIT,让会话级锁脱离事务继续生效
COMMIT;
-- ... 跨多个事务执行工作 ...
SELECT pg_advisory_unlock(6100);

注意:锁的持有与事务无关,所以这里可以放心先 COMMIT。这也是会话级锁相对事务级锁的核心价值。

注意事项 ​

  • 错误!在异常路径漏掉 pg_advisory_unlock。 这是会话级锁泄漏的第一原因,务必把解锁写在 finally / EXCEPTION 块里。
  • 错误!以为事务回滚会释放会话级锁。 它完全不遵守事务语义,ROLLBACK 后锁仍在。
  • 错误!在 PgBouncer 等连接池后使用会话级锁却不重置会话。 连接复用会把锁「传给」下一个请求,且因可重入而伪装成成功。
  • 不推荐在生产代码里使用无超时的阻塞版 pg_advisory_lock。至少配 lock_timeout,或直接用 pg_try_advisory_lock。
  • 错误!用 pg_advisory_unlock 去解共享锁或不同键空间的锁。 它会返回 f 但不断言,容易让你误以为已释放。
  • 不推荐依赖 lock_timeout 报错就断定「没拿到锁」。会话级锁存在报超时但实际已持有的竞态,必须复核 pg_locks。
  • 错误!指望 pg_advisory_unlock_all() 能清理别人的锁。 它只作用于当前会话。
  • 不推荐在应用重启逻辑里假设旧锁已释放。若旧连接尚未断开(如网络僵死),锁可能仍被占用直到 TCP 超时。

最佳实践 ​

  • 能事务级就事务级:只有在锁必须跨越事务边界时才用会话级。
  • 默认 try 版本:pg_try_advisory_lock + 明确的失败分支,避免无限阻塞。
  • try/finally 铁律:任何加锁与解锁必须成对出现在同一作用域,异常路径同样释放。
  • 连接归还前重置:在使用连接池时,把 DISCARD ALL 或 pg_advisory_unlock_all() 放进连接归还钩子。
  • 锁号集中登记:维护键号区间的文档或常量,避免跨模块撞号。
  • 监控三项指标:①未被授予的顾问锁数量;②单会话持有顾问锁数量;③idle 且持有顾问锁超过阈值(如 5 分钟)的会话数。
  • 落库业务状态:顾问锁只管「运行期互斥」,任务的历史状态写入业务表,别指望锁本身可追溯。
  • 崩溃即释放要接受:重启后所有会话级锁清空,应用要有幂等重跑能力。

下一节

继续学习:事务级顾问锁——pg_advisory_xact_lock 如何借助事务边界自动释放锁,以及与「检查-插入」串行化的配合。

参考链接 ​