Skip to content

PostgreSQL 中的 WAL — 4. 配置与调优(Setup and Tuning)

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

系列的最后一篇把前面三篇讲的原理(缓冲区缓存、日志本身、检查点)落到实际配置上,围绕三个主题展开:日志需要记多"细"(WAL level)、怎样保证写下去的日志真的可靠(可靠性相关参数)、以及在可靠性和吞吐量之间怎么取舍(性能相关参数)。

WAL 记录级别:wal_level

WAL 并不是对所有改动都记录同样多的信息,级别越高,记录的内容越丰富,但代价也越大。三个级别是层层叠加的关系,高级别在低级别记录的基础上再追加额外信息。

minimal:只保留崩溃恢复所必需的最少信息。为了省空间,一些批量操作(比如在同一个事务里先 CREATE TABLE 再整体 SELECT INTO,或者 CREATE INDEX)在这个级别下会走一条捷径——如果操作创建的对象在本事务提交前不可能被其他事务看到,那么与其把整个构建过程都记进 WAL,不如直接在提交时把这些新文件强制刷盘(fsync)来保证持久性,从而省下了大量本可以避免的日志量。这个级别下无法启用物理复制。

replica(也是默认级别):在 minimal 基础上,额外记录了支持"从一份基础备份出发、靠重放 WAL 追上当前状态"以及物理流复制所需的全部信息,包括排他咨询锁(exclusive advisory lock)相关的记录、以及每隔 15 秒记录一次的事务快照信息,供备库/只读副本据此判断可见性。

logical:在 replica 基础上,进一步加入逻辑解码(logical decoding)所需的信息——比如复制源(replication origin)相关记录,以及一些供逻辑复制插件自定义解读的"逻辑记录"。这是支持逻辑复制、以及诸如 pglogical、CDC 类工具的前提。

文章用一个实验直观展示了级别差异对日志量的影响:把 wal_level 降到 minimal(同时必须把 max_wal_senders 设为 0,因为该级别不允许有 WAL 发送进程),在事务内创建一张表再删除,对比同样操作在 replica 级别下产生的 WAL 字节数,minimal 级别明显更省:

sql
ALTER SYSTEM SET wal_level = minimal;
ALTER SYSTEM SET max_wal_senders = 0;
-- 重启生效

SELECT pg_current_wal_insert_lsn();

CREATE TABLE wallevel AS
  SELECT 1 AS n;

DROP TABLE wallevel;

SELECT pg_current_wal_insert_lsn();
-- 用两次 LSN 相减对比日志量

ALTER SYSTEM RESET wal_level;
ALTER SYSTEM RESET max_wal_senders;

写入可靠性:多层缓存与数据损坏

从应用程序发出一次写操作到数据真正落在物理磁盘的持久化存储介质上,中间其实要穿过好几层缓存:操作系统自己的页缓存、存储设备(或 RAID 控制器)自身的写缓存,甚至硬盘/SSD 内部也有一层缓存。这些缓存的存在都是为了性能,但每一层都意味着"进程以为写完了,其实数据还悬在半空中"的风险。PostgreSQL 依赖 fsync 这类系统调用,强制要求操作系统把数据真正下推到持久化介质,wal_sync_method 参数控制具体用哪种系统调用来做这件事;如果为了追求性能把 fsync 直接关掉,等于放弃了这层保证,一旦断电几乎必然导致数据损坏,生产环境不应该这样做。

页面写入的原子性问题与全页镜像(FPI)

数据库里的页面大小至少是 8KB(也可以配置成 16KB 或 32KB),但操作系统和磁盘执行底层写入的最小单位往往只有 512 字节或 4KB。这意味着,如果在写一个 8KB 页面的过程中恰好断电,可能只有页面的一部分真正写进了磁盘,另一部分还是旧内容——这就是所谓的"部分页写"(partial/torn page write)问题,仅靠常规的 WAL 重放逻辑(比较页 LSN 决定要不要应用某条记录)是处理不了这种"页面本身已经损坏、内部数据自相矛盾"的情形的。

PostgreSQL 用全页镜像(Full Page Image,FPI)来对付这个问题:每当一个检查点之后,某个页面第一次被修改时,WAL 里除了记录常规的改动内容外,还会额外把这个页面完整的当前内容整体写进日志。这样一来,即使后续真的发生了部分写导致页面损坏,恢复时只要用这份完整镜像直接覆盖整个页面(不管页面当前状态如何,FPI 都会无条件生效,这也是第二篇提到的恢复算法中的一条例外规则),就能把它恢复到一个已知的、完整一致的状态,之后再顺序重放它之后的常规记录即可。

full_page_writes 参数控制是否开启这个机制,默认是开启(on):

sql
SHOW full_page_writes;

代价是显而易见的:每个检查点周期内,每个被修改的页面第一次改动都要多写一份完整 8KB(或更大)的镜像,这会显著推高 WAL 体积。为了缓解这个问题,wal_compression 参数(PostgreSQL 9.5 引入)可以让这些全页镜像在写入前先压缩,在不牺牲原子性保护的前提下削减日志体积。

文章用 pgbench 做了一组对比实验,量化了两个参数的效果(30 秒压测窗口):

  • full_page_writes = on:约 25MB WAL,26851 笔事务;
  • full_page_writes = off:约 24MB WAL,27234 笔事务;
  • full_page_writes = onwal_compression = on:约 13MB WAL,26833 笔事务。

在开启了数据校验和(checksum)的集群上,用 pg_waldump --stats 统计发现 FPI 记录占了整个 WAL 体量的大约 56%,说明全页镜像确实是 WAL 体积的重要组成部分,wal_compression 在这种场景下能带来相当可观的收益,而单纯关闭 full_page_writes 虽然也能省空间,却直接放弃了对部分页写的保护,通常不建议在没有其他等效保护手段时这么做。

