开发者学堂课程【PolarDB-X 开源系列课程:分布式事务与数据分区(一)】学习笔记与课程紧密联系,让用户快速学习知识
课程地址:https://developer.aliyun.com/learning/course/1032/detail/15162
分布式事务与数据分区(一)
内容介绍:
一、前景提要
二、分布式事务
一、前景提要
1、定义
PolarDB-X 是一个支持计算/存储水平扩展,数据高可用,基于存储计算分离、shared-nothing 架构和一致性复制协议的分布式数据库。
定义比较长,其分为两部分,第一部分是“支持计算和存储能力的水平扩展,数据高可用”这是从用户的角度表达用户对分布式数据库的需求。
“存储计算分离、shared-nothing 架构和一致性复制协议”是 PolarDB-X 为了实现这些需求所采用的系统架构。其中“一致性复制协议”主要解决数据高可用的问题,是之后分享的主要内容。“存储计算分离、shared-nothing 架构”是 PolarDB-X 为了提供水平拓展能力与当今数据库相比增加的两个新的设计。
第一,是资源层面的设计,即将集群内的几个点分为计算节点和数据节点两类,成为存储计算分离,其中计算节点是无状态节点,通过增加计算节点提供算力的水平扩展能力。
第二,是数据层面的设计,将数据按照分区键切分为多个分区,通过分区键迁移到新增的数据节点上,提供存储容量的水平扩展能力。
由于分区后节点之间不再需要共享存储资源,也被称为 share-nothing 架构。
这节课的内容,是关于 polarDB-X 在分布式事务和数据分区方面的探索。
这节课选择分享分布式事务和数据分区的原因有两方面,一方面是分布式数据库当中比较关心的两个话题。另外一方面,分布式数据库其实本身也是一个数据库,其实和用单机数据库一样,对数据是有两个强需求的。
第一个强需求叫做希望数据库能够支持事物,第二个强需求叫做希望数据库能够支持 co,恰好分布式数据库由于它在架构上的不同,特别引入了 share-nothing 架构之后,对于事务的设计以及对于 co 里面查询的设计都有很大的影响。
这节课分享的两部分内容,其实就是采用 share-nothing 架构之后,分布式数据库 polarDB-X 是如何解决事务方面的问题,以及解决查询方面的问题,这也就是分布式事务和数据分区方面的特殊设计。这是关于之前内容的回顾,接下来开始这节课的分享内容。
二、分布式事务
首先为分布式事务相关介绍。众所周知,数据库是存储数据的系统,使用数据库最主要的需求就是读取数据,设计良好的数据库系统应该尽可能屏蔽底层的实现。
类比,去银行主要的目的是存钱和取钱,并不关心银行柜台后面发生了什么事情。但是,数据库本身是一个非常复杂的系统,经常面对各种各样复杂的情况。比如,由于数据库本身代码问题或硬件故障,可能导致数据库在启用数据的任何阶段,存在意外崩溃,客户端的应用程序也可能在一系列的连续操作过程中,突然退出,并且为了提高吞吐量,通常允许多个客户端并发修改同一行记录。类似这种复杂的情况,其实还有很多。
当这些复杂情况出现时,特别是出现异常时,数据库是需要保证失败的操作不会导致数据问题的,并且要引导用户继续完成业务需求,即需要有数据可靠性的保证。用户按照这种保证或者约定来使用数据库,就可以规避一些数据上的问题,这一 part 要讲的事务,其实就是关于数据可靠性的一个保证,即数据库提供给用户的关于如何保证数据可靠性的一个约定。
1、事务:用来表达业务含义的,一组读写操作,满足 ACID。
具体而言,事务的定义是一组用来表达业务含义的读写操作,满足 acid 特性。举例,下方列示有一个事务1,,它是转账的例子,该事务里面包含了四条 sco 语句,第一行 begin 和最后一行commit,圈定了事务的范围,其包含了两个 update 语句。第一条语句是在 account 表上为 id=Alice 的账户减30,第二条为 id=Bob 的账户加30,这样即表达了 Alice 向 Bob 转账30的业务语义。
2.ACID
原子性(A):“all or nothingno crashes”
一致性(C):“it looks correct to me”
隔离性(l):“nobodyelse”
持久性(D):“storage devices are perfectly reliable”
对于这样的事务,数据库提供了 icid 四个方面的保障,a 是原子性,代表如果出现异常,用户可以通过重试整个事务解决此问题,不需要担心失败的事务会对数据产生影响。C 是一致性,代表用户不需要担心数据操作会违反预定义的约束,比如会违反唯一约束、外键等。I 是隔离性,代表用户可以认为只有自己在操作数据库,不需要担心多个数据库并发可能产生数据库方面的异常。D 是持久性,代表一旦事务提交成功,用户就不再需要担心事务产生的变更会因为其他异常而丢失。直白的说,可以认为有了事务之后,存储是非常可靠的,事务只要提交了,就一定不会丢失。
3.关于分布式事务
shared-nothing 架构下,原子性,隔离性受到影响
2000s,第一代 NoSQL 系统不支持跨分片事务
2010s,NewSQL 系统开始重新支持分布式事务
We believe it is better to have application programmers deal with performance problems due to overuse of transactions as bottlenecks arise, rather than always coding around the lack of transactions.
四个特性中和分布式相关的是原子性和隔离性,所以再举一个具体的例子,来感受原子性和隔离性的含义。首先是原子性,其强调的是事务提交后,事务内的变更必须一起生效,比如下方例子中,事务1提交后,必须是 Alice 账户减30并且 Bob 账户加30,不能出现 Alice 减了30,而 Bob 没加,或者 Bob 加了30而 Alice 没减这样的情况。隔离性强调的是多个事务并发执行时,数据库必须使执行结果看起来像没有并发一样,比如,数据库可能按照任意顺序收到事务1和事务2里的四条 update,但执行结果应该看起来像先执行事务1,再执行事务2,或先执行事务2,再执行事务1。最终体现为,若事务1和事务2按照一先一后执行方式的话,看到账户中的总额,最后一定是206。如果隔离性出现了问题,有可能 Alice 按照转账后的值进行派息,而 Bob 按照转账前的值去派息,这就违反了隔离性的表现。在分布式事务中,由于存在多个分区,原子性和隔离性都是需要重新实现的,可能因为本身实现的复杂性或新的实现可能导致性能下降。2000年前后,出现的第一代 NoSQL 系统,包括谷歌的 iptable 等都是不支持跨分配事务的。但是,后来发现缺少跨分配事务其实不符合用户的使用习惯,谷歌也在 spander 论文里也总结到了。发现很多工程师将过多的精力放在了处理数据一致性上,原本封装在数据库内部的逻辑异处到了应用代码当中,所以大幅提高了应用代码的复杂度。因此,到了2010年后,市面上出现的 olt 类的分布式数据库都将分布式事务作为必选项。
4、举例
(1)事务1:转账
BEGIN;
UPDATE account SET balance=balance-30WHEREid="Alice;
UPDATE account SET balance=balance+30WHEREid="Bob";
COMMIT;
事务2:结息事务
BEGIN;
UPDATE account SET balance=balance*1.03WHEREid="Alice
UPDATE account SETbalance=balance*1.03WHEREid="Bob";
COMMIT;
接下来,看一下分布式事务为了保证原子性和隔离性都需要做那些工作。
5、原子性
(1)单分片事务:undo log
(2)分布式事务:跨分区,原子提交(Atomic Commit);两阶段提交(2PC):Percolator,XA
首先来看原子性,先回顾一下单分片上的原子性是怎么保证的,单分片上的原子性是由日志来保证的。在写入每条数据的时候,首先记录一个回滚日志,如果遇到事物的异常,那么就通过日志来将数据恢复到先前的版本。对于跨分变数,在单分片数的基础之上,还需要协调所有分区在提交阶段的行为,保证一起提交或者一起回滚。
这里举个例子,一起提交和一起回滚要怎么实现?简单的去按顺序提交到每一个分片上的单分片事务是不是可行?依然以事务一的转账为例,收到了用户的 commit 的请求之后如果 CN 直接向每一个分片都下发一个 commit 的语句,那么就可能会出现分片1已经提交成功,但是分片2发现了有异常,其直接把分片事物回本的情况,那么此时整个分布式事务其实已经失败。但是分片1上的数据无法回归,产生了不一致。
在业界通常使用两阶段提交协议来解决这个问题。两阶段提交协议把参与的节点,都分成了两种角色,包括了协调者和参与者,协调者是负责判断事物是否能够继续提交的角色,那 PolarDB-X 中使用计算节点作为协调者。参与者代表的是分布式事务涉及的数据节点。这里再来看一个例子,提交过程的第一个阶段称为 prepare,就是当收到用户肯定的请求之后,首先进入 prepare 阶段,那此阶段中,协调者会询问每个事物的参与者:“分支事务是否能够提交”,事务参与者,执行一些校验或者写入日志之后的操作,如果发现能够提交,那其就会返回 OK,那么协调者收到所有的 OK 之后,就认为大家都 prepare 完成,可以进入提交阶段,就进入第二个阶段,也就是 commit 阶段。
在这个阶段,协调者会去通知每一个参与者,所有的分支都已经具备提交条件,所以大家可以继续提交了。然后和一阶段一样,所有的参与者完成提交后,返回 OK,最后协调者确认所有参与者都完成提交之后,就可以告知用户分布式事务完成了。
这里面有一个细节,是在一阶段的 prepare 阶段成功之后,通常会保存一条日志,用来记录一阶段已经完成,可以进入二阶段,那记录这个日志的目的,是如果在进入二阶段之前,协调者出现了 Crash,出现了宕机,那么重启之后,依然可以根据这条日志来判断出 prepare 是否成功了,如果成功了则可以继续提交,如果没有成功,也是可以将其回滚掉。
再来看提交中如果有参与者失败,会是什么样的情况?Prepare 阶段依然是协调者首先询问每个参与者是否能够提交,那么这次有一个分片返回了失败,于是在第二阶段,协调者要通知所有参与者回滚事务,收到了参与者的成功回复之后去告知用户,事物在提交的时候发生了失败,已经自动回滚掉了。这就是现在业界最为常用的,解决原子性提交的两阶段提交协议,其本身是一个协议,在工程实现上,最常见的实现有 percolator 和 XA 两个实现,percolator 本身在提交阶段延迟比较高,而且只会在提交阶段去汇报,写入冲突。主要是支持乐观所的场景,与传统的关系型数据库,悲观所事物的模型有比较大的区别,因此 PolarDB-X 选择了通过拆协议来实现两阶段离交。这是 PolarDB-X 分布式事物在保障原子性方面选择的技术方案。
6. 隔离性:
(1)单分片事务:MVCC + 事务 ID
(2)分布式事务:MVCC,跨分区排序;活跃事务列表(GTM),时间戳(TSO)
接下来看一下,在隔离性上做了哪些处理。说到隔离性,这里依然是看一下单分片事物上是如何去保障隔离性的。大家可能听说过 MVCC 这个名词,那保障隔离性目前基本上所有的数据库,在做单分片事务时都会采用 MVCC 这样的方案,MVCC 全称是多版本并发控制,这时什么样的做法?如下图所示,它的意思是当有数据写入到表当中时,比如这个例子,这例更新了 Alice 和 Bob 的账户,将 Alice 的账户余额设置为70,将 Bob 的账户余额设置为130,那在做操作时,数据库会将老的数据在他们转账之前,也就是各自账户都是100元这样的数据,为它生成一个历史版本的快照,并且把这个快照每一行数据的快照和写入这行数据的 ID 进行关联,那么当有事物要来读取数据的时候,它会首先根据每一行记录上的事物 ID 去看这个事物是否提交,如果没有提交的事物,其会认为这个数据一定读不到。这时候就可以沿着这个版本链往前找,找到一条已经提交的事物,找到一条已经提交的事物写入的数据后,其又会根据自己的事物ID跟数据上的事务 ID 做对比,判断一下这条数据是在这个事务开始之前写过的,还是在开始之后进入的,如果是在开始之前就可以看见,如果是在开始之后那就看不见此版本,所以这样就可以确定一行数据到底是否可见,那么 MVCC 这种并发控制协议的好处是关键的解决了写和读的并发情况,就是下图中231号事务,也就是更新了 Alice 和 Bob 账户的事务,它其实没有提交。但是这个事务 ID 为220的这样的事务,依然可以继续读历史版本,这个时候就达到了一个比较好的并发路。因为其实不需要关心还没有提交的数据,所以直接用历史的数据去接着做下面要做的事情,这也是为什么所有单机数据库上,或者其实分布式数据库也是一样,大家都采用这个方式,都支持基于 MVCC 这样的事务实现。
那么在单分片上,通过 MVCC 加事务 ID,其实已经实现了一种隔离性,那么到了分布式事务,当引入了多个分区的情况下,需要做哪些改进?
其实这里最主要的一点就是原先采用的事务 ID,它是在单个分片上进行分配的,那现在有多个分片,分区。这时就需要有一个全局的统一分配这样的事务 ID 的地方,或者说采取一种时间排序的方式,需要有一个全局的、唯一的地方去获取时间戳,获取事务 ID,获取一个用于判断是否可以看见那条数据的一个版本号,那么关于这块的实现,业界目前有两种主要的方案:一种是像单机数据库一样,基于一个活跃事物列表 GTM,区别在于其把分发事物 ID 的地点,从每个分辨上挪到公共的服务上。
每次读写事物开始和写事物提交的时候,都会去活跃事物列表里,去做一些更新和读取。其实这种方案,对 GTM 本身的压力比较大,它会非常的依赖中心化的事物管理器,相对比较容易出现系统瓶颈。因此 PolarDB-X 选择了另外一种方案,也就是基于 TSO 的 MVCC 方案来支持隔离性。在这种方案之下,对于读写操作都有一定的改变,就是在数据写入的过程当中,依然是两阶段提交,但是在最终一阶段结束之后,会读取一个提交时间戳,然后把提交时间戳 commit 阶段一起下发到每个分片上,这样就将提交时间戳和数据关联到了一起。
正好看到群里有同学提问,一阶段的日志是记录在哪里的?一阶段的日志在每一个分片上都保留了一张事务的日志表,那么一阶段的日志就会根据写入的第一个写的分片,把日志落在这个分片上,那么如果出现 CN 的宕机恢复,会去扫描所有分片上的输入日志,看看有哪些事物在上一次宕机的时候,哪些事物处于一阶段结束,二阶段还没有开始的这样的情况,然后去进一步的推进它的提交和回滚。刚刚说到隔离性在数据写入方面的影响,就是在提交之前要获取提交时间戳,那么在数据读取的时候,同样也要获取时间戳,主要是用来判断这条数据是否可见,那么过程就变为首先去 TSO 服务,就是这里叫 GMS 服务上去获取时间戳,然后这是作为自己的这个snapshot.timestamp,然后和数据相关的这样的数据上的时间戳去做对比,如果这个时间戳比数据的时间戳要大,并且这个数据的所属的事物已经提交掉了,则认为这个数据应该是能看见的,反之如果没有提交,或者说这个时间戳比它要小,那这条数据就不应该被看到。所以这是 PolarDB-X 在实现分布式事物的隔离性方面所做的一些设计。
7.2PC,TSO + MVCC
接下来这个片子,把整个流程串了起来,这里简单过一下,就是事务抛了 PolarDB-X 上的分布式事务开启之后,首先会向 TSO 获取 commit ts 作为这个读的快照,然后开始接收用户的所有的读写请求,过程当中根据对应的事物状态和快照到时间戳,对数据和对应的提交时间戳来判断数据是否可见,保证隔离性。
然后在这个节点提交的过程当中还采取了 two PC 的方案,首先是通知所有参与企业操作的分区去执行 prepare,然后计入事务状态,最后通知所有的提交者进行提交,那么在两阶段提交的记录输入状态成功之前,就是记录输入状态为提交阶段,在这一步骤之前所有的产生的异常都会导致事物为滚来保证原子性。
采用 two PC 和 TSO 加 MVCC 方案实现这个分布式事物,经常被质疑的问题:第一个是提交阶段会增加延迟,因为毕竟以前提交可能一步就可以完成,现在变成两步。另外一个问题,TSO 是否也存在单点?因为这有两个地方都需要从 TSO 上获取时间戳。针对这两个问题, PolarDB-X 都进行了一些工程上的优化。
8.一阶段提升优化(1PC)
首先是对两阶段提交的优化,两阶段提交由于增加了 prepare 阶段,其提交延迟肯定会高于单分变式,但是实际对于单分区写入的事物,无论读是否跨分区,依然可以采用一阶段提交来保障原子性,那么PolarDB-X 支持自动识别这种情况,来减少这个这个此类场景下的提交延迟。
9. GMS 性能优化
(1)多场景优化
■autocommitatrue and单分区and读,不读TSO
■autocommit=true and单分区and写,采用0PC,不读TSO3. ■autocommit false and单分区and读/写,采用1 PC
■autocommitm false and多分区,识别物理分布,采用2PC合井提交
■其余场景,推荐Repeatable Read,begin/commit读取2次TSO带拆分条件的点查、点写,可不依赖GMS ,提升性能
(2)TSO合并优化
■Plan 访问,减少SQL解析成本,最小化网络包
■单个CN进程,对TSO的读取操作优化为batch获取GMS不成为系统瓶颈
另外一个问题是 TSO 方案,由于采用了单点数值,一个潜在的问题是可能存在单点故障和单点性能瓶颈的风险,PolarDB-X 的 GMS 是部署在三节点的集群上的,所以首先通过 paxos 协议来保证服务的高可用,同时 PolarDB-X 对多种场景进行了优化,也就使得带拆分条件的点查、点写可以不依赖 GMS,提升查询性能的同时,也降低了 GMS 的压力。另外对于单个 CN 进程默认采取 grouping 方式,将同一时间内发生的多个 TSO 请求合并为 batch 操作。也就是说很多事物可能在并发进行,那在任意的时间点,可能都会有一批事物在等待获取读时间戳或者提交时间戳,那就把这样的一批获取时间戳的操作,合并成 batch 操作,然后通过自定义的指令发送给 TSO 服务,一次性的获取到需要的所有时间戳。这样的话进一步保证了 CN 和 GMS 之间交互的指令包不会太多。避免了 GMS 成为系统名词。
事务这块已经了解了分布式事务,需要在原子性和隔离性方面做一些改造,PolarDB-X 采用的是 two PC 加 TSO 和 MVCC 的方案来解决这些问题,同时对于 two PC 和 TSO 本身可能存在的一些问题,做了工程上的优化。
最后来看一个例子,里面展示了一个 flash back query 这样的能力,由于刚刚提到了事物里面隔离性,采取的是 MVCC,它会把历史版本采取列表的方式记录下来。其实可以通过在查询中,去指定时间戳的方式来看到历史版本里面的数据。
接下来看一下演示,首先登录 PolarDB-X 数据库实例,在数据库中已经创建了两张测试表。查看一下表结构,Account 和 user 表都是默认使用主键拆分的分区表,然后向两张表中插入数据,查看一下插入结果,目前有 Alice 和 Bob 两个账户,并且账户余额都是100块钱,记录一下当前的时间点,接下来进行一些协助操作,首先是转账测试,在 Alice 向 Bob 转账了30块钱,然后新增一个名为 Tom 的账户,查看目前表中的数据情况,包含三个账户,并且 Alice 向 Bob 中发生过一次转账,使用 flash back query 语法,可以查询数据库当中的历史版本,这里来演示一下,通过刚刚记录的时间戳,可以查询到更新数据之前的版本,可以看到更新数据之前是两个账户,其他的账户里都是只有100块钱,那么 As of 语法同样可以使用在 join 当中,可以为 join 其中的一张表指定时间戳,而另外一张表不指定。那么这里对 user 表指定时间图,而对 account 表不指定。那么预计看到的效果就是 Alice 和 Bob 发生了转账,但是 Tom 的账户并没有插入到表中,实际看到的结果确实是这样。flash back query 语法最重要的一个用途是帮用户恢复误删的数据,这里来模拟一下,记录一下当前的时间戳。删除 account 表中的数据。数据删除掉以后,可以通过 insert select 的语句在 select 中指定删除前的时间点,将历史版本的数据重新写回到表中。
查看一下表中的数据,可以看到之前被删除的数据都恢复过来了。