数据库内核月报 - 2019 / 08

PgSQL· 引擎特性 · 多版本并发控制介绍及实例分析

前言

PostgreSQL 内部通过多版本并发控制MVCC模型来维护。这意味着每个SQL语句看到的可能都是一小段时间以前某本版本的数据快照,而非当前数据最新的状态。这样可以避免并发写操作而造成的数据不一致问题。每个数据库都提供事务隔离机制,MVCC避免了强锁定方法,通过锁征用最小化来保障多用户环境下查询的性能。使用这一套模型最主要的优点是MVCC中读写请求并不冲突,读写互相不会阻塞。

PostgreSQL 元组

PostgreSQL中表以元组(堆)的形式存储,元组的头结点中包含了一些重要的节点信息。介绍MVCC前只针对需要重点阐述的信息进行介绍。

  • t_xmin 插入这条元组务的事务 txid
  • t_xmax 保存的是更新或删除这条元组事务的 txid,如果这条元素没有被删除或更新,那么 t_xmax 字段被设置为0, 即valid
  • t_cid 保存的是插入这条元组命令的编号,一个事务中可能会有多个命令,那么事务中的这些命令会从0开始依次被编号
  • t_ctid 中保存元组的标识符,他指向元组本身或者该元组最新的版本。由于PostgreSQL对于记录的修改不会直接修改元组中的用户数据,而是重新生成一个元组,旧的元组通过t_cid 指向新的元组。如果一条记录被修改多次,那么该记录会存在多个版本。各个版本通过t_cid 串联,形成一个版本链。通过这个版本链,可以找到最新的版本。t_cid 是一个二元组(x,y)其中x(从0开始编号)代表元组所在page, y(从1开始编号)表示在该page的第几个位置上

元组的 INSERT/DELETE/UPDATE 操作

上文提到PostgreSQL中对记录的修改不是直接修改tuple结构,而是重新生成一个tuple,旧的tuple通过t_cid指向新的tuple。下面针对数据库的三种典型操作,增加、删除、修改进行分析

INSERT

INSERT操作最简单,直接将一条记录放到page中即可

插入操作的过程和结果分析:

  • t_xmin 被设置成了 1184 即事务ID
  • t_xmax 被设置成了 0 因为该元组还未被更新或删除
  • t_cid 被设置为了0 这条命令是该事务的第一条命令
  • t_ctid 被设置为(0,1)表示这是一个元组,被放置在0page的第一个位置上

DELETE

PostgreSQL中的删除并不是真正的删除,实际上该元组依然存在于数据库的存储页面,只是因为其对元组进行处理使得查询不可见。

删除操作使得tuple 的t_xmax字段被修改了,即被改成了1187,删除这个tuple数据的事务txid

当txid为1187的事务提交时,tuple就成了无效元组,称为“dead tuple”

但是这个tuple依然残留在页面上, 随着数据库的运行,这种 “dead tuple” 越来越多,它们会在 VACUUM时最终被清理掉。

UPDATE

相比于前面的操作,update操作复杂一些。同样按照准则:PostgreSQL对记录的修改不会修改数据,而是先删除旧数据,后新增加数据。(将旧数据标记为删除状态,再插入一条新数据)

这里涉及的操作较多,依次理一下思路

1 . 首先第一条语句 是一次普通的更新操作

第一元组, 原本的ID是 1184 - 0 - 0 - (0,1) 升级操作第一步,将t_max 与 t_cid 更新, 新的ID 为

1184 - 1187 - 0 - (0, 2) 。

  • 对于 t_max 来说,其更新为了新的事务ID,也标识该tuple为“dead tuple”
  • 对于 t_tcid 来说, 其更新了新的指向ID,即从1号元组 更新成了 2号元组

第二元组,新插入一条 元组,这条元组就像新插入的数据一样,t_cid 指向其本身

2 . 第二条升级语句是同一事务中,多次更新的例子

第一元组 已经过期,且为“dead tuple” 不更新

第二元组为当前的活跃元组,其被更新,方式如同上文,指向第三元组

第三元组新插入一条数据,这里不同的是,由于在同一事务中,其事务号不变,而是事务次序+1(t_cid)

事务快照

