顾问锁应用场景(Advisory Lock Use Cases)
顾问锁的价值不在语法——它总共只有十几个函数,半小时就能背完。价值在于你知道什么时候该用它。本页把生产环境中最常见、也最容易踩坑的几类场景逐一带代码讲清:定时任务防重、租户级串行、缓存防击穿、消息顺序消费、迁移互斥、限额发放,最后给出锁号规划方法与 PgBouncer 下的坑。
基础概念
场景选择的三条判据
面对一个并发问题,先回答三个问题,就能判断顾问锁是否合适:
问题一:要保护的「资源」在数据库里有对应的行吗?
→ 有,且是「更新已有行」 → 优先行锁(SELECT ... FOR UPDATE)
→ 没有,或「资源还不存在」 → 顾问锁合适
问题二:临界区能放进一个事务吗?
→ 能 → 用事务级(pg_advisory_xact_lock)
→ 不能(跨事务、耗时长) → 用会话级(pg_advisory_lock),并承担手动释放责任
问题三:互斥范围是「单个 PostgreSQL 集群」吗?
→ 是 → 顾问锁合适
→ 否(跨集群/跨数据库/跨存储) → 需要外部分布式锁(Redis/ZooKeeper)或专门的协调服务六类典型场景速览
| 场景 | 推荐范围 | 键的构成 | 关键点 |
|---|---|---|---|
| 定时任务防重 | 会话级 | 任务编号 + job_id | 任务跨事务、耗时长 |
| 租户级串行 | 事务级 | (域编号, tenant_id) | 同租户串行、跨租户并行 |
| 缓存防击穿 | 会话级或事务级 | 资源哈希 | 只放一个请求查库 |
| 消息顺序消费 | 事务级 | (topic编号, partition/业务键) | 同键串行、他键并行 |
| 迁移互斥 | 会话级 | 固定迁移编号 | 多批次提交、全局唯一 |
| 限额发放 | 事务级 | (额度池编号, 对象ID) | 检查-扣减必须在同事务 |
反模式:什么时候不该用顾问锁
✗ 保护一张表的批量更新 → 用行锁或表锁,顾问锁不会阻止任何人读写
✗ 跨集群互斥 → 顾问锁是每库本地的
✗ 需要「锁过期自动释放」的 TTL 语义 → 顾问锁没有 TTL,只有连接/事务边界
✗ 需要可观测的锁队列、优先级 → 顾问锁 FIFO 但不暴露队列
✗ 替代唯一约束 → 那应该建唯一索引
✗ 分布式选主(多集群) → 单集群可以用,多集群不行语法与用法
通用骨架
事务级(推荐默认):
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}); -- 必须执行一个可复用的最小示例
-- 事务级:同一订单号只允许一个并发处理
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; got
-----
t
(1 row)
UPDATE 1
COMMIT检查锁状态的通用查询
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 任务,必须保证同一时刻只有一个在跑。
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
$$;NOTICE: job 17 already running, skip this round
DO为什么必须是会话级:任务内部有多次 COMMIT(每批一个事务),事务级锁在第一批提交时就释放了,完全失去防重效果。
场景二:租户级串行
需求:同一租户的操作必须串行,不同租户可以并行。这是顾问锁最漂亮的应用——用第一键做域,第二键做租户号。
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;不同租户并行验证:
-- 租户 8842 与 8843 可以同时进行,互不阻塞
SELECT pg_try_advisory_xact_lock(2000, 8843); pg_try_advisory_xact_lock
---------------------------
t
(1 row)锁冲突范围可视化:
(2000, 8842) ←→ (2000, 8842) 冲突
(2000, 8842) ←→ (2000, 8843) 不冲突(第二键不同)
(2000, 8842) ←→ (2001, 8842) 不冲突(第一键不同,不同业务域)场景三:缓存防击穿
热点缓存过期瞬间,成千上万个请求同时回源查库(缓存击穿,Cache Stampede)。顾问锁只放一个请求去查,其余等待或返回旧值。
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
$$;NOTICE: another worker is rebuilding home:feed:9527, serve stale
DO为什么用会话级:回源逻辑往往包含多个查询、可能不止一个事务,且需要「拿锁 → 查库 → 写缓存 → 解锁」这一整套跨事务动作。
场景四:消息顺序消费
需求:同一业务键(如同一订单)的消息必须按顺序处理,不同业务键并行。典型的「按 key 分区串行」。
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 的写法:
SELECT ('x' || substr(md5('order:9527'), 1, 8))::bit(32)::int AS key2; key2
------------
2031285284
(1 row)也可以直接用 hashtext:
SELECT hashtext('order:9527') AS key2;不过 hashtext 在 32 位有符号范围内可正可负,作为键号没问题(顾问锁接受负的 int),但如果你在别处按「无符号」理解它就会出错。
场景五:迁移互斥
多个部署节点的启动脚本都想执行同一套数据库迁移,必须互斥;且迁移脚本分批提交,耗时较长。
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
$$;NOTICE: migration finished
DO对应地,从外部观察迁移状态:
SELECT pid, granted, mode FROM pg_locks
WHERE locktype = 'advisory'
AND ((classid::bigint << 32) | objid::bigint) = 900001; pid | granted | mode
-------+---------+---------------
23110 | t | ExclusiveLock
(1 row)场景六:限额发放(防超发)
秒杀、优惠券、配额发放的共同点:「检查余额」与「扣减余额」必须在同一个互斥区间内。
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;ERROR: already claimed注意这里顾问锁与行锁并用:FOR UPDATE 保护余额行的原子更新,顾问锁保护「检查是否已领取 + 插入领取记录」这一软规则。两者并不重复,各管一段。
锁号规划:一张表管住所有键
顾问锁事故的最大来源是撞号——两个模块用了同一个数字,互相阻塞却没人知道。建议在项目里维护一份锁号登记表(文档或常量类):
| 区间 | 业务域 | 形态 | 说明 |
|---|---|---|---|
100000–109999 | 定时任务 | bigint | 100000 + job_id |
2000 | 租户结算 | two-int | 第一键固定,第二键 = tenant_id |
3000 | 消息消费 | two-int | 第一键固定,第二键 = 业务键哈希 |
4000 | 额度发放 | two-int | 第一键固定,第二键 = user_id |
700000–799999 | 用户级串行 | bigint | 700000 + user_id |
900000–909999 | 迁移 | bigint | 900000 + step |
9000000+ | 缓存键 | bigint | 由 hashtextextended 生成 |
配套 SQL 常量:
-- 建议在应用层定义,此处仅为示意
-- LOCK_DOMAIN_TENANT_SETTLE = 2000
-- LOCK_DOMAIN_MSG_CONSUME = 3000
-- LOCK_DOMAIN_QUOTA_GRANT = 4000PgBouncer 的坑
PgBouncer 有三种池模式,对顾问锁影响截然不同:
| 池模式 | 连接复用粒度 | 对会话级锁的影响 |
|---|---|---|
| session(会话池) | 客户端连接绑定一个服务端连接直至断开 | 会话级锁的归属与客户端一致,相对安全 |
| transaction(事务池) | 每个事务可能用不同的服务端连接 | 会话级锁会跨客户端串味,极度危险 |
| statement(语句池) | 每条语句都可能换连接 | 同上,且更不可控 |
在 transaction 模式下,一段「会话级锁 + 多个事务」的代码会变成:
客户端 X:pg_advisory_lock(42) → 落在服务端连接 S1
客户端 X:事务 1 → 可能落在 S2
客户端 X:事务 2 → 可能落在 S3
客户端 X:pg_advisory_unlock(42) → 可能落在 S4(不在 S1 上!)→ 返回 f,锁泄漏在 S1-- 在 transaction 模式下,unlock 很可能返回 f
SELECT pg_advisory_unlock(42); pg_advisory_unlock
--------------------
f
(1 row)应对策略:
1. transaction 模式下禁用会话级顾问锁,全部改用事务级
2. 必须用会话级时,改用 session 池,或对这类连接使用 server_reset_query = DISCARD ALL
3. 会话级锁与业务事务尽量放在同一「客户端连接直连」的路径上检查 PgBouncer 是否配置了重置:
; pgbouncer.ini
[pgbouncer]
pool_mode = transaction
server_reset_query = DISCARD ALL
server_reset_query_always = 1死锁风险:多锁乱序
只要一个流程要同时持有多把顾问锁,就必须有统一顺序。
-- 危险:转账双方按参数顺序加锁
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 $$;-- 安全:先排序再加锁
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 会主动检测并牺牲一个事务:
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.可观测性:为顾问锁建监控
-- 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';实战示例
端到端:一个「幂等扣减 + 顺序保证」的完整流程
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; pg_advisory_xact_lock
-----------------------
(1 row)
DO
UPDATE 1
DO
INSERT 0 1
COMMIT观察「同一租户串行、不同租户并行」
-- 终端 1:租户 8842 持锁不放
BEGIN;
SELECT pg_advisory_xact_lock(2000, 8842);-- 终端 2:同一租户 → 阻塞
SET lock_timeout = '2s';
BEGIN;
SELECT pg_advisory_xact_lock(2000, 8842);ERROR: canceling statement due to lock timeout-- 终端 3:不同租户 → 立即成功
BEGIN;
SELECT pg_try_advisory_xact_lock(2000, 8843) AS other_tenant_ok; other_tenant_ok
-----------------
t
(1 row)-- 终端 3 观察锁分布
SELECT classid AS domain, objid AS tenant_id, granted
FROM pg_locks
WHERE locktype = 'advisory' AND objsubid = 2
ORDER BY tenant_id; domain | tenant_id | granted
--------+-----------+---------
2000 | 8842 | t
2000 | 8842 | f
2000 | 8843 | t
(3 rows)观察缓存防击穿的效果
-- 会话 A:模拟回源中(持锁)
SELECT pg_advisory_lock(hashtextextended('cache:home:feed', 0));
SELECT pg_sleep(3);-- 会话 B、C、D 同时到达:都应快速失败,而不是一起查库
SELECT pg_try_advisory_lock(hashtextextended('cache:home:feed', 0)) AS can_rebuild; can_rebuild
-------------
f
(1 row)清理一个疑似的会话级锁泄漏
-- 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; pid | usename | state | idle_for | key_readable
-------+---------+-------+-----------------------+--------------
23110 | app | idle | 01:12:44.883219 | 100017
(1 row)-- 2. 优先尝试让该连接自己执行 pg_advisory_unlock_all();
-- 若不可达,最后手段是终止连接(会连带杀掉它的其他工作)
SELECT pg_terminate_backend(23110); pg_terminate_backend
----------------------
t
(1 row)-- 3. 确认已释放
SELECT count(*) FROM pg_locks
WHERE locktype = 'advisory'
AND ((classid::bigint << 32) | objid::bigint) = 100017; 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 事务正是「跨库原子提交」的钥匙,也是顾问锁在分布式场景下的延伸。