Skip to content

顾问锁应用场景(Advisory Lock Use Cases) ​

顾问锁的价值不在语法——它总共只有十几个函数,半小时就能背完。价值在于你知道什么时候该用它。本页把生产环境中最常见、也最容易踩坑的几类场景逐一带代码讲清:定时任务防重、租户级串行、缓存防击穿、消息顺序消费、迁移互斥、限额发放,最后给出锁号规划方法与 PgBouncer 下的坑。

前置知识

阅读本节前,建议先了解:顾问锁概述、会话级顾问锁、事务级顾问锁

基础概念 ​

场景选择的三条判据 ​

面对一个并发问题,先回答三个问题,就能判断顾问锁是否合适:

text
问题一:要保护的「资源」在数据库里有对应的行吗?
  → 有,且是「更新已有行」 → 优先行锁(SELECT ... FOR UPDATE)
  → 没有,或「资源还不存在」 → 顾问锁合适

问题二:临界区能放进一个事务吗?
  → 能 → 用事务级(pg_advisory_xact_lock)
  → 不能(跨事务、耗时长) → 用会话级(pg_advisory_lock),并承担手动释放责任

问题三:互斥范围是「单个 PostgreSQL 集群」吗?
  → 是 → 顾问锁合适
  → 否(跨集群/跨数据库/跨存储) → 需要外部分布式锁(Redis/ZooKeeper)或专门的协调服务

六类典型场景速览 ​

场景推荐范围键的构成关键点
定时任务防重会话级任务编号 + job_id任务跨事务、耗时长
租户级串行事务级(域编号, tenant_id)同租户串行、跨租户并行
缓存防击穿会话级或事务级资源哈希只放一个请求查库
消息顺序消费事务级(topic编号, partition/业务键)同键串行、他键并行
迁移互斥会话级固定迁移编号多批次提交、全局唯一
限额发放事务级(额度池编号, 对象ID)检查-扣减必须在同事务

反模式:什么时候不该用顾问锁 ​

text
✗ 保护一张表的批量更新 → 用行锁或表锁,顾问锁不会阻止任何人读写
✗ 跨集群互斥 → 顾问锁是每库本地的
✗ 需要「锁过期自动释放」的 TTL 语义 → 顾问锁没有 TTL,只有连接/事务边界
✗ 需要可观测的锁队列、优先级 → 顾问锁 FIFO 但不暴露队列
✗ 替代唯一约束 → 那应该建唯一索引
✗ 分布式选主(多集群) → 单集群可以用,多集群不行

语法与用法 ​

通用骨架 ​

text
事务级(推荐默认):
BEGIN;
  SET LOCAL lock_timeout = '{n}s';
  SELECT pg_try_advisory_xact_lock({lock_key});   -- 或阻塞版
  -- 临界区
COMMIT;

会话级(跨事务):
SELECT pg_try_advisory_lock({lock_key});
-- 跨多个事务的临界区
SELECT pg_advisory_unlock({lock_key});            -- 必须执行

一个可复用的最小示例 ​

sql
-- 事务级:同一订单号只允许一个并发处理
BEGIN;
SELECT pg_try_advisory_xact_lock(hashtextextended('order:9527', 0)) AS got;
-- got = t 则继续,f 则放弃
UPDATE orders SET status = 'processing' WHERE id = 9527;
COMMIT;
text
 got
-----
 t
(1 row)

UPDATE 1
COMMIT

检查锁状态的通用查询 ​

sql
SELECT
    l.pid,
    a.usename,
    a.state,
    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.mode,
    l.granted
FROM pg_locks l
LEFT JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.locktype = 'advisory'
ORDER BY l.granted, key_readable;

详细说明 ​

场景一:定时任务防重 ​

多个应用实例同时部署了同一个 cron 任务,必须保证同一时刻只有一个在跑。

sql
DO $$
DECLARE
    got boolean;
BEGIN
    got := pg_try_advisory_lock(100017);   -- 100000 + job_id(17)
    IF NOT got THEN
        RAISE NOTICE 'job 17 already running, skip this round';
        RETURN;
    END IF;

    BEGIN
        -- 任务会被拆成多个事务提交
        INSERT INTO job_runs(job_name, status, started_at)
        VALUES ('daily_report', 'running', now())
        ON CONFLICT (job_name) DO UPDATE
            SET status = 'running', started_at = now(), finished_at = NULL;

        -- 逐批处理,每批一个事务
        FOR i IN 1..3 LOOP
            INSERT INTO daily_report_batches(batch_no, created_at)
            VALUES (i, now());
            PERFORM pg_sleep(0.05);
        END LOOP;

        UPDATE job_runs SET status = 'done', finished_at = now()
        WHERE job_name = 'daily_report';
    EXCEPTION WHEN OTHERS THEN
        UPDATE job_runs SET status = 'failed' WHERE job_name = 'daily_report';
        PERFORM pg_advisory_unlock(100017);
        RAISE;
    END;

    PERFORM pg_advisory_unlock(100017);
