Skip to content

PostgreSQL 中的锁 — 4. 内存中的锁(Locks in memory)

原文:https://habr.com/en/companies/postgrespro/articles/507036/ (作者 Egor Rogov,PostgresPro)

本篇的定位

前三篇讨论的关系级锁、行级锁、对象锁、谓词锁等,本质上都是为了协调用户事务对数据库逻辑对象的访问,它们对用户是"可见"的(可以通过 pg_locks 查询)。但 PostgreSQL 服务器本身作为一个多进程程序,内部还有大量共享内存结构——缓冲区(buffer)、WAL 缓冲区、各种哈希表、空闲列表等——这些结构在被多个后台进程并发读写时,同样需要互斥保护。这一层保护完全是内核实现细节,不会出现在 pg_locks 里,用户几乎感知不到,但它们的效率直接决定了数据库整体的吞吐量和延迟表现。本篇是系列的收官篇,专门介绍这一层"内存锁":自旋锁(spinlock)、轻量级锁(LWLock)和缓冲区固定(buffer pin)。

自旋锁(Spinlock):最原始也最快的互斥手段

自旋锁是 PostgreSQL 内存锁体系里粒度最细、开销最小的一种,专门用来保护那些"读写只需要几条指令就能完成"的极短临界区。它的关键特征包括:

  • 实现方式:直接建立在 CPU 提供的原子指令之上(比如 compare-and-swap),不依赖操作系统的调度或睡眠机制;
  • 模式单一:只有排他模式,没有共享/读模式的区分;
  • 等待方式是"忙等"(busy waiting):如果一个进程发现自旋锁已被占用,它不会像申请关系锁那样把自己挂起休眠,而是在一个短循环里反复尝试获取,直到成功为止;
  • 代价考量:忙等本身会消耗 CPU 周期,因此只有在临界区极短、冲突概率很低的前提下才划算——一旦持有时间稍长或者竞争激烈,忙等反而会浪费大量 CPU;
  • 没有死锁检测,也缺乏专门的监控手段:自旋锁的设计初衷就是"用得越少越好、持有时间越短越好",PostgreSQL 没有为它单独提供死锁检测或详细的等待统计机制。

轻量级锁(LWLock):介于自旋锁和常规锁之间

当需要保护的数据结构(比如某个哈希表、某条指针链表)访问耗时比自旋锁适用的场景稍长一些时,PostgreSQL 使用 LWLock(Lightweight Lock)。它的特点:

  • 持有时长:比自旋锁长,通常用于保护中等规模的数据结构访问,某些情况下甚至会跨越一次 I/O 操作;
  • 有两种模式:排他模式(用于修改)和共享模式(用于只读访问),这一点上比自旋锁更接近关系级锁的语义,但仍然轻量得多;
  • 调度方式:多个进程排队等待同一个 LWLock 时,唤醒顺序"大致随机",并不保证严格的先进先出公平性——这在原文中被提到是高并发场景下可能带来公平性问题的一个因素(某些进程可能会相对更容易"抢"到锁,导致个别进程等待时间偏长);
  • 可监控:与自旋锁不同,LWLock 的等待可以通过 PostgreSQL 的等待事件(wait event)体系观察到,这也是运维排查性能问题时更常打交道的一层。

缓冲区固定(Buffer Pin)

这是此前"缓冲区缓存"相关文章里已经介绍过的机制,这里再和其他内存锁放在一起做个呼应:Buffer Pin 的作用不是阻止对缓冲区内容的读写修改(那部分由 MVCC 可见性规则和更细的 content lock 负责),而是防止这块缓冲区在被"钉住"(pin)期间被置换出内存,也就是保护缓冲区本身不被驱逐、复用为别的页面。

它的等待策略也有自己的特点:一般情况下,如果某个进程发现自己想用的缓冲区正被别人固定着,它通常会选择跳过这个缓冲区,转而处理别的候选(比如 VACUUM 扫描时遇到被固定的页面通常会先跳过);但在少数必须操作这个特定缓冲区不可的场景下(比如需要冻结这一页所有元组),进程就只能老老实实排队等待,直到固定被释放为止。这类等待同样可以通过等待事件体系观察到。

