![87ebdcab22cc5642e32649c45e311429.png](https://i-blog.csdnimg.cn/blog_migrate/d198955178df7582cc88df0e9ee034da.png)
在之前的博客中讲述了,多个backend如何能并发的向wal的页buffer中写入wal记录,这篇博客会讲述,wal页buffer刷写到磁盘文件的过程。
一、wal页buffer的产生
wal页buffer与数据buffer不同,正常情况下,wal页buffer初始化于内存,填充于内存,然后刷写入磁盘。只有在数据库启动时,会从磁盘中加载上一次停机未写完的page。
![763bd7df91d65ed4a15203f87e5bdaac.png](https://i-blog.csdnimg.cn/blog_migrate/15f29ffd01968a07c973c6c75d04f158.png)
buffer槽是指共享缓存中为wal页分配的固定数量(wal_buffers配置)的空间,相当于是一个page数组。给定一个lsn位置,可以通过如下宏计算出存储这个lsn的page从属的buffer槽的索引下标。
#define XLogRecPtrToBufIdx(recptr)
(((recptr) / XLOG_BLCKSZ) % (XLogCtl->XLogCacheBlck + 1))
wal页buffer初始化的方式:1. walwrite进程在完成一次wal整体写入后,会在未使用的buffer槽位置初始化下一个buffer 。 2. 一个backend进程需求的wal槽正在被一个较旧的page占用,那么本进程在等待旧的page刷盘后自己初始化这个buffer。关于wal页初始化可以参考AdvanceXLInsertBuffer()
函数
二、wal页buffer的写入简述
在之前的博客中讲到过,将wal链数据从每一个backend中拷贝到wal页page的并发过程,这里再讲述一下wal页刷写到磁盘文件的过程。把wal页buffer写入wal文件分为几步?1.将wal页buffer写入操作系统缓存(write) 2.确保wal页buffer同步到磁盘(flush)。
但是在不同情况下,PostgreSQL对wal的write和flush是各不相同的,现在有一条DMl语句begin;insert into t1 values(1,1);
执行后会产生wal日志,如下说明几种场景下其 write和flush的方式
2.1 无提交写入
本事务没有提交,而且没有并发事务提交,这种情况下,walwrite进程在timeout后会刷write并flush wal日志页buffer。函数调用关系为XLogBackgroundFlush()->XLogWrite()
。
2.2 发生事务同步提交
执行完上述insert后,如果本进程发生了同步提交或者有并发进程发生了同步提交,那么发生提交的进程会积极的去调用XLogFlush(lsn1)
函数去write和flush数据。这会使lsn1之前的wal日志都完成write和flush(如果比lsn1大的wal位置也准备好了write,那么此时也不介意做点超前的flush)。
因为write的时候需要获取WALWriteLock
锁,所以有可能发生提交的进程获取到这个锁后发现,自己想写的数据已经被别人写完了。
2.3 发生事务异步提交
执行完上述insert后,如果本进程发生了异步提交或者有并发进程发生了异步提交,那么发生提交的进程会唤醒walwrite进程。被唤醒的walwrite进程会执行write操作,同时根据wal_write_delay和wal_writer_flush_after参数判断此时walwrite进程是否进行flush操作。
也就是说异步提交之后,只会保证walwrite成功,而没有保证flush,也就是说在异步提交的情况下,如果操作系统崩溃可能会导致wal_write_delay时间或wal_writer_flush_after日志量的数据丢失。当然换来的是数据库性能的提升。
2.4 组提交
组提交发生在事务同步提交,且正确配置了commit_delay和commit_siblings参数。当一个backend提交事务且已经获得写wal的权限后,会看一下当前正在运行的事务是否多于commit_siblings个,如果是则会等待commit_delay时间,以期望一次完成更多的wal日志flush。这也是为了提升数据库整体性能。
2.5 backend初始化wal页buffer
当某些极端情况下,会出现一个进程去初始化wal页buffer的情况,此时这个进程可能需要先完成之前的wal buffer的刷写操作,详情参考AdvanceXLInsertBuffer()
。
三、wal页buffer写入调度
从上面的描述可以看出,walwrite和各个backend进程都可能会去写文件,而且还有了write和flush不同步的情况,这一节,简单说明PostgreSQL中对write和flush位置的记录和控制。
typedef struct XLogwrtRqst
{
XLogRecPtr Write; /* last byte + 1 to write out */
XLogRecPtr Flush; /* last byte + 1 to flush */
} XLogwrtRqst;
typedef struct XLogwrtResult
{
XLogRecPtr Write; /* last byte + 1 written out */
XLogRecPtr Flush; /* last byte + 1 flushed */
} XLogwrtResult;
typedef struct XLogCtlData
{
...
XLogwrtRqst LogwrtRqst;
...
XLogwrtResult LogwrtResult;
...
}
LogwrtRqst.Write意义是,一次wal写动作(不包括flush)能包括的最大lsn,因此当往一个新的wal页中写入一个完整记录时,会更新这个值。另外事务提交和一些必须要完成wal同步落盘的操作后也会更新这个值。
LogwrtRqst.Flush的意义是,一次wal写动作(包括flush)能包括的最大lsn,事务同步提交、一些必须要完成wal同步的操作和walwrite进程会更新这个值。
LogwrtResult.Write意义是,已经完成wal写的lsn。
LogwrtResult.Flush意义是,已经完成wal同步的lsn。
3.1 发生事务同步提交
这时会以事务提交的lsn值更新LogwrtRqst.Write和LogwrtRqst.Flush的值,并以此为基础去同步wal日志。完成后同时更新LogwrtResult.Write和LogwrtResult.Flush的值。
下面画一组图来说明这种情况下的刷写调度
图1-1 初始状态
![dedb5c47086629dd31d69f2c4cbbc6a1.png](https://i-blog.csdnimg.cn/blog_migrate/b10911a0ac367ea636a4668ee4e851bd.jpeg)
这时有ABCDE五个进程,获取了插入锁槽21643,并且预置了自己想要写的wal位置,现在是正在COPY的过程。
图1-2 一个进程完成COPY
![c9a88bca5fc87790a8588cdd0bbd3db8.png](https://i-blog.csdnimg.cn/blog_migrate/1759dd6881236baada733e973f7b4909.jpeg)
假设进程B现在完成了COPY过程,而却没有发生提交。那么它写完后产生上面红字部分的变化。最大的变化是插入锁槽1变为了空闲状态。
图1-3 一个进程发生提交
![e59d3a0e22ce44d8afd40cfbf254edeb.png](https://i-blog.csdnimg.cn/blog_migrate/e240703af9b58c5ac5e68174b6889480.jpeg)
这时进程D完成了COPY过程,而且假设进程D需要提交。那么此时会把LogwrtRqst.Write和LogwrtRqst.Flush都置为lsn_b,然后想调用`XLogWrite()
函数去完成lsn_b之前的wal的write和flush。
但是此时进程A和进程C还没写完呢,所以在调用XLogWrite()
之前需要等待lsn_b之前的wal数据都已经COPY完了,在这里就是指需要进程A和进程C完成。
代码中实现这个等待的方式是,遍历一次插入锁槽,比如现在的0,1号锁槽是空闲的,直接跳过;2号锁槽是被占用的,那么只需要等待2号锁槽被释放一次(说明进程A已经完成了这次预置的空间的写入);
图1-3-1 等待过程中,又并发发生了锁槽占用
![a0409c1574d894b2d57b72ec6aea4497.png](https://i-blog.csdnimg.cn/blog_migrate/bd07eaba7cfa53e5c68502b3f3601e68.jpeg)
现在因为进程A完成COPY,索引2号锁槽被释放了。但是我们现在假设又有两个并发进程F和G发生了waL预置,且占用了0和5号锁槽。
此时D进程会等待依次判断3,4,5,6,7等待其发生锁槽释放,或者等待锁槽发生LSN变化(有兴趣的同学我们可以探讨,这里不再展开)。当前不会再关心发生新变化的0号锁槽。
图1-3-2 完成锁槽遍历
![aadb6c7a68104565b42d253295e09945.png](https://i-blog.csdnimg.cn/blog_migrate/a5b98abda02149020781a4074f79d4ef.jpeg)
锁槽遍历完成之后,进程CEG一定都完成了当前预置空间的写入,现在可以保证lsn_b之前的wal日志都已经写完了(虽然我们可能多等了一些时间)。
图1-4 完成write和flush操作
![d0820caaadba4c02a8750219731b4c5e.png](https://i-blog.csdnimg.cn/blog_migrate/bd1dff1663a42f0a1ca1ba7a6ba04a1f.jpeg)
现在进程D终于可以愉快的完成写文件了,最后更新LogwrtResult.Write=lsn_b,LogwrtResult.Flush=lsn_b
(注意:在并发场景下最后LogwrtResult.Write和LogwrtResult.Flush的值可能会大于lsn_b)
3.2 walwrite进程写wal文件
这时会首先保证的LogwrtRqst.Write之前的wal位置都完成COPY,然后执行wal写文件(write),但是不一定会写到LogwrtRqst.Write的位置(这是效率为先的考虑点)。
正常情况下walwriter进程在一次唤醒中,只会调用一次write()系统接口,因此如果发生wal段切换,或者到达wal buffer数组的最后一个槽,那么会把后面的write任务交给下一次walwrite进程唤醒。
关于copy等待的部分我们在图1系列中已经讲述了,这里画一下在一个walwrite周期内为什么会写不完。
图2-1 跨wal段导致的部分写
![16ee1f6e900a3557d23c13102ce660dd.png](https://i-blog.csdnimg.cn/blog_migrate/233e3b7b805cce7350af9ab6cd98cddf.png)
如图中,在一个walwrite进程周期内,根据LogwrtRqst.Write的值,需要写page1~page4。但是page2和page4不在同一个wal段内,因此为了完成这四个page的写文件,就需要至少两次write()系统调用。但是walwrite进程太懒只想做一次write()调用,那只能把剩下的工作交给下一次walwrite进程被唤醒,或者交给backend进程完成写任务。
图2-2 到达buffer末尾导致的部分写
![4e73ee2d7ba308ca0d9d302a94d48975.png](https://i-blog.csdnimg.cn/blog_migrate/03d859216ea3fb4d0e9d90c3051ba63a.png)
这种情况下,槽M是最后一个wal页buffer数组的最后一个位置,槽0是wal页buffer数组的第一个位置。因此M和0是不连续的,因此也无法在一次write()调用中同时完成page3和page4的写入。
3.3 发生事务异步提交时
这时会首先更新LogwrtRqst.Write的值,然后根据wal_write_delay和wal_writer_flush_after决定是否更新LogwrtRqst.Flush值,因此会发生只write而不flush的情况。然后根据实际情况更新LogwrtResult的值。
图3-1 异步提交的必达部分
![06b149b9ee4975247dfcf1924d62adec.png](https://i-blog.csdnimg.cn/blog_migrate/fa1d154875e10fd9be3ef8cb322da3ee.png)
异步提交引起的wal日志写操作是由walwrite进程完成的,只不过异步提交进程给了一个asyncXactLSN值,walwrite进程必须完成asyncXactLSN位置的写入,在上图中的情况下必须调用两次write()系统接口。
四、结论
最近仔细研究了,wal的buffer的产生和写入文件的过程,因此写了《PostgreSQL的wal日志并发写入机制》和本文。但是也没有研究的非常透彻,很多地方也是知其然而不知其所以然,比如大多数设计都是为了更好的性能,但不知为什么这是最好的设计,或者我们可以有改进方案?