深入浅出 Shared Buffers
前言
这两天看到了一份不错的分享 —— The Alchemy of Shared Buffers: Balancing Concurrency and Performance,讲述了 Shared Buffers 的核心价值,Shared Buffers 是 PostgreSQL 最重要的共享内存子系统,也是理解 PostgreSQL 内核性能的入口之一,此文会顺着作者的思路,带各位读者一探究竟神秘的 Shared Buffer。
正文
因为 PostgreSQL 不是线程模型 (截止 PG18,关于多线程的讨论也一直在进行中),所以高度依赖共享内存来共享状态和数据,每个连接、每个后台任务都是独立 OS 进程,进程间靠共享内存通信,要理解 Shared Buffers ,必须先理解 PostgreSQL 为什么需要一块"所有进程都能看到的内存区域"。
Linux 借助 tmpfs 提供共享内存语义;PostgreSQL 早期用 SysV,共享内存,后来从 9.3 开始默认转向基于 mmap 的 POSIX 共享内存。简而言之,PG 的 shared buffers 是数据库层逻辑,但底层承载它的,仍然是 Linux 的虚拟内存与映射机制。
我们通常通过 /dev/shm 接触 tmpfs;其可以动态扩缩容,但仍然受到 RAM 和 swap 限制;在 Docker 环境中,默认的 /dev/shm 很小,只有 64MB,可能导致 PostgreSQL 报 shared memory 相关错误。很多人把 PostgreSQL 在容器里启动失败,笔者见过太多这样的案例了,误以为是数据库配置问题,实际上常常是容器共享内存配额不够,可以使用 docker-run --shm-size 调大一点 (或者 shm_size docker-compose)。
在这一页中,tmpfs 数据页本质上是匿名内存页,可被 swap;并提到 tmpfs 支持 THP,但 PostgreSQL 官方通常建议数据库服务器禁用 THP。还提到 tmpfs 支持 NUMA 分配策略,shared buffers 虽然重要,但它仍然活在 Linux 的内存调度规则之内。
接下来回顾了一下 PG 的共享内存历史,早期版本依赖 SysV IPC,默认大小受限,因此操作系统层面我们经常需要去调整那些 shmmax/shmmin 等参数;从 9.3 起,PG 默认改为 mmap。在文章提到了这样一句话,感兴趣的读者可以做下实验
SysV allowed PG to detect multiple postmasters accessing the same data directory
在 postmaster 启动时,PG 便会分配共享内存;收到后端连接请求后调用 fork(),然后子进程会继承 MAP_SHARED 映射,因此所有进程看到同一块共享区域。文中还提到 Huge Pages 可以降低 TLB miss
shared buffers 是同一块内存被多个进程共享映射,不是每个进程各自一份。
接下来讲述了大页,默认 Linux 没开,vm.nr_hugepages=0;如果系统没预留 huge pages,huge_pages=try 基本没有实际效果;配置错误还可能带来内存问题,关于大页的好处就不再过多赘述。
在较新版本中,PG 还提供了 shared_memory_size_in_huge_pages 只读参数,我们可以在正式启动前进行验证,用于帮助我们进行估算所需的大页数,比如:
[postgres@mypg ~]$ postgres -D $PGDATA -C shared_memory_size_in_huge_pages73
作者举例说 8GB shared buffers 大约需要 4096 个 2MB huge pages,但实际值会更大,因为还包括其他共享内存对象。一个更稳妥的方式是:先强制使用,再观测实际消耗,再收敛配置,比如先设 huge_pages=on,启动实例,查看实际消耗,再把 vm.nr_hugepages 调整到合适值;另外作者提到长查询性能收益大约可到 10%-15%
然后作者介绍了老生常谈的透明大页 THP,Linux 发行版通常默认开启;内核线程如 khugepaged、kcompactd、kswapd 会参与页合并、压缩、交换;这些操作会带来延迟尖峰,因此 THP 不适合 PostgreSQL,值得注意的是,Huge Pages 和 THP 完全不是一回事。前者是显式、可控的;后者是动态、不可控的,动态合并/拆分页会带来不可预测的停顿和尖刺。作者还特意比对了一下其他的数据库
1.Oracle 强烈建议 Huge Pages 并禁用 THP2.MySQL/MariaDB 配置更复杂也常建议禁用 THP3.Redis、ClickHouse、Couchbase 等也往往不喜欢 THP
前文提到,说明 PostgreSQL 虽然主共享内存已默认走 mmap,但启动时仍会分配一个很小的 SysV interlock segment,用于实例存在标识、同步和防止多个 postmaster 同时使用同一数据目录,我们可以使用 ipcs -m 进行观察,比如我这个小破云主机上启了两个实例
[postgres@mypg ~]$ ps -ef | grep postgres | grep -w bin | grep -v greppostgres 12567 1 0 18:20 ? 00:00:00 /usr/pgsql-17.4/bin/postgrespostgres 16288 1 0 Feb02 ? 00:00:12 /usr/pgsql-12/bin/postgres[postgres@mypg ~]$ ipcs -m------ Shared Memory Segments --------key shmid owner perms bytes nattch status0x0052e6a9 262157 postgres 600 56 60x0014e046 262160 postgres 600 56 6
除了众所周知的 Shared Buffers,还有其他共享内存对象:WAL buffers、SLRU buffers、lock table 以及其他控制结构;并指出 shared_memory_size 反映的是主共享内存区域大小。
接下来两页正式介绍 Shared Buffers —— 它是 PostgreSQL 最大、最常被讨论的内存对象,是表和索引数据块的内存副本,启动时分配,由所有连接共享,如果把 PostgreSQL 看成一台机器,shared buffers 就是最核心的"数据工作台"。
我们可以通过多种手段进行观测,比如
•EXPLAIN (ANALYZE, BUFFERS) 中的 shared hit/read/dirtied/written•pg_stat_activity / wait event 中的 BufferPin、BufferContent(热点页竞争)、BufferMapping(映射/淘汰压力大)
那么 Shared Buffers 有没有最大值?在内部,其用页数表示,理论上可以很大,但实际上还收到 descriptor 内存、TLB、checkpoint、淘汰扫描成本等限制。
然后介绍了一下源码结构,给出关键结构体源码片段:BufferTag、BufferLookupEnt、BufferDesc、BufferDescPadded。BufferDesc 中包含 state、freeNext、io_wref、content_lock,BufferTag 负责"它是谁",BufferLookupEnt 负责"去哪找",BufferDesc 负责"它现在状态怎样"。接下来则是一大堆源码细节,此处就略过不提,可以参照 Internals DB。
接下来的一页值得特别关注,介绍了 PrivateRefCount
为了避免同一个 backend 在一次查询里反复对同一个 buffer 做"共享 refcount 加一/减一"而造成锁争用 (比如复杂查询 NestLoop、游标操作等),PostgreSQL 在每个进程本地又维护了一份"小型引用计数缓存",这就是 PrivateRefCount,它是一个减少共享内存竞争的优化机制,每个 backend 进程本地都有一个小数组,叫 PrivateRefCountArray,用来记录"我这个进程当前额外 pin 了哪些 buffer、各自 pin 了几次",其是进程私有,不在 Shared Buffer 中,所以它的特点是:
•访问快•不需要和别的 backend 同步•不会产生共享 cache line 抖动•很适合处理"本进程内部重复引用"的情况
这个数组只有 8 个 entry,目的是尽量放进 CPU cache,以覆盖最常见的重复 pin 场景。因此,对于同一个 backend 来说,只有"第一次 pin"和"最后一次 unpin"才需要更新 shared refcount;中间所有重复 pin/unpin,都只在本地的 PrivateRefCount 里增减。
同一进程在一次查询里可能多次 pin 同一 buffer;如果每次都改共享 refcount 会造成争用,于是 PostgreSQL 先在进程本地缓存一份计数。
接下里是 Partitioned Buffer Locks:buffer lookup table 分成 128 个 partition,每个分区一个轻量锁,这样的话我们就不必每次锁整个表,这是 shared buffers 并发扩展性的核心设计之一。PostgresPro 将此参数 NUM_BUFFER_PARTITIONS 设为了 512,奥利奥则尝试将内存缓冲区和磁盘缓冲区结合起来。
那么为什么 PostgreSQL 要把 Buffer Mapping 的锁做成分区锁 (partitioned locks),以及为什么默认分区数是 128?让我们好好捋一下。
PostgreSQL 的 shared buffers 里,查找某个 page 是否已经在缓存中,要经过一个 buffer lookup hash table。这个 hash table 不可能只用一把全局锁,否则高并发下所有 backend 都会排队。所以 PG 采用的办法是:
•把整个 buffer mapping hash table 切成多个 partition•每个 partition 配一把独立的 LWLock•一个 buffer tag 经过 hash 后,会落到其中某一个 partition•只锁对应 partition,而不是锁整个映射表
这样就把一把大锁拆成了多把小锁。至于为什么是 128?前提首先必须是 2 的幂,因为如果 partition 数量是 2 的幂,那么从 hash 值映射到 partition 时,就可以直接用位运算来取低几位,而不需要做较慢的取模运算,例如:
•128 = 2^7•那么一个 hash 值落到哪个 partition,本质上只要看它的某几位即可
这在 shared buffers 这种高频路径里很重要,因为每次访问 buffer,几乎都要走这个映射过程。至于 128 则是一个"折中值",小了那么并发冲突大,打了则全局操作变重,比如典型的 DROP TABLE/ TRUNCATE TABLE (参照之前的案例 从一个罕见案例聊聊我对社区的看法),并不是只碰某一个 partition 的操作,这些操作在某些阶段,可能需要:
•扫描大量 buffer•或者按顺序拿很多 partition lock•或者为了保证一致性,逐个 partition 做同步处理
于是分区数越多,这类操作的成本就越高:
•需要获取更多把锁•锁获取是串行发生的•更容易造成停顿•管理开销增加
这就是 trade-off。作者也提到 Probability of collision for two random tags is 1/128 = 0.78%,如果两个完全随机的 buffer tag,经过 hash 后映射到 partition,那么它们落在同一个 partition 的概率,大约就是 0.78%,因此如果 hash 分布足够均匀,如果访问模式比较随机,那么单看两次随机访问撞到同一 partition 的概率,其实不高。所以把锁拆成 128 份后,理论上并发冲突会被大幅摊薄。对于大机器,比如几百核 + 几百GB 内存,设置更高的分区数,可能会有帮助,更高分区数的收益,通常只会在超大机器上更明显。但在超大 NUMA 机器、超多核服务器上,128 个 partition 可能显得太少,这块内容记得唐成老师也有过测试,感兴趣的读者可以搜索一下。
Ring Buffer 就不做过多描述,此处略过,可以参照 https://www.interdb.jp/pg/pgsql08/05.html。
然后作者开始用 /proc/PID/smaps 观察单个 PostgreSQL 会话的内存布局:新建连接时,可看到 [anonymous]、[heap]、/dev/shm/...,以及一个很大的 /dev/zero (deleted) 区域
这里专门解释了,为什么 shared buffers 在 smaps 里会显示为 /dev/zero (deleted):因为 PostgreSQL 请求的是 MAP_ANONYMOUS | MAP_SHARED 的匿名共享映射,Linux 内核内部会创建一个 tmpfs 的"伪文件对象"作为管理句柄,它不挂到 VFS,因此显示成 (deleted)。
最后作者分享了几个有趣的实验,
1. 运行一个重查询后,再看 top 和 smaps:在 32GB 内存、38GB 表、8GB shared_buffers、64MB work_mem 的实验里,查询后 /dev/zero (deleted) 的 RSS 大幅上升,说明 shared buffers 被实际触页、装载,表明 shared buffers 并不是启动瞬间就把所有物理内存都"占满",而是按访问逐步落到物理页
2. 作者对多个会话做实验,分别统计"包含 /dev/zero"和"剔除 /dev/zero"两种 smaps 汇总。结论很直观:如果把 /dev/zero 那部分算进去,多个 postgres 进程看起来像各自占了很多 GB;但去掉它之后,每个连接的私有内存其实小得多,因此我们日常统计内存使用的时候,不能简单把多个 postgres 进程的 RES 相加。因为 shared buffers 会在每个进程地址空间里重复映射显示,比如看到 5 个 postgres 进程各占 8GB,不代表机器真的被用了 40GB
3. 最后一个实验是 Why Transparent Huge Pages Are Not Good for PostgreSQL
通过不同 work_mem(8/32/64/128MB)的实验,说明查询执行期间匿名内存和 heap 会增长;即使 work_mem 增大,排序依然可能是 external merge 并落盘,而进程堆叠 RSS 会增长到 work_mem 的 1.7x-2.2x 左右,这页是在用实验反证:THP 不适合 PostgreSQL 的一个重要原因,是查询执行期会不断产生中等规模匿名内存,THP 试图合并这些页时容易制造抖动,work_mem 不是"最多就这么多",实际进程峰值内存通常还会更高。
小结
shared buffers 从来都不只是一个存放数据页的内存区域,它同时承担了数据复用、并发控制、状态管理和淘汰协调等多重职责。无论是 partitioned buffer locks 通过拆分锁粒度来降低冲突,还是 PrivateRefCount 通过本地计数减少共享状态更新,它们背后体现的都是同一种思路:尽可能让高频路径更轻,让共享争用更少,让系统在复杂并发下依然保持可控。
参考
The Alchemy of Shared Buffers: Balancing Concurrency and Performance