PostgreSQL技术内幕(十五):深度解析PG事务管理和分布式事务
事务作为保障数据完整性和一致性的关键机制,在数据库操作中扮演着举足轻重的角色。事务通过将一系列逻辑上的操作捆绑在一起,确保它们要么全部成功执行,要么全部不执行,从而避免数据出现不一致状态。而在分布式系统中,如何确保跨多个数据库节点的事务一致性,成为了一个有挑战的问题。
Cloudberry Database(简称为“CBDB”或“CloudberryDB”)是面向分析和AI场景打造的下一代统一型开源数据库,搭载了PostgreSQL 14.4内核,采用Apache License 2.0许可协议。在PostgreSQL的成熟事务管理机制基础上,CBDB对分布式事务管理进行了精心设计和优化。本次直播我们与大家分享了PostgreSQL事务管理和CBDB分布式事务管理。以下内容根据直播文字整理而成。
数据库事务
事务的“全有或全无”特性确保了数据的完整性和一致性。
举个例子,假设Bob给Alice转账10元,这个转账会涉及到两个关键操作就是:将Bob的余额减少10元,将Alice的余额增加10元。如果两个操作之间突然出现错误,例如银行系统崩溃导致Bob余额减少,而Alice的余额没有增加,这样的系统是有问题的。事务就是保证这两个关键操作要么都成功,要么都要失败。
BEGIN;
UPDATE t_account SET account = account + 10 WHERE acc_name = ‘Alice’;
UPDATE t_account SET account = account - 10 WHERE acc_name = ‘Bob’;
END;
事务具有四个基本特性,通常被称为ACID属性:
- 原子性(Atomicity):事务被视为一个不可分割的最小单元,它包含的操作要么全部成功执行,要么全部失败回滚。例如转账的这两个关键操作(将Bob的余额减少10元,将Alice的余额增加10元)要么全部完成,要么全部失败。
- 一致性(Consistency): 确保从一个正确的状态转换到另外一个正确的状态,这就是一致性。在转账过程中,如果在扣款和存款之间发生系统崩溃,导致只有一方账户发生变化,那么数据库就处于不一致状态。事务的一致性要求确保这种情况不会发生。
- 隔离性(Isolation):在并发环境中,事务的执行不应该被其他事务干扰。每个事务都在自己的独立空间内运行,直到完成。这确保了并发事务之间不会相互冲突或产生不可预见的结果。
- 持久性(Durability):一旦事务成功提交,其对数据库的更改就是永久性的。即使发生系统崩溃或其他故障,已提交的事务所做的更改也不会丢失。
事务日志
事务日志是数据库的核心组件,它详细记录了数据库中的所有更改和操作,从而确保数据的完整性,在面临电源故障或其他服务器问题时,仍然能通过重新执行这些日志中的操作来恢复数据库状态。
事务日志(Transaction Log)一般也叫xlog,常见的事务日志类型有REDO和UNDO两种类型,且这两种事务日志的用法有明显的区别:
- REDO日志:记录对数据库修改之后的新值。Replay时用日志记录中的新值覆盖当前的值。
- UNDO日志:记录对数据库修改之前的旧值。Replay时用日志记录中的旧值覆盖当前的值。一般只用于事务回滚时,将数据恢复到修改之前的值。
在数据库操作中,一个至关重要的原则是,REDO/UNDO日志记录更新必须发生在相应数据被修改之前发生。不遵守这一原则可能会破坏数据的一致性,进而影响数据库的原子性和持久性。如果数据的修改先于日志记录,并且修改后的日志没有安全存储,那么在数据回滚时可能无法准确恢复到之前的状态。对于UNDO日志来说,如果数据发生了修改,但回滚时没有UNDO记录,那么数据没法恢复旧值。
此外,Replay日志记录时需保证幂等操作,即无论Replay多少次,结果都应该保持一致。例如,假设有一个add操作redo/undo: {Add, TupleID,ColumnIndex, 10},给指定的某个列的super加10。这个操作不是幂等的,因为执行一次和执行多次的结果是不同的。在实际应用中,必须注意这类非幂等操作的处理。
事务日志是一个序列的操作{W1, W2, W3, …}记录,如果监测到事务日志写入到Wn,那么Wn之前的写入操作都能保证生效。如果事务日志持久化存储到了Wn记录,那么即便是数据库发生故障/断电,重启数据库后,都能从存储介质读取到事务日志,进行恢复记录的操作,确保数据的完整性和一致性。
PostgreSQL的事务管理
PG事务管理
在PostgreSQL中,当实现事务时,它仅采用REDO日志,这在PostgreSQL内部也被称为WAL(Write Ahead Logging)。在之前的直播中我们曾详细介绍过WAL log模块基本原理,感兴趣的朋友可以回顾👉PostgreSQL 技术内幕(十)WAL log 模块基本原理。
MVCC多版本并发控制
这里值得注意的是,PostgreSQL回滚事务时并未使用UNDO日志,而是采用了MVCC(Multi-Version Concurrency Control)多版本并发控制机制。在并发环境中,多个事务同时读写数据库时可能会产生冲突,MVCC 通过维护数据的多个版本来解决这个问题。通过 MVCC,PostgreSQL 能够实现高度的隔离性,避免了许多并发问题,从而保障数据库的数据一致性。同时,MVCC 还提供了高并发性能,允许读写操作可以互不干扰,提升了数据库的并发处理能力。
以上表为例,假设当前向表中插入了一条数据,其中A=1,B=2,xmin=11,xmax=0。其中,xmin=11表示这条数据是由事务ID为11的事务插入的,xmax=0意味着这条数据尚未被更新或删除。
随后,假设执行了一个UPDATE操作,将a的值设置为10UPDATE t SET a = 10;,那么这条数据的xmax将更改为12(这里,假设12是当前UPDATE的事务ID)。
这并不意味着直接修改了A的值,而是在数据中新插入了一条数据,其xmin为12,表示:原数据(A=1)由事务ID为12的事务删除,同时新插入了一条数据,即A=10,B=2的数据。
然而,在执行这次update操作后,实际上我们应该只能看到一条数据,因为我们原来只有一条数据,并且执行的是一次更新操作。为了选择正确的版本,我们需要根据事务快照和提交记录来判断。在这个过程中,无论是更新还是删除操作,一旦xmax或xmin被设置,它们就不会再发生变化。
Commit&Abort
在PostgreSQL中,Commit和Abort操作是确保事务完整性和数据库一致性的关键步骤。
Commit:
- 事务提交标志:当事务成功完成时,会在WAL日志中写入一个CommitTransaction记录。这个记录包含了事务的ID(XID),用于唯一标识该事务。
- 更新CLOG:当事务提交时,对应的CLOG条目会被更新,以反映该事务的提交状态,以便为后续的查询操作。通过查询CLOG,系统可以快速确定一个事务是否已经提交,而无需扫描WAL日志。
Abort:
- 发生ERROR/故障/掉电等情况时,事务可能无法成功提交。在这种情况下,不会写入CommitTransaction记录,会写入一个AbortTransaction记录来明确标记事务的失败。
- 更新CLOG:在Abort的情况下,CLOG同样会被更新,以反映事务的取消状态。
MVCC与数据可见性判断
当我们获取到两条数据时(无论是提交还是取消的数据),它们的结果是一样的。如何判断下表中A的值是多少呢?这取决于事务11和事务12的状态以及我们获取到的事务快照(snapshot)是什么。
在PostgreSQL中,snapshot用于跟踪当前数据库系统中有哪些事务正在执行。通过snapshot,我们可以区分事务是正在执行中还是已经完成。如果我们看到的事务尚未完成,那么该事务的更新和写入操作对我们来说是不可见的。但是,有一个特例:当前事务自己写入的数据对当前事务是可见的,而其他未完成的事务写入的数据是不可见的。
struct SnapshotData {
TransactionId xmin; // 所有XID < xmin的事务都可见(已完成)
TransactionId xmax; // 所有XID ≥ xmax的事务都不可见(未来发生)
TransactionId *xip; // 正在执行的事务ID数组
uint32 xcnt;
TransactionId *subxip; // 子事务ID数组
int32 subxcnt;
CommandId curcid; // 当前事务可见的command ID
};
我们来看以上数据结构中与事务相关的部分。每条数据都有一个xmin和xmax:
- xmin表示小于该事务ID的所有事务都已完成;
- xmax表示大于该事务ID的所有事务都是未来发生的且不可见;
- 而介于xmin和xmax之间的事务可能已完成,也可能正在进行中。
为了判断这些事务ID介于xmin和xmax之间的事务的状态,我们需要一个数组(即上面的XIP数组)。如果介于xmin和xmax之间的事务ID在这个数组中被发现,那么就说明这个事务正在进行中且尚未完成,因此对我们来说是不可见的。
总的来说,在PostgreSQL中,为了高效地确定哪条数据是可读的以及它的可见性,采用了一种结合snapshot,clog和标志位的策略。当我们获取到数据时,首先会根据snapshot来判断当前事务是否已经完成。如果事务尚未完成,那么其更改对当前操作是不可见的。
对于已完成的事务,它们可能处于提交或取消两种状态之一。为了判断这些状态,我们会查询clog。在事务提交或取消时,PostgreSQL会在clog中更新相应的状态记录。当然,为了优化性能,PostgreSQL在每条数据中都包含了一些与事务相关的标志位。PostgreSQL的HEAP表中tuple带有多个flag标志和事务相关:
- HEAP_XMAX_INVALID: xmax无效,删除-取消
- HEAP_XMAX_COMMITTED:xmax已提交,加速查询
- HEAP_XMIN_COMMITTED:xmin已提交,加速查询
- HEAP_XMIN_INVALID: xmin无效,插入-取消
- HEAP_XMIN_FROZEN (HEAP_XMIN_COMMITTED|HEAP_XMIN_INVALID): vacuum标记数据已提交
- HEAP_XMAX_IS_MULTI: xmax是 MultiXactId
通常,查询过程会首先检查tuple中的这些标志位,以尝试直接判断事务的状态。只有在无法通过这些标志位获得明确状态时,才会回退到查询clog以获取更详细的信息。这种策略在保证了数据正确性的同时,也大大提高了查询性能。
CLOG格式
CLOG是一个普通文件,它使用页面来管理数据,并且不包含任何元信息,直接存储事务的状态。在CLOG中,每2比特(bit)用来表示一个事务的状态,因此一个字节可以表示四个事务的完成状态。在PostgreSQL内部,事务的状态定义如下:
- 0b00 表示事务正在进行中(in progress)。
- 0b01 表示事务已提交(committed)。
- 0b10 表示事务已中止(aborted)。
- 0b11 表示子事务已提交(sub-committed,属于特定情况下的内部状态)。
举个例子,如果一个页面的大小是4KB,那么该页面可以记录的事务状态数量是4K * 8bit / 2bit = 16K,也就是大约可以存储一万六千多个事务的完成状态。为了快速定位到特定事务ID的状态,PostgreSQL采用了基于事务ID的索引方法。具体来说,我们首先将事务ID除以每个页面所能记录的事务数量(比如16k),得到的商即为该事务状态所在的页面号。随后,利用这个页面号,我们便可以迅速找到对应的物理页面。接着,通过对事务ID进行模运算,以页面大小为基数,得到的余数即为该事务状态在页面内的偏移量。
两阶段提交(2PC: 2-phase commit)
PostgreSQL还支持两阶段提交以满足分布式事务的一致性需求。两阶段提交是为了满足多机环境下的事务一致性而设计的。设计思路是在事务提交之前,会有一个prepare阶段(第一阶段),当所有参与者都处于prepared状态时,事务管理器就可以提交全局事务(第二阶段)。
处于prepared状态的事务尚未完成,既可以保证提交成功,也可以回滚事务。原因是数据修改已经在事务日志里更新好,离事务提交只差一个CommitTransactionRecord;另一方面如果需要回滚事务,也只需要写一个AbortTransactionRecord即可,或者发生故障进入xlog recovery阶段再次写入AbortTransactionRecord。
如果COMMIT PREPARED过程中遭遇磁盘故障或磁盘空间耗尽,导致CommitTransactionRecord无法成功写入,数据库系统将陷入PANIC状态,此时通常需要人工干预以恢复系统。然而,在云环境下,我们通常会利用磁盘监控机制来自动进行扩容,从而避免此类问题的发生。由于云环境提供了近乎无限的存储空间扩展能力,因此,在实际应用中,这类由磁盘空间不足引发的问题较为罕见。
CBDB的分布式事务管理
Cloudberry Database(简称为“CBDB”或“CloudberryDB”)是面向分析和AI场景打造的下一代统一型开源数据库,搭载了PostgreSQL 14.4内核,采用Apache License 2.0许可协议。
CBDB分布式事务设计源自于PostgreSQL的单机事务管理,实现分布式事务的关键在于利用两阶段提交来管理多个PostgreSQL事务实例,称之为Segment。但值得注意的是,每个Segment的本地事务都是独立的,它们各自拥有独立的事务ID和clog。而分布式事务的调度执行则由master节点负责。
分布式事务ID
为了管理分布式事务,需要建立一个映射关系。例如,当在分布式数据库中插入大量数据时,这些数据往往会分散存储于多个Segment之中。从单个Segment的视角看,它仅负责处理部分数据的插入操作;然而,从全局视角来看,这些分布在各个Segment上的数据其实是一个不可分割的整体,共同构成了一个完整的分布式事务。因此,确保事务的原子性至关重要——若任何一个Segment上的数据写入操作失败,整个分布式事务中的其他Segment上的数据写入操作也必须回滚,以保持数据的一致性。
CBDB引入分布式事务ID的策略来管理这些复杂的操作。在执行过程中,QD(Query Dispatcher)进程承担着为每个分布式事务分配唯一且自增长的分布式事务ID的任务,并将这些ID与相应的分布式事务进行映射。随后,这些映射信息被传递给QE(Query Executor)进程,QE进程进一步将这些分布式事务ID与本地事务ID进行映射,以便在本地执行和管理事务。为了提高查询效率,QE进程还会将这些映射信息记录在本地的分布式日志中,并建立缓存机制,以便快速检索和访问。
值得注意的是,分布式事务与PostgreSQL的本地事务之间存在一种特定的依赖关系。具体来说,一个已经提交的分布式事务意味着其涉及的所有本地事务也必定已经提交;但反过来则不然,即单个Segment上的本地事务提交并不能直接反映整个分布式事务的提交状态。同样地,如果某个Segment上的本地事务正在进行中,我们可以推断出相关的分布式事务也处于进行状态。这种关系可以概括为:
- 分布式事务提交 ≥ 本地事务提交
- 本地事务提交 =\≥ 分布式事务提交
- 本地事务进行中 ≥ 分布式事务进行中
因此,我们必须明确一点:在分布式事务的上下文中,本地事务的提交状态并不能作为判断分布式事务是否已经提交的依据。这意味着,本地提交不能作为分布式系统中数据可见性的判断标准。
分布式事务快照
为了确保数据的一致性,CBDB引入了分布式事务快照的概念。在详细阐述分布式事务快照之前,我们通过一个例子来初步了解其背后的逻辑。
T1: Update t1 set a = a + 100;
T2: Select a from t1;
Seg0: T1: local committed; T2: see T1 for new value
Seg1: T1: local pending commit; T2: T1 is in progress, read old value
假设有两个事务T1和T2,其中T1负责更新表t1的数据,而T2则负责从表t1中读取数据。当T2发起查询时,T1可能正接近其提交状态。但是,在分布式环境中,各Segment节点的事务提交状态可能存在差异。因此,当T2的请求到达某个Segment时,如果T1在该节点已经提交,T2可能会看到T1的提交结果并读取到新的数据值。然而,在其他尚未完成T1提交的Segment节点上,T2读取到的将是旧的数据值。这种不一致性导致了数据的混乱,使得系统无法提供可靠的数据视图。
为了解决这个问题,CBDB采用了分布式事务快照机制。这个机制的核心在于确保T2在读取数据时,能够获取到一个全局一致的数据快照,无论T1在哪个Segment上的提交状态如何。
分布式事务快照的结构定义如下:
typedef struct DistributedSnapshot
{
DistributedTransactionId xminAllDistributedSnapshots;
DistributedSnapshotId distribSnapshotId;
DistributedTransactionId xmin; /* XID < xmin are visible to me /
* DistributedTransactionId xmax; /* XID >= xmax are invisible to me /
* int32 count; /* # of distributed xids in inProgressXidArray */
DistributedTransactionId *inProgressXidArray;
} DistributedSnapshot;
在这个结构中,分布式事务快照的部分字段与PostgreSQL的本地事务快照类似。xmin表示所有小于该值的事务ID对应的事务对当前快照是可见的;xmax则表示所有大于或等于该值的事务ID对应的事务对当前快照是不可见的;而数组中的事务ID则表示那些正在进行中且不可见的事务。
回到前面的例子,当T2事务访问各Segment节点时,即使T1事务只在部分节点上完成提交,T2也能通过查看分布式事务快照来确定分布式事务T1的完成状态。如果T1仍在执行中,它将被标记为正在进行中且不可见的事务。这样,即使分布式事务T1在某个节点上已经提交,T2也不会读取到不一致的数据,从而确保了数据的一致性和可见性。这种机制是CBDB分布式事务处理的核心保障,使得分布式系统能够提供可靠且一致的数据服务。
分布式日志恢复
CBDB还支持分布式日志恢复机制。这一机制在借鉴PostgreSQL本地恢复策略的基础上,针对分布式事务的复杂性进行了扩展处理。整个恢复过程精细划分为四个关键环节:
- 本地日志恢复阶段:此阶段的首要任务是确保各个节点(包括master节点和所有segment节点)的本地日志得以正确恢复。在此过程中,处于prepared状态的本地事务将被保留,因为它们作为分布式事务的组成部分,其最终状态将由后续步骤决定。
- 分布式事务提交确认:在本地日志恢复完成后,master节点将承担起确认分布式事务提交状态的责任。由于网络故障或节点故障可能导致部分segment节点未能及时提交事务,master节点需要主动通知这些节点提交已确认的分布式事务。这一步骤确保了所有已提交的事务在所有节点上都能得到一致的处理,并且由于提交操作具有幂等性,避免了重复操作导致的数据不一致。
- 未完成事务清理:接下来,为了保持数据的一致性,系统将对未完成的分布式事务进行清理。所有处于prepared状态的本地事务以及其他未完成的事务都将被取消。这一步至关重要,因为它能够清除因故障而中断的分布式事务的残留影响,确保系统状态的一致性。
- 恢复完成与新请求接纳:当上述三个步骤均成功完成后,CBDB的分布式日志恢复工作便告一段落。此时,系统已准备好接受新的分布式请求,并能够在确保数据一致性的基础上进行高效处理。
结语
本次直播我们向大家详细讲解了事务管理和分布式事务的核心原理、实现机制。CBDB通过引入分布式事务ID、分布式事务快照和分布式日志恢复等创新机制,成功解决了分布式环境下事务一致性的问题,确保了数据的完整性和一致性。