PostgreSQL的clog—从事务回滚速度谈起

PostgreSQL的clog—从事务回滚速度谈起


原文: https://www.enmotech.com/web/detail/1/701/1.html   


如果是之前学习别的数据库的人,看PostgreSQL会感觉到有句话非常奇怪:“PostgreSQL的回滚是立即完成的,不会受到事务大小本身的影响”。

 

奇怪在哪里呢?比方我曾经遇到过一次 MySQL 的故障,一个开发给生产数据库导入数据,用的是 Python 脚本,但是,他没有注意一个事情, Python MySQLdb 默认情况下,是设置 autocommit 的,于是这哥们导数据(这里说的导入,不是普通那种 load data ,而是带有业务操作的 SQL 语句,所以需要脚本操作)脚本跑了一天之后,整个数据库的状况就变得极为糟糕了:他导入所用的,是一个业务的核心表,一堆业务操作都需要操作这个表,但随着这个导入动作跑了一天,占掉了大量的行锁(几百万行锁)之后,整个业务系统的对外服务都会处于一个无法求到锁的状况了(还掺和着 MySQL 间隙锁的坑坑洼洼),业务服务停摆,于是,作为 DBA 来说,最终的决策,只有杀掉这个”大”事务了。一个 kill 命令过去之后,我们当时俩 DBA 开始慢慢数—小蚂蚁慢慢爬——碰到—颗大豆芽——碰到两颗大豆芽——


最终在将近三个小时的 rollback 之后,这个事务完成回滚,业务系统恢复。


所以看到 PostgreSQL 的这个描述之后,我第一时间的反应是, why how what


于是就有了这一篇文章,我从 PG 的事务可见性判断讲起,整理一下 PG 核心文件 clog 的机理 与作用


另注:从 pg 10 以后, clog 改名为 xact ,主要原因,是很多人习惯性地使用 *log 删除日志文件,总是会不小心删除掉原先的 xlog clog 文件,导致数据库不可用,所以分别改名为 wal xact ,后文依然以 clog 为讨论单词,需要注意。
 
clog简介


第一个问题,什么是 clog ?或者换个说法, PG 到底有哪些日志,它们分别是干啥的?


除了理所当前的各路文本记录(比方数据库的运行报错日志之类) PG 的二进制类日志文件主要有两个,一个就是对应传统数据库理论的 redo 日志,理论上,所有数据的修改操作都会被记录到这个日志,在事务提交的时候确保操作都记录到磁盘中,这样讲即便发生宕机,数据库也能以不丢数据的形态重新复活。


但是,各个数据库在这个点上都有不同的实现,比方 MySQL 会有一个 binlog 用于跨存储引擎的主从同步,而在 PG 中,主从同步已经通过 redo 日志( PG 术语为 XLOG )同步的情况下,为了处理没有 undo 带来的一系列问题,其中可见性判断这个功能,就是交给 clog 日志文件解决的。


Clog 中记录了每一个事务相关的 xid (记得之前曾吐槽过这个玩意的大小问题带来的 freeze 问题)以及 xid 对应的事务的提交状态。提交状态包括以下一些:执行中,已提交,已中断,已提交的子事务。看到这里,就可以明白,只要事务提交的时候,设置状态为已提交,而事务回滚的时候,设置状态为已中断,就可以达到目的,的确避免了操作数百万行的事务突然要回滚时候的巨大代价。


但我看到这里的时候,就产生一个疑惑,这样的话,我查数据的时候,见到一行的 xid 之后,需要马上确认其可见性,就需要去查 clog ,这个查询频率势必极高而且随机性很大,这个问题该怎么解决呢?

#define CLOG_BITS_PER_XACT    2

#define CLOG_XACTS_PER_BYTE 4

#define CLOG_XACTS_PER_PAGE BLCKSZ * CLOG_XACTS_PER_BYTE

#define CLOG_XACT_BITMASK    ((1 << CLOG_BITS_PER_XACT) - 1)

#define TransactionIdToPage(xid)    ((xid) / (TransactionId) CLOG_XACTS_PER_PAGE)