缓冲区缓存里的分层锁策略实例

原文用缓冲区缓存的内部实现举例,说明这几种内存锁是如何协同工作、各司其职的:

  • Buffer mapping lock(缓冲区映射锁):这是一个 LWLock,用来保护"页面编号 → 缓冲区槽位"这张哈希映射表。为了降低单一大锁带来的竞争,这张映射表被拆成了 128 个锁分区(tranche),不同分区的访问可以并发进行,互不阻塞;
  • Buffer header 的访问:用自旋锁保护,因为缓冲区头部里的字段(引用计数、标志位等)只需要极短的时间就能读写完毕;
  • Buffer content lock(缓冲区内容锁):读取或修改缓冲区实际存放的页面数据时需要持有的锁,属于更"重"一层的保护;
  • I/O lock:当缓冲区需要从磁盘读入或写出时持有,保证同一缓冲区不会被并发的 I/O 操作互相干扰;
  • Strategy lock(缓存替换策略锁):一个自旋锁,用来保护"空闲缓冲区链表指针"这类替换策略相关的极小状态。

可以看到,越靠近"CPU 指令级别的极短操作"越倾向于用自旋锁,越涉及"较长时间的结构性访问或 I/O"越倾向于用 LWLock,这正体现了这套锁体系分层设计的思路。

WAL 缓冲区的加锁方式

预写日志(WAL)缓冲区的实现在锁的组织上和普通数据缓冲区略有不同:

  • WALBufMappingLock:与数据缓冲区用 128 个分区的映射锁不同,WAL 缓冲区的映射只用一把 LWLock 保护,因为 WAL 缓冲区的写入模式(基本是顺序追加)相对简单,没必要拆分成多个分区;
  • WALWriteLock:保证 WAL 数据落盘(写入磁盘文件)这个动作是串行化的,避免多个进程同时往同一个文件写导致数据错乱;
  • Insert position 的自旋锁:每当有新的 WAL 记录要写入时,都需要先"占位"——确定这条记录该写在 WAL 缓冲区的哪个偏移量,这个"分配写入位置"的动作用自旋锁保护,因为它非常短;
  • WAL insert locks(WAL 插入锁组):这是一组由 8 把 LWLock 组成的锁池(tranche),允许多个进程同时把各自的 WAL 记录内容拷贝进已经分配好的缓冲区空间,从而把"占位"和"实际拷贝数据"这两个动作解耦,提升并发写入 WAL 的吞吐量。

通过等待事件(Wait Events)观测内存锁

pg_stat_activity 中的等待信息

从 PostgreSQL 9.6 开始,pg_stat_activity 视图新增了 wait_event_typewait_event 两列,可以实时看到每个后端进程当前正在等待什么:

sql
SELECT pid, backend_type, wait_event_type, wait_event
FROM pg_stat_activity;

wait_event_type 划分了几大类等待,原文提到的主要类别包括:

  • Lock:等待关系级锁、行锁等对象级锁(也就是前三篇讨论的那些"用户可见"的锁);
  • LWLock:等待某个轻量级锁;
  • BufferPin:等待某个被固定的缓冲区释放固定;
  • IO:等待磁盘 I/O 完成;
  • Client:等待客户端发来数据或指令(比如等待网络输入);
  • IPC:进程间通信相关的等待(比如等待并行查询的其他 worker);
  • Extension:由扩展插件自定义的等待类型;
  • Activity:后台进程(如 checkpointer、walwriter)在空闲循环中的等待;
  • Timeout:等待某个计时器到期。

如果 wait_event_type 是空的,说明该进程当前正在实际执行(CPU 计算中),并没有处于等待状态。

用 pg_wait_sampling 扩展做统计采样

单看 pg_stat_activity 只能获得"此时此刻"的等待快照,无法反映一段时间内等待事件的累计分布。为了做历史统计,原文演示了 pg_wait_sampling 这个扩展:

sql
ALTER SYSTEM SET shared_preload_libraries = 'pg_wait_sampling';
-- 需要重启数据库使其生效
CREATE EXTENSION pg_wait_sampling;

