PostgreSQL技术内幕:事务处理深度探索
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

1.5 事务处理流程

无论隐式事务还是显式事务,在开始时都会调用StartTransaction函数。在这个函数中会涉及多个事务状态(注意不是事务块状态),分别是TRANS_DEFAULT、TRANS_START和TRANS_INPROGRESS。

由于子事务(PostgreSQL数据库对应SAVEPOINT)的引入,同一个事务会有多个层级的子事务,PostgreSQL数据库会使用一个事务栈来保存每个层级子事务的状态,这个事务栈使用的是TransactionStateData结构体。

1.5.1 事务ID

事务在开始之前,会将TransactionStateData结构体中的事务状态设置为TRANS_DEFAULT,当进入StartTransaction函数就标志着一个事务的开始,因此事务状态需要从默认的TRANS_DEFAULT切换为TRANS_START。

此时并不会真正申请事务ID,事务ID是很宝贵的资源,通常只读事务(例如事务中只包含SELECT语句,SELECT语句中不包含FOR UPDATE等)不会申请事务ID,只有涉及写操作时,才会分配事务ID,这是因为读操作只需要通过快照(Snapshot)就能判断元组的可见性,不需要为只读事务产生事务日志。

事务会在执行第一个含有写操作的语句时分配事务ID,例如常规的INSERT、DELETE、UPDATE等。当执行heap_insert函数时(该函数负责插入元组),将尝试获取事务ID。如果该事务已经获取了事务ID(例如,在执行heap_insert函数之前,事务块中已经发生过写操作),那么会将已获取的事务ID直接返回;如果还没有获取,则分配新的事务ID。

同一个事务可能包含顶层的父事务及下层的子事务,因此在第一次分配事务ID时,不仅要给当前层的事务分配,还要给上层的事务分配。

我们假设一个事务中包含两个子事务(通过执行两次SAVEPOINT语句来实现),如图1-15所示,现在要对当前的事务块(最后一个SAVEPOINT p2对应的子事务)分配事务ID,示例如下。

图1-15 事务ID分配

在图1-15中,为子事务p2分配事务ID时,首先需要给它的顶层事务分配事务ID,然后依次从上向下逐个分配事务ID,因此,顶层事务和子事务的事务ID是不同的,顶层事务ID一定小于子事务ID。每个事务还有一个子事务编号(subTransactionId),这个编号从TopSubTransactionId(也就是1)开始计数,它在事务存活期间可以用来唯一标识一个子事务。

分配事务ID的工作在GetNewTransactionId函数中完成,事务ID的计数器保存在共享内存的VariableCacheData结构体中,每次获得事务ID之后都要对计数器做+1操作。

PostgreSQL数据库采用一个无符号整数做事务ID的计数器,因此事务ID的取值范围是无符号整数。当事务ID向前推进到“一定”程度时,事务ID就会回卷,我们可以认为所有的事务ID组成了环,PostgreSQL数据库使用Vacuum命令回收事务ID,被回收的事务ID可以被重复利用,这个过程类似一个生产者-消费者环形队列,新事务作为消费者不停地分配新的事务ID,而Vacuum作为生产者则不停地回收老的事务ID。

但是,这也不能保证万无一失,还必须预留出足够的事务ID。在VariableCacheData结构体中保存了多个“Limit”变量,在分配事务ID时会检查这些“Limit”变量。

xidVacLimit:当新的事务ID超过这个变量的值时,事务会考虑触发一次新的Vacuum。这个变量的作用是预警,因为此时距离触发事务ID回卷已经非常接近,考虑到可能有多个事务同时申请事务ID,所以并不是每个事务ID都触发Vacuum。当xidVacLimit%65536 == 0时才会触发。

xidWarnLimit:xidWarnLimit – xidVacLimit == 1000000 ≈ 1M,此时会产生告警,提醒用户手工执行Vacuum命令清理事务ID。

xidStopLimit:xidStopLimit – xidWarnLimit == 1000000 ≈ 1M,此时不能开启新的事务,用户只能手工清理事务ID。在用户手工清理事务ID的过程中,还需要分配新的事务ID,因此xidStopLimit和xidWrapLimit中间预留1M的事务ID是必要的。

xidWrapLimit:这是事务回卷的上限,事务ID不能超过它。

事务ID会记录到当前事务的结构体中,如果是子事务ID,则记录到事务结构体的子事务ID数组中。

