写点什么

PostgreSQL 技术内幕(十五):深度解析 PG 事务管理和分布式事务

  • 2024-04-08
    北京
  • 本文字数:7216 字

    阅读完需:约 24 分钟

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 属性:

  1. 原子性(Atomicity):事务被视为一个不可分割的最小单元,它包含的操作要么全部成功执行,要么全部失败回滚。例如转账的这两个关键操作(将 Bob 的余额减少 10 元,将 Alice 的余额增加 10 元)要么全部完成,要么全部失败。

  2. 一致性(Consistency): 确保从一个正确的状态转换到另外一个正确的状态,这就是一致性。在转账过程中,如果在扣款和存款之间发生系统崩溃,导致只有一方账户发生变化,那么数据库就处于不一致状态。事务的一致性要求确保这种情况不会发生。

  3. 隔离性(Isolation):在并发环境中,事务的执行不应该被其他事务干扰。每个事务都在自己的独立空间内运行,直到完成。这确保了并发事务之间不会相互冲突或产生不可预见的结果。

  4. 持久性(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 的事务管理

在 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 valueSeg1: 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 本地恢复策略的基础上,针对分布式事务的复杂性进行了扩展处理。整个恢复过程精细划分为四个关键环节:

  1. 本地日志恢复阶段:此阶段的首要任务是确保各个节点(包括 master 节点和所有 segment 节点)的本地日志得以正确恢复。在此过程中,处于 prepared 状态的本地事务将被保留,因为它们作为分布式事务的组成部分,其最终状态将由后续步骤决定。

  2. 分布式事务提交确认:在本地日志恢复完成后,master 节点将承担起确认分布式事务提交状态的责任。由于网络故障或节点故障可能导致部分 segment 节点未能及时提交事务,master 节点需要主动通知这些节点提交已确认的分布式事务。这一步骤确保了所有已提交的事务在所有节点上都能得到一致的处理,并且由于提交操作具有幂等性,避免了重复操作导致的数据不一致。

  3. 未完成事务清理:接下来,为了保持数据的一致性,系统将对未完成的分布式事务进行清理。所有处于 prepared 状态的本地事务以及其他未完成的事务都将被取消。这一步至关重要,因为它能够清除因故障而中断的分布式事务的残留影响,确保系统状态的一致性。

  4. 恢复完成与新请求接纳:当上述三个步骤均成功完成后,CBDB 的分布式日志恢复工作便告一段落。此时,系统已准备好接受新的分布式请求,并能够在确保数据一致性的基础上进行高效处理。

结语

本次直播我们向大家详细讲解了事务管理和分布式事务的核心原理、实现机制。CBDB 通过引入分布式事务 ID、分布式事务快照和分布式日志恢复等创新机制,成功解决了分布式环境下事务一致性的问题,确保了数据的完整性和一致性。

用户头像

还未添加个人签名 2021-03-10 加入

酷克数据是中国领先的云原生数据仓库软件公司,致力以领先技术降低大数据分析的门槛和成本,我们的产品广泛应用于金融、运营商、能源等领域,帮助企业构筑稳定高效、自主可控的数据底座。

评论

发布
暂无评论
PostgreSQL技术内幕(十五):深度解析PG事务管理和分布式事务_postgresql_酷克数据HashData_InfoQ写作社区