Size matter

如今,处理拥有数百 GB 内存的机器是很常见的。 充足的内存可以为 PostgreSQL 带来巨大的性能提升。 然而,事情的运作方式可能与您预期的略有不同。 让我们一起来了解一下!

shared_buffer 的问题

shared_buffer 是 PostgreSQL 在服务器内存中分配的一段区域,用于管理数据区和后端之间的数据流动。 无论是读取还是更改数据,数据库的物理页面都存储在 shared_buffer 中。 在正常的数据库活动期间,驱逐已存储的缓冲区以为 shared_buffer 中不存在的数据页面腾出空间是很常见的。

在早期的 PostgreSQL 7.4 中,有一个简单的最近最少使用(LRU)算法。 这种简单的方法有很多限制,特别是因为它没有考虑候选缓冲区是否被频繁使用。

在 PostgreSQL 8.0.0 中,内存管理器被自适应替换缓存(ARC)取代。 然而,由于 ARC 拥有专利,它在 PostgreSQL 8.0.1 中被效率较低的算法所取代。

PostgreSQL 8.1 再次用一种名为 clock sweep 的新实现取代了效率低下的 2q 算法,该算法至今仍在现代 PostgreSQL 中使用。

Clock Sweep 算法

shared_buffer 中,有一个“空闲列表”,其中包含用于替换的主要候选缓冲区。 不包含有效页面的缓冲区始终在此列表中。 应该可以将缓冲区添加到此列表中,例如,如果它们的页面不太可能很快被需要,但当前算法永远不会这样做。

因此,正常的缓冲区替换策略由 clock sweep 通过一个名为 nextVictimBuffer 的循环缓冲区来管理,该缓冲区受到一个单独的系统范围内的自旋锁(称为 buffer_strategy_lock)的保护。

这个自旋锁为访问缓冲区空闲列表或选择用于替换的缓冲区提供互斥。 当持有 buffer_strategy_lock 时,不应获取任何其他类型的锁,从而允许缓冲区替换在多个后端中以合理的并发方式发生。

正如 src/backend/storage/buffer/README 中解释的那样,内存中的每个缓冲区都有一个使用计数器,每次后端固定缓冲区时,该计数器都会增加一个单位,直到达到一个小的限制值。

当搜索要驱逐的缓冲区时,clock sweep 算法执行以下操作:

  1. 获取 buffer_strategy_lock
  2. 如果空闲列表中有缓冲区,则删除其头部缓冲区并释放 buffer_strategy_lock。 如果所选缓冲区被固定或具有非零使用计数,则忽略它并返回步骤 1。 否则,固定该缓冲区并返回它。
  3. 当缓冲区空闲列表为空时,选择 nextVictimBuffer 指向的缓冲区,并循环前进 nextVictimBuffer,为下次做好准备。 然后释放 buffer_strategy_lock
  4. 如果所选缓冲区被固定或具有非零使用计数,则如果非零则递减其使用计数,然后重新获取 buffer_strategy_lock,并返回步骤 3 以检查下一个缓冲区。
  5. 固定所选缓冲区,然后返回。

请注意,如果所选缓冲区是脏的,则必须先将其写出才能回收它。 如果同时固定了缓冲区,则整个过程将放弃并尝试另一个缓冲区。

首先要明确的是,空闲列表仅在实例启动后的固定时间内使用。 当所有缓冲区都有一个有效的页面时,空闲列表将变为空,并且该算法将不再重新填充它。 当空闲列表为空时,nextVictimBuffer 将始终在 shared_buffer 中移动,搜索用于驱逐的候选对象。

  ---