数据校验和(checksum):发现硬件层面的损坏

FPI 保护的是"写入过程中断电导致的部分写",但没法发现磁盘介质本身随时间推移产生的静默数据损坏(bit rot、硬件故障等)。为此 PostgreSQL 支持在初始化集群时开启数据页校验和:

sql
SHOW data_checksums;

一旦启用,每个页面在写盘前都会计算并存入一个校验和,读取时重新计算并比对,一旦发现不一致就报错,避免带着已经损坏的数据继续计算下去。相关参数 ignore_checksum_failure 可以在明确知道情况的前提下允许强行读取校验和不匹配的页面(用于抢救数据等场景,正常不建议开启);wal_log_hints 则是在没开启数据校验和的集群上,仍然可以选择性地把"提示位"(hint bit)修改也写入 WAL,这个功能本身也是为了支持某些依赖页面级校验/增量备份工具(如 pg_probackup)而存在的一个折衷开关。

上述 full_page_writeswal_compressiondata_checksums 三者是彼此关联又各自独立的机制:全页镜像对抗部分写,校验和对抗静默损坏,压缩则是在保留全页镜像保护的前提下降低其体积成本,实际生产环境的推荐组合通常是三者都开启。

性能相关:同步与异步提交

写日志本身是顺序 I/O,即使在机械硬盘上效率也相当不错,因此文章建议如果条件允许,把 WAL 单独放在一块独立的物理磁盘上,避免和随机 I/O 密集的数据文件互相干扰。

真正对吞吐量影响最大的,是提交(COMMIT)行为要不要等待日志真正落盘:

  • 默认情况下 synchronous_commit = on:事务提交前,必须等到对应的 WAL 记录已经被同步写到磁盘,才会向客户端返回"提交成功",这保证了一旦客户端收到成功响应,这个事务在任何后续崩溃后都不会丢失——这是标准的持久性(Durability)保证。
  • 把它设为 off(异步提交):提交在逻辑上立即完成,返回给客户端,但对应的 WAL 记录可能还没真正落盘,而是留给后台的 WAL writer 进程稍后再统一处理。这样做能显著提高吞吐量,但代价是一旦此时发生崩溃,最近一小段时间内已经"提交成功"的事务有可能在恢复后凭空消失——用可控的数据丢失窗口换取性能。
sql
ALTER SYSTEM SET synchronous_commit = off;
SELECT pg_reload_conf();

文章的 pgbench 测试给出了直观的数字:同步提交约 895 TPS,异步提交约 1514 TPS,接近 70% 的吞吐提升,可见这个参数对高并发写入场景的影响相当可观。

需要强调的是,synchronous_commit 是可以按会话甚至按事务动态设置的(SET LOCAL synchronous_commit = off;),因此完全可以做到"关键的金融交易走同步提交确保绝不丢失,普通的日志类、消息类写入走异步提交换取吞吐"这种混合策略,不必对整个集群一刀切。

WAL writer 进程的写入节奏

即便在同步提交模式下等待的是"当前事务对应的记录被写盘",WAL 本身仍然依赖一个专门的 WAL writer 后台进程按周期把内存里 WAL 缓冲区的内容刷向磁盘,间隔由 wal_writer_delay 控制(默认 200 毫秒)。它的写入策略比较讲究:每次醒来时,优先只把从上次醒来后已经"完全填满"的整页写盘,避免反复同步同一个仍在被写入中的、尚未填满的页面;只有在没有任何完整页面可写、又超过了一定周期的情况下,才会把当前这个尚未填满的部分页也强制写一次。在异步提交模式下,数据可能丢失的时间窗口大致是几倍 wal_writer_delay(文章给出的说法是"不到 3 倍 wal_writer_delay",在默认配置下大约略超过半秒)。

攒批提交:commit_delay 与 commit_siblings

除了整体切换同步/异步这种"全有全无"的选择外,PostgreSQL 还提供了一种折中手段:commit_delay 参数允许在真正把 WAL 刷盘之前,先主动等待若干微秒,如果这段时间内又有其他并发事务也排队要提交,就可以把它们的日志记录合并成一次磁盘同步操作一起刷出去,这就是所谓的"电梯算法"式攒批——用极小的单次延迟换取更少次数的(相对昂贵的)磁盘同步调用,从而提高整体吞吐。但这个延迟只有在同时活跃的事务数达到 commit_siblings 指定的门槛时才会真正生效,避免在低并发场景下平白无故拖慢单个事务的提交延迟。

小结

这一篇把整个 WAL 体系的"旋钮"讲全了。wal_level 决定日志记录的详细程度,需要根据是否要做物理复制、逻辑复制来选择,级别越高开销越大;可靠性方面,全页镜像(full_page_writes)对抗断电导致的部分页写、数据校验和(data_checksums)对抗介质静默损坏、wal_compression 则在保留全页镜像保护的同时压低其体积代价,三者通常应该在生产环境同时启用;性能方面,synchronous_commit 是持久性和吞吐量之间最直接的权衡开关,可以按需在会话或事务级别灵活设置,配合 commit_delay/commit_siblings 的攒批机制以及 WAL writer 自身的写入节奏(wal_writer_delay),共同决定了整个数据库在"绝不丢一笔提交"和"尽可能快"之间落在哪个点上。

至此,"WAL in PostgreSQL"系列四篇——缓冲区缓存如何管理内存中的脏页、预写日志如何保证崩溃后可恢复、检查点如何限定恢复范围并驱动脏页落盘、以及一整套围绕可靠性与性能的配置参数——共同构成了理解 PostgreSQL 崩溃恢复与持久性机制的完整图景。