PostgreSQL咨询锁的使用

上一篇我们聊了表级锁、行级锁和轻量级锁,看到 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 和业务最长执行时间之间留了多少余量------有没有遇到过锁提前过期或故障后锁滞留的问题?欢迎在评论区聊聊。

相关推荐
ycydynq1 小时前
django 添加信息时,对外键设置条件
数据库·python·django
m0_639906941 小时前
MySQL 索引底层原理|B + 树、聚簇索引、非聚簇索引、覆盖索引
数据库·mysql
全栈陈序员1 小时前
【Python】基础语法入门(二十八)—— 数据库操作入门:用 SQLite 持久化数据
数据库·人工智能·python
jnrjian2 小时前
Oracle RAC crs stop时force 关闭DB,另一个node会立即进行recover
数据库·oracle
次次皮2 小时前
Oracle判空踩坑(对比mysql)
数据库·mysql·oracle
严谨的麻辣烫2 小时前
固定 IP 稳定性如何检测?使用 Python 实现网络连通性与耗时监控
开发语言·数据库·php
牧瀬クリスだ2 小时前
Redis List实战:从队列到最新列表的高效应用
数据库·redis·list
码智社2 小时前
Redis生产环境高频踩坑总结(Java后端视角)
java·数据库·redis
江苏汉软2 小时前
制造企业为什么需要MES
java·数据库·制造·mes系统·mes软件