Author: 佑熙
PostgreSQL 内部通过多版本并发控制MVCC模型来维护。这意味着每个SQL语句看到的可能都是一小段时间以前某本版本的数据快照,而非当前数据最新的状态。这样可以避免并发写操作而造成的数据不一致问题。每个数据库都提供事务隔离机制,MVCC避免了强锁定方法,通过锁征用最小化来保障多用户环境下查询的性能。使用这一套模型最主要的优点是MVCC中读写请求并不冲突,读写互相不会阻塞。
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的第几个位置上
上文提到PostgreSQL中对记录的修改不是直接修改tuple结构,而是重新生成一个tuple,旧的tuple通过t_cid指向新的tuple。下面针对数据库的三种典型操作,增加、删除、修改进行分析
INSERT操作最简单,直接将一条记录放到page中即可
插入操作的过程和结果分析:
- t_xmin 被设置成了 1184 即事务ID
- t_xmax 被设置成了 0 因为该元组还未被更新或删除
- t_cid 被设置为了0 这条命令是该事务的第一条命令
- t_ctid 被设置为(0,1)表示这是一个元组,被放置在0page的第一个位置上
PostgreSQL中的删除并不是真正的删除,实际上该元组依然存在于数据库的存储页面,只是因为其对元组进行处理使得查询不可见。
删除操作使得tuple 的t_xmax字段被修改了,即被改成了1187,删除这个tuple数据的事务txid
当txid为1187的事务提交时,tuple就成了无效元组,称为“dead tuple”
但是这个tuple依然残留在页面上, 随着数据库的运行,这种 “dead tuple” 越来越多,它们会在 VACUUM时最终被清理掉。
相比于前面的操作,update操作复杂一些。同样按照准则:PostgreSQL对记录的修改不会修改数据,而是先删除旧数据,后新增加数据。(将旧数据标记为删除状态,再插入一条新数据)
这里涉及的操作较多,依次理一下思路
1 . 首先第一条语句 是一次普通的更新操作
第一元组, 原本的ID是 1184 - 0 - 0 - (0,1) 升级操作第一步,将t_max 与 t_cid 更新, 新的ID 为
1184 - 1187 - 0 - (0, 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 的隔离级别是 可重复读。这些阶段我们分别来讨论
Transaction_A 开始并执行第一条命令,获取 txid 和 snapshot ,事务系统分配给 Transaction_A 分配的 txid 为 200, 并获取当前快照为 200:200
Transaction_B 开始执行第一条命令,获取txid 和 snapshot, 事务系统给 Transaction_B 分配 txid 为 201并获取当前快照为 200:200 ,因为Transaction_A正在运行,所以Transaction_B 无法看到 A中的修改
Transaction_C 开始执行第一条命令,获取txid和snapshot, 事务给Transaction_B 分配xid为 202,并获取当前快照为 200:200, Transaction_C 无法看到 A、B 中的修改
Transaction_A 进行了commit,事务管理系统删除了 Transaction_A 信息
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不可见。
元组可见性利用以下几个点确定:
- tuple 中的 t_xmin 和 t_max
- clog
- 当前的snapshot
判断一个tuple对当前执行的事务是否可见,既事务是否会处理该tuple。下面只针对最简单的情形进行讨论
定则1:如果t_xmin事务废弃其不可见
定则2:如果t_xmin事务正在运行且非当前事务,事务中的tuple不可见(不然就是读脏数据)
定则3:如果t_xmin事务运行中,且tuple状态为死亡(t_xmax!=0)不可见
定则4:如果t_xmin事务运行中,且tuple状态为活跃(t_xmax=0)可见
定则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 多版本并发控制是种乐观锁的体现,解决了读多写少场景下的大规模读并发问题。一个事务在读取数据时应该读取哪个版本的数据,取决于该数据对于该事务的可见性。这种元组可见性检测可以帮助数据库找到”正确“版本的tuple,而且实现了ANSI SQL-92 标准中定义的异常:脏读、不可重复读、幻读