#define TransactionIdToPgIndex(xid) ((xid) % (TransactionId) CLOG_XACTS_PER_PAGE)

#define TransactionIdToByte(xid)    (TransactionIdToPgIndex(xid) / CLOG_XACTS_PER_BYTE)

#define TransactionIdToBIndex xid )      (( xid % TransactionId CLOG_XACTS_PER_BYTE

PG 代码给了一个非常精彩的回答。


还记得之前 vacuum 那个里面,我大力吐槽 PG 32 xid 的执着,但这个 32 id 果真一无是处吗?看到这里才明白,还留着这么一笔思路。


一个简单的算术,每个事务标记占据 2 个比特位(无符号 0 1 2 3 对应前面提到的事务状态),也就是说,每个字节可以保存 4 个事务,每当 PG 需要确定当前事务状态的时候,就直接根据当前事务 id 计算得到对应的 clog 页位置(除每页 clog 之后的整数商是页数字,而余数则是在页中的具体位置)。真是把文件当 hash 表用的典范啊。


32 xid 的情况下,假设 xid 限制是 20 亿,每个 8K clog 页存储 32k 事务位的情况下, clog 最大也才五百来 MB ,这部分交给操作系统的文件缓存足以保障访问效率了。


真是一个绝妙的主意不是么?如果不考虑 64 xid 的情况下, clog 大小完全不可控的情况的话。


还是把话题集中在 clog 下面我们来探讨的是,当事务提交或者回滚的时候,其内部的运作机理又是如何呢?


以及,前文中可以看到的一个明显问题, pg 这种操作的话,写入的行必然是一个”执行中事务状态”的行,这种行难道是每次查的时候,都得去找 clog 判断吗?如果频繁扫他几百万行,是不是会有问题?
 
clog实现内部


前面提到, clog 里面会记录的是 xid 对应的事务状态。在 PG 里面, xid 是一个珍贵的资源(考虑到每 20 亿大限的成住空坏),因此并不是每个事务都会被分配到 xid


一般来说,只有一个事务进行了数据修改(比如 insert update delete )之类的操作,才会被分配给一个 xid


这个事务最终提交或者回滚的时候,其最终状态就会被记录入 clog


事务提交与回滚时候的clog操作



首先来说提交。


抛开其他各种过程,每次事务提交的时候,主要的调用路径是: CommitTransaction (提交事务时候调用)-> RecordTransactionCommit(记录事务为已提交)-> TransactionIdCommitTree(同步标记事务为提交)/TransactionIdAsyncCommitTree(异步标记事务为提交,调用下一步需要提供lsn)-> TransactionIdSetTreeStatus(设置事务与子事务状态)-> TransactionIdSetPageStatus(设置单数据页内事务状态)-> TransactionIdSetPageStatusInternal(设置实际文件页)-> TransactionIdSetStatusBit(设置比特位)

 

其中值得拿出来讲的,主要是 TransactionIdSetTreeStatus 这个方法。

 

这里涉及到一个概念,子事务。在 PG 这个地方,子事务的概念主要指:事务从开始到结束,期间可以 savepoint ,之后 rollback savepoint 而不是事务起点,在实际情况中多有应用,因此这里父事务与子事务(比如事务最终提交,但期间有回滚的情况,或者事务期间多次 save point )必须尽可能原子性的方式写入,否则事务可见性就会出现问题。

 

在代码注释里面,对这里的写入做了一个比较直观的例子:
比如一个事务t,有子事务 t1,t2,t3,t4,其中t,t1被映射到clog页p1,t2和t3在p2,t4在页p3。那么写入的时候,顺序如下: 

设置p2 的t2 t3为子提交,之后设置p3的t4位子提交

设置t1为子提交,之后设置t为已提交,之后设置t1为已提交 

设置 t2 t3 为已提交,设置t4位已提交

对于回滚,实际上也是调用TransactionIdSetTreeStatus方法,只是上层函数是TransactionIdAbortTree,设置的标记是TRANSACTIONSTATUSABORTED,也就是记录事务为中断。语义上来说,对于事务中断,由于事务的原子性要求,中断的事务数据就是不可见的了,没啥问题。

 
数据行事务可见性的判断与clog


