九、分布式数据库之隔离性
1、读写冲突的处理
图中事务 T1、T2 先后启动,分别对数据库执行写操作和读操作。写操作是一个过程,在过程中任意一点,数据的变更都是不完整的,所以 T2 必须在数据写入完成后才能读取,也就形成了读写阻塞(通过锁机制)。反之,如果 T2 先启动,T1 也要等待 T2 将数据完全读取后,才能执行写入。
相比之下,用 MVCC 来解决读写冲突(写写冲突还是要依赖锁,见11节“广义乐观和悲观协议”),就不存在阻塞问题,要优雅得多了。多版本并发控制(Multi-Version Concurrency Control,MVCC)就是通过记录数据项历史版本的方式,来提升系统应对多事务访问的并发处理能力。
2、单体数据库的 MVCC
在 PGXC 架构中,因为数据节点就是单体数据库,所以 PGXC 的 MVCC 实现方式其实就是单体数据库的实现方式。
2.1、MVCC 的存储方式
MVCC 有三类存储方式:
2.1.1、Append-Only 方式
优点:
缺点:
2.1.2、Delta 方式
优点:
缺点:
ORACLE回滚段
回滚段概述
回滚段用于存放数据修改之前的值(包括数据修改之前的位置和值)。回滚段的头部包含正在使用的该回滚段事务的信息。一个事务只能使用一个回滚段来存放它的回滚信息,而一个回滚段可以存放多个事务的回滚信息。
回滚段的作用
|
2.1.3、Time-Travel 方式
优点:
缺点:
3、PGXC 的MVCC
PGXC 底层使用单体数据库存储,所以PGXC 就是用单体数据库MVCC的方式。
4、NewSQL的MVCC
NewSQL 底层使用分布式键值系统来存储数据,MVCC 的存储方式与 PostgreSQL 类似,采用 Append 方式追加新版本。
5、MVCC存储方式小结
6、MVCC 的工作过程
MVCC是要解决多事务的并发控制问题,也就是保证事务的隔离性。本质也是在解决读写冲突:如果某个数据正在被某个事务处理,如果用锁来处理,结果就是等待或者阻塞;如果用MVCC来处理,就是取消冲突,但是只能看到上一个版本的数据。
6.1、“已提交读”(Read Committed,RC)隔离级别下 MVCC 的工作过程
按照 RC 隔离级别的要求,事务只能看到的两类数据:
6.2、例子
T1 到 T7 是七个数据库事务,它们先后运行,分别操作数据库表的记录 R1 到 R7。事务 T6 要读取 R1 到 R6 这六条记录,在 T6 启动时(T6-1)会向系统申请一个活动事务列表,活动事务就是已经启动但尚未提交的事务,这个列表中会看到 T3、T4、T5 等三个事务。
T6 查询到 R3、R4、R5 时,看到它们最新版本的事务 ID 刚好在活动事务列表里,就会读取它们的上一版本。而 R1、R2 最新版本的事务 ID 小于活动事务列表中的最小事务 ID(即 T3),所以 T6 可以看到 R1、R2 的最新版本。
MVCC相比锁的最大优势就是减少了可能造成阻塞的事物的数量。在这个例子中,T6 不会被正在执行写入操作的三个事务阻塞,而如果按照原来的锁方式,T6 要在 T3、T4、T5 三个事务都结束后,才能执行。 |
6.2、“可重复读”(Repeated Read,RR)隔离级别下 MVCC 的工作过程
6.1节的方法可以用于RR级别么?
RR级别就是在一个事务的执行期间,读取到的数据都是一样的,比如到了T6-2时刻
此时,T1 到 T4 等 4 个事务都已经提交,此时 T6 再次向系统申请活动事务列表,列表包含 T5 和 T7。遵循同样的规则,这次 T6 可以新增地看到 R1 到 R4 等四条记录的最新版本,同时看到 R5 的上一版本。
T6 刚才和现在这两次查询得到了不同的结果集,这是不符合 RR 要求的。
实现 RR 的办法也很简单,我们只需要记录下 T6-1 时刻的活动事务列表,在 T6-2 时再次使用就行了。那么,这个反复使用的活动事务列表就被称为“快照”(Snapshot)。
RC 与 RR 的区别在于 RC 下每个 SQL 语句会有一个自己的快照,所以看到的数据库是不同的,而 RR 下,所有 SQL 语句使用同一个快照,所以会看到同样的数据库。
为了提升效率,快照不是单纯的事务 ID 列表,它会统计最小活动事务 ID,还有最大已提交事务 ID。这样,多数事务 ID 通过比较边界值就能被快速排除掉,如果事务 ID 恰好在边界范围内,再进一步查找是否与活跃事务 ID 匹配。 |
在 MVCC 出现前读写操作是相互阻塞的,并行能力受到很大影响。而使用 MVCC 可以实现读写无阻塞,并能够达到 RC(读已提交)隔离级别。基于 MVCC 还可以构建快照,使用快照则能够更容易地实现 RR(可重复读)和 SI(快照隔离)两个隔离级别。
此外,MVCC 机制是用时间戳作为重要依据来判别哪个数据版本是可读取的。但是,如果这个时间戳本身有误差,就需要特定的机制来管理这个误差,从而读取到正确的数据版本。参见第10节。 |
7、PGXC 读写冲突处理
在 PGXC 架构中,实现 RC 隔离级的处理过程与单体数据库差异并不大。在实现 RR 时遇到两个挑战,也就是实现快照的两个挑战。
所以,PGXC 风格的分布式数据库都有这样一个集中点,通常称为全局事务管理器(GTM)。又因为事务 ID 是单调递增的,用来衡量事务发生的先后顺序,和时间戳作用相近,所以全局事务管理器也被称为“全局时钟”。
8、NewSQL读写冲突处理
8.1、TiDB
TiDB使用了Percolator模型,底层是分布式键值系统,假设两个事务操作同一个数据项。其中,事务 T1 执行写操作,由 Prewrite 和 Commit 两个阶段构成,T2 在这两个阶段之间试图执行读操作,但是 T2 会被阻塞,直到 T1 完成后,T2 才能继续执行。
即TiDB就是用MVCC 出现前的读写阻塞来处理读写冲突,TiDB 根本没有设计全局事务列表。事实上,PGXC 中的全局事务管理器就是一个单点,很容易成为性能的瓶颈,而分布式系统一个普遍的设计思想就是要避免对单点的依赖。
8.1、CockroachDB
CockroachDB有全局事务列表。但是没有照搬单体数据库的“快照”。
依旧是 T1 事务先执行写操作,中途 T2 事务启动,执行读操作,此时 T2 会被优先执行。待 T2 完成后,T1 事务被重启。重启的意思是 T1 获得一个新的时间戳(等同于事务 ID)并重新执行。
即CockroachDB还是会产生读写阻塞。CockroachDB 没有使用快照,不是因为没有全局事务列表,而是因为它的隔离级别目标不是 RR,而是 SSI,也就是可串行化。
事实上,被重启的事务并不一定是执行写操作的事务。CockroachDB 的每个事务都有一个优先级,出现事务冲突时会比较两个事务的优先级,高优先级的事务继续执行,低优先级的事务则被重启。而被重启事务的优先级也会提升,避免总是在竞争中失败,最终被“饿死”。
类似于多线程中,线程被中断,但是可以重入。 |
9、小结
10、读写操作与时间误差
在MVCC中,使用了时间戳作为重要依据来判别哪个数据版本是可读取的。但时间是不精确的,存在一个置信区间
因为时间误差的存在,T2-C 时间点附近会形成一个不确定时间窗口,也称为置信区间或可信区间。严格来说,我们只能确定 T2-C 在这个时间窗口内,但无法更准确地判断具体时间点。同样,T6-S 也只是一个时间窗口。时间误差不能消除,但可以通过工程方式控制在一定范围内,例如在 Spanner 中这个不确定时间窗口(记为ɛ)最大不超过 7 毫秒,平均是 4 毫秒。
因为时间窗口的存在,T2的结束和T6的开始存在重叠,所以无法判断 T2-C 与 T6-S 的先后关系。如何避免呢?答案是等待。“waiting out the uncertainty”,用等待来消除不确定性。
“等待”是为了让事件先后关系明确,消除模糊的边界。 |
10.1、写等待:Spanner
Spanner 采用了写等待方案,也就是 Commit Wait,理论上每个写事务都要等待一个时间置信区间。对 Spanner 来说这个区间最大是 7 毫秒,均值是 4 毫秒。但是,由于 Spanner 的 2PC 设计,需要再增加一个时间置信区间,来确保提交时间戳晚于预备时间戳。所以,实际上 Spanner 的写等待时间就是两倍时间置信区间,均值达到了 8 毫秒。
10.2、读等待:CockroachDB
CockroachDB 采用了读等待方式,就是在所有的读操作执行前处理时间置信区间。读等待的优点是偶发,只有读操作落入写操作的置信区间才需要重启,进行等待。但是,重启后的读操作可能继续落入其他写操作的置信区间,引发多次重启。所以,读等待的缺点是等待时间可能比较长。
上图中,事务 T6 启动获得了一个时间戳 T6-S1,此时虽然事务 T2 已经在 T2-C 提交,但 T2-C 与 T6-S1 的间隔小于集群的时间偏移量,所以无法判断 T6 的提交是否真的早于 T2。
这时,CockroachDB 的办法是重启(Restart)读操作的事务,就是让 T6 获得一个更晚的时间戳 T6-S2,使得 T6-S2 与 T2-C 的间隔大于 offset,那么就能读取 T2 的写入了。如下图
不过,接下来又出现更复杂的情况, T6-S2 与 T3 的提交时间戳 T3-C 间隔太近,又落入了 T3 的不确定时间窗口,所以 T6 事务还需要再次重启。而 T3 之后,T6 还要重启越过 T4 的不确定时间窗口。见下图
10.3、在什么情况下,不用“等待”也能达到线性一致性或因果一致性呢?”
读等待和写等待都是通过等待的方式,度过不确定的时间误差,从而给出确定性的读写顺序,但性能会明显下降。那么在什么情况下,不用“等待”也能达到线性一致性或因果一致性呢?”
10.3.1、如果分布式数据库使用了 TSO,保证全局时钟的单向递增,那么就不再需要等待了,因为在事件发生时已经按照全序排列并进行了记录。
10.3.2、假设两个事件发生地的距离除以光速得到一个时间 X,两个事件的时间戳间隔是 Y,时钟误差是 Z。如果 X>Y+Z,那么可以确定两个事件是并行发生的,事件 2 就不用读等待了。这是因为既然事件是并行的,事件 2 看不到事件 1 的结果也就是正常的了。
11、广义乐观和狭义乐观
之前讨论的MVCC主要处理的是读写冲突,但是,读写冲突只是事务冲突的部分情况,更多时候我们要面对的是写写冲突,甚至后者还要更重要些。
11.1、并发控制技术的分类
并发控制分为乐观协议和悲观协议两大类,单体数据库大多使用悲观协议。TiDB 和 CockroachDB 都在早期版本中提供了乐观协议,但在后来的产品演进又改回了悲观协议,其主要原因是事务竞争激烈和对遗留应用系统的兼容。
悲观协议是使用锁的,而乐观协议是不使用锁的。所以两个名词分别是:悲观锁和乐观协议(注意:没有“锁”字!)。 |
11.2、并发控制的三个阶段
在经典理论教材“Principles of Distributed Database Systems”中,作者将乐观协议和悲观协议的操作,都统一成四个阶段,分别是有效性验证(V)、读(R)、计算(C)和写(W)。
两者的区别就是这四个阶段的顺序不同:
因为在比较两种协议时,计算(C)这个阶段没有实质影响,可以忽略掉。那么简化后,悲观协议的顺序是 VRW,而乐观协议的顺序就是 RVW。
RVW的图示如下:
11.2.1、读阶段(Read Phase)
每个事务对数据项的局部拷贝进行更新。要注意,此时的更新结果对于其他事务是不可见的。这个阶段的命名特别容易让人误解,明明做了写操作,却叫做“读阶段”。我想它大概是讲,那些后面要写入的内容,先要暂时加载到一个仅自己可见的临时空间内。
11.2.2、有效性确认阶段(Validation Phase)
验证准备提交的事务。这个验证就是指检查这些更新是否可以保证数据库的一致性,如果检查通过进入下一个阶段,否则取消事务。
首先这里提到的检查与隔离性目标有直接联系;其次就是检查可以有不同的手段,也就是不同的并发控制技术,比如可以是基于锁的检查,也可以是基于时间戳排序。
在分布式架构下,有效性验证又分为局部有效性验证和全局有效性验证。因此,乐观又分为狭义乐观和广义乐观,而狭义乐观就是学术领域常说的 OCC。TiDB 的乐观锁,因为没有全局有效性验证,不严格符合 VRW 悲观协议排序,所以是广义乐观(或者说相对乐观)。而 TiDB 后来增加的悲观锁,增加了全局有效性验证,是严格的 VRW,所以是悲观协议。
相对乐观和局部悲观是一体两面的关系,识别它的要点就在于是否有全局有效性验证。 |
11.2.3、写阶段(Write Pharse)
将读阶段的更新结果写入到数据库中,接受事务的提交结果。就是完成最终的事务提交操作。
|
11.3、TiDB的乐观锁
但是TiDB却宣城自己使用了乐观锁!这是怎么回事?
TiDB 的乐观锁基本上就是 Percolator 模型(参见《原子性》4.1节),它的运行过程可以分为三个阶段。
之所以是乐观的锁,是因为虽然对于每一个修改行来说,TiDB 都做了有效性验证,而且顺序是 VRW,可以说是悲观的,但这只是局部的有效性验证;从整体看,TiDB 没有做全局有效性验证,不符合 VRW 顺序,所以不能称为悲观锁,只能还是相对乐观的。
上述第2个阶段,对私有版本的操作也执行了有效性检查,规避锁冲突的情况。为啥私有版本还需要规避呢?不是有MVCC的控制,其它事务不可见么?
这是因为虽然对其它事务不可见,可是如果是两个新的事物都需要给某个数值(比如余额)增加100,那就必须都进行上锁才行,即遇到了“写写冲突”。处理这个写写冲突就是在做有效性检查V。 |
即TiDB是局部悲观,从另外的角度来说就是相对乐观。
11.4、乐观协议的挑战
一是事务冲突少是使用乐观协议的前提,但这个前提是否普遍成立要看具体情况,二是现有应用系统使用的单体数据库多是悲观协议,兼容性上的挑战。
11.4.1、事务频繁冲突
首先,事务冲突少这个前提,随着分布式数据库的适用场景越来越广泛,显得不那么有通用性了。
比如,金融行业就经常会有一些事务冲突多又要保证严格事务性的业务场景,一个简单的例子就是银行的代发工资。代发工资这个过程,其实就是从企业账户给一大批个人账户转账的过程,是一个批量操作。在这个大的转账事务中可能涉及到成千上万的更新,那么事务持续的时间就会比较长。如果使用乐观协议,在这段时间内,只要有一个人的账户余额发生变化,事务就要回滚。 |
11.4.2、遗留应用的兼容性需求
回到悲观协议还有一个重要的原因,那就是保证对遗留应用系统的兼容性。这个很容易理解,因为单体数据库都是悲观协议,甚至多数都是基于锁的悲观协议,所以在 SQL 运行效果上与乐观协议有直接的区别。
一个非常典型的例子就是 select for update。这是一个显式的加锁操作,或者说是显式的方式进行有效性确认,广义的乐观协议都不提供严格的 RVW,所以也就无法支持这个操作。
11.4.3、TiDB 的新的悲观锁
TiDB 悲观锁的理论基础很简单,就是在原有的局部有效性确认前,增加一轮全局有效性确认。这样就是严格的 VRW,自然就是标准的悲观协议了。具体采用的方式就是增加了悲观锁,这个锁是实际存在的,表现为一个占位符,随着 SQL 的执行即时向存储系统(TiKV)发出,这样事务就可以在第一时间发现是否有其他事务与自己冲突。
12、悲观协议
这里主要讨论了可串行化的实现方案。
12.1、悲观协议的分类
首先要看看并发控制技术整体的分类。
作者认为狭义乐观协议和其他悲观协议这种分类方式更清晰些。按照这个分类,并发控制协议分类如下图(图里的乐观协议是指狭义乐观并发控制);
图里的几个名词:
基于锁的协议,数据库系统主要使用的 2PL。
12.2、两阶段封锁(Two-Phase Locking,2PL)
2PL 就是事务具备两阶段特点的并发控制协议,这里的两个阶段指加锁阶段和释放锁阶段,并且加锁阶段严格区别于紧接着的释放锁阶段。
在 t1 时刻之前是加锁阶段,在 t1 之后则是释放锁阶段,可以从时间上明确地把事务执行过程划分为两个阶段。2PL 的关键点就是释放锁之后不能再加锁。
根据加锁和释放锁时机的不同,2PL 又有一些变体。
12.2.1、保守两阶段封锁协议(Conservative 2PL,C2PL)
事务在开始时设置它需要的所有锁(即读锁和写锁 )。
12.2.2、严格两阶段封锁协议(Strict 2PL,S2PL)
事务一直持有已经获得的所有写锁,直到事务终止。
S2PL 模式下,事务持有锁的时间过长,会导致系统并发性能较差。 |
12.2.3、强两阶段封锁协议(Strong Strict 2PL,SS2PL)
事务一直持有已经获得的所有锁,包括写锁和读锁,直到事务终止。SS2PL 与 S2PL 差别只在于一直持有的锁的类型,所以它们的图形是相同的。
Percolator 模型:当主锁(Primary Lock)没有释放前,所有的记录上的从锁(Secondary Lock)实质上都没有释放,在主锁释放后,所有从锁自然释放。所以,Percolator 也属于 S2PL。TiDB 的乐观锁机制是基于 Percolator 的,那么 TiDB 就也是 S2PL。 |
S2PL 模式下,事务持有锁的时间过长,导致系统并发性能较差,所以实际使用中往往不会配置到可串行化级别。这就意味着我们还是没有生产级技术方案。
一种可能是性能更优的可串行化工程化实现,这就是 CockroachDB 的串行化快照隔离(SSI)。而 SSI 的核心,就是串行化图检测(SGT)。
12.3、串行化图检测(SGT,Serializable Graph TEST)
串行化图(Serializable Graph,SG)。这个图用来分析数据库事务操作的冲突情况。每个事务是一个节点,事务之间的关系则表示为一条有向边。
串行化图的构建规则是这样的,事务作为节点,当一个操作与另一个操作冲突时,在两个事务节点之间就可以画上一条有向边。
具体来说,事务之间的边又分为三类情况:
串行化图检测的原理是,如果途中形成了环,那么就是不可串行化的。
比如下面的事物
对应的SG就没有环:
而
就有环
SGT通过检测环, SGT 没有锁的管理成本,所以性能比 S2PL 更好。但它和传统的 S2PL 一样属于悲观协议。 |
12.4、MVCC与乐观协议、悲观协议的区别
MVCC 已经是数据库的底层技术,与乐观协议、悲观协议下的各项技术是两个不同的维度,最后形成了 MVTO、MV2PL、MVSGT 等技术。这些技术考虑了多版本情况下的处理,但遵循的基本原理还是一样的。