buffer readme¶
关于共享缓冲区访问规则的说明 (Notes About Shared Buffer Access Rules)
共享磁盘缓冲区有两种独立的访问控制机制:引用计数(也称为 Pin 计数)和 缓冲区内容锁。(实际上还有第三层访问控制:在合法访问属于某个关系的任何页面之前,必须持有该关系的适当类型的锁。)
Pins(引脚/引用计数)¶
在对缓冲区进行任何操作之前,必须先“持有该缓冲区的 Pin”(增加其引用计数)。未 pinned 的缓冲区随时可能被回收并用于其他页面,因此访问它是不安全的。
通常通过 ReadBuffer 获取 Pin,通过 ReleaseBuffer 释放 Pin。
单个后端进程同时多次 Pin 同一个页面不仅是允许的,而且很常见;缓冲区管理器会高效地处理这种情况。
长时间持有 Pin 也是允许的——例如,顺序扫描会在处理完页面上的所有元组之前一直持有当前页面的 Pin,如果该扫描是连接操作的外部扫描,这可能会持续相当长的时间。同样,B-Tree 索引扫描也可能持有当前索引页面的 Pin,这是可以的,因为正常操作永远不会等待页面的 Pin 计数降为零。(任何可能需要等待 Pin 计数归零的操作,转而通过等待获取关系级锁来处理,这就是为什么你最好先持有关系级锁的原因。)但是,Pin 不能跨越事务边界持有。
Buffer Content Locks(缓冲区内容锁)¶
缓冲区锁有两种:共享锁和排他锁,其行为符合预期:多个后端可以持有同一缓冲区的共享锁,但排他锁会阻止其他人持有任何共享或排他锁。(这些也可以称为 READ 锁和 WRITE 锁。)
这些锁旨在短期持有:不应长时间持有。缓冲区锁通过 LockBuffer() 获取和释放。
单个后端尝试对同一缓冲区获取多个锁是行不通的。在尝试锁定缓冲区之前,必须先 Pin 住该缓冲区。
缓冲区访问规则 (Buffer access rules)¶
-
扫描页面中的元组:必须持有 Pin 以及共享或排他内容锁。要检查共享缓冲区中元组的提交状态(XIDs 和状态位),同样必须持有 Pin 以及共享或排他锁。
-
确定元组可见后释放内容锁:一旦确定某个元组是感兴趣的(对当前事务可见),就可以放下内容锁,但只要持有缓冲区 Pin,就可以继续访问该元组的数据。堆扫描(heap scans)通常这样做,因为
heap_fetch返回的元组包含指向共享缓冲区中元组数据的指针。因此,只要持有 Pin,元组就不会消失(见规则5)。其状态可能会改变,但在初始可见性判定完成后,假设这无关紧要。 -
修改元组:要添加元组或更改现有元组的
xmin/xmax字段,必须持有包含该元组的缓冲区的 Pin 和排他内容锁。这确保了在进行可见性检查时,其他人不会看到元组的半更新状态。 -
更新提交状态位(Hint Bits):在仅持有缓冲区的共享锁和 Pin 的情况下,更新元组的提交状态位(即对
t_infomask执行 OR 操作,设置HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_COMMITTED, 或HEAP_XMAX_INVALID)是被允许的。- 原因:另一个后端如果在大约同一时间查看该元组,也会将相同的位 OR 进字段,因此冲突更新的风险很小或没有。即使真的发生冲突,也仅仅意味着一次位更新丢失,稍后需要重做。
- 注意:这四个位只是提示(它们缓存了
pg_xact中事务状态的查找结果),所以如果因冲突更新而被重置为零,也不会造成太大危害。 - 例外:冻结元组是通过同时设置
HEAP_XMIN_INVALID和HEAP_XMIN_COMMITTED来完成的;这是一个关键更新,因此需要排他缓冲区锁(并且必须进行 WAL 日志记录)。
-
物理删除元组或压缩空闲空间:必须持有 Pin 和排他锁,并且在持有排他锁期间观察到缓冲区的共享引用计数为 1(即没有其他后端持有 Pin)。
- 如果满足这些条件,则在排他锁释放之前,没有其他后端可以执行页面扫描,也没有其他后端可以持有对现有元组的引用(它可能期望再次检查该元组)。
- 注意:另一个后端可能在执行清理时 Pin 住缓冲区(增加 refcount),但在获取共享或排他内容锁之前,它无法实际检查页面。
获取规则 #5 所需的锁由 bufmgr 例程
LockBufferForCleanup()或ConditionalLockBufferForCleanup()完成。它们首先获取排他锁,然后检查共享 Pin 计数当前是否为 1。如果不是,ConditionalLockBufferForCleanup()释放排他锁并返回 false;而LockBufferForCleanup()释放排他锁(但不释放调用者的 Pin)并等待,直到被另一个后端信号唤醒,然后重试。当UnpinBuffer将共享 Pin 计数递减到 1 时,会发生信号。如上所述,此操作在获取锁之前可能需要等待很长时间,但这对于并发 VACUUM 来说应该没关系。当前实现仅支持每个特定共享缓冲区上只有一个等待 Pin 计数为 1 的等待者。这对于 VACUUM 的使用来说已经足够,因为我们不允许在同一关系上并发进行多个 VACUUM。任何希望在恢复或 VACUUM 之外获取清理锁的人必须使用该函数的条件变体。
缓冲区管理器的内部锁定 (Buffer Manager's Internal Locking)¶
在 PostgreSQL 8.1 之前,共享缓冲区管理器的所有操作都受单一的系统级锁 BufMgrLock 保护,这 unsurprisingly(不出所料地)成为争用的来源。新的锁定方案避免了在常见代码路径中获取系统级排他锁。其工作原理如下:
- BufMappingLock:有一个系统级的 LWLock,名为
BufMappingLock,名义上保护从缓冲区标签(页面标识符)到缓冲区的映射。(物理上,可以认为它保护由buf_table.c维护的哈希表。) - 要查找是否存在某个标签对应的缓冲区,只需获取
BufMappingLock的共享锁。 - 注意:在释放
BufMappingLock之前,必须 Pin 住找到的缓冲区(如果有)。 -
要更改任何缓冲区的页面分配,必须持有
BufMappingLock的排他锁。在调整缓冲区头字段和更改buf_table哈希表时必须持有此锁。唯一需要排他锁的常见操作是读取尚未在共享缓冲区中的页面,这至少需要一个内核调用,通常还需要等待 I/O,因此无论如何都会很慢。 -
分区 BufMappingLock:从 PG 8.2 开始,
BufMappingLock已被拆分为NUM_BUFFER_PARTITIONS个单独的锁,每个锁保护一部分缓冲区标签空间。这进一步减少了正常代码路径中的争用。特定缓冲区标签所属的分区由标签哈希值的低位决定。上述规则独立适用于每个分区。如果需要同时锁定多个分区,必须按分区编号顺序锁定它们,以避免死锁风险。 -
buffer_strategy_lock:一个单独的系统级自旋锁
buffer_strategy_lock,为访问缓冲区空闲列表或选择替换缓冲区的操作提供互斥。这里使用自旋锁而不是轻量级锁(LWLock)以提高效率;在持有buffer_strategy_lock时,不应获取任何其他类型的锁。这对于允许多个后端以合理的并发性进行缓冲区替换至关重要。 -
缓冲区头自旋锁:每个缓冲区头包含一个自旋锁,在检查或更改该缓冲区头的字段时必须获取。这允许诸如
ReleaseBuffer之类的操作在不获取任何系统级锁的情况下进行本地状态更改。我们使用自旋锁而不是 LWLock,因为没有情况需要持有该锁超过几条指令的时间。 -
注意:缓冲区头的自旋锁不控制对缓冲区内数据的访问。每个缓冲区头还包含一个 LWLock,即“缓冲区内容锁”,它确实代表访问缓冲区中数据的权利。它按照上述规则使用。
-
BM_IO_IN_PROGRESS 标志:充当一种锁,用于等待缓冲区上的 I/O 完成(在版本 14 之前,它伴随着一个每缓冲区的 LWLock)。执行读取或写入的进程在此期间设置该标志,需要等待其清除的进程则在条件变量上睡眠。
正常缓冲区替换策略 (Normal Buffer Replacement Strategy)¶
有一个“空闲列表”(free list),其中的缓冲区是替换的主要候选者。特别是,完全空闲(不包含有效页面)的缓冲区始终在此列表中。如果我们认为某些页面不太可能很快被需要,也可以将它们放入此列表;然而,当前算法从不这样做。
该列表是使用缓冲区头中的字段链接的单链表;我们在全局变量中维护头尾指针。(注意:虽然列表链接在缓冲区头中,但它们被认为受 buffer_strategy_lock 保护,而不是缓冲区头自旋锁。)
当没有空闲缓冲区可用时,为了选择要回收的受害者缓冲区,我们使用简单的 Clock-Sweep(时钟扫描)算法,这避免了在常见操作期间获取系统级锁。其工作原理如下:
每个缓冲区头包含一个 usage counter(使用计数器),每当缓冲区被 Pin 时,该计数器就会递增(上限为一个较小的限制值)。(这只需要缓冲区头自旋锁,无论如何为了增加缓冲区引用计数都必须获取该锁,因此几乎是免费的。)
“时钟指针”是一个缓冲区索引 nextVictimBuffer,它在所有可用缓冲区中循环移动。nextVictimBuffer 受 buffer_strategy_lock 保护。
需要获取受害者缓冲区的进程的算法如下:
- 获取
buffer_strategy_lock。 - 如果缓冲区空闲列表非空,移除其头部缓冲区。释放
buffer_strategy_lock。如果该缓冲区被 Pin 住或使用计数不为零,则不能使用;忽略它并回到步骤 1。否则,Pin 住该缓冲区并返回。 - 否则,缓冲区空闲列表为空。选择
nextVictimBuffer指向的缓冲区,并为下次循环推进nextVictimBuffer。释放buffer_strategy_lock。 - 如果选定的缓冲区被 Pin 住或使用计数不为零,则不能使用。递减其使用计数(如果不为零),重新获取
buffer_strategy_lock,并返回步骤 3 以检查下一个缓冲区。 - Pin 住选定的缓冲区,并返回。
(注意:如果选定的缓冲区是脏的,我们在回收它之前必须将其写出;如果与此同时其他人 Pin 住了该缓冲区,我们将不得不放弃并尝试另一个缓冲区。然而,这不是基本选择受害者缓冲区算法的关注点。)
缓冲区环替换策略 (Buffer Ring Replacement Strategy)¶
当运行需要一次性访问大量页面的查询时(例如 VACUUM 或大型顺序扫描),会使用不同的策略。 仅由此类扫描触及的页面不太可能很快再次被需要,因此与其运行正常的时钟扫描算法并吹掉整个缓冲区缓存,不如使用正常的时钟扫描算法分配一个小环(ring)的缓冲区,并在整个扫描过程中重用这些缓冲区。这也意味着由此类语句引起的大部分写流量将由后端本身完成,而不是推卸给其他进程。
-
顺序扫描:使用 256KB 的环。这足够小以放入 L2 缓存,这使得从 OS 缓存传输页面到共享缓冲区缓存变得高效。即使更少通常也足够了,但环必须足够大以容纳扫描中同时被 Pin 的所有页面。256KB 也应该足以留下一个小缓存轨迹,供其他后端加入同步顺序扫描。如果环缓冲区被弄脏且其 LSN 更新,我们通常必须在重用缓冲区之前写入并刷新 WAL;在这种情况下,我们改为从环中丢弃该缓冲区,并(稍后)使用正常的时钟扫描算法选择替换。因此,这种策略最适用于只读扫描(或者最多更新 hint bits 的扫描)。在修改扫描中每个页面的扫描中,如批量 UPDATE 或 DELETE,环中的缓冲区将始终被弄脏,环策略实际上退化为正常策略。
-
VACUUM:像顺序扫描一样使用环,但是,这个环的大小由 GUC 参数
vacuum_buffer_usage_limit控制。脏页面不会从环中移除。相反,如果需要,会刷新 WAL 以允许重用缓冲区。在 8.3 引入缓冲区环策略之前,VACUUM 的缓冲区被发送到空闲列表,这实际上是一个大小为 1 的缓冲区环,导致过多的 WAL 刷新。 -
批量写入:工作方式类似于 VACUUM。目前这仅适用于
COPY IN和CREATE TABLE AS SELECT。(使 seqscan UPDATE 和 DELETE 使用 bulkwrite 策略是否有趣?)对于批量写入,我们使用 16MB 的环大小(但不超过shared_buffers的 1/8)。较小的尺寸已被证明会导致 COPY 因 WAL 刷新而过于频繁地阻塞。虽然后台 vacuum 因执行自己的 WAL 刷新而变慢是可以接受的,但我们希望 COPY 不受此影响,所以我们让它使用更多的缓冲区区域。
Background Writer 的处理 (Background Writer's Processing)¶
Background Writer 旨在写出可能很快被回收的页面,从而将写入工作从活动后端卸载。
为此,它从 nextVictimBuffer 的当前位置向前循环扫描(它不会改变 nextVictimBuffer!),寻找那些脏的、未被 Pin 住且未标记正使用计数的缓冲区。它 Pin 住、写入并释放任何这样的缓冲区。
如果我们可以假设读取 nextVictimBuffer 是一个原子动作,那么 writer 甚至不需要获取 buffer_strategy_lock 来寻找要写入的缓冲区;它只需要自旋锁定每个缓冲区头足够长的时间来检查 dirtybit。即使没有这个假设,writer 也只需要获取锁足够长的时间来读取变量值,而不是在扫描缓冲区时。(与 PG 8.0 相比,这是 writer 争用成本的实质性改进。)
Background Writer 在写出缓冲区时对其获取共享内容锁(任何将缓冲区内容刷新到磁盘的人也必须这样做)。这确保了传输到磁盘的页面图像具有合理的一致性。我们可能会错过一两个 hint-bit 更新,但这不是问题,原因与缓冲区访问规则下提到的相同。
从 8.4 开始,background writer 在执行某种形式的潜在扩展恢复时在恢复模式期间启动。它提供与正常处理相同的服务,除了它写入的检查点在技术上是 restartpoints。