PostgreSQL的事务ID扩容已经被提上日程,相信在不久的将来就会改进为64位的事务ID,这样即使每小时消耗1亿事务ID,也需要2^64 / (1亿×24×365) = 2106万年才能耗尽,这时就没有必要考虑事务回卷问题了。

1.5.2 pg_subtrans日志

在PostgreSQL的数据目录下有pg_subtrans目录,这个目录借助SLRU(SLRU的分析参见附录A3)记录事务ID的父子关系。目前,事务ID的长度为32位。在SLRU中,每个页面默认都是8KB,所以每个页面可以保存2048个事务的父子关系。顶层的事务保存的是InvalidTransactionId(也就是0),子事务保存的是自己上一层事务的事务ID。假如有多层的嵌套事务,当知道某个子事务的ID之后,就能很容易地向上追溯其父事务ID。这种追溯是单向的,由父事务ID无法获得子事务ID。

在AssignTransactionId函数中,如果新获得的事务ID是子事务ID,那么就需要设置它的父事务ID到pg_subtrans日志中。

pg_subtrans对SLRU的操作不记录WAL,因为它只在事务“活跃”期间有效,数据库重启后,pg_subtrans中的内容会被彻底清理掉。

1.5.3 启动事务

通常来说,如果一个事务没有进行IUD(INSERT、UPDATE、DELETE)操作,那么就不会分配事务ID,但事务仍然用一个虚拟事务ID来代表自己。虚拟事务ID由两部分组成,第一部分是Backend ID,这是会话独有的ID,另一个是每个会话自己维护的本地事务ID计数器。通过两部分组合,就能保证这个虚拟事务ID的唯一性。

在事务启动阶段,由于还不知道事务是否含有写操作,因此此时只会分配虚拟事务ID。

子事务中设置的GUC参数只会保留在本层子事务块中,当子事务回滚之后,子事务中设置的GUC参数也会恢复到原来的值。

1.5.4 事务结束

事务结束可以分成两种情况,一种是事务提交,另一种是事务回滚(事务异常终止也属于事务回滚的范畴)。触发事务结束的行为如下。

• 手动终止,通过显式地指定COMMIT、END、ROLLBACK、ABORT命令来结束事务。

• 执行过程中出现错误导致事务不得不异常终止,例如事务中包含除零操作。

• 数据库故障导致的终止,例如机房发生断电事故。

数据库采用WAL的方式保证事务提交成功,PostgreSQL还使用clog记录事务的提交状态,clog是建立在SLRU之上的一种记录事务提交状态的机制。

每个事务都可以有4种状态,在做可见性判断时可以通过clog检查事务的状态。

PostgreSQL可以通过系统函数查询每个事务的提交状态。

每个事务都需要用2位来记录状态,SLRU中每个页面的大小为8192字节。也就是说,每个页面能存放32768个事务状态。

在分析SAVEPOINT命令时,可以发现同一个事务中会同时有顶层事务和子事务,每个子事务都会有自己的事务ID,子事务同样会在clog中占有2位来标识自己的状态。

PostgreSQL数据库通过CommitTransaction函数提交事务,在这个过程中会对clog中的事务状态进行设置,其函数调用关系如图1-16所示。

顶层事务和子事务在设置clog的事务状态时,是需要按照顺序的。以事务提交为例,可以分成两种情况,一种情况是事务中的所有事务ID(包括顶层事务和所有子事务)都映射在同一个clog页面,那么只需要获得一次锁就可以设置完所有的事务状态,可以将这个设置的过程看成原子操作。可是当事务ID比较多时,可能会跨clog页面设置事务状态,那么设置步骤是:先将子事务的状态设置为SUB_COMMITTED,这个状态不是最终状态。如果要判断子事务是否提交,当它处于SUB_COMMITTED状态时,还需要判断父事务是否已经COMMITTED。

图1-16 异步提交函数调用关系

这种设置方法采用的是两阶段思想,和两阶段提交相似(可参考第9章)。顶层事务可以看作事务的协调者,设置子事务为SUB_COMMITTED状态可以看作两阶段提交的PREPARE状态,所有的子事务都设置为SUB_COMMITTED状态代表第一阶段成功,所有的子事务都统一提交,然后开始第二阶段,设置主事务和子事务为COMMITTED状态。clog的写入顺序如图1-17所示。

图1-17 clog的写入顺序

事务提交预示着事务即将结束,无论提交还是异常终止,事务的生命周期都将完结,在事务运行过程中所获取的资源,都需要在事务结束时被释放。