八、分布式数据库之原子性
1、定义
关于事务的原子性,图灵奖得主、事务处理大师詹姆斯·格雷(Jim Gray)给出了一个更权威的定义:
Atomicity: Either all the changes from the transaction occur (writes, and messages sent), or none occur.
原子性就是要求事务只有两个状态:
- 一是成功,也就是所有操作全部成功;
- 二是失败,任何操作都没有被执行,即使过程中已经执行了部分操作,也要保证回滚这些操作。
原子性提交协议有不少,按照其作用范围可以分为面向应用层和面向资源层。
2、面向应用层的 TCC
TCC 是 Try、Confirm 和 Cancel 三个单词的缩写,它们是事务过程中的三个操作。对于“单元架构 + 单体数据库”
需要在应用层实现事务的原子性,经常会用到 TCC 协议。
比如说银行存款系统:
系统由多个单元构成,每个单元包含了一个存款系统的部署实例和对应的数据库,专门为某一个地区的用户服务。
跨单元的转账需要引入额外的处理机制,而 TCC 就是一种常见的选择。
TCC 的整个过程由两类角色参与,一类是事务管理器,只能有一个;另一类是事务参与者,也就是具体的业务服务,可以是多个,每个服务都要提供 Try、Confirm 和 Cancel 三个操作。
小明和小红的账户分别在单元A和单元B上,小明的账户余额是 4,900 元,要给小红转账 2,000 元,TCC的流程如下:
- 第一阶段,事务管理器会发出 Try 操作,要求进行资源的检查和预留。也就是说,单元 A 要检查小明账户余额并冻结其中的 2,000 元,而单元 B 要确保小红的账户合法,可以接收转账。在这个阶段,两者账户余额始终不会发生变化。
即这个阶段要求有对资源进行检查和预留对应的操作或者接口,比如这里的冻结账户。 |
- 第二阶段:
- 如果参与者都做好了准备,那么事务管理器会发出 Confirm 操作,执行真正的业务,完成 2,000 元的划转。
- 如果小红账户无法接收转账
所以事务管理器向所有参与者发出 Cancel 指令,让已经成功执行 Try 操作的单元 A 执行 Cancel 操作,撤销在 Try 阶段的操作,也就是单元 A 解除 2,000 元的资金冻结。
这个阶段要求系统具有对应于第一阶段预留资源接口的“反向”接口,比如解冻操作。 |
TCC 仅是应用层的分布式事务框架,具体操作完全依赖于业务编码实现,可以做针对性的设计,但是这也意味着业务侵入会比较深。但是可以不依赖于底层的存储系统。
考虑到网络的不可靠,操作指令必须能够被重复执行,这就要求 Try、Confirm、Cancel 必须是幂等性操作。 |
3、面向资源层的2PC协议
两阶段提交协议(Two-Phase Commit,2PC)是面向资源层的典型协议。
2PC 的处理过程也分为准备和提交两个阶段,每个阶段都由事务管理器与资源管理器共同完成。其中,事务管理器作为事务的协调者只有一个,而资源管理器作为参与者执行具体操作允许有多个。
比如还是转账操作,系统变为如下结构,应用层看不到数据存储的结构:所有客户的数据被分散存储在多个数据库实例中,这些数据库实例具有完全相同的表结构。业务逻辑部署在应用服务器上,通过数据库中间件访问底层的数据库实例。数据库中间件作为事务管理器,资源管理器就是指底层的数据库实例。
小明和小红的数据分别被保存在数据库 D1 和 D2 上。
- 第一阶段是准备阶段,事务管理器首先向所有参与者发送待执行的 SQL,并询问是否做好提交事务的准备(Prepare);参与者记录日志、分别锁定了小明和小红的账户,并做出应答,协调者接收到反馈 Yes,准备阶段结束。
和TCC类似,这个阶段也是预留资源,只是此处是面向资源的,所以用的是底层的资源管理器即数据库的锁定操作,而不是像TCC那样,使用的是具有业务含义的接口或者操作。
对于记录日志的操作,这里额外提一句:
对大多数的数据库来说,实时写入数据时,并不是真的将数据写入数据表在磁盘中的对应文件里,因为数据表的组织形式复杂,不像 预写日志WAL 那样只是在文件尾部追加,所以 I/O 操作的延迟太长。因此,写入过程往往是这样的,记录 WAL 日志,同时将数据写入内存(应该是先写WAL日志再写内存),两者都成功就返回客户端了。这些内存中的数据,在 Oracle 和 MySQL 中都被称为脏页,达到一定比例时会批量写入磁盘。
写入内存和 WAL 这两个操作构成了一个事务,必须一起成功或失败。
对于MYSQL来说,WAL就是redo log,是InnoDB 引擎特有的日志。
写redo log操作本身就是一个两阶段提交(这应该就是MYSQL实现2PC的基础),用来维护redo log和binlog的一致性:
|
- 第二阶段是提交阶段:
- 如果所有数据库的反馈都是 Yes,则事务管理器会发出提交(Commit)指令。这些数据库接受指令后,会进行本地操作,正式提交更新余额,给小明的账户扣减 2,000 元,给小红的账户增加 2,000 元,然后向协调者返回 Yes,事务结束。
如果在commit时,收到了D1的应答,但是没有收到D2的应答怎么办?参见3.2节,即使用paxos协议,事物管理器是一个集群,总会有若干个成员收到应答,采取投票的办法来决定数据库D2的状态。
- 如果数据库 D1 发现无法锁定小明的账户,只能向事务管理器返回失败。为事务管理器发现数据库 D1 不具备执行事务的条件,只能向所有数据库发出“回滚”(Rollback)指令。所有数据库接收到指令后撤销第一阶段的操作,释放资源,并向协调者返回 Yes,事务结束。小明和小红的账户余额均保持不变。
3.1、2PC的三大问题
- 同步阻塞:执行过程中,数据库要锁定对应的数据行。如果其他事务刚好也要操作这些数据行,那它们就只能等待。其实同步阻塞只是设计方式,真正的问题在于这种设计会导致分布式事务出现高延迟和性能的显著下降。
XA 事务与单机事务的对比数据。XA 协议是 2PC 在数据库领域的具体实现,而 MySQL(InnoDB 存储引擎)正好就支持 XA 协议。
其中,横坐标是并发线程数量,纵坐标是事务延迟,以毫秒为单位;蓝色的折线表示单机事务,红色的折线式表示跨两个节点的 XA 事务。我们可以清晰地看到,无论并发数量如何,XA 事务的延迟时间总是在单机事务的 10 倍以上。 |
- 单点故障:事务管理器非常重要,一旦发生故障,数据库会一直阻塞下去。尤其是在第二阶段发生故障的话,所有数据库还都处于锁定事务资源的状态中,从而无法继续完成事务操作。
- 数据不一致:在第二阶段,当事务管理器向参与者发送 Commit 请求之后,发生了局部网络异常,导致只有部分数据库接收到请求,但是其他数据库未接到请求所以无法提交事务,整个系统就会出现数据不一致性的现象。比如,小明的余额已经能够扣减,但是小红的余额没有增加,这样就不符合原子性的要求了。即没有重试机制和幂等性。
3.2、2PC与Paxos协议的联系
2PC 第一阶段“准备阶段”也被称为“投票阶段”,和 Paxos 协议处理阶段的命名相近,那 2PC 和 Paxos 协议有没有关系,如果有又是什么关系呢?
总的来说,就是 Paxos 是对单值或者一种状态达成共识的过程,而 2PC 是对多个不同数据项的变更或者多个状态,达成一致的过程。它们是有区别的,Paxos 不能替换 2PC,但它们也是有某种联系的。
还有,当事务协调者收不到事务参与者的反馈时,就需要使用Paxos协议了。这种故障多数是和网络故障有关,而Paxos 协议就是针对网络分区而设计的共识算法,在其之上衍生了 Paxos Commit 协议,这是一个融合了 Paxos 算法的原子提交协议,也是我们前面所说的 2PC 和 Paxos 的联系所在。
3.2.1、Paxos Commit 协议
Paxos Commit 协议中有四个角色:
- 有两个与 2PC 对应,分别是:
- TM(Transaction Manager,事务管理者)也就是事务协调者;
- RM(Resource Manager,资源管理者)也就是事务参与者;
- 另外两个角色与 Paxos 对应:
- 一个是 Leader;
- 一个是 Acceptor。
其中,TM 和 Leader 在逻辑上是一个,所以在图中隐去了。因为 Leader 是选举出来的,所以第一个 Leader 标识为 Initial Leader。这里隐含的含义是TM已经是一个集群了,通过选主算法选举的Leader执行TM的角色。
整体的逻辑如下:既然TM没有收到应答,那就把TM拆成Leader + Acceptors的组合,RM与Leader的通讯断了,还有N条与Acceptors的通讯,Acceptors再把结果汇总给Leader,这样就大大较少了网络分区带来的影响。具体如下:
- 首先由 RM1,就是某个 RM,向 Leader 发送 Begin Commit 指令。这个操作和第 9 讲介绍的 2PC 稍有不同,但和客户端向 Leader 发送指令在效果上是大致相同的。同时,RM1 要向所有 Acceptor 发送 Prepared 指令。因为把事务触发也算进去了,所以整个协议由三个阶段构成,Prepare 是其中的第二阶段,而 RM 对 Prepare 指令的响应过程又拆分成了 a 和 b 两个子阶段。所以,这里的 Prepared 指令用 2a Prepared 表示,要注意这是一个完成时,表示已经准备完毕。
- Leader 向除 RM1 外的所有 RM,发送 Prepare 指令。RM 执行指令后,向所有 Acceptor 发送消息 2a Prepared。这里的关键点是,一个 2a Prepared 消息里只包含一个 RM 的执行情况。而每个 Acceptor 都会收到所有 RM 发送的消息,从而得到全局 RM 的执行情况。
这两步完成后,TM集群就有了整体上RM准备是否完成的情况。
此外,RM1直接向Acceptor发送了Prepared 指令,因为它实质已经到了Begin Commit阶段。而其余RM是收到Leader的指令后才开始Prepare,完成后再向Acceptor发送Prepared 指令。 |
- 每个 Acceptor 向 Leader 汇报自己掌握的全局状态,载体是消息 2b Prepared。2b Prepared 是对 2a Prepared 的合并,每个消息都记录了所有 RM 的执行情况。最后,Leader 基于多数派得出了最终的全局状态。这一点和 2PC 完全不同,事务的状态完全由投票决定,Leader 也就是事务协调者,是没有独立判断逻辑的。
- Leader 基于已知的全局状态,向所有 RM 发送 Commit 指令。
如果 Acceptor 总数是 2F+1,那么每个 RM 就有 2F+1 条路径与 Leader 通讯。只要保证其中 F+1 条路径是畅通的,整个协议就可以正常运行。因此,Paxos Commit 协议的优势之一,就是在很大程度上避免了网络故障对系统的影响。但是,相比于 2PC 来说,它的消息数量大幅增加,而且多了一次消息延迟。目前,实际产品中还很少有使用 Paxos Commit 协议的。 |
4、分布式数据库的两个 2PC 改进模型
4.1、NewSQL 阵营:Percolator
Percolator是基于分布式存储系统 BigTable 建立的模型,所以可以和 NewSQL 无缝链接。
使用 Percolator 模型的前提是事务的参与者,即数据库,要支持多版本并发控制(MVCC)。
在转账事务开始前,小明和小红的账户分别存储在分片 P1 和 P2 上。
分片的账户表中各有两条记录,第一行记录的指针(write)指向第二行记录,实际的账户余额存储在第二行记录的 Bal. data 字段中。
Bal.data 分为两个部分,冒号前面的是时间戳,代表记录的先后次序;后面的是真正的账户余额。
- 准备阶段,事务管理器向分片发送 Prepare 请求,包含了具体的数据操作要求。它是事务操作的主体,包含若干读操作和若干写操作。
分片接到请求后要做两件事,写日志和添加私有版本。关于私有版本,你可以简单理解为,在 lock 字段上写入了标识信息的记录就是私有版本,只有当前事务能够操作,通常其他事务不能读写这条记录。(可能是使用MVCC机制,使得其它事物看不到这条记录,比如MVCC实现时的事物ID就能起到这个作用)
注意:两个分片上的 lock 内容并不一样。主锁的选择是随机的,参与事务的记录都可能拥有主锁,但一个事务只能有一条记录拥有主锁,其他参与事务的记录在 lock 字段记录了指针信息“primary@Ming.bal”,指向主锁记录。
异常情况:本阶段会执行写操作,即写入私有版本,每个修改行都可能碰到锁冲突的情况,如果冲突了,就终止事务,返回给 TiDB,那么整个事务也就终止了。
- 提交阶段,事务管理器只需要和拥有主锁的分片通讯,发送 Commit 指令,且不用附带其他信息。
分片 P1 增加了一条新记录时间戳为 8,指向时间戳为 7 的记录,后者在准备阶段写入的主锁也被抹去。这时候 7、8 两条记录不再是私有版本,所有事务都可以看到小明的余额变为 2,900 元,事务结束。
这里提交的目的或者说结果是使得所有事物都可以读到这条记录。 |
注意:不用更新小红的记录。Percolator 最有趣的设计就是这里,因为分片 P2 的最后一条记录,保存了指向主锁的指针。其他事务读取到 Hong7 这条记录时,会根据指针去查找 Ming.bal,发现记录已经提交,所以小红的记录虽然是私有版本格式,但仍然可视为已经生效了。
这种通过指针查找的方式,会给读操作增加额外的工作。如果每个事务都照做,性能损耗就太大了。所以,还会有其他异步线程来更新小红的余额记录,最终变成下面的样子。
异步线程变事物管理器同步协调所有资源节点为异步协调资源节点。这个异步线程既可以做提交(即更新小红的记录),也可以做回滚操作。
异步线程和联机交易处理系统中的落地手工处理的作用是一样的。
仔细想一想,这个异步线程在哪呢?只能运行在分片上,这样就可以解决事务管理器的单点故障问题。此外,既然异步线程可以用来更新小红的余额记录,也可以用于回滚等事物的恢复性操作了;所以,再进一步的思考,线程需要有操作的依据,这个依据就是日志。
日志的设计是可以抵抗分片故障的,比如MYSQL 的REDO LOG就使得MYSQL具备了CRASH SAFE的能力(见下面“Percolator 模型对2PC的改进之处”)。 |
Percolator 模型对2PC的改进之处:
- 数据不一致
2PC 的一致性问题主要缘自第二阶段,不能确保事务管理器与多个参与者的通讯始终正常。
但在 Percolator 的第二阶段,事务管理器只需要与一个分片通讯,这个 Commit 操作本身就是原子的。所以,事务的状态自然也是原子的,一致性问题被完美解决了。
- 单点故障
一是,Percolator 引入的异步线程可以在事务管理器宕机后,回滚各个分片上的事务,提供了善后手段,不会让分片上被占用的资源无法释放。
二是,事务管理器可以用记录日志的方式使自身无状态化,日志通过共识算法同时保存在系统的多个节点上。这样,事务管理器宕机后,可以在其他节点启动新的事务管理器,基于日志恢复事务操作。
CockroachDB 也从 Percolator 模型获得灵感,设计了自己的 2PC 协议。CockroachDB 的变化在于没有随机选择主锁,而是引入了一张全局事务表,所有分片记录的指针指向了这个事务表中对应的事务记录。
此外,TiDB也借鉴了Percolator 模型。 |
4.2、PGXC 阵营:GoldenDB 的一阶段提交
GoldenDB 遵循 PGXC 架构,包含了四种角色:协调节点、数据节点、全局事务器和管理节点,其中协调节点和数据节点均有多个。GoldenDB 的数据节点由 MySQL 担任,后者是独立的单体数据库。
全局事物器可以参见《隔离性》的第7节,它的作用是给分布式集群中的节点提供一个全局的事务状态列表,供MVCC使用。比如,在下面的“第一阶段”的内容中,协调节点就会在全局事物管理器中更新事务的状态。 |
虽然名字叫“一阶段提交”,但 GoldenDB 的流程依然可以分为两个阶段。
- 第一阶段。第一阶段,GoldenDB 的协调节点接到事务后,在全局事务管理器(GTM)的全局事务列表中将事务标记成活跃的状态。
这个标记过程是 GoldenDB 的主要改进点,实质是通过全局事务列表来申请资源,规避可能存在的事务竞争。或者可以类比为TOKEN的思想?
(20201030更新)标记状态主要是更新全局事物管理器中事物的状态,供MVCC使用。MVCC的目的是为了实现一定级别的隔离性(主要处理读写冲突),隔离性是原子性的前提——只有使事物完成前的操作对其它事务不可见,即隔离开,才能让事务看起来是原子的。但类比为TOKEN的思想仍然有一定的意义。
这样的好处是避免了与所有参与者的通讯,也减少了很多无效的资源锁定动作。 |
- 第二阶段。协调节点把一个全局事务分拆成若干子事务,分配给对应的 MySQL 去执行。如果所有操作成功,协调者节点会将全局事务列表中的事务标记为结束,整个事务处理完成(即正常情况下协调节点与数据节点只通讯一次)。如果失败,子事务在单机上自动回滚,而后反馈给协调者节点,后者向所有数据节点下发回滚指令。
GoldenDB 的“一阶段提交”,本质上是改变了资源的申请方式,更准确的说法是,并发控制手段从锁调度变为时间戳排序(Timestamp Ordering)。这样,在正常情况下协调节点与数据节点只通讯一次,降低了网络不确定性的影响,数据库的整体性能有明显提升。因为第一阶段不涉及数据节点的操作,也就弱化了数据一致性和单点故障的问题。
关于“时间戳排序”,可以参见《隔离性》6.2节讲解读提交隔离级别的一个图片
上图根据时间戳排序,事务T6决定了自己可以看见那些事物的操作结果,比如可以看到T1、T2最新的操作结果,而只能看到T3、T4、T5对应的行R3、R4、R5上一个版本的值。 |
GoldenDB 的“一阶段提交”与Percolator 相比,前者的读写操作在第二个阶段,而后者的读写操作主要集中在第一个阶段。而且分布式数据库的写入并不是简单的本地操作,而是使用共识算法同时在多个节点上(对于NEWSQL就是多个分片,对于PGXC,就是主备节点)写入数据。 |
5、3PC协议
3PC 虽然试图解决 2PC 的问题,但它的通讯开销更大,在网络分区时也无法很好地工作,很少在工程实践中使用。
6、两阶段封锁协议(Two-Phase Locking,2PL)
从整个分布式事务看,原子性协议之外还有一层隔离性协议,由后者保证事务能够成功申请到资源。在相当长的一段时间里,2PC 与 2PL 的搭配都是一种主流实现方式,可能让人误以为它们是可以替换的术语。实际上,两者是截然不同的,2PC 是原子性协议,而 2PL 是一种事务的隔离性协议,也是一种并发控制算法。
7、原子性协议小结
8、性能
对Percolator的延迟估算方法如下:
- 与写操作相比,忽略读操作的延迟
- 分布式数据库的写入,并不是简单的本地操作,而是使用共识算法同时在多个节点上写入数据。所以,一次写入操作延迟等于一轮共识算法开销
计算过程如下(写操作次数记为 W,Lc 代表一轮共识算法的用时):
最后可得
总结: 可以看到,这个公式里事务的延迟是与写操作 SQL 的数量线性相关的,而真实场景中通常都会包含多个写操作,那事务延迟肯定不能让人满意。
8.1、优化
8.1.1、缓存写提交(Buffering Writes until Commit),TiDB
第一个办法是将所有写操作缓存起来,直到 commit 语句时一起执行,这种方式称为 Buffering Writes until Commit,我把它翻译为“缓存写提交”。
缺点:
- 首先是在客户端发送 Commit 前,SQL 要被缓存起来,如果某个业务场景同时存在长事务和海量并发的特点,那么这个缓存就可能被撑爆或者成为瓶颈。
- 其次是客户端看到的 SQL 交互过程发生了变化,在 MySQL 中如果出现事务竞争,判断优先级的规则是 First Write Win,也就是对同一条记录先执行写操作的事务获胜。而 TiDB 因为缓存了所有写 SQL,所以就变成了 First Commit Win,也就是先提交的事务获胜。例子详解见原文。
8.1.2、管道(Pipeline),CockroachDB
既能缩短延迟,又能保持交互事务的特点。具体过程就是在准备阶段是按照顺序将 SQL 转换为 K/V 操作并执行,但是并不等待返回结果,直接执行下一个 K/V 操作。
8.1.3、并行提交(Parallel Commits)
即将准备与提交并行执行。详见原文。
比如CockroachDB,在优化前的处理流程中,CockroachDB 会记录事务的提交状态:
TransactionRecord{ Status: COMMITTED, ... } |
并行执行的过程是这样的。
准备阶段的操作,在 CockroachDB 中被称为意向写。所谓并行执行,就是在执行意向写的同时,就写入事务标志,当然这个时候不能确定事务是否提交成功的,所以要引入一个新的状态“Staging”,表示事务正在进行。那么这个记录事务状态的落盘操作和意向写大致是同步发生的,所以只有一轮共识算法开销。事务表中写入的内容是类似这样的:
TransactionRecord{ Status: STAGING, Writes: []Key{"A", "C", ...}, ... } |
Writes 部分是意向写的 Key。这是留给异步进程的线索,通过这些 Key 是否写成功,可以倒推出事务是否提交成功。这样事务操作中就减少了一轮共识算法开销。
并行提交的优化思路其实和 Percolator 很相似,那就是不要纠结于在一次事务中搞定所有事情,可以只做最少的工作,留下必要的线索,就可以达到极致的速度。而后续的异步进程,只要根据线索,完成收尾工作就可以了。 |
8.2、小结