问题的历史。
顾问锁首次出现在PostgreSQL 8.2中,提供轻量级的应用级同步原语,并在MVCC元组可见性系统之外操作。它们旨在处理队列处理和幂等摄取等工作流,在这些工作流中,基于表的锁定在语义上是不合适的,或者在性能上是不可接受的。不同于绑定到特定表元组并记录在xmax系统列中的行级锁,顾问锁完全驻留在共享内存锁管理器中,提供了一种调度对抽象资源的访问机制,而不会生成死元组或WAL流量。
问题所在。
在高并发的幂等摄取管道中,通过传统的INSERT ... ON CONFLICT或SELECT FOR UPDATE强制业务键(例如,外部UUID)的唯一性会产生严重的瓶颈。行级方法需要写入堆以设置锁位,这会导致表的膨胀,加快VACUUM压力,并在冲突解决期间在唯一索引中造成热点。挑战在于提供对逻辑实体(如哈希业务键)的互斥,且不接触存储层,同时确保锁失败不会将资源泄漏到持久连接池中。
解决方案。
关键属性是顾问锁仅存储在共享内存中的LOCKTAG哈希表中,使用LOCKMETHOD_ADVISORY,因此从不修改底层关系页。通过使用pg_advisory_xact_lock(hashtext(business_key)),应用程序获取一个事务范围的互斥锁,该锁在COMMIT或ROLLBACK时自动释放,从而防止与会话级pg_advisory_lock相关的锁泄漏。这种方法消除了表的膨胀和索引争用,因为锁仅存在于内存中的轻量条目,正如下文所示:
BEGIN; -- 在哈希业务键上获取事务绑定锁 SELECT pg_advisory_xact_lock(hashtext('a1b2c3d4')); -- 安全插入;如果另一个会话持有锁,则没有唯一索引争用 INSERT INTO events (business_key, payload) VALUES ('a1b2c3d4', '{"event":"click"}') ON CONFLICT (business_key) DO NOTHING; COMMIT;
一家遥测公司的数据平台团队需要确保每秒处理50,000个事件,该事件由Kafka摄取到PostgreSQL中,每个事件携带一个客户端生成的UUID,作为幂等性键。使用INSERT ... ON CONFLICT DO NOTHING对唯一UUID列的初始负载测试导致严重的尾部延迟,这是由于唯一B-tree索引上的自旋锁争用和快速积累的膨胀造成的,这些膨胀源于HOT更新失败。在高峰时段,WAL生成速率翻倍,威胁到复制延迟和存储容量。
一个提议的修复涉及使用SELECT * FROM events WHERE business_key = $1 FOR UPDATE预先检查键的存在,然后仅在结果为空时插入。虽然这防止了重复,但它强迫每个写入者在现有行或代理保留行上获取行锁,导致保留表页面上的巨大热点。这种方法产生了大量的表膨胀——每十五分钟需要VACUUM来回收死元组——并且无法在检查和插入之间防止竞争条件,而不必在整个事务持续时间内保持锁,从而严重限制了吞吐量。
架构团队建议将协调移动到外部Redis缓存中,使用SETNX操作来限制插入。这样消除了数据库的膨胀,并减少了PostgreSQL的负载,但引入了关键的故障模式:Redis集群与数据库之间的网络分区可能在Redis锁过期但PostgreSQL事务尚未提交时允许重复插入。此外,维护两个分布式系统之间的一致性增加了操作复杂性,并需要实施Redlock或类似算法,使每次操作的延迟增加约5毫秒。
选择的设计通过pg_advisory_xact_lock(hashtext(business_key))利用PostgreSQL的本地顾问锁,在尝试插入之前获取哈希UUID上的事务绑定锁。由于这些锁仅存在于共享内存中,并且不接触堆,它们不会产生任何存储开销,并在事务结束时自动释放,从而避免了会话级锁观察到的锁泄漏。为了避免不可检测的死锁,应用层在获取锁之前将每批次中的所有UUID按其哈希整数值进行排序,确保所有并发工作者的全局排序协议。
顾问锁被选中是因为它们提供了最低延迟(亚毫秒获取)和无存储副作用,同时在没有外部依赖的情况下保持严格的正确性。与Redis的方法不同,锁的生命周期与数据库事务绑定,确保锁获取与插入提交之间的原子性。与SELECT FOR UPDATE不同,没有生成表的膨胀,而与原始的ON CONFLICT不同,由于序列化发生在堆访问之前,因此唯一索引从未受到竞争性插入的压力。
部署后,摄取管道以每秒80,000个事件维持p99延迟低于10毫秒,而在争用高峰期间之前则为200毫秒的峰值。表的膨胀降至微乎其微的水平,允许autovacuum仅在非高峰时段运行,WAL的数量减少了40%,显著降低了归档存储成本和副本延迟。系统在经历多次数据库重启和连接池波动时,保持了精确一次语义,没有出现单个重复事件或死锁引起的超时。
为什么在高吞吐量工作者架构中使用pg_advisory_lock**(会话范围)而不是pg_advisory_xact_lock会导致连接池耗尽和重复摄取的风险?**
候选人经常未能认识到pg_advisory_lock会持续存在,直到显式解锁或会话断开,即使事务中止。在一个连接池环境中,工作者重用长期存在的连接,如果逻辑错误或异常绕过解锁调用,锁将无限期保持,从而导致后续处理同一业务键的工作者永远等待。而应使用pg_advisory_xact_lock,因为它将锁的生命周期绑定到事务边界,确保在ROLLBACK时自动释放,从而防止互斥锁泄漏,否则将使工作者池耗尽并停滞摄取管道。
在获取多个顾问锁时缺乏总排序保证如何导致不可检测的死锁,以及什么特定应用模式可以消除这一危险?
与PostgreSQL的deadlock_timeout检测器通过杀死一个受害者事务来解决行级死锁不同,顾问锁死锁对引擎是不可见的,因为它们发生在用户定义的命名空间中。如果工作者A锁定资源X然后Y,而工作者B锁定Y然后X,两个会话会无限期等待而没有错误。强制模式是在发出任何锁请求之前,先在整个应用程序中以严格单调的顺序(升序或降序)对所有资源标识符(例如,**hashtext(uuid)**值)进行排序。此全局排序确保等待图保持无环,从而使循环依赖成为不可能,消除静默挂起的风险。
什么共享内存限制限制了单个事务可以持有的顾问锁的数量,以及超出max_locks_per_transaction的表现与行级锁耗尽相比如何?
许多候选人认为顾问锁是无限的,但它们在共享锁表中消耗条目,该表由max_locks_per_transaction配置参数(默认为64)管理。在一个事务中持有超过此限制的锁将引发ERROR: out of shared memory (SQLSTATE 53200),立即中止事务。这与行级锁形成对比,后者在超出限制时通常会触发锁升级或根据lock_timeout等待,但不会耗尽固定的共享内存池。缓解措施包括将操作分批成较小的子事务,或通过复合哈希将多个逻辑资源聚合在一个顾问锁键下,而不是试图同时锁定成千上万个单独键。