围绕 PostgreSQL[1]的工作让我更加专注于缓冲区。如果你是普通的PostgreSQL用户,可能听说过调整 shared_buffers,并遵循老建议,把内存设置为可用内存的四分之一。但在最近一期Postgres FM[2]节目中我们对他们过于热情后,有人问我这是怎么回事。
缓冲区是那种容易被遗忘的话题。虽然它们是PostgreSQL性能架构的基础,但我们大多数人都把它们当作黑箱。本文将试图改变这种状况。
在深入探讨缓冲区之前,有一个概念我们需要先讲清楚。这就是8KB页面的概念。PostgreSQL 中的所有数据都存储在宽 8KB 的块中。
PostgreSQL 读取数据时,不会逐行读取,而是读取整个数据页。写入数据时也是同理,同样以数据页为单位。即便你只想要查询一行体量很小的数据,系统也会顺带读取到更多相关数据。只要仔细理解就能发现,数据写入操作同样遵循这一规则。
-- you can check the block size (which should be almost always 8192 bytes)
show block_size;
block_size
------------
8192
(1 row)每张表格和索引都是这些页面的集合。如果一行足够大,可以跨越多个页面,但页面仍然是I/O的原子单位。
想看看里面吗? 如果你想了解 PostgreSQL 如何将你的数据、头部和行指针打包到这 8,192 字节中,可以查看 Inside PostgreSQL 8KB[3] 页面中的字节级导览。
有趣的是,为什么 PostgreSQL 需要维护自己的缓冲缓存基础设施,而操作系统已经可以缓存磁盘页面。
答案很简单。PostgreSQL 理解它读取的数据。而操作系统只看到文件和字节。PostgreSQL 能看到表格、索引、查询计划,并且具备语义知识,可以更快地缓存数据。
举个例子:某条查询语句需要对一张大表执行顺序扫描。操作系统或许会直接缓存所有相关数据页,但 PostgreSQL 清楚这只是一次性操作,于是会采用环形缓冲区这一特殊机制,防止主缓存中的数据被清理置换。
第二个重要方面是 ACID 原则,更准确来讲,是确保预写日志(WAL)先写入稳定存储,再对数据页进行修改。操作系统无法对此做出区分,也无法有效保障这类持久性需求,若要强行实现,只会造成性能损耗。
接下来我们来讲众所周知的 PostgreSQL 核心 "缓存"。shared_buffers 参数用于控制共享内存的大小,所有后端进程都能够访问该内存区域。当任意后端进程需要读取数据页时,会优先检索共享缓冲区。若该数据页已存在,即为缓存命中,无需执行磁盘 I/O 操作。若是缓存未命中,则从磁盘(或操作系统缓存)中读取数据页,并将其存入共享缓冲区,方便后续再次调用。
show shared_buffers;
shared_buffers
----------------
128MB
(1 row)默认的128MB非常保守,这是确保PostgreSQL默认安装几乎能在任何系统上运行(包括内存有限的系统)的一部分。但在你平常的环境中,这个数值通常应该高得多。
这里的128MB指的是实际缓冲的内容。如果你把单页看作8KB,可以想象成16,384个独立的数据插槽。
不过,缓冲池不仅仅是页面本身。PostgreSQL 需要跟踪元数据并提供快速查找,因此共享内存区域被组织为三个部分:
随后每个描述符都会记录槽位中缓存的页面信息(标签)、各类状态标志(脏页、有效页、正在进行输入输出操作)以及固定计数与使用计数。
哈希表可实现快速查找。当后端需要调取指定页面时,会对页面标识符进行哈希运算,直接定位到对应的哈希桶,无需遍历全部 16384 个槽位。如此一来,无论缓冲池规模大小,缓冲区查找操作的时间复杂度始终维持在常数级 O (1)。
当后端需要订单数据表的第 N 页数据时,会对标识符进行哈希运算并查询哈希表,以此实现命中与未命中的判定逻辑。
每个缓冲槽的处理由上述两个计数器决定:引脚和使用次数。
固定计数用于追踪活跃引用。当后端(例如正在执行的查询)正在读取或修改数据页时,会锁定对应的缓冲区,使其无法被淘汰。后端操作结束后,便会解除对该缓冲区的锁定。
使用计数用于追踪缓冲区的访问时长与访问频次。每一次访问都会使该计数值增加(最大值为 5)。执行驱逐操作时,时钟扫描机制会降低这一数值,计数值越高的缓冲区留存时间越久,计数值为 0 的缓冲区则会被驱逐。
使用计数器十分重要,可避免单次顺序扫描清空整个缓冲池这类问题。试想读取一张完整的 1GB 数据表,若没有该防护机制,扫描操作会清空共享缓冲区里的所有数据,无视那些被频繁访问的数据。后续我们也会在讲解环形缓冲区时,进一步探讨这一特定运行机制。
PostgreSQL 提供你使用扩展 pg_buffercache[4] 实时检查共享缓冲缓存中发生了什么的功能。
既然我们已经讲了使用跟踪,那么当PostgreSQL需要加载页面且所有插槽都被占用时会发生什么?它需要尝试腾出一些空间。
这就轮到时钟扫描算法登场了。为何取名时钟?不妨将缓冲池视作一个环形时钟,该算法会持续向前推进,一路遍历扫描。当它经过每一个槽位时:
时钟扫描是一种简便方式,能够确保冷页面被快速淘汰,而热页面通常可以留存下来,历经多次扫描后仍不被清理。
截至目前我们已经讲到,从磁盘加载的缓冲区与共享缓冲区缓存中的缓冲区是同一个。当后端进程修改该数据页后,该缓冲区就会变为 "脏缓冲区",但并不会立即写入存储设备。脏缓冲区代表着尚未执行的 I/O 操作,这是因为立即写入数据的效率极低。同一数据页可能在短时间内被多次修改,如此一来前期的 I/O 写入操作也就失去了意义。
脏页会持续累积,直至触发以下任一事件。
PostgreSQL 在检查点期间将所有脏缓冲写入磁盘。这是一个周期性过程(你可以用CHECKPOINT命令强制执行)。这是磁盘上数据变得一致的节点。崩溃恢复成功完成后,只需从此刻开始重放写入日志。
第二种机制是后台写入进程。它会持续扫描脏缓冲区,并在缓冲区被驱逐前将其中数据写入磁盘。如此一来,后端进程执行时钟清扫操作时,就能直接找到可直接驱逐的干净缓冲区,无需等待磁盘 I/O 操作完成。同时该机制还能将写入操作均匀分摊在不同时段,避免缓冲区驱逐压力出现时产生集中式的写入流量峰值。
正如前文所述,将脏页写入磁盘的最后时机,就是时钟扫描机制检索到脏缓冲区的时候。此时必须把数据写入磁盘,该缓冲区才能腾出空间用于存放新页面。这种情况属于最坏情形,因为该过程会触发同步输入输出操作。
最终目标是平衡可用空闲缓冲区的数量,进而避免后端服务在缓冲区驱逐过程中因同步写入操作发生阻塞。只要仔细梳理就能发现,不合理的运行流程或是参数配置都会引发各类故障。加载新缓冲区(即执行输入输出操作)时就会触发缓冲区驱逐机制,倘若被驱逐的是脏缓冲区,系统就不得不额外执行一次输入输出操作。
如上所述,还存在多种特殊类型的缓冲区。我们此前已经讲到过查询执行大表扫描时的相关场景。
在简易实现方案中,顺序扫描会将全部数据载入共享缓冲区,顺带清空其余所有缓存数据。已预热完成的缓存会彻底失效,后续查询操作也会在接下来数分钟内出现性能卡顿。
针对这种场景,PostgreSQL 的解决办法是环形缓冲区,这是专为批量操作设立的小型私有缓冲池。部分操作不会调用共享缓冲池,而是使用专属的有限环形缓冲区。
具体案例如下:
在超过四分之一 shared_buffers 的大型表上进行顺序扫描时,使用专用的256 KB环形缓冲区。页面会在这个小环中循环,从不触碰主缓存。
-- perform a sequential scan over a large table
EXPLAIN (ANALYZE, BUFFERS) SELECT count(1) FROM ring_buffer_test;这样就会显示缓冲区信息: Buffers: shared read=127285 ,而非缓存命中数。后续我会单独发布一篇文章,讲解如何解读执行计划中的缓冲区相关数据。
批量写入操作(COPY、CREATE TABLE AS)采用上限为 16 兆字节的环形缓冲区,该缓冲区大小足以实现高效批处理,同时体量小巧,不会挤占共享缓冲池资源。
由于 VACUUM 操作会遍历所有数据页且不应清理热数据,因此它会使用专属的独立环形缓冲区。该缓冲区以往固定为 256 千字节,而从 PostgreSQL 17 版本开始,可通过 vacuum_buffer_usage_limit 参数对其进行自定义配置(前文已介绍另外两类缓冲区相关配置)。
共享缓冲池的第二种例外情况是基于会话的临时表。该场景下不存在并发相关问题,每个后台进程都拥有独立的本地缓冲池,其大小由参数 temp_buffers 控制,默认值为 8 兆字节。
本地缓冲区比共享缓冲区速度更快,原因是其锁定机制更为简易,无需像主缓存那样进行繁杂的跨进程协同操作。
虽然这看似只是一处微小的实现细节,却能演变为一套高效的优化方案。不少开发者习惯采用复杂的公共表表达式逻辑来处理中间数据,而使用临时表具备显著优势,能够有效降低输入输出开销。这是因为临时表的数据变更不会被预写日志记录,并且其自身特性还能减少对共享缓冲池资源的占用与干扰。
如果业务场景中会用到大型临时表,调高 temp_buffers 参数能够让这类操作完全在内存中执行。但要注意,该内存资源是按单个连接分配的,所有后端进程叠加后会大幅占用内存总量。
PostgreSQL 不会绕过操作系统。所有读写操作都需经过系统内核,而内核会维护自身的页面缓存。这就形成了双重缓冲机制:同一个 8KB 大小的页面,能够同时存放在 PostgreSQL 共享缓冲区与操作系统缓存当中。
这看似是资源浪费,实则是一项实用特性。当 PostgreSQL 为腾出空间清空干净页面时,该页面会留存至操作系统缓存中,而非直接清除。片刻之后若再次需要该页面数据,操作系统可直接从内存中调取,无需进行磁盘读写操作。
操作系统还具备预读能力 —— 能够识别顺序访问模式,并在 PostgreSQL 发起请求之前预先加载数据页。
这种对应关系印证了一条经典配置建议:将 shared_buffers 大小设置为内存总量的 25%。这样做是特意预留出空间,让操作系统充当缓存兜底保障。
-- on dedicated servers with large RAM, 40% can work well
-- but always leave room for OS cache and other processes
ALTER SYSTEM SET shared_buffers = '8GB';PostgreSQL 需要了解这个合并缓存以便进行查询规划。这就是effective_cache_size发挥作用的地方——它告诉规划师在估算成本时应假设多少总缓存(共享缓冲区+操作系统)。
-- estimate of total cache available (shared + OS)
SHOW effective_cache_size;值越高,规划者会倾向于支持索引扫描,假设数据可能被缓存在某处,即使不在共享缓冲区中。
缓冲区是PostgreSQL内部结构的关键之一。它们控制查询是访问快速RAM还是低速磁盘;同时,它们在脏页、WAL和耐用性之间起着关键作用。
共享缓冲池不仅仅是缓存——它是一个复杂的内存管理器,支持时钟扫频驱逐、使用次数减少、环缓冲区隔离和后台维护。理解这些机制有助于你有效调校并在问题出现时及时诊断。
[1] PostgreSQL: https://boringsql.com/products/regresql/
[2] Postgres FM: https://postgres.fm/episodes/regresql
[3] Inside PostgreSQL 8KB: https://boringsql.com/posts/inside-the-8kb-page/
[4] pg_buffercache: https://www.postgresql.org/docs/current/pgbuffercache.html