11. WAL模式
11.1 性能
当服务器在正常运行状态下,WAL文件会不断地写入磁盘。但是这些写操作是顺序的:几乎没有随机访问操作,这样即使HDD也能处理这个任务。既然这种类型的装载与典型的数据文件访问有非常大的不同,将WAL文件设置一个独立的物理存储也是值得的,将PGDATA/pg_wal替换成文件系统另一个目录的符号链接也是值得的。
有很多种情况下WAL文件同时参与读和写。第一件明显的例子就是crash的恢复;第二是流复制。walsender[1]进程直接从文件中读WAL日志[2]。这样如果一个复制接收不到WAL日志,而需要的页仍然在主服务器操作系统的缓冲里头,那些数据不得不从磁盘上读取。但是相应的访问仍然是顺序的,而不是随机的。
WAL日志可以采用下边的模式进行写操作:
同步模式,禁止任何进一步的操作,直到一个事务提交了,并将所有相关的WAL日志写到磁盘。 异步模式,暗示了立即的事务提交,WAL日志会通过后台进程写回到磁盘。
当前模式是由参数synchronous_commit定义。
**同步模式.**为了可靠的注册一个提交的事实,光简单的把WAL日志传给操作系统是不够的;你们必须确保磁盘同步已经成功完成。因为同步意味着真实的I/O操作(非常慢),它执行的尽可能越少越好。
基于这个目的,完成事务并写入WAL日志到磁盘的后端可以使用一个小的暂停(由参数commit_delay定义, 缺省为0s)。但是,它只在系统中至少有*commit_siblings*(缺省为5)[3]个有效事务的情况下发生:在暂停期间,其中一些事务可能完成了,但是服务器仍会试图将所有的WAL日志一次全部同步完。这特别像将电梯门按住,等人冲进去的样子。
缺省情况下,是没有暂停的。修改参数commit_delay参数值,只适用于系统中会执行大量的OLTP短事务的情况。
在潜在的暂停以后,完成事务的进程会将所有累积的WAL日志全部刷盘并执行同步(存储提交日志和以前与事务相关的日志比较重要;剩下的写出来只是因为它不增加成本)。
从这时起,ACID的持久性需求得到了保证---事务也被认为得到可靠的提交[4]。那也是同步模式是缺省模式的原因。
同步提交的缺点是较长时间的延迟(COMMIT命令直至同步结束才会返回控制)和较低的系统吞吐量,特别是对于OLTP负载。
异步模式. 要启用异步提交[5],你不得不关掉参数:synchronous_commit。
在异步模式下,WAL日志通过walwriter[6]进程写入磁盘,它会在工作与休眠状态下切换。暂停的时间由wal_writer_delay值(缺省200ms)来定义。
进程从暂停中唤醒,检查新被填满的WAL页的缓存。如果这样的页已出现,进程会跳过当前页将它们写入磁盘。否则,它会写入当前半空页,反正它已经被唤醒[7]。
这个算法的目的是避免刷新同一页几次,对于具有大量数据更改的工作负载,这可以带来显著的性能提升。
尽管WAL缓存被用作一个环缓冲,在到达缓存的最后一页时walwriter会停止;暂停一会儿后,下一个写周期又从第一页开始。这样在最坏情况下,walwriter需要跑三次才能得到一个特定的WAL日志:首先,它会把缓存末尾的所有全页写出,接着它又回到最开始的位置,最后,它会处理包含那个WAL日志的未填充完的页。但是大多数情况下,它只需要1到2个周期。
每当wal_writer_flush_after(缺省1MB)大小的数据被写时,同步操作执行一次同步,然后在写周期的末尾也会执行一次。
异步提交比同步要快,因为他们不需要等待物理写盘。但是可靠性遭受:在失败前你可能丢失提交的时间窗口为3*wal_writer_delay的数据(缺省大概为0.6秒)。
在真实世界中,这两种模式互为补充。同步模式下,长事务相关的WAL日志仍然可以异步的写到空间的WAL缓冲里。反过来,即使在异步模式下,某页中一条即将从buffer缓存里替掉掉的WAL日志也会立即被刷盘---否则,它不可能继续往下操作。
在大多数情况下,系统设计人员必须在性能和持久性之间做出艰难的选择。
参数synchronous_commit也同样被用于设置特定的事务。如果可以将应用程序级别的所有事务分类为绝对关键的(如处理财务数据)或不那么重要的事务,那么您就可以提高性能,同时冒着只丢失非关键事务的风险。
为了了解异步提交的潜在性能提升,让我们使用pgbench测试[8]比较两种模式下的延迟和吞吐量。
mydb=# create database internals
mydb-# ;
CREATE DATABASE
mydb=# \c internals
[06:15:29-postgres@centos1:/var/lib/pgsql]$ pgbench -i internals
dropping old tables...
NOTICE: table "pgbench_accounts" does not exist, skipping
NOTICE: table "pgbench_branches" does not exist, skipping
NOTICE: table "pgbench_history" does not exist, skipping
NOTICE: table "pgbench_tellers" does not exist, skipping
creating tables...
generating data (client-side)...
100000 of 100000 tuples (100%) done (elapsed 0.11 s, remaining 0.00 s)
vacuuming...
creating primary keys...
done in 0.23 s (drop tables 0.00 s, create tables 0.01 s, client-side generate 0.12 s, vacuum 0.05 s, primary keys 0.05 s).
同步模式下启动一个30秒的测试:
[06:15:31-postgres@centos1:/var/lib/pgsql]$ pgbench -T 30 internals
pgbench (14.4)
starting vacuum...end.
transaction type: <builtin: TPC-B (sort of)>
scaling factor: 1
query mode: simple
number of clients: 1
number of threads: 1
duration: 30 s
number of transactions actually processed: 37369
latency average = 0.803 ms
initial connection time = 5.461 ms
tps = 1245.831504 (without initial connection time)
现在再在异步模式下做相同的测试:
ALTER SYSTEM SET synchronous_commit = off;
SELECT pg_reload_conf();
[06:17:54-postgres@centos1:/var/lib/pgsql]$ pgbench -T 30 internals
pgbench (14.4)
starting vacuum...end.
transaction type: <builtin: TPC-B (sort of)>
scaling factor: 1
query mode: simple
number of clients: 1
number of threads: 1
duration: 30 s
number of transactions actually processed: 63534
latency average = 0.472 ms
initial connection time = 7.618 ms
tps = 2118.326192 (without initial connection time)
在异步模式下,这个简单的基准测试显示了较低的时延和较高的吞吐量(TPS)。自然,每个特定的系统都有它的 自己的数字取决于当前的负载,但很明显,对短期事务的影响是非常明显的。
我们恢复一下缺省设置:
ALTER SYSTEM RESET synchronous_commit;
SELECT pg_reload_conf();
11.2 容错
不言而喻的是,提前写日志必须保证在任何情况下的崩溃恢复(除非持久存储本身被破坏)。 影响数据一致性的因素有很多,但我只讨论最重要的其中之一: 缓存、数据损坏和非原子写入[9]。
缓存
在到达非易失性存储(如硬盘)之前,数据可以通过各种缓存。
磁盘写操作只是指示操作系统将数据放入它的缓存中(这也容易崩溃,就像RAM的任何其他部分一样)。 的I/O调度程序的设置定义了实际的写入是异步执行的,这是由操作系统的I/O调度程序的设置定义的。
一旦调度器决定刷新累积的数据,这些数据就被移动到存储设备的缓存中(像一个HDD)。存储设备也可以延迟写入,例如,将相邻的页面分组在一起。RAID控制器在磁盘和操作系统之间增加了一个缓存级别。
除非采取特殊措施,否则数据何时被可靠地存储在磁盘上是未知的。这通常也不重要,因为我们使用了WAL。但是WAL日志自身必须立即可靠地存储到磁盘上[10]。对于异步模式同样如此——否则,就不可能保证WAL日志条目在修改的数据之前被写入磁盘。
检查点进程必须也要将数据以可靠的方式存储,确保脏页从OS缓存刷到磁盘。另外,它必须同步所有的 由其他进程执行的文件操作(如写页面或删除文件): 当检查点完成时,所有这些操作的结果必须已经存储到了磁盘上[11]。
还有其他一些情况需要进行故障保护写入,例如在最小WAL级别执行未记录操作。
操作系统提供各种方法来保障将数据立即写入非易失性存储器。所有这些方法都可以总结为以下两种主要方法:在写入之后调用一个单独的同步命令(比如fsync或fdatasync),或者在打开或写入文件时指定执行同步(甚至是绕过OS缓存的直接写入)的要求。
pg_test_fsync实用程序可以帮助您确定根据操作系统和文件系统同步WAL的最佳方式; 首选方法可以在wal_sync_method参数中指定。 对于其他操作,系统会自动选择合适的同步方式,无法进行配置[12]。
这里有一个微妙的方面,在每个特定的情况下,最合适的方法取决于硬件。 例如,如果您使用带有备份电池的控制器,您可以利用它的缓存,因为电池将在停电的情况下保护数据。
您应该记住,异步提交和缺乏同步(缺省是on)是完全不同的两个故事。 关闭同步(通过fsync参数)可以提高系统性能,但任何故障都将导致致命的数据丢失。 异步模式保证崩溃恢复达到一致的状态,但可能会丢失一些最新的数据更新。
数据损坏
技术设备不完善,数据可能在内存和磁盘上损坏,或者在通过接口电缆传输时损坏。 这类错误通常在硬件级别处理,但也有一些可能会逃脱。
要在合适的时间点捕获问题,PG总是通过校验和来保护WAL条目。
检验和也可以用于计算数据页[13]。它总是在实例初始化期间完成,或者在服务器停止运行后直接运行pg_checksums[14]实用程序来。
在生产系统中,必须始终启用校验和,尽管有一些(次要的)计算和验证开销。 它提高了及时发现数据毁坏的机会,尽管仍然存在一些极端案例:
校验和只在页被访问的时候被执行,因此数据损坏可能很长时间未被发现,直到它进入所有备份并没有留下正确的数据来源。 被清空的页面被认为是正确的,因此如果文件系统错误地清空了某个页面,也不会发现这个问题。 校验和只对关系的主分叉进行计算; 其他的分叉和文件(例如CLOG中的事务状态)仍然不受保护。
我们来看下只读的data_checksums参数,确定校验和是被启用的:
=> SHOW data_checksums;
data_checksums
−−−−−−−−−−−−−−−−
on
(1 row)
现在停止服务器,并在表的主分叉清空若干页:
=> SELECT pg_relation_filepath('wal');
pg_relation_filepath
−−−−−−−−−−−−−−−−−−−−−−
base/16391/16562
(1 row)
postgres$ pg_ctl stop
postgres$ dd if=/dev/zero of=/usr/local/pgsql/data/base/16391/16562 \
oflag=dsync conv=notrunc bs=1 count=8
8+0 records in
8+0 records out
8 bytes copied, 0,00765127 s, 1,0 kB/s
再次启动服务器:
postgres$ pg_ctl start -l /home/postgres/logfile
事实上,我们可以一直让服务器运行---它完全可以将页写回磁盘,并将它刷出缓存(否则,服务器会继续使用缓存的版本)。但是宋的流程很难重现。
现在我们尝试读表:
=> SELECT * FROM wal LIMIT 1;
WARNING: page verification failed, calculated checksum 24386 but
expected 33119
ERROR: invalid page in block 0 of relation base/16391/16562
如果数据不能从备份中恢复, 至少尝试阅读损坏的页面是有意义的(冒着得到混乱输出的风险)。 为了这个目的,您必须启用参数:ignore_checksum_failure (缺省为off)。
=> SET ignore_checksum_failure = on;
=> SELECT * FROM wal LIMIT 1;
WARNING: page verification failed, calculated checksum 24386 but
expected 33119
id
−−−−
2
(1 row)
在本例中,一切正常,因为我们损坏了页头的一个非关键部分(最新WAL条目的LSN),而不是数据本身。
非原子写
数据库页通常大小为8kB,但是行一级的写是按块大小执行的,通常都比较小(典型的512字节或者4kB)。因此,如果发生故障,则可能只写入部分页面。 在恢复期间将常规WAL条目应用到这样的页面是没有意义的。
为避免部分写,检查点开始以后,第一次页被修改时,PG在WAL中存储一个完全页映像(FPI)。这个行为由参数full_page_writes(缺省为on)控制,减掉它会导致致命的数据损坏。
如果恢复进程跨过WAL中的一个FPI,它将无条件的写入磁盘(无须检查它的LSN);就像任何WAL条目一样,FPI受检验和保护,这样它们一旦受损就会被查觉。常规的WAL条目将应用到此状态,它确保是正确的。
没有单独的WAL条目类型来设置提示位:这个操作被认为是非关键的,因为任何访问页面的查询都会重新设置所需的位。但是,任何提示位的改变都会影响页的校验和。这样如果校验和被启用(或者如果wal_log_hints参数设为on,缺省为off),提示位的修改会以FPI的形式写入日志。
尽管日志机制从FPI[15]中排除了空白空间,生成的WAL文件的大小仍然显著增加。 如果通过wal_compression参数启用FPI压缩,这种情况会大大改善。
我们使用pgbench工具来运行一个简单的实验。我们将执行一个检查点,并使用固定数量的事务,立即启动基准测试:
=> CHECKPOINT;
=> SELECT pg_current_wal_insert_lsn();
pg_current_wal_insert_lsn
−−−−−−−−−−−−−−−−−−−−−−−−−−−
0/43A58068
(1 row)
postgres$ /usr/local/pgsql/bin/pgbench -t 20000 internals
=> SELECT pg_current_wal_insert_lsn();
pg_current_wal_insert_lsn
−−−−−−−−−−−−−−−−−−−−−−−−−−−
0/450F0628
(1 row)
产生的WAL的条目的大小是:
=> SELECT pg_size_pretty('0/450F0628'::pg_lsn - '0/43A58068'::pg_lsn);
pg_size_pretty
−−−−−−−−−−−−−−−−
23 MB
(1 row)
在这个例子中,FPI几乎占了一半的WAL大小。你可以自己看看收集的显示WAL条目统计信息,普通条目的大小(记录大小),以及每种资源类型的FPI大小:
postgres$ /usr/local/pgsql/bin/pg_waldump --stats \
-p /usr/local/pgsql/data/pg_wal -s 0/43A58068 -e 0/450F0628
Type N (%) Record size (%) FPI size (%)
−−−− − −−− −−−−−−−−−−− −−− −−−−−−−− −−−
XLOG 1848 ( 1,51) 90552 ( 1,14) 14860928 ( 96,72)
Transaction 20001 ( 16,37) 680114 ( 8,53) 0 ( 0,00)
Storage 1 ( 0,00) 42 ( 0,00) 0 ( 0,00)
CLOG 1 ( 0,00) 30 ( 0,00) 0 ( 0,00)
Standby 2 ( 0,00) 96 ( 0,00) 0 ( 0,00)
Heap2 20221 ( 16,55) 1282112 ( 16,08) 16384 ( 0,11)
Heap 80047 ( 65,52) 5917982 ( 74,22) 273392 ( 1,78)
Btree 49 ( 0,04) 2844 ( 0,04) 213480 ( 1,39)
−−−−−− −−−−−−−− −−−−−−−−
Total 122170 7973772 [34,17%] 15364184 [65,83%]
如果检查点间的数据页变了多次,那个比值将会变小,这也是检查点执行不要那么频繁的另一个原因。
我们重复相同的实验,看看压缩是否有帮助。
=> ALTER SYSTEM SET wal_compression = on;
=> SELECT pg_reload_conf();
=> CHECKPOINT;
=> SELECT pg_current_wal_insert_lsn();
pg_current_wal_insert_lsn
−−−−−−−−−−−−−−−−−−−−−−−−−−−
0/450F06D8
(1 row)
postgres$ /usr/local/pgsql/bin/pgbench -t 20000 internals
=> SELECT pg_current_wal_insert_lsn();
pg_current_wal_insert_lsn
−−−−−−−−−−−−−−−−−−−−−−−−−−−
0/45B94888
(1 row)
这里看看启用压缩以后的WAL大小:
=> SELECT pg_size_pretty('0/45B94888'::pg_lsn - '0/450F06D8'::pg_lsn);
pg_size_pretty
−−−−−−−−−−−−−−−−
11 MB
(1 row)
postgres$ /usr/local/pgsql/bin/pg_waldump --stats \
-p /usr/local/pgsql/data/pg_wal -s 0/450F06D8 -e 0/45B94888
Type N (%) Record size (%) FPI size (%)
−−−− − −−− −−−−−−−−−−− −−− −−−−−−−− −−−
XLOG 1836 ( 1,50) 93636 ( 1,17) 2820704 ( 98,05)
Transaction 20001 ( 16,38) 680114 ( 8,53) 0 ( 0,00)
Storage 1 ( 0,00) 42 ( 0,00) 0 ( 0,00)
CLOG 1 ( 0,00) 30 ( 0,00) 0 ( 0,00)
Standby 3 ( 0,00) 150 ( 0,00) 0 ( 0,00)
Heap2 20220 ( 16,56) 1285090 ( 16,12) 244 ( 0,01)
Heap 80013 ( 65,54) 5911850 ( 74,16) 37188 ( 1,29)
Btree 15 ( 0,01) 906 ( 0,01) 18568 ( 0,65)
−−−−−− −−−−−−−− −−−−−−−−
Total 122090 7971818 [73,48%] 2876704 [26,52%]
总而言之,当启用校验和或full_page_write(也就是说,几乎总是)导致大量FPI时,尽管有一些额外的CPU开销,但使用压缩是有意义的。
11.3 WAL级别
预写日志的主要目标是启用崩溃恢复。但如果你想扩展日志信息的范围,WAL也能用于其它目的。
PG提供了minimal、replica以及logical日志级别。每个级别包含了前一个级别所有的内容,并且添加了更多的信息。
正在使用的级别由wal_level参数来定义(缺省值为replica);修改此参数需要重启服务器实例 。
Minimal
minimal级别只保证崩溃恢复。为节省空间,如果当前事务内已建的或截断的关系上的操作招致大量数据插入(像CREATE TABLE AS SLECT,CRETAE INDEX命令[16]),则不被记入日志。除了不被记入日志,所有必需的数据会立即刷盘,系统catalog的变化在数据提交后就会变成可见。
如果一个操作被故障中断,让此操作进入磁盘的数据仍然不可见,不会影响一致性。如果操作完成时故障发生了,应用后续WAL条目所需的所有数据都已保存到磁盘。
数据容量不得不写到一个新建的关系中,是否生效,由参数:wal_skip_threshold(V13, 缺省2MB)定义。
我们看下在minimal级别下会记录什么样的日志。
缺省情况下(v10),会使用较高的replica级别,它支持数据复制。如果你选择级别为minimal,你也可以设置walsender进程的数据为0,使用的参数是:max_wal_senders:
=> ALTER SYSTEM SET wal_level = minimal;
=> ALTER SYSTEM SET max_wal_senders = 0;
服务器需要重启让这两个参数生效:
postgres$ pg_ctl restart -l /home/postgres/logfile
现在当前的WAL位置:
=> SELECT pg_current_wal_insert_lsn;
pg_current_wal_insert_lsn
−−−−−−−−−−−−−−−−−−−−−−−−−−−
0/45B96B70
(1 row)
在当前事务中截断表或者新插入的行,直到wal_skip_threshold参数越界了:
=> BEGIN;
=> TRUNCATE TABLE wal;
=> INSERT INTO wal
SELECT id FROM generate_series(1,100000) id;
=> COMMIT;
=> SELECT pg_current_wal_insert_lsn();
pg_current_wal_insert_lsn
−−−−−−−−−−−−−−−−−−−−−−−−−−−
0/45B96D18
(1 row)
这里运行TRUNCATE命令而不是创建一个新表,因为前者产生更少的WAL条目。
我们使用熟悉的pg_waldump工具检查下产生的WAL日志:
postgres$ /usr/local/pgsql/bin/pg_waldump \
-p /usr/local/pgsql/data/pg_wal -s 0/45B96B70 -e 0/45B96D18#
rmgr: Storage len (rec/tot): 42/ 42, tx: 0, lsn:
0/45B96B70, prev 0/45B96B38, desc: CREATE base/16391/24784
rmgr: Heap len (rec/tot): 123/ 123, tx: 134966, lsn:
0/45B96BA0, prev 0/45B96B70, desc: UPDATE off 45 xmax 134966 flags
0x60 ; new off 48 xmax 0, blkref #0: rel 1663/16391/1259 blk 0
rmgr: Btree len (rec/tot): 64/ 64, tx: 134966, lsn:
0/45B96C20, prev 0/45B96BA0, desc: INSERT_LEAF off 176, blkref #0:
rel 1663/16391/2662 blk 2
rmgr: Btree len (rec/tot): 64/ 64, tx: 134966, lsn:
0/45B96C60, prev 0/45B96C20, desc: INSERT_LEAF off 147, blkref #0:
rel 1663/16391/2663 blk 2
rmgr: Btree len (rec/tot): 64/ 64, tx: 134966, lsn:
0/45B96CA0, prev 0/45B96C60, desc: INSERT_LEAF off 254, blkref #0:
rel 1663/16391/3455 blk 4
rmgr: Transaction len (rec/tot): 54/ 54, tx: 134966, lsn:
0/45B96CE0, prev 0/45B96CA0, desc: COMMIT 2022−09−19 14:54:24.911435
MSK; rels: base/16391/24783
第一条日志记录用于关系的新文件的创建(因为TRUNCATE虚拟的重写该表)。
另四条与系统catalog操作相关联。他们反映了pg_class表和它三个索引的变化。
最后,还有一个与提交相关的日志。数据插入没有记录日志。
Replica
在崩溃恢复期间,WAL的条目会被重放来恢复数据到磁盘,以达到一个一致的状态。备份恢复以类似的方式工作,但是它也可以使用WAL归档把数据库的状态恢复到指定的恢复目标点。归档的WAL条目的数量可以非常高(例如,它们可以跨好几天),因此恢复期将包括多个检查点。所以,minimal WAL级是不够的:如果没有事先记录,它不太可能去重复一个操作。对于备份恢复而言,WAL文件必须包含所有操作。
复制也是如此:未记录日志的命令不会被发送到副本,也不会在副本上回放。
如果使用副本执行查询,事情会变得更加复杂。 首先,它需要在主服务器上获得排他锁的信息,因为它们可能与副本上的查询发生冲突。 其次,它必须能够捕获快照,这需要有关活动事务的信息。 在处理副本时,必须同时考虑本地事务和运行在主服务器上的事务。
发送数据到副本上的唯一方式是周期性的写入WAL文件[17]。它通过bgwriter进程[18]完成,15秒钟写一次(在内部是硬编码的)。
从备份中进行数据恢复和使用物理复制的能力在replica级别得到保证。
replica级别是默认使用的,所以我们可以简单地重置上面配置的参数并重新启动服务器 :