#DevOps

PostgreSQL Advisory Locks:应用级协调锁

分布式锁用 Redis?但如果你已有 PostgreSQL,Advisory Locks 直接在 DB 里做应用级锁——不需要额外中间件。本文讲清 Advisory Locks 的两种模式(session 级/transaction 级)、pg_advisory_lock vs try_lock、实际场景(并发任务调度/迁移/幂等),以及它和行锁的区别。

本文要点

  • 主题聚焦:DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年1月26日·更新于 2026年7月23日

一提到「分布式锁」,大多数人的第一反应是 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 就上场了:

sql
-- 抢"订单对账任务"这把锁
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。

sql
BEGIN;
SELECT pg_advisory_lock(100);  -- 加锁
-- 事务里的操作
COMMIT;
-- 注意:COMMIT 之后,锁依然持有!
-- 必须显式释放:
SELECT pg_advisory_unlock(100);

这把锁的语义是"这个连接(session)独占了 key 100"。它的优势是灵活——可以跨多个事务持有;劣势是你必须自己管理生命周期,忘了 unlock 就是灾难。

Transaction 级(事务级)

Transaction 级锁的生命周期严格绑定在当前事务上。事务 COMMIT 或 ROLLBACK 时,锁自动释放,无需也无法手动 unlock。

sql
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)阻塞等待Sessionvoid
pg_try_advisory_lock(key)非阻塞Sessionboolean
pg_advisory_xact_lock(key)阻塞等待Transactionvoid
pg_try_advisory_xact_lock(key)非阻塞Transactionboolean

两两组合:阻塞 vs 非阻塞 × session vs transaction。

阻塞型:pg_advisory_lock / pg_advisory_xact_lock

拿不到锁就一直等,直到拿到为止。适合"我必须做这件事,做不了就排队"的场景。

sql
-- 阻塞等待,直到拿到锁
SELECT pg_advisory_xact_lock(7);
-- 走到这里说明已经拿到锁了

它的好处是简单,不用写轮询逻辑。但要小心:如果持有锁的会话 hang 住不释放,等待方会无限期阻塞。在生产环境里,长事务 + 阻塞锁是个危险组合。

非阻塞型:pg_try_advisory_lock / pg_try_advisory_xact_lock

拿不到锁就立刻返回 false,拿到了返回 true。绝不等待。适合"抢不到就放弃/干别的"的场景。

sql
-- 尝试抢锁,拿不到立刻返回
SELECT pg_try_advisory_lock(7);
-- 拿到: true   拿不到: false

实战中,非阻塞型用得更多。原因:大多数协调场景的诉求是"快速判断能不能做",而不是"排队等"。比如多 worker 抢任务,抢不到的 worker 应该立刻去试别的任务,而不是傻等。

一个常见的应用层封装(伪代码):

python
# 抢锁,拿不到就跳过
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 签名

上面所有函数都有两个重载版本:

sql
-- 单 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:简单粗暴

sql
SELECT pg_advisory_lock(42);

适合 key 数量少、含义固定的场景。比如"系统里只有一把全局锁,用来串行化某个操作"。直接写死一个魔法数字就行。

但如果你有多个不同类型的锁,都用单 bigint,很快就会变成"42 是啥来着?77 呢?"——魔法数字地狱。这时要么用常量定义,要么换双 int。

双 int:(类型, ID) 的经典组合

sql
-- key1 = 资源类型, key2 = 具体资源 ID
SELECT pg_advisory_lock(1, 1234);  -- 类型 1(比如"用户"),ID 1234
SELECT pg_advisory_lock(2, 5678);  -- 类型 2(比如"订单"),ID 5678

这是我最推荐的写法。第一个 int 当命名空间,第二个 int 当具体 ID。语义清晰,不容易冲突。

一个常见的工程实践是在应用层定义类型常量:

python
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"),需要先哈希成整数:

sql
-- 用 PostgreSQL 内置的哈希
SELECT pg_advisory_lock(hashtext('sync_inventory'));

注意 hashtext 返回 int4(32 位),会有碰撞概率。对于锁来说通常可以接受(碰撞的极端情况就是偶尔误等,不会数据损坏),但如果你偏执,可以:

sql
-- 拆成两个 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 更合适。

sql
-- 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 任务了

关键点:

  1. 用 pg_try_advisory_xact_lock 非阻塞抢锁,抢不到立刻放弃,去试下一个任务。
  2. 锁和业务在同一个事务里,保证"持锁期间处理任务,任务结束锁释放"。
  3. 用 (task_type, task_id) 作为 key,粒度精准。

