11 日志模式
PostgreSQL 18 · 基于俄文 PG18 原版的简体中文译本
11.1. 性能
服务器正常运行时,会持续顺序写入日志文件。由于几乎不存在随机访问,HDD 也完全可以应付这项工作。但这种负载特征与数据文件的访问方式有很大差异。因此,把日志放在单独的物理存储上可能更有利:可以将 PGDATA/pg_wal 目录替换为指向已挂载文件系统目录的符号链接。(v. 17)
日志写入有两种模式:
同步模式——提交事务时,只有等该事务的所有日志记录都写入磁盘后,才能继续工作;
异步模式——事务立即完成,日志在后台写入。
该模式由 synchronous_commit 参数设置。(默认值: on)
同步模式。 要可靠地保存提交这一事实,仅仅把日志记录交给操作系统还不够;还必须确保它们已经与磁盘完成同步。由于同步涉及实际的(也就是缓慢的)输入输出,因此应尽量少执行同步。
为此,负责完成事务并写入日志的服务进程可以按照 commit_delay 参数指定的时间短暂暂停。不过,只有系统中至少有 commit_siblings 个活跃事务时才会这样做3:等待期间,其中一些事务可能完成,这样服务器就能一次性同步所有日志记录。这很像扶住电梯门,让别人也能赶上。(默认值: 0s) (默认值: 5)
使用默认值时不会发生暂停。只有在系统执行大量短 OLTP 事务时,修改 commit_delay 参数才有意义。
可能的暂停结束后,负责完成事务的进程会把积累的所有日志记录刷到磁盘,并执行同步(重要的是,磁盘上必须有提交记录以及此前与该事务有关的所有记录;其余记录被写入,只是因为这样不会增加成本)。
从这一刻起,事务就被视为可靠完成4,并保证满足 ACID 要求中的持久性(Durability)。因此,同步模式默认启用。
同步写入的代价是响应时间变长(COMMIT 命令要等同步结束才会返回控制权),系统吞吐量降低,尤其是在 OLTP 负载下。
异步模式。 要启用异步写入5,需要把 synchronous_commit 参数设置为 off。
在异步模式下,日志记录由 walwriter 进程写入磁盘6;该进程在工作周期和等待之间交替运行。暂停时长由 wal_writer_delay 参数设置。(默认值: 200ms)
每次暂停后醒来时,进程都会检查缓存中是否出现了新的完全填满的 WAL 页面。如果出现了,就把这些页面写入磁盘,跳过当前页面。如果从上次运行以来没有任何日志页面完全填满,进程就写入当前尚未填满的页面,毕竟它已经醒来了7。
这个算法的目标是尽量避免多次刷写同一个页面;在修改量很大的负载下,这一点很重要。
虽然日志缓存采用环形缓冲区,但写入到达缓存最后一个页面时就会停止;暂停后,下一轮写入从第一个页面开始。因此在最坏情况下,walwriter 要到第三轮才能处理某条日志记录:先刷写位于缓存末尾的所有已填满页面,再回到开头,最后处理包含目标记录的未填满页面。不过,大多数情况下一到两轮就够了。
每写入 wal_writer_flush_after MB 数据后都会同步一次,并在写入周期结束时再同步一次。(默认值: 1MB)
异步写入比同步写入更高效,因为提交不必等待物理写入磁盘。但可靠性会降低:如果故障发生在提交后不到 3 × wal_writer_delay 个时间单位之内,已经提交的数据可能会丢失;按默认设置,这段时间为 0.6 秒。
实际情况是,这两种模式相互补充。即使采用同步提交,长事务的日志记录也可以异步写入,以释放 WAL 缓冲区。反过来,如果从缓冲区缓存逐出页面时发现相应日志记录还没有写入磁盘,那么即使处于异步模式,也会立即刷写该记录,否则系统无法继续工作。
不过,系统设计者通常仍然要在效率和持久性之间做出艰难选择。
synchronous_commit 参数也可以针对单个事务设置。如果应用层能够把事务分为绝对关键的事务(例如处理财务数据的事务)和不太重要的事务,就可以只牺牲部分事务的可靠性来提高性能。
为了大致了解异步提交带来的收益,我们使用 pgbench 基准测试8比较两种模式下的响应时间和吞吐量。
初始化所需的表,并在同步模式下运行 30 秒测试:
然后在异步模式下运行相同的测试:
在这个简单测试中,异步提交显著降低了响应时间(latency),并提高了吞吐量(tps)。当然,具体系统在具体负载下会得到不同的比例,但可以看出,对短 OLTP 事务来说,效果可能非常明显。
恢复参数的默认值:
11.2. 可靠性
显然,日志机制必须保证在各种情况下都能从故障中恢复(当然不包括存储介质本身被毁坏的情况)。有许多因素会影响数据一致性,其中最重要的是缓存、数据损坏和写入非原子性9。
缓存
数据到达非易失性存储(例如硬盘盘片)之前,会经过多层缓存。
向磁盘写入的系统调用只会让操作系统把数据移入自己的缓存(这个缓存也位于运行内存中,同样容易因故障丢失)。真正的写入会根据操作系统输入输出调度器的设置异步进行。
调度器决定写入数据后,数据会进入存储设备(硬盘)的缓存。存储设备的电子部件也可能推迟写入,例如把适合同时写入的数据收集成一组。RAID 控制器又在操作系统和磁盘之间增加了一层缓存。
如果不采取特殊措施,就无法知道数据何时可靠地保存下来。通常这并不重要,因为我们有日志;但日志记录本身必须立即可靠地保存10。异步模式也一样,否则无法保证日志记录先于修改后的数据写入磁盘。
检查点也必须可靠地保存数据,确保脏页真正刷到磁盘,而不是留在操作系统缓存中。此外,检查点还要同步此前由其他进程执行的所有文件操作(例如写入页面或删除文件):检查点完成时,这些操作的结果也必须已经可靠地写入磁盘11。
还有一些情况也要求可靠写入,例如在日志级别 minimal 下执行无日志操作。
操作系统提供了多种手段,用来保证数据立即写入非易失性存储。
这些手段可以归结为两类:写入之后执行单独的同步命令(fsync、fdatasync),或者在打开文件或向文件写入时指定同步要求(甚至指定绕过操作系统缓存直接写入)。
pg_test_fsync 工具可以帮助确定特定操作系统和文件系统最合适的日志同步方式;选定的方式由 wal_sync_method 参数指定。其他情况会自动选择同步方式12。
选择方法时还要考虑硬件特性,这是一个容易被忽略的细节。例如,如果使用带备用电池的控制器,就没有理由不用它的缓存,因为电池可以在断电时保护数据。
异步提交和不进行同步是两个完全不同的模式。关闭同步(设置 fsync 参数)会进一步提高系统速度,但任何故障都会导致集簇中的全部数据永久丢失。异步模式可以保证故障后恢复到一致的数据状态,但恢复后的状态中可能缺少最近的一部分事务。(默认值: on)
数据损坏
硬件并不完美,数据可能在内存或存储介质中损坏,也可能在接口电缆传输过程中发生变化。这些错误有一部分由硬件层处理,但并非全部如此。
为了及时发现问题,日志记录总是附带校验和。
数据页面也可以使用校验和保护13。既可以在初始化集簇时启用,也可以在服务器停止后使用 pg_checksums 工具14启用15。(v. 12)
在生产环境中必须启用校验和,尽管计算和检查校验和会带来少量开销。这会提高及时发现故障的机会,但不能提供完整保证:
只有访问页面时才会检查校验和,因此数据损坏可能长时间不被发现,甚至进入所有备份,最终不再留下任何正确数据的来源;
填零的页面会被视为正确页面,因此如果文件系统错误地把文件清零,检查也不会发现问题;
校验和只保护关系的主分支,其他分支和所有其他文件(例如 clog 中的事务状态)都不受保护。
通过检查只读参数 data_checksums 的值,确认校验和已经启用:
停止服务器,并把表的主分支的第 0 页中的几个字节清零:
再次启动服务器:
实际上也可以不停止服务器。只要页面被写入磁盘并从缓存中逐出即可(否则服务器会继续使用缓存中的页面)。不过,这种情形更难复现。
现在尝试读取该表:
如果无法从备份恢复数据,至少可以尝试读取损坏的页面(当然有得到失真信息的风险)。为此,需要设置 ignore_checksum_failure 参数:(默认值: off)
本例之所以成功,是因为损坏的是页头中不重要的部分(最后一条日志记录的 LSN),而不是数据本身。
写入非原子性
数据库页面通常占用 8 KB,但底层写入按块进行,而块在多数情况下更小(通常是 512 字节或 4 KB)。因此发生故障时,数据页面可能只被部分写入。恢复时,对这样的损坏页面应用普通日志记录没有意义。
为了防止部分写入,PostgreSQL 会在检查点开始后第一次修改页面时把整页镜像(full page image,FPI)保存到日志中。(第 223 页)
这一行为由 full_page_writes 参数控制。关闭整页镜像可能导致无法修复的数据损坏,但即使关闭该参数,提示位变化也可能让日志中出现整页镜像。提示位的变化本身被认为不重要,因此不会单独记录;访问页面的第一个查询会重新设置所需的位。然而,任何位(即使是不重要的位)发生变化都会改变校验和。因此,启用校验和(或设置 wal_log_hints 参数)时,检查点之后第一次修改提示位会记录整页镜像16,不受 full_page_writes 的值影响。(默认值: on)(*第 86 页)(默认值: off)
恢复时如果在日志中遇到整页镜像,就会无条件地(不检查 LSN)将它写入磁盘,因为它和所有日志记录一样受到校验和保护,不可能在不被发现的情况下损坏。然后,普通日志记录会应用到这个已经确定正确的镜像上。
整页镜像会显著增加生成的日志量,尽管日志机制会从镜像中排除页面内部的空闲空间17。可以通过 wal_compression 参数启用整页镜像压缩,从而大幅改善这一情况。(默认值: off)
使用 pgbench 工具做一个简单实验。执行检查点,然后立即运行一个事务数量固定的测试:
日志记录大小:
本例中整页镜像占生成总量的很大一部分,可以通过 pg_waldump 工具的 --stats 选项或 pg_walinspect 扩展的 pg_get_wal_stats 函数查看统计信息。(v. 15)
表中列出了每种资源类型(Type)的日志记录数量(N)、普通记录大小(Record size)和整页镜像大小(FPI size):
如果页面能在两次检查点之间多次发生变化,这个比例就会降低。这也是不要过于频繁执行检查点的另一个理由。
现在仍以这个例子看看压缩能带来什么帮助:
压缩后的日志记录大小:
除了传统的 PGLZ 压缩算法外,还有两种算法可用:LZ4 和 Zstandard。LZ4 通常占用较少的 CPU 资源,但压缩率大致相同。Zstandard 把字典压缩与熵编码结合起来;它对 CPU 的负担更重,但压缩效果更好。(v. 15)
使用 Zstandard 压缩后的日志记录大小:
结论是:当页面整页镜像很多时(由于启用了校验和或 full_page_writes,这几乎总是如此),使用压缩很有意义。多种算法可以让我们在额外 CPU 负载和日志量减少之间找到折中。
11.3. 日志级别
预写式日志的主要任务是保证故障恢复。但扩大日志记录的范围后,日志还可以用于其他任务。日志级别有 minimal、replica 和 logical 三种。每个更高的级别都包含前一级别写入日志的全部内容,并增加一些新信息。
使用的级别由 wal_level 参数设置;修改该参数需要重启服务器。(默认值: replica)
Minimal
最低级别 minimal 只保证故障恢复。为了节省空间,如果当前事务中创建或清空的关系涉及大量数据插入,就不会记录相关操作(特别是 CREATE TABLE AS SELECT 和 CREATE INDEX 这类命令)18。系统不会记录这些操作,而是立即把所需数据刷到磁盘;系统目录的变化会在事务提交时变得可见。
如果操作执行过程中发生故障,已经写入磁盘的数据仍然不可见,不会破坏一致性。如果操作完成后才发生故障,那么应用后续日志记录所需的全部数据已经在磁盘上。
要让这种优化对刚创建的关系生效,需要写入的数据量由 wal_skip_threshold 参数决定。(v. 13)(默认值: 2MB)
看看 minimal 级别会写入哪些内容。
默认使用更高的 replica 级别来支持复制。设置 minimal 级别时,还必须把 max_wal_senders 参数清零;该参数决定允许多少个 walsender 进程:(默认值: 10)
修改参数需要重启服务器:
记下当前日志位置:
清空表,并在同一个事务中插入新行,使其超过 wal_skip_threshold 的限制:
我使用 TRUNCATE 命令,而不是创建单独的表,因为这样生成的日志记录更少。
使用熟悉的 pg_walinspect 扩展查看日志内容。(v. 15)
第一条记录(CREATE)表示为关系创建了一个新文件(因为清空表实际上会重写该表)。(第 177 页)
接下来是几条与系统目录有关的记录。它们反映了 pg_class 表的变化(UPDATE),以及向该表上三个索引中添加记录(INSERT_LEAF)。把 rel 列显示的文件路径与关系名称对应起来,就可以验证这一点。(第 30 页)
最后是事务提交记录。向表中插入数据不会被记录到日志。
Replica
系统发生故障后恢复时,会通过日志记录把磁盘上的数据更新到一致状态。从备份恢复时也是如此。但在这种情况下,还可以使用归档日志,把数据状态推进到目标恢复点,而不只是恢复一致性。这些记录的数量可能很大(例如覆盖数天),恢复期间会经过多个检查点,而不是一个。因此 minimal 日志级别不够:没有记录的操作无法重放。要支持从备份恢复,日志中必须包含所有操作。
复制也是如此:没有记录到日志中的内容不会传送到副本,也不会在副本上重放。
如果副本还要用于执行查询,事情会更加复杂。首先,需要知道主服务器上出现的排他锁,因为它们可能与副本上的查询冲突。其次,需要能够构建数据快照,这需要知道正在运行的事务。对副本来说,不仅要考虑本地事务,还要考虑主服务器上的事务。(*第 261 页)(*第 97 页)
把这些信息传给副本的唯一方式,是定期将其写入日志19。这项工作由后台写入进程 bgwriter 负责20:系统有活动时,它每 15 秒(间隔不可配置)创建一条 RUNNING_XACTS 类型的记录。
从备份恢复数据和使用物理复制的能力由 replica 日志级别保证。
默认使用的正是这个级别,因此只需重置上面设置的参数并重新加载服务器:
重复上一次的操作顺序(但只插入一行,以免输出过长):
现在检查日志记录。
minimal 级别会出现的记录之外,还增加了以下记录:
与复制有关的
Standby资源管理器记录:LOCK(锁)和RUNNING_XACTS(活跃事务);初始化新页面并同时向其中插入一行的记录
INSERT+INIT。
Logical
最后,最高级别 logical 提供逻辑解码和逻辑复制能力。发布服务器必须启用这个级别。
从日志记录的角度看,这个级别与 replica 几乎没有区别:现有记录中会加入行标识符21;还会出现与复制来源和缓存失效有关的记录,以及应用可以添加的任意逻辑记录。不过,逻辑解码主要依赖正在运行的事务信息(RUNNING_XACTS):这些信息用于构建数据快照,以跟踪系统目录的变化。
backend/access/transam/xlog.c, 函数 WALReadFromBuffers. ↩︎
backend/access/transam/xlog.c, 函数 XLogFlush. ↩︎
backend/access/transam/xlog.c, 函数 RecordTransactionCommit. ↩︎
backend/access/transam/xlog.c, 函数 XLogBackgroundFlush. ↩︎
backend/access/transam/xlog.c, 函数 issue_xlog_fsync. ↩︎
backend/storage/file/fd.c, 函数 pg_fsync. ↩︎
backend/storage/buffer/bufmgr.c, 函数 MarkBufferDirtyHint. ↩︎
backend/access/transam/xloginsert.c, 函数 XLogRecordAssemble. ↩︎
include/utils/rel.h, 宏 RelationNeedsWAL. ↩︎
backend/storage/ipc/standby, 函数 LogStandbySnapshot. ↩︎
postgrespro.ru/docs/postgresql/18/sql-altertable#SQL-ALTERTABLE-REPLICA-IDENTITY. ↩︎