END
$$;
text
NOTICE:  job 17 already running, skip this round
DO

为什么必须是会话级:任务内部有多次 COMMIT(每批一个事务),事务级锁在第一批提交时就释放了,完全失去防重效果。

场景二:租户级串行 ​

需求:同一租户的操作必须串行,不同租户可以并行。这是顾问锁最漂亮的应用——用第一键做域,第二键做租户号。

sql
BEGIN;
-- 域 2000 = 租户结算;租户号 8842
SELECT pg_advisory_xact_lock(2000, 8842);

-- 同一租户的结算逻辑,天然串行
UPDATE tenant_balances SET balance = balance - 100 WHERE tenant_id = 8842;
INSERT INTO billing_records(tenant_id, amount) VALUES (8842, 100);

COMMIT;

不同租户并行验证:

sql
-- 租户 8842 与 8843 可以同时进行,互不阻塞
SELECT pg_try_advisory_xact_lock(2000, 8843);
text
 pg_try_advisory_xact_lock
---------------------------
 t
(1 row)

锁冲突范围可视化:

text
(2000, 8842)  ←→ (2000, 8842)   冲突
(2000, 8842)  ←→ (2000, 8843)   不冲突(第二键不同)
(2000, 8842)  ←→ (2001, 8842)   不冲突(第一键不同,不同业务域)

场景三:缓存防击穿 ​

热点缓存过期瞬间,成千上万个请求同时回源查库(缓存击穿,Cache Stampede)。顾问锁只放一个请求去查,其余等待或返回旧值。

sql
DO $$
DECLARE
    cache_key text := 'home:feed:9527';
    payload   jsonb;
    got       boolean;
BEGIN
    SELECT data INTO payload FROM cache_entries WHERE key = cache_key AND expires_at > now();
    IF payload IS NOT NULL THEN
        RAISE NOTICE 'cache hit';
        RETURN;
    END IF;

    -- 缓存未命中:尝试拿锁去回源
    got := pg_try_advisory_lock(hashtextextended('cache:' || cache_key, 0));
    IF NOT got THEN
        -- 别人正在回源,直接返回旧数据(或空),不要一起涌向数据库
        RAISE NOTICE 'another worker is rebuilding %, serve stale', cache_key;
        RETURN;
    END IF;

    BEGIN
        PERFORM pg_sleep(0.2);   -- 模拟回源查询

        INSERT INTO cache_entries(key, data, expires_at)
        VALUES (cache_key, jsonb_build_object('items', jsonb_build_array(1,2,3)), now() + interval '5 minutes')
        ON CONFLICT (key) DO UPDATE
            SET data = EXCLUDED.data, expires_at = EXCLUDED.expires_at,
                updated_at = now();
    EXCEPTION WHEN OTHERS THEN
        PERFORM pg_advisory_unlock(hashtextextended('cache:' || cache_key, 0));
        RAISE;
    END;

    PERFORM pg_advisory_unlock(hashtextextended('cache:' || cache_key, 0));
END
$$;
text
NOTICE:  another worker is rebuilding home:feed:9527, serve stale
DO

为什么用会话级:回源逻辑往往包含多个查询、可能不止一个事务,且需要「拿锁 → 查库 → 写缓存 → 解锁」这一整套跨事务动作。

场景四:消息顺序消费 ​

需求:同一业务键(如同一订单)的消息必须按顺序处理,不同业务键并行。典型的「按 key 分区串行」。

sql
BEGIN;
-- 第一键 3000 = 消息消费域;第二键用订单号取模压缩到 int 范围
SELECT pg_advisory_xact_lock(3000, ('x' || substr(md5('order:9527'), 1, 8))::bit(32)::int);

-- 消费该订单的消息(同订单的消息在此串行,其他订单并行)
UPDATE outbox_messages
   SET status = 'processing', started_at = now()
 WHERE id = (
     SELECT id FROM outbox_messages
      WHERE topic = 'order-events' AND aggregate_id = 9527 AND status = 'pending'
      ORDER BY id
      LIMIT 1
 );

-- 处理逻辑...

UPDATE outbox_messages SET status = 'done', finished_at = now()
 WHERE topic = 'order-events' AND aggregate_id = 9527 AND status = 'processing';