事务快照是一个很形象的词,所谓快照就像是相机按下快门,记录当前瞬间的信息。 观察者通过快照只能获取当前时间点之前的信息,而按下快门以后的信息便无法察觉,即 INVISIBLE

类比于快照,事务快照就是当一个事务执行期间,那些事务active、那些非active。即这个事务要么在执行中,要么还没开始。

postgres=# SELECT txid_current_snapshot();
 txid_current_snapshot 
-----------------------
 1197:1197:
(1 row)

快照由这样一个序列构成 xmin:xmax:xip_list

  • xmin : 最早的active的 tid,所有小于该值的事务状态为visible(commit)或dead(abort)
  • xmax: 第一个还未分配的xid,大于等于该值的事务在快照生成时都不可见
  • xip_list 快照生成时所有active事务的txid

事务快照举例

在PostgreSQL中,当一个事务的隔离级别是 已提交读 时, 在该事务的每个命令执行之前都会重新获取一次 snapshot, 如果事务的隔离级别是 可重复度 和 可串行化时,事务只在第一条命令获取 snapshot。

现在假设一个多事务的场景,其中事务 A 事务 B 的隔离级别是 已提交读,而 事务 C 的隔离级别是 可重复读。这些阶段我们分别来讨论

  • T1

Transaction_A 开始并执行第一条命令,获取 txid 和 snapshot ,事务系统分配给 Transaction_A 分配的 txid 为 200, 并获取当前快照为 200:200

  • T2

Transaction_B 开始执行第一条命令,获取txid 和 snapshot, 事务系统给 Transaction_B 分配 txid 为 201并获取当前快照为 200:200 ,因为Transaction_A正在运行,所以Transaction_B 无法看到 A中的修改

  • T3

Transaction_C 开始执行第一条命令,获取txid和snapshot, 事务给Transaction_B 分配xid为 202,并获取当前快照为 200:200, Transaction_C 无法看到 A、B 中的修改

  • T4

Transaction_A 进行了commit,事务管理系统删除了 Transaction_A 信息

  • T5

Transaction_B Transaction_C 分别执行其 SELECT 命令,此时 Transaction_B 获取一个新的 snapshot(其隔离级别是已提交读),该snapshot为 201:201 。因为Transaction_A 已提交,所以Transaction_A 对 Transaction_B 可见。

同时,Transaction_C 隔离级别是 已提交读,所以它不会更新自己的 snapshot,因此 Transaction_A和Transaction_B仍然对Transaction_C不可见。

元组可见性规则

元组可见性利用以下几个点确定:

  1. tuple 中的 t_xmin 和 t_max
  2. clog
  3. 当前的snapshot

判断一个tuple对当前执行的事务是否可见,既事务是否会处理该tuple。下面只针对最简单的情形进行讨论

1 .t_xmin 的状态为 ABORTED

定则1:如果t_xmin事务废弃其不可见

2 .t_xmin 的状态为 IN_PROGRESS

定则2:如果t_xmin事务正在运行且非当前事务,事务中的tuple不可见(不然就是读脏数据)

定则3:如果t_xmin事务运行中,且tuple状态为死亡(t_xmax!=0)不可见

定则4:如果t_xmin事务运行中,且tuple状态为活跃(t_xmax=0)可见

3. t_xmin的状态为 COMMITTED

定则5:如果t_xmin事务已提交,当前快照中t_xmin活跃,则不可见

定则6:如果t_xmin事务已提交,t_xmax事务在运行中且t_max事务为当前事务,则不可见

定则7:如果t_xmin事务已提交,t_xmax事务在运行中且t_max事务不为当前事务,则可见

定则8:如果t_xmin事务已提交,t_xmax事务状态为已提交,当前快照中t_xmax活跃,则可见

定则9: 如果t_xmin事务已提交,t_xmax事务状态为已提交,当前快照t_xmax不活跃,不可见

PostgreSQL 中的 MVCC

PostgreSQL 多版本并发控制是种乐观锁的体现,解决了读多写少场景下的大规模读并发问题。一个事务在读取数据时应该读取哪个版本的数据,取决于该数据对于该事务的可见性。这种元组可见性检测可以帮助数据库找到”正确“版本的tuple,而且实现了ANSI SQL-92 标准中定义的异常:脏读、不可重复读、幻读