PostgreSQL Advisory Locks:应用级协调锁
分布式锁用 Redis?但如果你已有 PostgreSQL,Advisory Locks 直接在 DB 里做应用级锁——不需要额外中间件。本文讲清 Advisory Locks 的两种模式(session 级/transaction 级)、pg_advisory_lock vs try_lock、实际场景(并发任务调度/迁移/幂等),以及它和行锁的区别。
本文要点
- 主题聚焦:DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
一提到「分布式锁」,大多数人的第一反应是 Redis:SETNX、Redlock、Redisson。这套方案成熟,但要付出代价——你得再维护一套中间件,处理它的持久化、主从切换、网络抖动。
可如果你已经在用 PostgreSQL,事情可以更简单。PostgreSQL 自带一套应用级锁机制,叫 Advisory Locks(咨询锁)。它不锁任何数据,只锁一个由你定义的"名字",纯粹用来做多进程之间的协调信号。不需要 Redis,不需要 Zookeeper,单靠数据库就能把活干了。
这套锁在真实生产里非常有用:多 worker 抢任务、迁移脚本防重入、幂等控制、限流。但它的坑也真实存在——session 级锁忘记释放、连接池复用导致的"幽灵锁"、key 设计冲突。
这篇文章把 Advisory Locks 讲透:两种模式、四个核心函数、key 怎么设计、三个实战场景、它和行锁的本质区别、以及那些会让你半夜被叫起来的陷阱。
一、Advisory Locks 到底是什么
先建立一个最关键的认知:
Advisory Locks 不锁数据,它锁的是一个数字(key)。
这是它和所有其他 PostgreSQL 锁的根本区别。行锁(Row Lock)锁的是具体的某一行数据;表锁锁的是某张表;而 Advisory Lock,它锁的对象根本不存在于任何表里——它只存在于 PostgreSQL 内部的一个全局锁表里,标识就是一个 64 位整数(或者两个 32 位整数的组合)。
"Advisory"这个词本身就很说明问题——"咨询性的"。意思是 PostgreSQL 不会强制你用它,它不会自动给任何查询加这把锁。它只是提供了一个锁原语,至于你想拿它来协调什么、什么时候加、什么时候解,完全由你的应用决定。PostgreSQL 充当一个可信的第三方仲裁者。
一个最直白的例子:你想确保全系统同一时刻只有一个进程在跑某个定时任务。这个"任务"不是一行数据,它就是个抽象概念。你怎么锁它?传统行锁束手无策,因为你没有具体的行可以 SELECT FOR UPDATE。这时候 Advisory Lock 就上场了:
-- 抢"订单对账任务"这把锁
SELECT pg_advisory_lock(12345);
-- 拿到锁了,开始干活...
-- 干完,释放
SELECT pg_advisory_unlock(12345);
12345 这个数字的含义完全由你的应用定义。它可能代表"订单对账任务",也可能代表"用户 42 的资料"。PostgreSQL 不关心,它只保证:同一时刻只有一个会话能持有这个 key 的锁。
这就是 Advisory Locks 的全部精髓——一个不绑定任何数据的、全局可见的协调信号。
二、两种模式:Session 级与 Transaction 级
Advisory Locks 分两种生命周期模式,理解它们的差异是避免踩坑的前提。
Session 级(会话级)
Session 级锁一旦获取,会一直持有到数据库连接关闭,或者你显式调用 unlock。它和事务边界无关——你在事务 A 里加了锁,事务 A 提交了,锁还在;你得自己 unlock。
BEGIN;
SELECT pg_advisory_lock(100); -- 加锁
-- 事务里的操作
COMMIT;
-- 注意:COMMIT 之后,锁依然持有!
-- 必须显式释放:
SELECT pg_advisory_unlock(100);
这把锁的语义是"这个连接(session)独占了 key 100"。它的优势是灵活——可以跨多个事务持有;劣势是你必须自己管理生命周期,忘了 unlock 就是灾难。
Transaction 级(事务级)
Transaction 级锁的生命周期严格绑定在当前事务上。事务 COMMIT 或 ROLLBACK 时,锁自动释放,无需也无法手动 unlock。
BEGIN;
SELECT pg_advisory_xact_lock(100); -- 加事务级锁
-- 事务里的操作
COMMIT; -- 锁在这里自动释放
注意函数名里的 xact——transaction 的缩写。它的语义是"这把锁跟着这个事务走,事务结束即释放"。
该用哪个?我的建议
默认用 transaction 级。 原因很简单:它能保证锁一定会被释放。哪怕你的应用抛了异常、哪怕连接池在某个边界情况下回收了连接、哪怕你忘了写 unlock——只要事务结束(无论成功还是失败),锁就没了。这是"防呆设计"。
Session 级锁只在一种场景下才考虑:你需要锁跨越多个事务,或者干脆不在事务里用。比如某个长周期的后台任务,它的工作流天然是"拿到锁 → 干一堆零散的事 → 放锁",这种场景 session 级更合适。但用它的代价是,你必须极其小心地保证 unlock 一定被执行——理想情况下用 try/finally 包起来。
一个我反复见到的反模式:开发者图省事用 pg_advisory_lock(session 级),然后忘了 unlock,连接还回连接池,下一个请求复用这个连接时——锁还在。于是系统莫名其妙地"卡住",排查半天才发现是上一个请求留下的幽灵锁。这种 bug 极其隐蔽,因为表现是偶发性的、和当前请求逻辑完全无关。能用 transaction 级就别用 session 级,这是经验之谈。
三、四个核心函数
PostgreSQL 提供了一组函数来操作 Advisory Locks,记熟这四个就够了。
| 函数 | 阻塞 | 级别 | 返回值 |
|---|---|---|---|
pg_advisory_lock(key) | 阻塞等待 | Session | void |
pg_try_advisory_lock(key) | 非阻塞 | Session | boolean |
pg_advisory_xact_lock(key) | 阻塞等待 | Transaction | void |
pg_try_advisory_xact_lock(key) | 非阻塞 | Transaction | boolean |
两两组合:阻塞 vs 非阻塞 × session vs transaction。
阻塞型:pg_advisory_lock / pg_advisory_xact_lock
拿不到锁就一直等,直到拿到为止。适合"我必须做这件事,做不了就排队"的场景。
-- 阻塞等待,直到拿到锁
SELECT pg_advisory_xact_lock(7);
-- 走到这里说明已经拿到锁了
它的好处是简单,不用写轮询逻辑。但要小心:如果持有锁的会话 hang 住不释放,等待方会无限期阻塞。在生产环境里,长事务 + 阻塞锁是个危险组合。
非阻塞型:pg_try_advisory_lock / pg_try_advisory_xact_lock
拿不到锁就立刻返回 false,拿到了返回 true。绝不等待。适合"抢不到就放弃/干别的"的场景。
-- 尝试抢锁,拿不到立刻返回
SELECT pg_try_advisory_lock(7);
-- 拿到: true 拿不到: false
实战中,非阻塞型用得更多。原因:大多数协调场景的诉求是"快速判断能不能做",而不是"排队等"。比如多 worker 抢任务,抢不到的 worker 应该立刻去试别的任务,而不是傻等。
一个常见的应用层封装(伪代码):
# 抢锁,拿不到就跳过
got = query("SELECT pg_try_advisory_xact_lock(%s)", [task_lock_key])
if not got:
return # 别的 worker 在处理,跳过
# 拿到锁,在同一个事务里处理任务
process(task)
commit() # 锁随事务释放
注意:必须保证 pg_try_advisory_xact_lock 和后续的业务操作在同一个事务里,否则锁和业务脱节,就失去了协调的意义。
两套 key 签名
上面所有函数都有两个重载版本:
-- 单 bigint 版本:一个 64 位整数
pg_advisory_lock(bigint)
-- 双 int 版本:两个 32 位整数
pg_advisory_lock(int, int)
这两个版本是等价的(理论上 key1 * 2^32 + key2 能映射到同一个 64 位空间),但语义上更好用双 int 版本来表达"类型 + ID"的组合。比如 (1, user_id) 表示"用户类资源",(2, order_id) 表示"订单类资源"。下一节细说。
四、Key 怎么设计
Advisory Lock 的 key 就是一个数字,但它承载着"这把锁保护什么"的全部语义。设计 key 是用这套机制时最需要动脑子的地方。
单 bigint:简单粗暴
SELECT pg_advisory_lock(42);
适合 key 数量少、含义固定的场景。比如"系统里只有一把全局锁,用来串行化某个操作"。直接写死一个魔法数字就行。
但如果你有多个不同类型的锁,都用单 bigint,很快就会变成"42 是啥来着?77 呢?"——魔法数字地狱。这时要么用常量定义,要么换双 int。
双 int:(类型, ID) 的经典组合
-- key1 = 资源类型, key2 = 具体资源 ID
SELECT pg_advisory_lock(1, 1234); -- 类型 1(比如"用户"),ID 1234
SELECT pg_advisory_lock(2, 5678); -- 类型 2(比如"订单"),ID 5678
这是我最推荐的写法。第一个 int 当命名空间,第二个 int 当具体 ID。语义清晰,不容易冲突。
一个常见的工程实践是在应用层定义类型常量:
LOCK_TYPE_USER = 1
LOCK_TYPE_ORDER = 2
LOCK_TYPE_MIGRATE = 100
# 锁住用户 1234
query("SELECT pg_advisory_xact_lock(%s, %s)", [LOCK_TYPE_USER, 1234])
字符串 key?用哈希
Advisory Lock 不支持字符串 key。如果你天然有一个字符串标识(比如任务名 "sync_inventory"),需要先哈希成整数:
-- 用 PostgreSQL 内置的哈希
SELECT pg_advisory_lock(hashtext('sync_inventory'));
注意 hashtext 返回 int4(32 位),会有碰撞概率。对于锁来说通常可以接受(碰撞的极端情况就是偶尔误等,不会数据损坏),但如果你偏执,可以:
-- 拆成两个 int4,用 (namespace, hash) 组合
SELECT pg_advisory_lock(100, hashtext('sync_inventory'));
100 当命名空间,把所有"任务名哈希锁"圈在一起,避免和别的用途撞车。
关键原则:全局命名空间
Advisory Lock 的 key 是全库全局的,不分表、不分 schema。所有应用共享这同一个 64 位空间。所以你必须有一套应用内部的命名约定,防止不同模块撞 key。
实战建议:建一张"锁 key 字典"表,把每个 key 的含义、所属模块、负责团队登记下来。这个表不参与运行时逻辑,纯粹是文档作用。等三个月后有人排查"这个 9527 是啥锁",你会感谢自己。
五、三个实战场景
理论讲完,看 Advisory Locks 在真实场景里怎么用。
场景 1:并发任务调度
最经典的用例。你有 N 个 worker 进程,从一个任务队列里抢任务执行。要求:同一个任务同一时刻只能被一个 worker 处理。
朴素做法是 SELECT ... FOR UPDATE SKIP LOCKED(这本身也是好东西,后面会对比),但有时候你的任务"锁"的逻辑比行锁更复杂——比如一个任务对应多个表的多次写入,或者你想锁的是"任务类型 + 时间窗口"这种抽象维度。这时 Advisory Lock 更合适。
-- Worker 抢任务的伪流程
BEGIN;
-- 用 (任务类型, 任务 ID) 作为锁 key
SELECT pg_try_advisory_xact_lock(task_type, task_id) AS got;
-- 根据结果决定
DO $$
BEGIN
IF NOT (SELECT got) THEN
RAISE EXCEPTION 'task locked by other worker';
END IF;
END $$;
-- 拿到锁了,处理任务(写各种表)
UPDATE tasks SET status = 'running', worker_id = $worker_id
WHERE id = $task_id;
-- ... 业务逻辑 ...
UPDATE tasks SET status = 'done' WHERE id = $task_id;
COMMIT; -- 锁自动释放,别的 worker 可以接下一个同 key 任务了
关键点:
- 用
pg_try_advisory_xact_lock非阻塞抢锁,抢不到立刻放弃,去试下一个任务。 - 锁和业务在同一个事务里,保证"持锁期间处理任务,任务结束锁释放"。
- 用
(task_type, task_id)作为 key,粒度精准。
这个模式的好处是:哪怕你的任务逻辑涉及多张表、多次写入,只要锁在,别的 worker 就进不来。比单纯靠 UPDATE ... WHERE status = 'pending' 的"乐观抢"更可靠——后者在并发下还是会有边界问题。
场景 2:迁移脚本防重入
数据库迁移脚本(migration)必须串行执行,否则结构变更互相踩踏、数据错乱。但你不能保证运维不会手滑同时跑两次 migrate,或者 CI 跑了一半超时了又被重试触发了一次。
加一把迁移锁:
-- 迁移脚本开头
SELECT pg_advisory_lock(99999); -- 99999 = "数据库迁移"专用 key
-- ... 一堆 DDL / 数据迁移 ...
-- 脚本结尾
SELECT pg_advisory_unlock(99999);
这里故意用 session 级而不是 transaction 级。为什么?因为迁移脚本通常不在单个事务里跑(有些 DDL 在事务里有问题,或者你想让迁移可以分步提交)。session 级锁跨整个脚本生命周期,连接关了才释放,正好覆盖"脚本从开始到结束"。
但要注意:必须保证脚本异常退出时连接真的关了。如果用 Bash 包一层:
psql -c "SELECT pg_advisory_lock(99999)"
psql -f migration.sql
psql -c "SELECT pg_advisory_unlock(99999)" # 理想情况下执行
这个写法是错的——三个 psql 是三个独立连接,第一个连接加锁后立刻关闭(session 结束,锁释放),根本没起到保护作用。正确做法是把锁和迁移放在同一个连接里:
psql <<EOF
SELECT pg_advisory_lock(99999);
\i migration.sql
SELECT pg_advisory_unlock(99999);
EOF
或者用 pg_advisory_xact_lock 包在事务里(如果你的迁移支持事务)。这是个经典的坑,初学者很容易踩。
场景 3:幂等控制
某个接口/任务可能被重复触发(用户连点、MQ 重投递、重试机制),但业务要求只执行一次。常见的幂等方案是"唯一约束 + INSERT",但有时候你不想引入额外的去重表,或者幂等的判断维度比较复杂。
Advisory Lock 提供了一种轻量的"短期幂等":
-- 处理某个幂等性敏感的操作
BEGIN;
SELECT pg_try_advisory_xact_lock(operation_type, business_id);
-- 如果拿到锁,说明当前没有其他请求在处理同一个 business_id 的这个操作
-- (注意:这只防并发,不防重放,见下文)
process_business(business_id);
COMMIT;
重要警告:Advisory Lock 防的是并发,不是重放。如果两个相同请求前后脚到达(第一个已经处理完、锁已释放),第二个依然会拿到锁再处理一次。要防重放,必须配合持久化的幂等记录(一张 processed_events 表,唯一约束在 event_id 上)。
Advisory Lock 在幂等场景里的真正价值是:处理中的那段时间内,防止并发重复处理。它是幂等体系里的一层"快速短路",不是全部。完整的幂等方案通常是:
BEGIN;
-- 1. Advisory Lock 防并发(快速短路)
SELECT pg_try_advisory_xact_lock(event_type, event_id);
-- 2. 查幂等表防重放
SELECT 1 FROM processed_events WHERE event_id = $1;
-- 3. 业务处理
process();
-- 4. 记录已处理
INSERT INTO processed_events(event_id) VALUES ($1);
COMMIT;
两层防护,各司其职。
六、和行锁(SELECT FOR UPDATE)的区别
很多人会把 Advisory Lock 和 SELECT FOR UPDATE 混在一起,觉得都是"锁"。但它们的定位完全不同,混用会出问题。
行锁:锁的是数据本身
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- 这一行被锁了,别的事务不能 FOR UPDATE / UPDATE / DELETE 它
-- 直到当前事务 COMMIT
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
行锁是 MVCC 体系的一部分,它直接绑定在具体的行上,目的是保证并发事务对同一行数据的修改串行化。它是 ACID 的组成部分,PostgreSQL 自动管理,你不需要(也没法)手动释放——事务结束自动释放。
Advisory Lock:锁的是协调信号
BEGIN;
SELECT pg_advisory_xact_lock(12345);
-- 12345 这把锁被持有了,但没有锁任何具体的数据
-- 别的事务可以正常读写 accounts 表的所有行
COMMIT;
Advisory Lock 不锁任何数据。它只是个信号灯,告诉其他"也来抢 12345 这把锁"的会话:"我在用,你等着/放弃吧"。它和表里的数据没有任何耦合。
一张对比表
| 维度 | 行锁 (FOR UPDATE) | Advisory Lock |
|---|---|---|
| 锁对象 | 具体的数据行 | 一个数字 key |
| 是否保护数据 | 是,阻止并发修改 | 否,纯协调信号 |
| 释放时机 | 事务结束自动 | 事务级自动 / session 级手动 |
| 必须在事务里 | 是 | session 级不必 |
| 性能开销 | 与行数、索引相关 | 很轻,纯内存锁表 |
| 适用场景 | 数据一致性 | 应用协调逻辑 |
什么时候用哪个?
用行锁:当你需要保护具体数据不被并发修改时。比如转账——必须锁住账户行,防止两个事务同时扣减导致余额错乱。这是行锁的本职工作,Advisory Lock 替代不了(它不防数据修改)。
用 Advisory Lock:当你的协调对象不是具体数据,而是某种"逻辑意图"或"抽象资源"时。比如"同一时刻只允许一个全局索引重建任务在跑"——索引重建不是一个数据行,锁不了,只能用 Advisory Lock。
一个判断技巧:问自己"我锁的对象在数据库里有对应的行吗?"——有,优先考虑行锁;没有,或者锁的维度超出单行(比如"某类操作"),用 Advisory Lock。
很多时候两者配合使用效果最好。比如任务调度场景:Advisory Lock 做"快速抢锁判断"(避免大量 worker 涌入),行锁做"任务行的状态变更保护"(保证数据一致性)。
七、五个陷阱
最后讲坑。Advisory Locks 用好了是利器,用不好是定时炸弹。
陷阱 1:Session 级锁忘记释放
最大也是最经典的坑。pg_advisory_lock 加了锁,但代码路径异常退出,没走到 unlock。锁就一直挂着,直到连接关闭。
-- 反例
SELECT pg_advisory_lock(100);
-- ... 这里抛异常了 ...
SELECT pg_advisory_unlock(100); -- 没执行到
如果用的是连接池(几乎所有生产环境都是),这个连接会被复用,下一个请求在这个连接上发现"咦,我莫名其妙持有锁 100"。于是系统出现各种诡异的串行化行为。
对策:
- 能用
pg_advisory_xact_lock就别用pg_advisory_lock。 - 实在要用 session 级,必须用 try/finally 保证 unlock 一定执行。
- 监控
pg_locks视图,发现长期持有的 advisory 锁要警觉:
-- 查看当前持有的 advisory 锁
SELECT pid, locktype, objid, granted, query_start, state, query
FROM pg_locks
JOIN pg_stat_activity USING (pid)
WHERE locktype = 'advisory';
陷阱 2:连接池下的 session 锁泄漏
这是陷阱 1 的变种。很多 ORM/连接池(比如 PgBouncer 在 transaction pooling 模式下)会在事务结束后重置连接状态,但 Advisory Lock 的 session 级锁不一定被重置(取决于具体实现和配置)。这就可能出现:你以为事务结束了锁该释放,结果锁跟着连接漂到下一个请求去了。
对策:
- 在 PgBouncer transaction pooling 模式下,不要用 session 级 Advisory Lock,只用 transaction 级。
- 如果你需要 session 级语义,PgBouncer 必须配 session pooling 模式。
陷阱 3:Key 冲突
不同模块的开发者各自挑了一个"看起来没人用的数字"当 key,结果撞车了。表现为:明明不相关的两个功能,偶尔会互相阻塞,排查极其困难,因为表面上看代码没有任何关联。
对策:
- 建立全局 key 字典(前面提过)。
- 用双 int 版本,第一个 int 强制当模块/命名空间标识。
- Code review 时把 Advisory Lock 的 key 当作公共资源审查。
陷阱 4:死锁
和行锁一样,Advisory Lock 也会死锁。如果两个会话以不同顺序抢多把锁,就会形成循环等待。
-- 会话 A
SELECT pg_advisory_lock(1);
SELECT pg_advisory_lock(2); -- 等 B 释放 2
-- 会话 B(并发)
SELECT pg_advisory_lock(2);
SELECT pg_advisory_lock(1); -- 等 A 释放 1
-- 死锁!PostgreSQL 会检测到并 abort 其中一个
好消息是 PostgreSQL 有死锁检测器,会在 deadlock_timeout(默认 1 秒)后自动 abort 一个会话,不会永远卡死。但坏消息是你依然会收到一个 ERROR: deadlock detected,业务受影响。
对策:和行锁死锁一样——保证加锁顺序全局一致。如果所有代码都按 1 → 2 → 3 的顺序加锁,就不会死锁。
陷阱 5:把 Advisory Lock 当数据锁用
这是认知层面的坑。有人写:
-- 想防止别人修改账户 1
SELECT pg_advisory_lock(1);
-- ... 但别的会话依然可以 UPDATE accounts WHERE id = 1 ...
Advisory Lock 不阻止任何数据操作。别的会话照样能 UPDATE/DELETE,因为这把锁和 accounts 表没有任何关系。你以为锁住了,其实数据早就被改了。
对策:刻在脑子里——Advisory Lock 不保护数据,只做协调。要保护数据,用行锁(FOR UPDATE)、用约束、用触发器。Advisory Lock 是补充,不是替代。
小结
写到这里,把要点收一收:
-
Advisory Locks 是应用级锁,不是数据锁。 它锁的是一个由你定义的数字 key,纯粹用来做多进程协调。PostgreSQL 只是个可信的仲裁者,不关心这个 key 代表什么。
-
优先用 transaction 级(
pg_advisory_xact_lock)。 它随事务自动释放,不会因为忘记 unlock 而泄漏。Session 级只在需要跨事务持锁时才用,并且必须用 try/finally 严防死守。 -
非阻塞
pg_try_advisory_*在实战中用得更多。 抢不到立刻返回 false,适合"快速判断能不能做"的场景。阻塞型pg_advisory_*适合"必须排队等"的场景,但要警惕无限期阻塞。 -
Key 设计是工程问题,不是技术问题。 用双 int 版本(命名空间 + ID),维护一份全局 key 字典,避免模块间冲突。字符串 key 用
hashtext转 int。 -
它和行锁定位不同,配合使用最好。 行锁保护数据一致性(ACID 本职),Advisory Lock 做应用协调(抢任务、防重入、限流)。判断标准:锁的对象在表里有对应行吗?有优先行锁,没有用 Advisory Lock。
-
陷阱集中在"生命周期"和"误用"上。 Session 锁泄漏、连接池模式下行为异常、key 冲突、死锁、当数据锁用——前五个靠纪律和监控规避,最后一个靠正确认知。
最后送一句我反复跟团队强调的话:
Advisory Locks 是协调信号,不是数据护栏。你想用它挡住并发改数据,就是用错了工具;你想用它让一群进程有序地抢活干,它就是最趁手的那把锁。
别动不动就上 Redis。先看看手边的 PostgreSQL 能不能搞定。
Related
相关推荐
文章
Docker 部署 Next.js 社区项目的注意事项
围绕 DevOps、Next.js 的技术趋势、工程实践和独立开发视角。
文章
OpenTelemetry 如何帮助你观察 AI 应用?
围绕 DevOps、AI 的技术趋势、工程实践和独立开发视角。
文章
长运行工具调用:Operation、Poll 与 Cancel 的终态协议
初始请求超时后,工具可能仍在服务端运行;发出取消也不等于副作用已停止。本文为单次长运行工具调用建立 operation 身份、轮询水位、取消竞态和唯一终态接受点。
文章
Agent 记忆删除传播:从用户遗忘请求到可验证清除
删除主表一行不会清掉向量、摘要、缓存和恢复后的备份。本文用删除状态机、代际墓碑、派生血缘与端到端 canary 验证真正遗忘。