众所周知的是,pg新增行都会对原先的行打一个删除标记,然后写在原先行的旁边,理所当然地,每个数据行都会记录一个事务标记(当然还有数据行对应的事务id),来确保可见性,避免看到事务层面已经rollback的事务。

 

首先,写入的当时,事务没有结束的时候,必然是”执行中”这个状态。当事务之后提交,或者回滚的时候,pg是必然不会回头改这个标记的,否则无论提交还是回滚,都是一个代价巨大的事情。

 

就前文所言,pg的事务可见性,是通过行的事务id,找到clog里面对应的标记位置,然后判断的,这里非常理所当然的一个事情是,这种判断,每一行做一次就足够了,判断清楚后,修改掉这个事务标记为已提交或者是中断事务,后续读取的时候,就不需要回查clog了。

 

PG当然就是这么干的。

 

也就是说,前一个事务所有修改的数据,它没有在提交或者回滚的当时改掉所有的修改标记,而是把烂摊子丢给后来的人。

 

而这里还藏着一个问题:你既然修改了行的标记,那理所当然地,行所在数据块的校验和就变了,校验和变了,那块是不是就必须得传到wal缓存走流程了?即便没有涉及数据的变更?而且考虑到从库查询的时候,查数据也可以直接走从库的clog流程,这个数据块是不是必须传给从库?

 

那么,现在就有一个现成的面试问题了:PostgreSQL单纯的select执行,会不会产生WAL日志?

 

事实上,这里的事务标记带来的校验和的问题,在PG里面的处理是比较特殊的。

 

PostgreSQL里面,当且仅当设置了walloghints或者初始化时候,initdb启用了checksum的情况下,才会在设置标记为的时候去写WAL日志。

 

而且这里还不是每次设置标记位都会写。

 

必须得是,前一次checkpoint之后,数据块第一次被修改就是sethintbit操作的情况下,才会写整个数据块到WAL。
 
clog的一些衍生思考



实际上就清理过期数据,MySQL也是用delete+insert替代update,但在清理以及处理上,并没有搞到vacuum这么大代价,比如MySQL的purge线程的执行,一般很少需要特别关注,而PostgreSQL的vacuum虽然说是并行化,但是在单表内却是串行的,民间贡献的表内并行vacuum的补丁因为各种bug迟迟没有合并(目前来看PG12没戏了),这个事情为什么会这样呢?

 

因为clog毕竟只是事务可见性的标记,而不是事务的修改关联。在传统的undo类实现中,修改的数据,以及关联的事务等,都在undo按照顺序存储,purge执行的之后,直接从undo就可以找到对应的需要处理的数据块直接处理。

 

但是对于PG来说,由于仅仅只有事务标记,vacuum必须扫描所有的数据文件的数据块来处理这个问题,虽然pg里面,vacuum和统计信息采集合二为一(统计信息采集是传统数据库最大的全库扫描行为了),但必然需要付出的全库扫描代价却一个都不会少。

 

因此vacuum对超大表非常慢,极端情况下在vacuum freezen时候导致全库不可用(freezen结束前不允许执行新事务),就是有极大可能的事情了。

 

为了解决超大表,传统建议是使用分区表,但PostgreSQL的官方实现里面,分区表一直不太稳定,并且支持不足,因此又不得不引入pathman这个外部组件来协调处理,导致运维复杂度的进一步上升,就成了理所当然的事情。

 

不过目前就PostgreSQL 12来说,已经在逐渐开放存储引擎层面的接口,而社区中实现的undo版本的存储引擎,虽然因为完成度问题没有在本次release中发布,但未来可期,相信vacuum这一类问题,在未来必然会得到更好的处理。

 

PostgreSQL的clog—从事务回滚速度谈起


PostgreSQL的clog—从事务回滚速度谈起



想了解更多关于数据库、云技术的内容吗?

快来关注“数据和云”公众号、“云和恩墨”官方网站,我们期待与大家一同学习和进步!


PostgreSQL的clog—从事务回滚速度谈起

(扫描上方二维码,关注“数据和云”公众号,即可查看更多科技文章)