Atomicity: Either all the changes from the transaction occur (writes, and messages sent), or none occur.
原子性就是要求事务只有两个状态:
多数情况下事务是由多个操作构成的序列。而分布式事务原子性的外在表现与事务原子性一致,但前者要涉及多个物理节点,而且增加了网络这个不确定性因素。为了协调内部的多项操作,对外表现出统一的成功或失败状态,这需要一系列的算法或协议保证。
TCC 协议是『面向应用层』中比较典型的协议,是Try、Confirm 和 Cancel 三个单词的缩写,它们是事务过程中的三个操作。
用一个例子来阐明 TCC 处理流程:
系统架构图如下:
银行的存款系统是单元化架构的,也就是说,系统由多个单元构成,每个单元包含了一个存款系统的部署实例和对应的数据库,专门为某一个地区的用户服务。比如,单元 A 为北京用户服务,单元 B 为上海用户服务。
单元化架构的好处是每个单元只包含了部分用户,这样运行负载比较小,而且一旦出现问题,也只影响到少部分客户,可以提升整个存款系统的可靠性。
不过这种架构也有局限性。那就是虽然单元内的客户转账非常容易,但是跨单元的转账需要引入额外的处理机制,而 TCC 就是一种常见的选择。
TCC 的整个过程由两类角色参与,一类是事务管理器,只能有一个;另一类是事务参与者,也就是具体的业务服务,可以是多个,每个服务都要提供 Try、Confirm 和 Cancel 三个操作。
下面是 TCC 的具体执行过程。
A 和 B 的账户分别在单元 A 和单元 B 上。现在 A 的账户余额是 4,900 元,需要给 B 转 2,000 元,一个正常流程如下:
第一阶段,事务管理器会发出 Try 操作,要求进行资源的检查和预留。也就是说,单元 A 要检查 A 账户余额并冻结其中的 2,000 元,而单元 B 要确保 B 的账户合法,可以接收转账。在这个阶段,两者账户余额始终不会发生变化。
第二阶段,因为参与者都已经做好准备,所以事务管理器会发出 Confirm 操作,执行真正的业务,完成 2,000 元的划转。
但是很不幸,B 账户是无法接收转账的非法账户,处理过程就变成下面的样子:
第一阶段,事务管理器发出 Try 指令,单元 B 对 B 账户的检查没有通过,回复 No。而单元 A 检查 A 账户余额正常,并冻结了 2,000 元,回复 Yes。
第二阶段,因为前面有参与者回复 No,所以事务管理器向所有参与者发出 Cancel 指令,让已经成功执行 Try 操作的单元 A 执行 Cancel 操作,撤销在 Try 阶段的操作,也就是单元 A 解除 2,000 元的资金冻结。
从上述流程可以发现,TCC 仅是应用层的分布式事务框架,具体操作完全依赖于业务编码实现,可以做针对性的设计,但是这也意味着业务侵入会比较深。
此外,考虑到网络的不可靠,操作指令必须能够被重复执行,这就要求 Try、Confirm、Cancel 必须是幂等性操作,也就是说,要确保执行多次与执行一次得到相同的结果。显然,这又增加了开发难度。
2PC 的首次正式提出是在 Jim Gray 1977 年发表的一份文稿中,文稿的题目是 "Notes on Data Base Operating Systems",对当时数据库系统研究成果和实践进行了总结,而 2PC 在工程中的应用还要再早上几年。
2PC 的处理过程也分为准备和提交两个阶段,每个阶段都由事务管理器与资源管理器共同完成。其中,事务管理器作为事务的协调者只有一个,而资源管理器作为参与者执行具体操作允许有多个。
用一个例子来阐明 2PC 处理流程:
系统架构图如下:
在该银行的存款系统中,所有客户的数据被分散存储在多个数据库实例中,这些数据库实例具有完全相同的表结构。业务逻辑部署在应用服务器上,通过数据库中间件访问底层的数据库实例。数据库中间件作为事务管理器,资源管理器就是指底层的数据库实例。
假设,A 和 B 的数据分别被保存在数据库 D1 和 D2 上。
下面是 2PC 的具体执行过程。
第一阶段是准备阶段,事务管理器首先向所有参与者发送待执行的 SQL,并询问是否做好提交事务的准备(Prepare);参与者记录日志、分别锁定了 A 和 B 的账户,并做出应答,协调者接收到反馈 Yes,准备阶段结束。
第二阶段是提交阶段,如果所有数据库的反馈都是 Yes,则事务管理器会发出提交(Commit)指令。这些数据库接受指令后,会进行本地操作,正式提交更新余额,给 A 的账户扣减 2,000 元,给 B 的账户增加 2,000 元,然后向协调者返回 Yes,事务结束。
那如果 A 的账户出了问题,导致转账失败,处理过程如下:
第一阶段,事务管理器向所有数据库发送待执行的 SQL,并询问是否做好提交事务的准备。
由于 A 之前在银行购买了基金定投产品,按照约定,每月银行会自动扣款购买基金,刚好这个自动扣款操作正在执行,先一步锁定了账户。数据库 D1 发现无法锁定 A 的账户,只能向事务管理器返回失败。
第二阶段,因为事务管理器发现数据库 D1 不具备执行事务的条件,只能向所有数据库发出『回滚』(Rollback)指令。所有数据库接收到指令后撤销第一阶段的操作,释放资源,并向协调者返回 Yes,事务结束。A 和 B 的账户余额均保持不变。
相比于 TCC,2PC 的优点是借助了数据库的提交和回滚操作,不侵入业务逻辑。但是,它也存在一些明显的问题:
事实上,多数分布式数据库都是在 2PC 协议基础上改进,来保证分布式事务的原子性。这里介绍两个有代表性的 2PC 改进模型,它们分别来自分布式数据库的两大阵营,NewSQL 和 PGXC。
Percolator 来自 Google 的论文 "Large-scale Incremental Processing Using Distributed Transactions and Notifications",因为它是基于分布式存储系统 BigTable 建立的模型,所以可以和 NewSQL 无缝链接。
Percolator 模型同时涉及了隔离性和原子性的处理。这里主要关注原子性的部分。
使用 Percolator 模型的前提是事务的参与者,即数据库,要 支持多版本并发控制(MVCC)。现在主流的单体数据库和分布式数据库都是支持的 MVCC。
在转账事务开始前,A 和 B 的账户分别存储在分片 P1 和 P2 上。
分片的账户表中各有两条记录,第一行记录的指针(write)指向第二行记录,实际的账户余额存储在第二行记录的 Bal. data 字段中。
Bal.data 分为两个部分,冒号前面的是时间戳,代表记录的先后次序;后面的是真正的账户余额。我们可以看到,现在 A 的账户上有 4,900 元,B 的账户上有 300 元。
下面是 Percolator 的具体执行过程。
第一,准备阶段,事务管理器向分片发送 Prepare 请求,包含了具体的数据操作要求。
分片接到请求后要做两件事,写日志和添加私有版本。关于私有版本,可以简单理解为,在 lock 字段上写入了标识信息的记录就是私有版本,只有当前事务能够操作,通常其他事务不能读写这条记录。
这里要注意一下,两个分片上的 lock 内容并不一样。
主锁的选择是随机的,参与事务的记录都可能拥有主锁,但一个事务只能有一条记录拥有主锁,其他参与事务的记录在 lock 字段记录了指针信息『primary@Ming.bal
』,指向主锁记录。
准备阶段结束的时候,两个分片都增加了私有版本记录,余额正好是转账顺利执行后的数字。
第二,提交阶段,事务管理器只需要和拥有主锁的分片通讯,发送 Commit 指令,且不用附带其他信息。
分片 P1 增加了一条新记录时间戳为 8,指向时间戳为 7 的记录,后者在准备阶段写入的主锁也被抹去。这时候 7、8 两条记录不再是私有版本,所有事务都可以看到 A 的余额变为 2,700 元,事务结束。
在提交阶段是不用更新 B 的记录的。因为分片 P2 的最后一条记录,保存了指向主锁的指针。其他事务读取到 B7 这条记录时,会根据指针去查找 A.bal,发现记录已经提交,所以 B 的记录虽然是私有版本格式,但仍然可视为已经生效了。
当然,这种通过指针查找的方式,会给读操作增加额外的工作。如果每个事务都照做,性能损耗就太大了。所以,还会有其他异步线程来更新 B 的余额记录,最终变成下面的样子。
对于 2PC 的问题,Percolator 有以下几点改进:
1、数据不一致
2PC 的一致性问题主要缘自第二阶段,不能确保事务管理器与多个参与者的通讯始终正常。
但在 Percolator 的第二阶段,事务管理器只需要与一个分片通讯,这个 Commit 操作本身就是原子的。所以,事务的状态自然也是原子的,一致性问题被完美解决了。
2、单点故障
Percolator 通过日志和异步线程的方式弱化了这个问题。
一是,Percolator 引入的异步线程可以在事务管理器宕机后,回滚各个分片上的事务,提供了善后手段,不会让分片上被占用的资源无法释放。
二是,事务管理器可以用记录日志的方式使自身无状态化,日志通过共识算法同时保存在系统的多个节点上。这样,事务管理器宕机后,可以在其他节点启动新的事务管理器,基于日志恢复事务操作。
Percolator 模型在分布式数据库的工程实践中被广泛借鉴。比如,分布式数据库 TiDB,完全按照该模型实现了事务处理;CockroachDB 也从 Percolator 模型获得灵感,设计了自己的 2PC 协议。
CockroachDB 的变化在于没有随机选择主锁,而是引入了一张全局事务表,所有分片记录的指针指向了这个事务表中对应的事务记录。
GoldenDB 展现了另外一种改良思路,称之为『一阶段提交』。
GoldenDB 遵循 PGXC 架构,包含了四种角色:协调节点、数据节点、全局事务器和管理节点,其中协调节点和数据节点均有多个。GoldenDB 的数据节点由 MySQL 担任,后者是独立的单体数据库。
虽然名字叫『一阶段提交』,但 GoldenDB 的流程依然可以分为两个阶段。
第一阶段,GoldenDB 的协调节点接到事务后,在全局事务管理器(GTM)的全局事务列表中将事务标记成活跃的状态。这个标记过程是 GoldenDB 的主要改进点,实质是通过全局事务列表来申请资源,规避可能存在的事务竞争。
这样的好处是避免了与所有参与者的通讯,也减少了很多无效的资源锁定动作。
第二阶段,协调节点把一个全局事务分拆成若干子事务,分配给对应的 MySQL 去执行。如果所有操作成功,协调者节点会将全局事务列表中的事务标记为结束,整个事务处理完成。如果失败,子事务在单机上自动回滚,而后反馈给协调者节点,后者向所有数据节点下发回滚指令。
GoldenDB 的『一阶段提交』,本质上是改变了资源的申请方式,更准确的说法是,并发控制手段从锁调度变为时间戳排序(Timestamp Ordering)。这样,在正常情况下协调节点与数据节点只通讯一次,降低了网络不确定性的影响,数据库的整体性能有明显提升。因为第一阶段不涉及数据节点的操作,也就弱化了数据一致性和单点故障的问题。
加餐:2PC 第一阶段『准备阶段』也被称为『投票阶段』,和 Paxos 协议处理阶段的命名相近,2PC 和 Paxos 协议有没有关系,如果有又是什么关系呢?
Paxos 是对单值或者一种状态达成共识的过程,而 2PC 是对多个不同数据项的变更或者多个状态,达成一致的过程。它们是有区别的,Paxos 不能替换 2PC,但它们也是有某种联系的:Paxos Commit。
Paxos Commit 协议是 2006 年在论文 "Consensus on Transaction Commit" 中首次提出的。值得一提的是,这篇论文的两位作者,是 Jim Gray 和 Leslie Lamport。他们分别是数据库和分布式领域的图灵奖获得者,也分别是 2PC 和 Paxos 的提出者。Paxos Commit 的处理过程图如下:
Paxos Commit 协议中有四个角色,有两个与 2PC 对应,分别是 TM(Transaction Manager,事务管理者)也就是事务协调者,RM(Resource Manager,资源管理者)也就是事务参与者;另外两个角色与 Paxos 对应,一个是 Leader,一个是 Acceptor。其中,TM 和 Leader 在逻辑上是不可能分的,所以在图中隐去了。因为 Leader 是选举出来的,所以第一个 Leader 标识为 Initial Leader。处理过程如下:
这个过程中,如果 Acceptor 总数是 2F+1,那么每个 RM 就有 2F+1 条路径与 Leader 通讯。只要保证其中 F+1 条路径是畅通的,整个协议就可以正常运行。因此,Paxos Commit 协议的优势之一,就是在很大程度上避免了网络故障对系统的影响。但是,相比于 2PC 来说,它的消息数量大幅增加,而且多了一次消息延迟。目前,实际产品中还很少有使用 Paxos Commit 协议的。
整个 2PC 的事务延迟由两个阶段组成,可以用公式表达为:
$L_{txn}$ =$ L_{prep}$ + $L_{commit}$
其中,$ L_{prep}$ 是准备阶段的延迟, $L_{commit}$ 是提交阶段的延迟。
准备阶段,它是事务操作的主体,包含若干读操作和若干写操作。把读操作的次数记为 R,读操作的平均延迟记为 Lr,写操作次数记为 W,写操作平均延迟记为 Lw。那么整个准备阶段的延迟可以用公式表达为:
$L_{prep}$ = R * $L_r$ + W * $L_w$
在不同的产品架构下,读操作的成本是不一样的。选一种最乐观的情况,CockroachDB。因为它采用 P2P 架构,每个节点既承担了客户端服务接入的工作,也有请求处理和数据存储的职能。所以,最理想的情况是,读操作的客户端接入节点,同时是当前事务所访问数据的 Leader 节点,那么所有读取就都是本地操作。
磁盘操作相对网络延迟来说是极短的,所以可以忽略掉读取时间。那么,准备阶段的延迟主要由写入操作决定,可以用公式表达为:
$L_{prep}$ = W * $L_w$
分布式数据库的写入,并不是简单的本地操作,而是使用共识算法同时在多个节点上写入数据。所以,一次写入操作延迟等于一轮共识算法开销,我们用 $L_c$ 代表一轮共识算法的用时,可以得到下面的公式:
$L_{prep}$ = W * $L_c$
再来看第二阶段,提交阶段,Percolator 模型的提交阶段只需要写入一次数据,修改整个事务的状态。对于 CockroachDB,这个事务标识可以保存在本地。那么提交操作的延迟也是一轮共识算法,也就是:
$L_{commit}$ = $L_c$
分别得到两个阶段的延迟后,带入最开始的公式,可以得到:
$L_{txn}$ = (W + 1) * $L_c$
把这个公式带入具体例子里来看一下。还是 A 给 B 转账,金额是 500 元。
在这个转账事务中,包含两条写操作 SQL,分别是扣减 A 账户余额和增加 B 账户余额,W 等于 2。再加上提交操作,一共有 3 个 $L_c$。可以看到,这个公式里事务的延迟是与写操作 SQL 的数量线性相关的,而真实场景中通常都会包含多个写操作,那事务延迟肯定不能让人满意。
第一个办法是将所有写操作缓存起来,直到 commit 语句时一起执行,这种方式称为 Buffering Writes until Commit,把它翻译为『缓存写提交』。而 TiDB 的事务处理中就采用这种方式,借用 TiDB 官网的一张交互图来说明执行过程。
所有从 Client 端提交的 SQL 首先会缓存在 TiDB 节点,只有当客户端发起 Commit 时,TiDB 节点才会启动两阶段提交,将 SQL 被转换为 TiKV 的操作。这样,显然可以压缩第一阶段的延迟,把多个写操作 SQL 压缩到大约一轮共识算法的时间。那么整个事务延迟就是:
$L_{txn}$ = 2 * $L_c$
但缓存写提交存在两个明显的缺点。
首先是在客户端发送 Commit 前,SQL 要被缓存起来,如果某个业务场景同时存在长事务和海量并发的特点,那么这个缓存就可能被撑爆或者成为瓶颈。
其次是客户端看到的 SQL 交互过程发生了变化,在 MySQL 中如果出现事务竞争,判断优先级的规则是 First Write Win,也就是对同一条记录先执行写操作的事务获胜。而 TiDB 因为缓存了所有写 SQL,所以就变成了 First Commit Win,也就是先提交的事务获胜。用一个具体的例子来演示这两种情况。
在 MySQL 中同时执行 T1,T2 两个事务,T1 先执行了 update,所以获得优先权成功提交。而 T2 被阻塞,等待 T1 提交后才完成提交。
在 TiDB 中执行同样的 T1、T2,虽然 T2 晚于 T1 执行 update,但却先执行了 commit,所以 T2 获胜,T1 失败。
First Write Win 与 First Commit Win 在交互上是显然不同的,这虽然不是大问题,但对于开发者来说,还是有一定影响的。可以说,TiDB 的『缓存写提交』方式已经不是完全意义上的交互事务了。
有一种方法,既能缩短延迟,又能保持交互事务的特点。CockroachDB 采用的就是这种方式,称为 Pipeline。具体过程就是在准备阶段是按照顺序将 SQL 转换为 K / V 操作并执行,但是并不等待返回结果,直接执行下一个 K / V 操作。
这样,准备阶段的延迟,等于最慢的一个写操作延迟,也就是一轮共识算法的开销,所以整体延迟同样是:
$L_{prep}$ = $L_c$
加上提交阶段的一轮共识算法开销:
$L_{txn}$ = 2 * $L_c$
再结合转账的例子看一下:
同样的操作,按照 Pipeline 方式,增加小红账户余额时并不等待小明扣减账户的动作结束,两条 SQL 的执行时间约等于 1 个 $L_c$。加上提交阶段的 1 个 $L_c$,一共是 2 个 $L_c$,并且延迟也不再随着 SQL 数量增加而延长。
2 个 $L_c$ 是多久,需要带入真实场景计算一下。
首先,评估一下期望值。对于联机交易来说,延迟通常不超过 1 秒,如果用户体验良好,则要控制在 500 毫秒以内。其中留给数据库的处理时间不会超过一半,也就是 250-500 毫秒。这样推算,$L_c$ 应该控制在 125-250 毫秒之间。
再来看看真实的网络环境。目前人类现有的科技水平是不能超越光速的,这个光速是指光在真空中的传播速度,大约是 30 万千米每秒。而光纤由于传播介质不同和折线传播的关系,传输速度会降低 30%,大致是 20 万千米每秒。但是,这仍然是一个比较理想的速度,因为还要考虑网络上的各种设备、协议处理、丢包重传等等情况,实际的网络延迟还要长很多。
这里引用了论文 "Highly Available Transactions: Virtues and Limitations" 中的一些数据,这篇论文发表在 VLDB2014 上,在部分章节中初步探讨了系统全球化部署面临的延迟问题。论文作者在亚马逊 EC2 上,使用 Ping 包的方式进行了实验,并统计了一周时间内 7 个不同地区机房之间的 RTT(Round-Rip Time,往返延迟)数据。
简单来说,RTT 就是数据在两个节点之间往返一次的耗时。在讨论网络延迟的时候,为了避免歧义,通常使用 RTT 这个概念。
实验中,地理跨度较大两个机房是巴西圣保罗和新加坡,两地之间的理论 RTT 是 106.7 毫秒(使用光速测算),而实际测试的 RTT 均值为 362.8 毫秒,P95(95%)RTT 均值为 649 毫秒。将 649 毫秒代入公式,那 $L_{txn}$ 就是接近 1.3 秒,这显然太长了。考虑到共识算法的数据包更大,这个延迟还会更长。
像 CockroachDB、YugabyteDB 这样分布式数据库,它们的目标就是全球化部署,所以还要努力去压缩事务延迟。
准备阶段的操作已经压缩到极限了,目前的办法就是让两个动作并行执行。
在优化前的处理流程中,CockroachDB 会记录事务的提交状态:
TransactionRecord{
Status: COMMITTED,
...
}
并行执行的过程是这样的:
准备阶段的操作,在 CockroachDB 中被称为意向写。这个并行执行就是在执行意向写的同时,就写入事务标志,当然这个时候不能确定事务是否提交成功的,所以要引入一个新的状态『Staging』,表示事务正在进行。那么这个记录事务状态的落盘操作和意向写大致是同步发生的,所以只有一轮共识算法开销。事务表中写入的内容是类似这样的:
TransactionRecord{
Status: STAGING,
Writes: []Key{"A", "C", ...},
...
}
Writes 部分是意向写的 Key。这是留给异步进程的线索,通过这些 Key 是否写成功,可以倒推出事务是否提交成功。
而客户端得到所有意向写的成功反馈后,可以直接返回调用方事务提交成功。客户端只在当前进程内判断事务提交成功后,不维护事务状态,而直接返回调用方;事后由异步线程根据事务表中的线索,再次确认事务的状态,并落盘维护状态记录。这样事务操作中就减少了一轮共识算法开销。
并行提交的优化思路其实和 Percolator 很相似,那就是不要纠结于在一次事务中搞定所有事情,可以只做最少的工作,留下必要的线索,就可以达到极致的速度。而后续的异步进程,只要根据线索,完成收尾工作就可以了。
加餐:并行提交中的 异步写事务日志只是根据每个数据项的写入情况,追溯出事务的状态,然后落盘保存,整个过程并没有任何重试或者回滚的操作。这是因为,在之前的同步操作过程中,负责管道写入的同步线程,已经明确知道了每个数据项的写入情况,也就是确定了事务的状态,不同步落盘只是为了避免由此带来的共识算法延迟。
事务状态置为staging,是表示事务已经开始但状态未知,而后在所有写入执行完毕后,事务的状态是明确的。但是,如果此时更新事务状态会带来一轮多副本写入的开销,增加延迟。所以,协调者直接向调用方返回事务处理的结果,再由异步线程来更新持久化的事务状态。这个更新过程,不需要考虑多副本的问题,因为所有写入操作都已经完成了多副本的一致性投票。只是要确认每个写入都成功,则可以判定事务成功,否则事务失败。
Daniel Peng and Frank Dabek: Large-scale Incremental Processing Using Distributed Transactions and Notifications
Jim Gray: [Notes on Data Base Operating Systems](Jim Gray: Notes on Data Base Operating Systems)
Peter Bailis et al.: [Highly Available Transactions: Virtues and Limitations
本文由 caroly 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载 / 出处外,均为本站原创或翻译,转载前请务必署名
原文链接:https://caroly.fun/archives/分布式数据库三
最后更新:2022-02-07 13:36:37
Update your browser to view this website correctly. Update my browser now