1. 导语:快照、多版本和可见性
我们生活的外部世界是一个不断变化的系统,使用手机拍照,可以记录外部世界某一时刻的画面。
同样的,数据库也是一个动态运行的系统,如果我们不断地更新数据,数据库的状态也会随之不断变化。
数据库的快照就像是对数据库进行拍照,可以记录某一时刻的数据库状态。这样在支持多版本的数据库中,我们可以根据快照查询某一时刻的数据。
什么是多版本呢?在对某条数据进行更新时,不会立即在磁盘上使用新版本替换旧版本,而是同时保存多个版本。
这样,如果同时有其他用户在读该数据,也不会发生冲突。读用户可以继续读取旧版本数据,写用户只需要增加新版本数据即可,互不影响。
多版本的机制,可以避免读写冲突,大大提高了数据库的并发处理能力。
既然同一个数据会有多个版本,那么对于某个特定的快照来说,有的数据是可见的,有的数据是不可见的。这就是可见性。
2. 数据库的隔离级别
在介绍快照和可见性机制的原理之前,我们先了解下数据库的隔离级别概念。
数据库的隔离级别是数据库系统中定义事务之间可见性的重要概念。不同的级别代表了事务在并发执行时能看到其他事务修改数据的程度。
注:后文中采用小写t表示时间,大写T表示事务。
事务可以看到其他事务未提交的数据。缺点:脏读。
下例中,事务2读取了最终未提交的数据tuple1,出现脏读现象。
只能看到已提交的数据。可以避免脏读,但是无法避免不可重复读。
下例中,事务2在t2时刻读不到tuple1,在t4时刻才能读取tuple1,避免了脏读问题。
下例中,事务1在t4时刻对tuple1进行更新,产生了新版本的tuple1。事务2在t3时刻读到旧版本tuple1,在t6时刻读到新版本tuple1。
同一个事务中,前后读取的数据不一致,出现不可重复读现象。
事务期间,多次读取同一数据结果一致。可以避免不可重复读,但是无法避免幻读。
下例中,事务2在t3时刻和t6时刻读到的都是旧版本的tuple1,避免了不可重复读现象。
下例中,事务2在t3时刻查询表的所有数据,读到了一条数据,但是在t6时刻查询表,读到两条数据。出现了幻读现象。
不可重复读主要针对同一行数据,两次读取数据不一致。而幻读主要针对同一范围数据,两次读取同一范围,数据个数发生变化。
完全隔离,如同顺序执行。可以避免所有并行事务导致的问题。
3. TDSQL PG的隔离级别
TDSQL PG的隔离级别与上述描述基本一致,但也有一些细微区别。
TDSQL PG的默认数据库隔离级别是读已提交。TDSQL PG的读未提交和读已提交的行为是一致的,即只能看到其他事务已经提交的数据。
TDSQL PG的可重复读级别,可以避免幻读问题,但是并不是完全的串行化问题,还存在写偏斜问题(Write Skew)。
读已提交:每次使用快照时,都会重新拿当前最新的快照。
可重复读:在事务第一次使用快照时,拿当前最新的快照。后续使用快照时,都使用第一次获取的快照(也即事务快照)。
对应的代码如下:
GetTransactionSnapshot(void)
{
// 如果是事务第一次拿快照,则去拿最新的快照
if (!FirstSnapshotSet)
{
CurrentSnapshot = GetSnapshotData(&CurrentSnapshotData);
FirstSnapshotSet = true;
return CurrentSnapshot;
}
// 如果不是第一次拿快照,则对于可重复读及以上的隔离级别,返回事务快照(即本事务第一次拿的快照)即可
if (IsolationUsesXactSnapshot())
return CurrentSnapshot;
// 对于读已提交级别,则去拿最新的快照
CurrentSnapshot = GetSnapshotData(&CurrentSnapshotData);
return CurrentSnapshot;
}
#define IsolationUsesXactSnapshot() (XactIsoLevel >= XACT_REPEATABLE_READ)
读已提交隔离级别,在t6时刻,因为事务2获取的是当前最新快照,该快照可以看到新版本tuple1,看不到旧版本tuple1,所以两次读取的数据不一致。
可重复读隔离级别,在t6时刻,因为事务2获取的是事务快照,该快照可以看到旧版本tuple1,看不到新版本tuple1,所以两次读取的数据一致。
3.3 可重复读级别为什么可以避免幻读
下例中,在t6时刻,因为事务快照看不到新插入的元组2,所以返回的数据仍只有元组1。因此避免了幻读
上述说明中,一个比较重要的概念就是可见性。元组对于快照是否可见,是实现事务隔离级别的一个重要基础。
那么元组对于快照是否可见,受什么因素影响呢?
从上面的介绍中,我们可以简单了解到,如果一个元组在快照获取前插入,则快照可以看到该元组,如果元组在快照获取后插入,那么快照看不到该元组。
TDSQL PG关于快照和可见性的实现机制整体是较为复杂的,这里我们抛开大量的细节,只分享基本的设计思想。
首先我们要了解几个基础概念。
元组对于快照可见有两条规则:
根据上述可见性机制的文字描述,再来看可见性的代码,会很容易理解。
HeapTupleSatisfiesMVCC(HeapTuple htup, Snapshot snapshot,
Buffer buffer)
{
// xmin无效,则不可见
if (HeapTupleHeaderXminInvalid(tuple))
return false;
// xmin对于快照是活跃事务,则不可见
if (XidInMVCCSnapshot(HeapTupleHeaderGetRawXmin(tuple), snapshot))
return false;
// 走到这里,说明xmin有效,且xmin对于快照来说不是活跃事务
// xmax无效,则可见
if (tuple->t_infomask & HEAP_XMAX_INVALID)
return true;
// xmax虽然有效,但是对于快照是活跃事务,则可见
if (XidInMVCCSnapshot(HeapTupleHeaderGetRawXmax(tuple), snapshot))
return true;
// 走到这里说明,xmax无效,且xmax对于快照不是活跃事务,则不可见
return false;
}
在元组对于某个快照是否可见的判断中,一个比较重要的问题是事务对于快照是否为活跃事务。
也可以简单理解为,事务对于快照来说有没有提交,如果已提交,则不是活跃事务,如果未提交,则为活跃事务。
这里,我们先介绍下MVCC的快照组成。快照组成结构较为复杂,这里我们仍然是抛去大量细节,只说明关键的部分。
快照可以简单理解为一个三元组,(xmin,xmax,xip数组)。要注意区分快照中的xmin和xmax,与元组中的xmin和xmax不是一个概念。
我们可以发现,快照本质上是记录当前的活跃事务信息。用这个信息,可以去判断一个事务对于该快照来说,是提交还是未提交。
判断事务对于快照是否为活跃事务:
同样的,明白原理后,再去看代码,会比较清晰。
XidInMVCCSnapshot(TransactionId xid, Snapshot snapshot)
{
// 小于xmin的一定已提交,不是活跃事务
if (TransactionIdPrecedes(xid, snapshot->xmin))
return false;
// 大于等于xmax的一定未提交,是活跃事务
if (TransactionIdFollowsOrEquals(xid, snapshot->xmax))
return true;
// 位于xip数组中的,一定是活跃事务,未提交。
for (i = 0; i < snapshot->xcnt; i++)
{
if (TransactionIdEquals(xid, snapshot->xip[i]))
return true;
}
// 不在xip数组中,表示已提交
return false;
}
4. 全局快照
4.1 分布式系统
上述的快照和可见性机制在集中式上执行没有问题,但如果在分布式系统中,则会出现一些问题。
这里把上述集中式的快照机制简单记为本地快照,用于区分全局快照。
下面,我们以pgxc分布式为例,说明分布式系统中使用本地快照有哪些问题
首先简单介绍下pgxc分布式系统
在pgxc分布式中,节点分为协调节点CN和数据节点DN,CN仅存储元数据信息,具体的数据存储在DN节点。应用的增删改查等操作从CN发起,CN会把相应的请求下发到指定的DN执行,DN执行完成后会把结果发给CN,CN经过一些处理,再把结果返回给应用端。
例1,因为网络延迟,事务1 t1时刻发起的查询请求等到t4时刻才到达DN1上,事务2 t2时刻的插入在t3时刻就已完成。使用本地快照会导致看到不应该看到的数据。
例2,理论上事务1的写操作要么全看见,要么全看不见。但是事务2使用本地快照,导致看到部分数据。
可以看到,上述问题的根因是,在分布式系统下,事务涉及到多个节点,本地事务变成了分布式事务。
对于分布式事务,由于网络延迟问题,使用本地快照不能保证一个全局的顺序。所以导致分布式场景下一致性出现问题。
如果我们能保证分布式事务的全局顺序,则可以解决这个问题。
比如在上述例1中,事务1提交时,我们给他一个序号80,事务2查询的时候我们给他一个序号90,事务3提交的时候给他一个序号100
然后我们指定序号大的能看到序号小的,序号小的看不到序号大的。
可以看到,不管事务2发起请求后,网络怎么阻塞,那么他到达DN2后,还是看不到事务3的修改。
同样的,在例2中,我们规定涉及到多节点的写事务,只有在全部节点提交后,才会给一个序号。那么事务1在t6以后才会分配一个100,事务2比t4早,我们分配一个90。
所以即便事务1在DN1上插入的更早,但是他的序号其实更大为100。所以事务2看不到事务1的任何修改。因为从事务2的角度来看,事务1并没有真正的提交。这是合理的
在上述分析中,我们知道,为了保证分布式事务的一致性,需要保证分布式下事务的全局顺序。有些事务虽然发起的早,但是可能结束的晚。
为了保证全局顺序,我们引入了全局快照。
全局快照一般被叫做事务提交序列号csn(Commit Sequence Number),或者全局时间戳gts(Global Timestamp)。全局快照本质是一个64位的数字,且按照事务提交的顺序不断递增。
gts通过单调时间来递增,csn通过事务提交来递增。不管哪种方式,都是为了保证事务的提交和事务的查询在全局是有序的。一般由一个独立的组件GTM(全局事务管理器)管理这个全局快照。
全局快照的使用
原理:
在全局快照的背景下,判断元组可见性的整体逻辑是没有变化的。
主要变化的地方在于,判断事务对于快照来说是不是活跃事务。之前通过快照的xmin xmax xip这个三元组来判断事务是不是活跃事务
在全局快照下,只要对比事务对应的csn与快照中的csn大小即可。事务的csn < 快照的csn,则说明事务不是活跃事务。反过来,则说明事务是活跃事务
这种方式其实比起三元组判断更加直观容易理解,在快照前面就是可见,在快照后面就是不可见。
例1:
t1时刻,事务1发起查询请求,假设此时事务1获取的快照csn为80
t3时刻,事务2完成插入并提交,事务2对应的csn为81
t4时刻,对于事务1来说,tuple1的xmin有效且已提交,但是xmin对应的csn为81大于快照的80,说明xmin对于快照来说是活跃事务,则tuple1不可见。符合预期
例2:
虽然事务1在t2和t6时刻分别完成两个元组的插入,但是事务的csn对应的是81。所以,在事务1未提交前,事务2看不到事务1的插入。而在事务1提交后,事务3可以看到事务1的插入。
对于事务2来说,在t4时刻,tuple1的xmin有效但是未提交,所以不可见。t5时刻,tuple2还未插入,仍然不可见。
对于事务3来说,t9时刻,tuple1的xmin有效且提交,且xmin对应的csn81小于快照的csn82,不是活跃事务。则可见。
TencentDB