这个模式的好处是:哪怕你的任务逻辑涉及多张表、多次写入,只要锁在,别的 worker 就进不来。比单纯靠 UPDATE ... WHERE status = 'pending' 的"乐观抢"更可靠——后者在并发下还是会有边界问题。

场景 2:迁移脚本防重入

数据库迁移脚本(migration)必须串行执行,否则结构变更互相踩踏、数据错乱。但你不能保证运维不会手滑同时跑两次 migrate,或者 CI 跑了一半超时了又被重试触发了一次。

加一把迁移锁:

sql
-- 迁移脚本开头
SELECT pg_advisory_lock(99999);  -- 99999 = "数据库迁移"专用 key

-- ... 一堆 DDL / 数据迁移 ...

-- 脚本结尾
SELECT pg_advisory_unlock(99999);

这里故意用 session 级而不是 transaction 级。为什么?因为迁移脚本通常不在单个事务里跑(有些 DDL 在事务里有问题,或者你想让迁移可以分步提交)。session 级锁跨整个脚本生命周期,连接关了才释放,正好覆盖"脚本从开始到结束"。

但要注意:必须保证脚本异常退出时连接真的关了。如果用 Bash 包一层:

bash
psql -c "SELECT pg_advisory_lock(99999)"
psql -f migration.sql
psql -c "SELECT pg_advisory_unlock(99999)"  # 理想情况下执行

这个写法是错的——三个 psql 是三个独立连接,第一个连接加锁后立刻关闭(session 结束,锁释放),根本没起到保护作用。正确做法是把锁和迁移放在同一个连接里:

bash
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 提供了一种轻量的"短期幂等":

sql
-- 处理某个幂等性敏感的操作
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 在幂等场景里的真正价值是:处理中的那段时间内,防止并发重复处理。它是幂等体系里的一层"快速短路",不是全部。完整的幂等方案通常是:

sql
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 混在一起,觉得都是"锁"。但它们的定位完全不同,混用会出问题。

行锁:锁的是数据本身

sql
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:锁的是协调信号

sql
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。锁就一直挂着,直到连接关闭。

sql
-- 反例
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 锁要警觉:
sql
-- 查看当前持有的 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 也会死锁。如果两个会话以不同顺序抢多把锁,就会形成循环等待。

sql
-- 会话 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 当数据锁用

这是认知层面的坑。有人写:

sql
-- 想防止别人修改账户 1
SELECT pg_advisory_lock(1);
-- ... 但别的会话依然可以 UPDATE accounts WHERE id = 1 ...

Advisory Lock 不阻止任何数据操作。别的会话照样能 UPDATE/DELETE,因为这把锁和 accounts 表没有任何关系。你以为锁住了,其实数据早就被改了。

对策:刻在脑子里——Advisory Lock 不保护数据,只做协调。要保护数据,用行锁(FOR UPDATE)、用约束、用触发器。Advisory Lock 是补充,不是替代。


小结

写到这里,把要点收一收:

  1. Advisory Locks 是应用级锁,不是数据锁。 它锁的是一个由你定义的数字 key,纯粹用来做多进程协调。PostgreSQL 只是个可信的仲裁者,不关心这个 key 代表什么。

  2. 优先用 transaction 级(pg_advisory_xact_lock)。 它随事务自动释放,不会因为忘记 unlock 而泄漏。Session 级只在需要跨事务持锁时才用,并且必须用 try/finally 严防死守。

  3. 非阻塞 pg_try_advisory_* 在实战中用得更多。 抢不到立刻返回 false,适合"快速判断能不能做"的场景。阻塞型 pg_advisory_* 适合"必须排队等"的场景,但要警惕无限期阻塞。

  4. Key 设计是工程问题,不是技术问题。 用双 int 版本(命名空间 + ID),维护一份全局 key 字典,避免模块间冲突。字符串 key 用 hashtext 转 int。

  5. 它和行锁定位不同,配合使用最好。 行锁保护数据一致性(ACID 本职),Advisory Lock 做应用协调(抢任务、防重入、限流)。判断标准:锁的对象在表里有对应行吗?有优先行锁,没有用 Advisory Lock。

  6. 陷阱集中在"生命周期"和"误用"上。 Session 锁泄漏、连接池模式下行为异常、key 冲突、死锁、当数据锁用——前五个靠纪律和监控规避,最后一个靠正确认知。

最后送一句我反复跟团队强调的话:

Advisory Locks 是协调信号,不是数据护栏。你想用它挡住并发改数据,就是用错了工具;你想用它让一群进程有序地抢活干,它就是最趁手的那把锁。

别动不动就上 Redis。先看看手边的 PostgreSQL 能不能搞定。

Related

相关推荐