COMMIT;

把文本哈希成 32 位 int 的写法:

sql
SELECT ('x' || substr(md5('order:9527'), 1, 8))::bit(32)::int AS key2;
text
    key2
------------
 2031285284
(1 row)

也可以直接用 hashtext:

sql
SELECT hashtext('order:9527') AS key2;

不过 hashtext 在 32 位有符号范围内可正可负,作为键号没问题(顾问锁接受负的 int),但如果你在别处按「无符号」理解它就会出错。

场景五:迁移互斥 ​

多个部署节点的启动脚本都想执行同一套数据库迁移,必须互斥;且迁移脚本分批提交,耗时较长。

sql
DO $$
DECLARE
    got boolean;
BEGIN
    got := pg_try_advisory_lock(900001);   -- 固定迁移编号
    IF NOT got THEN
        RAISE NOTICE 'migration already running on another node, waiting...';
        -- 可选:改为阻塞等待并带超时
        PERFORM set_config('lock_timeout', '300s', true);
        PERFORM pg_advisory_lock(900001);
    END IF;

    BEGIN
        -- 迁移步骤 1(独立事务)
        CREATE TABLE IF NOT EXISTS schema_migrations_applied (
            version text PRIMARY KEY,
            applied_at timestamptz DEFAULT now()
        );

        -- 迁移步骤 2
        ALTER TABLE orders ADD COLUMN IF NOT EXISTS channel text;

        INSERT INTO schema_migrations_applied(version) VALUES ('2026_09_add_channel')
        ON CONFLICT (version) DO NOTHING;
    EXCEPTION WHEN OTHERS THEN
        PERFORM pg_advisory_unlock(900001);
        RAISE;
    END;

    PERFORM pg_advisory_unlock(900001);
    RAISE NOTICE 'migration finished';
END
$$;
text
NOTICE:  migration finished
DO

对应地,从外部观察迁移状态:

sql
SELECT pid, granted, mode FROM pg_locks
WHERE locktype = 'advisory'
  AND ((classid::bigint << 32) | objid::bigint) = 900001;
text
  pid  | granted |     mode
-------+---------+---------------
 23110 | t       | ExclusiveLock
(1 row)

场景六:限额发放(防超发) ​

秒杀、优惠券、配额发放的共同点:「检查余额」与「扣减余额」必须在同一个互斥区间内。

sql
BEGIN;
SET LOCAL lock_timeout = '2s';

-- 以「额度池 + 领取人」为键,防同一人重复领取
SELECT pg_advisory_xact_lock(4000, 8891);   -- 4000 = 额度池域,8891 = user_id

DO $$
DECLARE
    remaining integer;
BEGIN
    SELECT total - used INTO remaining FROM quota_pools WHERE pool_id = 501 FOR UPDATE;

    IF remaining IS NULL OR remaining <= 0 THEN
        RAISE EXCEPTION 'quota exhausted';
    END IF;

    IF EXISTS (SELECT 1 FROM quota_claims WHERE pool_id = 501 AND user_id = 8891) THEN
        RAISE EXCEPTION 'already claimed';
    END IF;

    INSERT INTO quota_claims(pool_id, user_id, claimed_at) VALUES (501, 8891, now());
    UPDATE quota_pools SET used = used + 1 WHERE pool_id = 501;
END
$$;

COMMIT;
text
ERROR:  already claimed

注意这里顾问锁与行锁并用:FOR UPDATE 保护余额行的原子更新,顾问锁保护「检查是否已领取 + 插入领取记录」这一软规则。两者并不重复,各管一段。

锁号规划:一张表管住所有键 ​

顾问锁事故的最大来源是撞号——两个模块用了同一个数字,互相阻塞却没人知道。建议在项目里维护一份锁号登记表(文档或常量类):

区间业务域形态说明
100000–109999定时任务bigint100000 + job_id
2000租户结算two-int第一键固定,第二键 = tenant_id
3000消息消费two-int第一键固定,第二键 = 业务键哈希
4000额度发放two-int第一键固定,第二键 = user_id
700000–799999用户级串行bigint700000 + user_id
900000–909999迁移bigint900000 + step
9000000+缓存键bigint由 hashtextextended 生成

配套 SQL 常量:

sql
-- 建议在应用层定义,此处仅为示意
-- LOCK_DOMAIN_TENANT_SETTLE = 2000
-- LOCK_DOMAIN_MSG_CONSUME  = 3000
-- LOCK_DOMAIN_QUOTA_GRANT  = 4000

PgBouncer 的坑 ​

PgBouncer 有三种池模式,对顾问锁影响截然不同:

池模式连接复用粒度对会话级锁的影响
session(会话池)客户端连接绑定一个服务端连接直至断开会话级锁的归属与客户端一致,相对安全
transaction(事务池)每个事务可能用不同的服务端连接会话级锁会跨客户端串味,极度危险
statement(语句池)每条语句都可能换连接同上,且更不可控

在 transaction 模式下,一段「会话级锁 + 多个事务」的代码会变成:

text
客户端 X:pg_advisory_lock(42)   → 落在服务端连接 S1
客户端 X:事务 1                  → 可能落在 S2
客户端 X:事务 2                  → 可能落在 S3
客户端 X:pg_advisory_unlock(42)  → 可能落在 S4(不在 S1 上!)→ 返回 f,锁泄漏在 S1
sql
-- 在 transaction 模式下,unlock 很可能返回 f
SELECT pg_advisory_unlock(42);
text
 pg_advisory_unlock
--------------------
 f
(1 row)

应对策略:

text
1. transaction 模式下禁用会话级顾问锁,全部改用事务级
2. 必须用会话级时,改用 session 池,或对这类连接使用 server_reset_query = DISCARD ALL
3. 会话级锁与业务事务尽量放在同一「客户端连接直连」的路径上

检查 PgBouncer 是否配置了重置:

ini
; pgbouncer.ini
[pgbouncer]
pool_mode = transaction
server_reset_query = DISCARD ALL
server_reset_query_always = 1

死锁风险:多锁乱序 ​

只要一个流程要同时持有多把顾问锁,就必须有统一顺序。

sql
-- 危险:转账双方按参数顺序加锁
CREATE OR REPLACE FUNCTION transfer_bad(from_id int, to_id int, amt numeric)
RETURNS void LANGUAGE plpgsql AS $$
BEGIN
    PERFORM pg_advisory_xact_lock(from_id);
    PERFORM pg_advisory_xact_lock(to_id);
    -- A→B 与 B→A 并发时死锁
END $$;
sql
-- 安全:先排序再加锁
CREATE OR REPLACE FUNCTION transfer_safe(from_id int, to_id int, amt numeric)
RETURNS void LANGUAGE plpgsql AS $$
BEGIN
    PERFORM pg_advisory_xact_lock(least(from_id, to_id));
    PERFORM pg_advisory_xact_lock(greatest(from_id, to_id));
    -- 无论调用方向如何,加锁顺序都一致
END $$;

死锁发生时 PostgreSQL 会主动检测并牺牲一个事务:

text
ERROR:  deadlock detected
DETAIL:  Process 23110 waits for ExclusiveLock on advisory lock [16384,0,42,1]; blocked by process 23111.
HINT:  See server log for query details.

可观测性:为顾问锁建监控 ​

sql
-- 1. 当前等待顾问锁的会话数
SELECT count(*) AS advisory_waiters
FROM pg_locks
WHERE locktype = 'advisory' AND NOT granted;

-- 2. 单会话持有顾问锁数量的 Top 10
SELECT pid, count(*) AS held
FROM pg_locks
WHERE locktype = 'advisory' AND granted
GROUP BY pid
ORDER BY held DESC
LIMIT 10;

-- 3. 空闲却持有顾问锁超过 5 分钟的会话(泄漏嫌疑)
SELECT l.pid, a.state, 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';

实战示例 ​

端到端:一个「幂等扣减 + 顺序保证」的完整流程 ​

sql
BEGIN;
SET LOCAL lock_timeout = '3s';

-- 1. 业务键级互斥(同一账户的操作串行)
SELECT pg_advisory_xact_lock(5000, 8891);

-- 2. 幂等检查
DO $$
BEGIN
    IF EXISTS (
        SELECT 1 FROM processed_requests
        WHERE request_id = 'req-abc-123'
    ) THEN
        RAISE EXCEPTION 'duplicate request';
    END IF;
END
$$;

-- 3. 用行锁保证余额原子性
UPDATE accounts SET balance = balance - 50 WHERE id = 8891 AND balance >= 50;

-- 4. 若上一句影响 0 行,说明余额不足
DO $$
BEGIN
    IF NOT FOUND THEN
        RAISE EXCEPTION 'insufficient balance';
    END IF;
END
$$;

-- 5. 记录已处理(幂等键)
INSERT INTO processed_requests(request_id, account_id, created_at)
VALUES ('req-abc-123', 8891, now());

COMMIT;
text
 pg_advisory_xact_lock
-----------------------

(1 row)

DO
UPDATE 1
DO
INSERT 0 1
COMMIT

观察「同一租户串行、不同租户并行」 ​