id: 7abddde8-f6cd-4e1c-998e-1afa6c2bc327
---
flowchart TD
  A(Start Clock Sweep) --> B{Is Current Buffer a Candidate?}
  D --> B
  B -- No --> D[Advance Clock Hand]
  
  B -- Yes --> C{Is Buffer in Use?}
  
  
  C -- Yes --> D[Advance Clock Hand]
  C -- No --> F{Is Buffer Dirty?}
  F -- Yes --> G[Schedule Write to Disk]
  F -- No --> H[Evict Buffer]
  
  G --> I
  H --> J[Replace with New Buffer]
  I[Wait for Write Completion] --> H
  J --> K(End Clock Sweep)
  style A fill:#fff,stroke:#333,stroke-width:2px
  style B fill:#ccf,stroke:#333,stroke-width:2px
  style C fill:#9cf,stroke:#333,stroke-width:2px
  style D fill:#fcc,stroke:#333,stroke-width:2px
  
  style F fill:#f9c,stroke:#333,stroke-width:2px
  style G fill:#f6c,stroke:#333,stroke-width:2px
  style H fill:#c9f,stroke:#333,stroke-width:2px
  style I fill:#9ff,stroke:#333,stroke-width:2px
  style J fill:#9fc,stroke:#333,stroke-width:2px
  style K fill:#eee,stroke:#333,stroke-width:2px

由于使用计数器在缓冲区固定时递增,因此也很明显,当空闲列表变为空时,shared_buffer 中的所有缓冲区的使用计数都将至少设置为 1。 因此,在找到候选缓冲区之前,至少需要一次完整的 shared_buffer 扫描。

环形缓冲区策略

在某些可能导致 shared_buffer 完全重写的条件下,正常的缓冲区替换策略将被覆盖。 诸如 VACUUM 之类的批量操作或大型顺序扫描将使用一个小的环形缓冲区,该缓冲区不会影响 shared_buffer 的其余部分。

特别是对于大型顺序扫描,使用 256KB 的环。 对于 VACUUM,环的大小由 vacuum_buffer_usage_limit GUC 控制。 对于批量写入(当前仅 COPY IN 和 CREATE TABLE AS SELECT),环的大小将为 16MB,但不超过 shared_buffers 大小的 1/8。

中庸之道

PostgreSQL 文档 建议将 shared_buffer 设置为系统 RAM 的 25% 作为起点,不鼓励超过 40%。

当服务器内存介于 4 GB 和 100 GB 之间时,设置为 RAM 的 25% 的 shared_buffer 可以很好地工作,范围介于 1GB 和 25GB 之间。 但是,对于小于 4 GB 的内存,正确调整 shared_buffer 的大小并非易事,并且取决于许多因素,包括 Linux 上的 vm 设置。

shared_buffer 大小的有趣方面是当有大量 RAM 时。 例如,如果我们考虑一个具有 400 GB 的系统,则 25% RAM 的 shared_buffer 应为 100 GB。

大型 shared_buffer 的性能取决于底层数据区域。 最有利的情况是数据区域小于 shared_buffer。 所有缓冲区都将仅使用空闲列表缓存在 shared_buffer 中。 然后,clock sweep 不需要额外的工作来管理内存。 使用计数器将达到最大值,并且永远不会下降。

但是,如果数据区域不适合 shared_buffer,则空闲列表将变为空,然后正常的缓冲区替换策略将启动。 当将缓冲区加载到内存中时,会将其固定,因此使用计数从 1 开始。 这意味着,对于完全打包的 shared_buffernextVictimBuffer 应至少扫描所有缓冲区一次,以查找使用计数设置为零的缓冲区。

现代 DDR4 内存的理论吞吐量为 25-30 GB/s。 更实际的范围是 5-10 GB/s。 对于 100 GB 的完全打包的 shared_buffer,执行单个完整扫描所需的时间范围在 3 到 20 秒之间。

显然,时间可能会因条件和工作负载而异。 例如,如果 nextVictimBuffer 将缓冲区的使用计数设置为零,但随后另一个后端在再次扫描之前固定了它,则无法在下次扫描时驱逐该缓冲区,从而增加了等待新缓冲区的时间。

根据经验,考虑到 64GB 是 shared_buffer 的上限,然后出现回归是合理的。

总结

调整 PostgreSQL 的 shared_buffer 大小并非易事。 了解缓冲区管理器如何处理块驱逐以获得底层数据区域的正确设置非常重要。

在任何情况下,除非您正在进行本地测试,否则更改 initdb 设置的 shared_buffer 默认值非常重要。 该参数的值保守地设置为少量内存(当前为 128MB),并且 PostgreSQL 即使使用默认设置也可以正常工作,但这并不是忘记它的好理由。