预写式日志WAL
预写式日志write ahead log,是数据库保证数据完整性的重要数据结构。数据库管理器将数据库发生的变更记录写入wal日志缓冲区,进而写入wal日志文件中,在数据库崩溃时利用wal日志进行重演恢复,这几乎是所有数据库的统一实现原理。
设计wal日志的原因在于数据脏页的刷盘是消耗很大的操作,我们应该尽量避免这种随机写,而wal日志是顺序写,速度很快,即便如此,写wal日志也是目前数据库消耗最大的操作,基于预写式日志和checkpoint配合实现脏页数据的推进,就好像一个滚筒不断向前,同时清理过期的wal段文件。当然目前硬件技术的发展也在慢慢改变数据库的架构,我了解到目前有一些公司在研究持久化内存对数据库的影响,如果内存可以做到持久化,那么我们可能不再需要wal_buffer,甚至如果内存持久化性能可观,我们甚至可能不再需要wal日志。
PostgreSQL中的WAL
PG中的wal日志默认存放在数据目录的pg_wal目录里,每个文件16MB,这个大小可以通过initdb的--with-wal-size选项进行更改,当一个wal段文件写满后会进行切换,我们也可以手工调用pg_switch_wal()函数进行切换。每个文件在内存中被分为多个页,每个页面默认8k,文件名是从000000010000000000000000开始的数字,第一部分是timeline,第二三部分是lsn相对文件的hash信息,具体命名规则可以参考我之前的这篇文章《PostgreSQL数据库xlog文件命名为何如此优美?》
每次数据库新的变更记录都会以wal记录的方式被追加到wal日志中,记录的位置也就是我们常说的LSN,也就是该日志在wal中的偏移量,pg的lsn设计非常精巧,wal的文件名就是一张hash表,给出某一lsn值能够迅速定位到wal日志中的位置。
pg通过synchronous_commit来控制提交方式。synchronous_commit=off/local/remote_write/on/remote_apply共五种选项可选,默认是on,代表主备库都需要等待wal日志刷盘。如果设置synchronous_commit=off,那么在提交时不会等待wal_buffer中的wal内存段刷盘,这样如果发生意外宕机则会存在数据丢失风险。但是pg中某些命令会强制刷盘而忽略synchronous_commit参数设置,比如drop table、prepare transaction等,即使被设置为异步提交,发出这些命令时也会强制同步提交,这是为了保证文件系统和数据库逻辑状态的一致性。wal日志的写入是由walwriter进程负责的,默认的写入间隔有wal_writer_delay参数控制,单位是毫秒,所以异步提交丢失的数据量也是和wal_writer_delay参数的设置值有关。
pg中还有个与wal相关的参数很有意思,那就是commit_delay,单位微秒,commit_delay会使事务从提交到WAL磁盘之前有一个延迟,从这个参数的解释看起来很像是异步提交,但它实际上是一种同步提交方法,其实commit_delay参数会在异步提交时被忽略。它假设系统的负载足够高,使得在一个给定的时间间隔内有其他事务准备好提交,这样通过一次刷写磁盘提交了一组事务,这种方式可以在多个事务之间平摊刷盘的开销。但是它也将每次提交的延迟增加了commit_delay微秒,commit_delay与commit_siblings参数配合使用,如果异步提交被开启或者当前处于活跃事务中的会话数少于commit_siblings,提交休眠等待将不会发生,这样就避免了在其它事务不会很快提交的情况下进行休眠。
要注意commit_delay是以事务延迟为代价,在设置该参数之前有必要对代价进行量化。代价越高,在一定程度上commit_delay对于提高事务吞吐量的效果就越好。pg提供了pg_test_fsync工具来测试一次WAL刷写操作需要的平均微秒数。我们一般建议将commit_delay设置为其结果中的一次8kB写操作后的刷出所用的平均时间的一半,比如针对下面的测试结果,我们建议将commit_delay设置为20左右。
检查点
检查点是用来保证被更新的堆和索引数据文件的所有信息在该检查点之前已被写入磁盘。在检查点发生时,所有脏页被刷写到磁盘数据文件,并且将一个特殊的检查点记录写入到日志文件。在发生实例crash时,将会从最新的检查点利用wal日志开始重做。在这一点之前对数据文件所做的任何修改都已经被保证位于磁盘之上。因此检查点之前的wal日志文件不再被需要,所以检查点会触发wal段文件的回收。
检查点几乎是数据库消耗最大的操作,所以检查点不是随时随意进行的,它通过一些参数进行控制。pg中通过checkpointer进程自动的执行检查点。checkpoint_timeout参数定义了两次检查点的时间间隔,默认是5分钟,另外如果wal段的总大小快要超过 max_wal_size时也会执行检查点,该参数默认是1GB。值得注意的是如果从前一个检查点以来没有WAL段文件变化,那么则即使过了checkpoint_timeout检查点也不被执行。
降低checkpoint_timeout或max_wal_size会导致检查点更频繁地发生。这虽然能够减少实例恢复的时间,但是带来了更加昂贵的开销。另外full_page_writes=on(默认情况)的情况下,为了确保数据页一致性,不发生块折断,在每个检查点之后对数据页的第一次修改将导致整个页面内容被记录下来。这样将大大增加wal日志量和磁盘io。pg提供了一个checkpoint_warning参数来支持你对checkpoint参数的设置进行评估,如果检查点的发生时间间隔比checkpoint_warning秒还要小,pg将向日志中写入一条消息推荐你增加max_wal_size值。
另外,检查点写入不是全速进行的,为了平均io消耗,pg将一次检查点的刷盘操作散布到一段时间内,这段时间由checkpoint_completion_target参数控制,它是一个分数,默认0.5,代表要在两次checkpoint时间间隔的这个比例内完成脏页写盘,也就是在下次检查点启动之前一半的时间完成刷盘。该值设置越小,检查点进程刷盘越“拼命”,反之检查点进程刷盘越“懒惰”。当然checkpoint_completion_target也可以设置为大于1.0的值,但是不保证能在下次检查点开始时刷完脏页,一般不建议这么设置。
在检查点完成后会更新控制文件pg_control里的检查点位置信息。在恢复开始时pg首先读取pg_control控制文件中的检查点记录,然后通过该位置信息定位到wal日志中的位置来进行前向redo操作。pg_control控制文件很小,它的大小甚至不到一个磁盘页面,所以不存在写pg_control失败造成pg_control文件损坏不可用的情况。目前官方也在探索wal日志反向读取的功能来避免pg_control文件损坏造成的数据库不可用,主要思路就是反向读取wal日志定位到最新的检查点位置。
wal日志的清理
因为生产环境经常可能出现wal日志堆积造成数据库磁盘打满的情况,所以了解wal段文件的清理策略有助于我们对这类问题进行分析。总体来说wal段文件的清理和下面几个因素有关:
1.max_wal_size、min_wal_size参数
min_wal_size指定pg_wal目录里的wal段的最小值,这些数量的段文件总是被回收使用,即便可能用不到这么多段也是如此,设置该值有助于防止备库需要的日志被主库删掉,但是只是减缓,并不是根治。max_wal_size限制了最多的wal段日志的大小,但是该限制并不是硬限制,如果某段时间由于业务量比较大造成wal日志量超过max_wal_size限制的值,那么检查点进程会启动,将一些以前的段文件变为无用进行清理。
2.wal_keep_segments参数
该参数独立于其他参数设置,pg总是保留最少wal_keep_segments个wal段文件,设置该值也对主流复制环境的wal日志保留有所缓解,但是同样不能彻底解决。
3.archive进程
如果配置了归档,当wal段文件还未来及被归档时,即使满足了其他清理条件,wal段文件也不能被清理。甚至假设我们配置的archive_command错误造成归档失败,那将是灾难性的,所有的wal都将无法清理。
4.复制槽的使用
不管是物理复制槽还是逻辑复制槽的使用都可能带来主库的wal日志不能被清理或者清理速度较慢带来数据堆积。我们一般使用物理复制槽来确保流复制环境中备库需要的wal日志不被主库清理,其实逻辑复制槽也是一样,两者原理都是备库通过restart_lsn来消费主库的wal位点,假设主备网络失败断连,那么将造成主库wal日志堆积。另外值得注意的一点是在多租户环境使用逻辑复制槽可能由于某个库没有业务造成wal日志无法被清理,具体可以参考我之前的文章《为什么要慎用replication slot?》