从一道PG知识的选择题谈起
昨天一个网友问我一道关于PG的选择题:Postgresql数据库中哪些进程可以将shared buffers中的脏数据回写到数据文件?A) BACKEND B) BGWRITER C)CHECKPOINTER D) WALWRITER。
稍微懂点PG数据库的人不难回答,答案是A、B、C。一些Oracle DBA可能会觉得这个答案有点出乎意料。因为在Oracle数据库中,回写DB CACHE脏数据的只有DBWR。可能这些人不太清楚的CKPT负责回写部分脏数据是80年代早期关系型数据库的共同特点,Oracle数据库中,CKPT也曾经负责过写脏块。后来随着数据库规模的增大,CKPT的功能被独立出来了,只负责CKPT的推进工作,不再负责写脏块了。在Oracle数据库中,为了更快的将脏块回写,通过将DB CACHE分区,采用多个DBWR进程可以并发写脏块,从而满足大型数据库的需要。
PG在刷脏块的算法方面的设计比较传统,这种设计是从90年代一脉相承的,改起来成本较大,所以就一直沿用下来了。只不过让backend也去写脏块,这未免也有点太出乎一般人的想象了。这会产生几个比较奇怪的现象,一个是某条SQL语句,可能在执行计划相同、数据量相同的情况下,不同的时间执行,其执行效率有较大的不同。另外一个奇怪现象是某条SELECT语句可能会产生较多的写操作。实际上Oracle数据库中也会存在类似的现象,这种情况是由延迟块清理产生的。当一个大型事务结束后,ITL的状态清理可能并没有完成,如果马上有SELECT操作访问这些数据,那么执行SELECT的前台进程将负责完成这些块的清理,这时候select操作也会产生大量的写操作,产生大量的REDO,同时这条SELECT比起平时会慢很多。
通过pg_stat_bgwriter系统表中的buffers_backend和buffers_backend_fsync可以查询到系统中的backend写脏块和fsync的计数。
从我们的一个D-SMART的PG数据库中看到一个十分神奇的情况,按理说被用来写脏块的bgwriter居然很小(buffers_clean),大多数脏块都是checkpointer和backend写的 。如果你去检查一下你们的PG数据库,可能bgwriter写脏块的比例也不是很高。
为什么会出现这种情况,为什么大量的写脏块工作不由本应该处理脏块的 bgwriter去做,而让Backend去写脏块呢?这方面的资料很少,我们只能从 PG的代码上去找找答案。首先我们看一下src/backend/storage/bufmgr.c,在这里可以看到backend从shared buffers中分配空闲buffer的算法。一般我们都能理解从shared buffers中查找空闲的buffer,如果找不到非脏的空闲块,那么就有可能找到一个存储了脏数据的数据块,这时候才需要backend去写脏块。在Oracle数据库中,前台进程是顺着lru链的冷端去查找空闲缓冲块的,如果前台进程发现了某个没有被Pin住的块是脏块,就会把这个数据块移到lru-w中,然后继续往下搜索,如果连续搜素到了N个脏块,无法获得所需要的空闲块的时候,就会发出一个free buffer requests的事件,让DBWR加快刷脏块,然后再去重试。从PG的代码上看,PG的大体思想类似,不过策略要复杂得多。
BufferAlloc里包含了backend查找某个buffer的顶层逻辑。不阅读一下还真没发现PG这方面的代码逻辑会搞得如此复杂。要想访问某个buffer,先要生成一个BUFFER TAG(关于这方面的详细算法请参考我2021年写过的一篇8000多字的长篇《PG SHARED BUFFER POOL的优化》)。然后查找这个BUFFER。
如果BUFFER存在,还有两种可能性,一种是成功的PIN住了这个BUFFER,那么就可以返回这个BUFFER了。不过BUFFER存在还有一种可能性是无法PIN住,无法PIN住的原因是可能被其他的会话PIN住了,也可能是一些其他的原因。这种情况,Oracle被称为read by other session或者buffer busy waits。这个部分不是我们今天分析的重点,我们继续往下看代码。
GetVictimBuffer函数通过时钟扫描算法去找一个空闲的buffer。这里就涉及到查找空闲shared buffers了。我们下钻到这个函数的代码中去继续分析。
首先调用StrategyGetBuffer去找一个BUFFER。
如果发现找到的是一个脏块,那么就把脏块刷盘,这就是BACKEND也需要刷脏块的原因之一。作为数据库缓冲的算法,我们肯定应该尽可能的找到非脏块来复用,总是让BACKEND写脏块肯定会降低数据库的整体性能。
StrategyGetBuffer函数在src/backend/buffer/freelist.c中定义。首先,它会检查是否有一个策略对象(strategy),如果有,就调用GetBufferFromRing函数,从策略对象的环形缓冲区(ring buffer)中获取一个缓冲区。如果获取成功,就返回这个缓冲区,并设置from_ring标志为true。如果没能正常找到free buffer,它会尝试唤醒bgwriter,让它刷新脏的缓冲区到磁盘,以便释放一些空间。接下来,backend会检查StrategyControl->firstFreeBuffer变量,如果大于等于0,就表示有空闲的缓冲区,那么就通过一个循环从空闲链表中获取一个缓冲区。这部分算法与Oracle的free buffer requests十分类似。
此时如果空闲链表为空,backend会进入另一个循环,尝试从victim pool中选择一个缓冲区,victim pool是一个循环队列,存储了最近被访问过的缓冲区的编号。从nextVictimBuffer的当前位置开始,顺时针扫描victim pool,寻找一个既不被锁定,也没有被引用,也不是脏的缓冲区。如果找到了,就返回这个缓冲区,并将其从victim pool中移除。如果没有找到合适的缓冲区,它会继续扫描victim pool,寻找一个既不被锁定,也没有被引用,但是是脏块的缓冲区。如果找到了,就将这个缓冲区的内容写入磁盘,然后返回这个缓冲区(这是代码中backend中另外一个写脏块的地方),并将其从victim pool中移除,并添加到策略对象的环形缓冲区中。
我们先不去吐槽PG在这块代码的质量问题,仅仅从算法来看,backend直接刷脏块的机会也应该是比较小的。那么为什么我们会在pg_stat_bgwriter中看到如此奇葩的数据呢?这里面其实也有一个十分有意思的逻辑。
首先是关于buffers_backend这个指标,本身这个指标就有一定的误导性,我们今天看的代码上包含了处写脏块的地方,其实不用看代码我们都能想到第三处,那就是VACUUM。因为backend中还包含了auto vacuum,vacuum操作等写脏块的统计数据,因此我们可能会被这个指标误导。PG社区中十多年前就有人希望PG代码中把这些情况区分开来,从而让buffers_clean更有指向性,不过没有获得PG社区核心研发的认同。
另外一点是PG数据库的策略是尽可能让数据在内存中多存放一段时间,而不急着把脏数据写盘。因此在PG数据库中,还是将检查点进程作为写脏块的主力,如果你的系统中的buffers_alloc增长很缓慢的话,那么只要按照checkpointer的节奏慢慢写脏块就可以了,backend总是能够找到所需要的buffer,因此也就没必要让bgwriter去写脏块了。这种算法对于早些年比较缓慢的IO子系统来说是十分友好的,不过对于当今高性能的IO系统来说,不够高效,比较适合目前IO性能一般的云上小型数据库,而对于采用高性能IO设备的大型数据库来说,并不一定是很优化的。
基于上面的分析我们可以了解到,如果你看到你的系统中的buffers_clean总是为0或者总是慢速增长,那么并不说明系统存在问题,而是说明你的系统写负载还不算太高,bgwriter还犯不着去帮你刷盘而已。对于IO性能还不错的系统,或者说规模不算太大的数据库来说,PG的这种刷脏块的方法还是可以胜任的,在一些超大型系统中,可能这方面会成为瓶颈。我看到Polardb-PG、openGauss等基于PG代码的数据库产品中,对这方面都做了一些优化,引入了专门的机制来替换BGWRITER。目前还没有对这些代码进行分析,因此不知道这方面的改善如何。
今天分析PG这方面源代码的另外一个收获是从中学到一些PG的SHARED BUFFERS相关的优化策略的。首先shared buffers不能设置得太少,否则backend真正开始大量刷脏块了,那么SQL的性能是会受到很大的影响的。其次是CHECKPOINTER的相关参数设置要合理,根据底层IO的能力配置合适的参数,让CHECKPOINTER刷盘的速度能够跟得上buffers_alloc的速度。如果我们发现buffers_clean的增长比较快了,那么说明目前系统的负载对shared buffers 有一定的压力了,那么我们就需要考虑调整bgwriter相关的参数了。
最后的源码链接是我两年多前写的一篇关于PG SHARED BUFFERS的内部结构的分析文章,文章很长,有8000多字,有兴趣的朋友可以阅读一下。文中有些观点可能和今天的文章有些不大一致了,如果存在这方面的观点,那么就以今天的文章为准吧。对PG数据库的理解都是一点一点的从模糊到清晰,从不大准确到相对准确的。认知的提升是从一个个案例,一段段源码的分析中逐渐完成的。