sql
-- 终端 1:租户 8842 持锁不放
BEGIN;
SELECT pg_advisory_xact_lock(2000, 8842);
sql
-- 终端 2:同一租户 → 阻塞
SET lock_timeout = '2s';
BEGIN;
SELECT pg_advisory_xact_lock(2000, 8842);
text
ERROR:  canceling statement due to lock timeout
sql
-- 终端 3:不同租户 → 立即成功
BEGIN;
SELECT pg_try_advisory_xact_lock(2000, 8843) AS other_tenant_ok;
text
 other_tenant_ok
-----------------
 t
(1 row)
sql
-- 终端 3 观察锁分布
SELECT classid AS domain, objid AS tenant_id, granted
FROM pg_locks
WHERE locktype = 'advisory' AND objsubid = 2
ORDER BY tenant_id;
text
 domain | tenant_id | granted
--------+-----------+---------
   2000 |      8842 | t
   2000 |      8842 | f
   2000 |      8843 | t
(3 rows)

观察缓存防击穿的效果 ​

sql
-- 会话 A:模拟回源中(持锁)
SELECT pg_advisory_lock(hashtextextended('cache:home:feed', 0));
SELECT pg_sleep(3);
sql
-- 会话 B、C、D 同时到达:都应快速失败,而不是一起查库
SELECT pg_try_advisory_lock(hashtextextended('cache:home:feed', 0)) AS can_rebuild;
text
 can_rebuild
-------------
 f
(1 row)

清理一个疑似的会话级锁泄漏 ​

sql
-- 1. 定位
SELECT l.pid, a.usename, a.state, 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'
ORDER BY idle_for DESC;
text
  pid  | usename | state |       idle_for        | key_readable
-------+---------+-------+-----------------------+--------------
 23110 | app     | idle  | 01:12:44.883219       | 100017
(1 row)
sql
-- 2. 优先尝试让该连接自己执行 pg_advisory_unlock_all();
--    若不可达,最后手段是终止连接(会连带杀掉它的其他工作)
SELECT pg_terminate_backend(23110);
text
 pg_terminate_backend
----------------------
 t
(1 row)
sql
-- 3. 确认已释放
SELECT count(*) FROM pg_locks
WHERE locktype = 'advisory'
  AND ((classid::bigint << 32) | objid::bigint) = 100017;
text
 count
-------
     0
(1 row)

注意事项 ​

  • 错误!把定时任务防重写成事务级锁。 任务跨多个事务,事务级锁在第一个 COMMIT 就没了,防重失效。
  • 错误!用顾问锁保护一张表的批量更新。 它不阻止任何读写,保护表数据要用锁或唯一约束。
  • 错误!在 PgBouncer transaction 模式下使用会话级顾问锁。 加锁与解锁可能落在不同服务端连接上,导致锁泄漏且 unlock 返回 f。
  • 错误!多把锁按业务参数顺序申请。 会触发 deadlock detected,必须全局统一按锁号顺序。
  • 不推荐用哈希后的字符串做资金类关键路径的锁号——碰撞会造成错误串行;优先用整数业务 ID。
  • 错误!以为顾问锁能跨数据库或跨集群生效。 它是每库本地的。
  • 不推荐用顾问锁替代唯一约束。唯一性应由数据库约束保证,顾问锁只解决约束表达不了的软规则。
  • 错误!遗忘异常路径的解锁。 会话级锁在 EXCEPTION 分支与正常分支都要释放。
  • 不推荐在持锁事务里做网络调用或大文件 IO,锁的持有时间等于事务时间,长事务会拖垮并发。

最佳实践 ​

  • 按判据选范围:单事务临界区用事务级;跨事务用会话级并显式管理释放。
  • 键号登记制度:维护锁号区间表,第一键固定表示业务域,第二键表示资源 ID。
  • 默认 try 版:pg_try_* + 明确失败分支(返回旧值、跳过、退避重试),优于无限阻塞。
  • SET LOCAL lock_timeout:在事务内设置,事务结束自动恢复,不污染连接。
  • 统一加锁顺序:多锁场景用 least()/greatest() 或排序后逐一加锁。
  • 监控四个指标:等待中顾问锁数、单会话持有数、idle 且持锁超时的会话、prepared 事务持有的锁。
  • 与行锁协同:顾问锁防「软规则竞态」,行锁保证「行原子更新」,二者叠加而非互相替代。
  • 连接池兼容性检查:使用会话级锁前,先确认池模式与 server_reset_query 配置。

下一节

继续学习:两阶段提交原理——prepared 事务正是「跨库原子提交」的钥匙,也是顾问锁在分布式场景下的延伸。

参考链接 ​