SELECT * FROM pg_wait_sampling_profile;

这个扩展的工作原理是按固定周期(默认约 10 毫秒一次)对所有后端进程的等待状态做一次快照采样,长期累计后就能形成"哪类等待事件出现频率最高"的统计画像。

使用时需要注意的几个局限

  • 它本质上是采样而不是精确计数,只能反映概率意义上的分布,要得到有说服力的结论,需要足够多的样本量;
  • 采样周期如果比某次等待本身的持续时间还长,那次等待就可能被完全"漏采",压根不会被计入统计;
  • 默认 10 毫秒的采样周期意味着,如果要把采样次数换算成大致的"秒数",需要把计数值除以 100(因为每秒大约采样 100 次)。

实测示例

WAL 写入压力下的等待分布

原文构造了一个场景:用 pgbench 施加写负载,同时人为把磁盘 I/O 延迟调慢到十分之一秒这个量级,然后观察采样结果,大致得到类似这样的等待计数分布:

WALWrite(IO 类等待):1445 次
WALWriteLock(LWLock 类等待):803 次
DataFileExtend(IO 类等待):20 次

这个结果直观印证了前面提到的机制:事务提交时需要同步写 WAL,WALWriteLock 的出现说明确实有多个进程在争抢"串行化写 WAL 文件"这个动作,而 WALWrite 这个 IO 类等待事件的高计数则反映了磁盘写入本身(被人为调慢后)成为了瓶颈。

缓冲区固定(Buffer Pin)的实际观察

原文还设计了一个实验来演示 Buffer Pin 的行为:通过打开一个游标(cursor)并让它保持在某个缓冲区上,人为制造一个长期被固定的页面,然后观察不同操作面对这个"钉住"页面时的反应:

  1. 普通的 VACUUM(不带 FREEZE)遇到这个被固定的页面时,直接跳过,不等待,继续处理其他页面;
  2. VACUUM ... FREEZE(或者其他必须处理这一页不可的冻结类操作)就没有退路了,只能老老实实排队,一直等到游标关闭、固定被释放为止;
  3. 通过对这次等待中的 VACUUM 会话做采样,可以看到大量 BufferPin 类型的等待事件(原文给出的示例计数是 294 次),直观证明了它确实卡在了等待缓冲区解除固定这一步上。

这个实验说明了为什么某些"冻结/清理类"的维护操作有时会看起来卡住不动——根源可能就是有其他会话(比如一个长期未关闭的游标)一直固定着相关的缓冲区页面。

小结

这篇文章把视角从"用户可见的数据库对象锁"转向了 PostgreSQL 内核自身用来保护共享内存结构的三层机制:

  • 自旋锁:最细粒度、纯排他、忙等、几乎零监控,适合极短临界区;
  • 轻量级锁(LWLock):支持排他/共享两种模式,持有时间比自旋锁长,调度上不保证严格公平,但可以通过等待事件体系观测;
  • 缓冲区固定(Buffer Pin):防止缓冲区在被使用期间被置换出内存,多数情况下发生冲突时选择跳过而非等待,仅在必须操作特定缓冲区时才会排队;
  • 缓冲区缓存和 WAL 子系统的具体实现展示了这几类锁如何分层组合、各自守护不同粒度和时长的临界区;
  • 从 PostgreSQL 9.6 起,pg_stat_activitywait_event_type/wait_event 两列提供了实时等待状态的可见性,而 pg_wait_sampling 扩展可以在此基础上做周期采样、积累历史统计,但使用时必须意识到它是概率性采样,采样周期设置不当会导致短暂等待被漏记,结论需要建立在足够样本量之上。

至此,"PostgreSQL 中的锁"系列四篇文章分别覆盖了关系级锁、行级锁、其他专用锁(死锁检测、对象锁、扩展锁、页锁、建议锁、谓词锁)以及内存中保护内核数据结构的自旋锁/轻量级锁/缓冲区固定机制,构成了一幅从"用户可感知"到"内核实现细节"的完整锁体系图景。