上一篇我们聊了表级锁、行级锁和轻量级锁,看到 PostgreSQL 如何用一套分层的锁管理器保护数据库对象。但有一类互斥问题,行锁和表锁都处理得很别扭:它锁的不是某一行数据,而是应用层面的一件"事" ------比如"同一时刻只允许一个 worker 执行全量对账""某个外部 API 的调用必须串行化""同一租户的数据迁移不能并发跑"。这一类问题,正是咨询锁(advisory lock) 的用武之地。这一篇,我们把咨询锁的两种生命周期、锁 ID 的设计、以及与 Redis 分布式锁的边界,逐一拆开。

一、咨询锁到底在锁什么?
咨询锁的名字已经说明了它的本质:PostgreSQL 提供机制,但不强制语义 。行锁和表锁是数据库"自动"加的,用来保护数据一致性;咨询锁是应用程序显式加、显式解的合作锁,数据库只负责"同一时刻只让一个会话拿到这个 ID",至于这个 ID 代表什么业务含义,完全由应用决定。
官方文档对适用场景的描述很精确:咨询锁适合 "用 MVCC 模型处理起来很别扭的锁策略" ,比如模拟悲观锁。你当然可以用一张表、一个布尔字段来表示"某个任务正在运行",但咨询锁更快、不产生表膨胀,而且会话结束时数据库会自动清理。这就是它相对于"用数据表做锁"的核心优势。
但"咨询"二字也意味着责任转移:数据库不会阻止你绕过咨询锁直接操作数据。它的互斥性只在"所有参与者都遵守同一套锁 ID 约定"的前提下成立。
二、会话锁与事务锁:两种生命周期,行为差异巨大
咨询锁有两种获取方式,它们的释放时机和事务语义完全不同,选错会直接导致锁泄漏或互斥失效。
会话锁(Session-level)
sql
SELECT pg_advisory_lock(12345); -- 获取会话锁
-- ... 业务逻辑 ...
SELECT pg_advisory_unlock(12345); -- 显式释放
会话锁一旦获取,持有到显式释放或会话结束 ,而且不遵循事务语义 :在一个事务中获取的会话锁,即使该事务后来回滚,锁仍然被持有;同样,解锁操作即使在事务失败后也会生效。同一进程可以多次获取同一个会话锁,但每次获取都需要一次对应的解锁,锁才真正释放。
这个设计的好处是不受事务边界干扰 ,适合跨多个事务的长周期互斥;代价是必须确保会话最终关闭或显式解锁,否则锁会一直挂着。
事务锁(Transaction-level)
sql
BEGIN;
SELECT pg_advisory_xact_lock(12345); -- 获取事务锁
-- ... 业务逻辑 ...
COMMIT; -- 锁自动释放,无需手动 unlock
事务锁的行为更接近普通锁 :它在事务结束时(提交或回滚)自动释放 ,没有显式的解锁函数。对于短期的、与单个事务绑定的互斥,事务锁是更安全的选择------不存在"忘记解锁"的风险。
两者共享同一个锁空间
一个关键细节:会话锁和事务锁使用同一套锁 ID 空间 。如果会话 A 持有 ID 12345 的事务锁,会话 B 尝试获取 ID 12345 的会话锁,会被正常阻塞。但有一个例外:如果同一个会话已经持有某个锁,它对该锁的额外请求总是成功,即使其他会话正在等待这个锁。这意味着,在同一个连接内,咨询锁的互斥是"对外的",而不是"对内的"。
三、锁 ID 的设计:单键、双键与命名空间
咨询锁的 ID 有两种形式:单个 64 位整数 ,或一对 32 位整数。
单键适合简单的全局互斥,比如 pg_advisory_lock(42)。但单键的问题在于命名空间扁平:所有业务共用同一个整数空间,不同子系统很容易"撞 ID"。
双键解决了这个问题。一对 (class_id, object_id) 让你可以把第一个 32 位整数当作命名空间 ,第二个当作具体资源:
sql
-- 类 ID 1 表示"对账任务",对象 ID 是租户 ID
SELECT pg_try_advisory_xact_lock(1, tenant_id);
-- 类 ID 2 表示"数据迁移",对象 ID 是迁移批次 ID
SELECT pg_try_advisory_xact_lock(2, batch_id);
这样,对账任务和迁移任务永远不会因为 ID 相同而误阻塞。Moodle 的 PostgreSQL 锁实现就采用了双键形式,刻意与会话锁使用不同的命名空间,避免应用锁和内部会话锁冲突。
一个更工程化的做法是用哈希把字符串键映射到整数 。Apache 的源码中可以看到这种模式:先把字符串存储在表中,用自增 ID 作为锁键;或者直接对字符串做稳定哈希(如 hashtext()),映射到 64 位整数。哈希的好处是无需维护映射表,代价是理论上存在碰撞------需要选择一个足够稳定的哈希函数,并在设计时接受极低概率的碰撞风险。
锁 ID 设计的一条原则:把"互斥的粒度"编码进 ID 结构,而不是依赖 ID 的数值大小。 双键的 (namespace, resource) 结构,比"用不同数值段区分业务"更清晰、更不易出错。
四、一个必须知道的危险模式:LIMIT 与锁函数的执行顺序
官方文档专门警告了一种写法:
sql
SELECT pg_advisory_lock(id) FROM foo WHERE id > 12345 LIMIT 100; -- 危险
问题在于:SQL 表达式求值的顺序,并不保证 LIMIT 在锁函数执行之前生效 。PostgreSQL 可能在内部选出并锁定超过 100 行的 ID,然后才应用 LIMIT 截断输出。那些"多锁的"行对应的锁,应用可能根本不知道它们存在,也就永远不会显式解锁,直到会话结束才被清理。
安全写法是把锁函数放在外层,让 LIMIT 先于锁生效:
sql
SELECT pg_advisory_lock(q.id) FROM (
SELECT id FROM foo WHERE id > 12345 LIMIT 100
) q;
规则:永远不要让锁函数直接出现在带有 LIMIT 或 ORDER BY 的查询的 SELECT 列表中。 先用子查询确定要锁定的行集合,再在外层对集合加锁。
五、咨询锁与行锁、表锁的关系
咨询锁不是行锁或表锁的替代品,它们解决的是不同层面的互斥。
| 锁类型 | 保护对象 | 谁来决定加锁 | 典型场景 |
|---|---|---|---|
| 行锁 | 具体的数据行 | 数据库根据 UPDATE/DELETE 自动加 |
并发修改同一行 |
| 表锁 | 整张表 | 数据库根据 DDL/DML 自动加,也可手动 LOCK TABLE |
DDL 与 DML 的互斥 |
| 咨询锁 | 应用定义的"事" | 应用显式加 | 分布式 worker 互斥、外部资源串行化、跨多表的业务约束 |
一个典型的组合场景是"跨多行的业务约束 "。比如"同一账户下的所有操作必须串行",但操作涉及多张表,行锁无法表达"这个账户"这个抽象实体。这时可以用咨询锁在账户 ID 上加互斥,然后在锁的保护下执行多表操作。咨询锁不关心你锁的是什么数据,它只保证"同一个 ID 同一时刻只有一个持有者"。
六、与 Redis 分布式锁的对比:选型看什么
这是咨询锁最常被拿来比较的对象。两者的概念相似------都是"不绑在具体数据行上的命名互斥"------但释放模型和故障语义截然不同。
| 维度 | PostgreSQL 咨询锁 | Redis 锁(SET NX EX + token + Lua) |
|---|---|---|
| 释放模型 | 会话锁:显式释放或会话结束;事务锁:事务结束自动释放 | TTL / 租约到期;需要续期机制 |
| 崩溃安全 | 强:进程崩溃后数据库关闭会话,锁自动清理 | 依赖 TTL;TTL 过长则故障后锁滞留,过短则持有者仍在运行时锁提前失效 |
| 锁状态持久性 | 内存中的锁管理器,不写入 WAL,不复制到备库 | 内存状态;Redis 故障或主从切换可能丢失锁 |
| 实现复杂度 | 低:一条 SQL 即可获取,事务锁无需手动释放 | 中高:需要原子释放(Lua)、owner token、续期、TTL 与业务耗时的匹配 |
| 延迟 | 受数据库往返和事务开销影响 | 通常更低,Redis 为高吞吐协调设计 |
| 适用边界 | 应用已使用 PostgreSQL,互斥需要与数据库事务绑定 | 需要跨数据库/跨技术栈协调,或对锁延迟极度敏感 |
核心差异在"锁的生命周期绑定在什么上" :PostgreSQL 咨询锁绑定在连接/事务的存活 上,Redis 锁绑定在 TTL 计时器上。前者的崩溃安全是"免费的"------进程崩溃,连接断开,锁自动释放;后者需要你仔细设计 TTL 和续期,否则要么锁泄漏,要么互斥失效。
什么时候咨询锁是更好的选择: 应用已经以 PostgreSQL 为唯一状态存储,锁保护的临界区本身就是数据库操作,而且你希望锁的释放与事务边界严格一致------事务提交或回滚,锁随之消失,不存在"业务已经结束但锁还没过期"或"锁已过期但业务还在跑"的窗口。
什么时候 Redis 更合适: 你需要跨多个独立系统协调(不只是数据库操作),或者锁的临界区包含不可回滚的外部副作用(调用第三方 API、创建容器等)。这时咨询锁与数据库事务的绑定反而成为一种限制:外部副作用不在事务里,锁的释放语义无法覆盖它。Redis 的 TTL 模型更适合这种"尽力而为 + 幂等 + 事后对账"的场景。
一个容易被忽略的咨询锁短板:锁状态不复制。 咨询锁只存在于持有它的那个 PostgreSQL 实例上,备库不会复制锁状态。如果发生主备切换,客户端必须重新连接到新的主库,锁的互斥性才继续成立。Redis 集群也有类似的故障转移语义问题,但那是另一个层面的设计。
七、总结
| 主题 | 核心结论 |
|---|---|
| 咨询锁本质 | 应用级合作锁,数据库只保证同一 ID 的互斥,语义由应用定义 |
| 会话锁 | 持有到显式释放或会话结束;不遵循事务语义,回滚不释放;适合跨事务的长周期互斥 |
| 事务锁 | 事务结束自动释放,无需手动 unlock;连接池场景下更安全,不会因为忘记解锁而泄漏 |
| 共享锁空间 | 会话锁和事务锁共享 ID 空间,互相阻塞;同一会话的重复请求总是成功 |
| 锁 ID 设计 | 单键 64 位用于简单互斥;双键 (namespace, resource) 用于分类隔离;哈希用于字符串键映射 |
| 危险模式 | LIMIT 不保证在锁函数之前执行;先用子查询限定行集,再在外层加锁 |
| 与行锁/表锁 | 不同层面:咨询锁保护"应用定义的事",不保护具体数据行 |
| vs Redis | 咨询锁绑定连接/事务存活,崩溃安全免费;Redis 绑定 TTL,适合跨技术栈、低延迟、外部副作用场景;咨询锁状态不复制到备库 |
两句话回答核心问题:
咨询锁适合哪些场景? 应用已经以 PostgreSQL 为状态存储,需要互斥的是一"件"应用层面的事(worker 选主、跨多表的业务约束、外部资源的串行化),并且希望锁的生命周期与数据库连接或事务严格绑定------崩溃即释放,提交即释放,不需要额外管理 TTL。
与 Redis 分布式锁相比,优劣在哪里? 咨询锁的优势是正确性和运维简单 :不需要额外基础设施,锁随连接/事务自动清理,崩溃安全由数据库兜底;劣势是绑定在数据库上 ,跨实例不复制、延迟受数据库影响、临界区如果包含不可回滚的外部副作用,事务绑定的释放语义反而成为限制。Redis 的优势是低延迟和跨技术栈灵活性,代价是需要自己处理原子释放、TTL 与业务耗时的匹配、以及故障转移下的锁状态语义。选型的核心问题不是"哪个更快",而是"你的互斥对象和故障边界,天然属于数据库事务的世界,还是属于外部副作用的世界"。
下一篇,我们进入死锁的诊断与处理:PostgreSQL 如何检测死锁?deadlock_timeout 到底在等什么?应用层如何设计加锁顺序来从根源上避免死锁?
思考题:
你的系统里有没有"用表字段做锁"的场景------比如一个 is_running 布尔列来控制任务互斥?如果有,换成咨询锁能消除哪些表膨胀和清理逻辑?另外,如果你已经在用 Redis 分布式锁,你的 TTL 和业务最长执行时间之间留了多少余量------有没有遇到过锁提前过期或故障后锁滞留的问题?欢迎在评论区聊聊。