架构风格
传统单体架构
- 所有的功能都在同一个线程中
不过传统单体架构的是可以水平伸缩的,这个样子就不在同一个线程中,伸缩架构如图
这个样子所有的压力都来到了数据库层面,根据木桶原理。数据库成为了整个系统的瓶颈。
传统的将数据集中存储至单一节点的解决方案,在性能、可用性和运维成本这三方面已经难于满足海量数据的场景。
从性能方面来说,由于关系型数据库大多采用 B+ 树类型的索引,在数据量超过阈值的情况下,索引深度的增加也将使得磁盘访问的 IO 次数增加,进而导致查询性能的下降; 同时,高并发访问请求也使得集中式数据库成为系统的最大瓶颈。
从可用性的方面来讲,服务化的无状态性,能够达到较小成本的随意扩容,这必然导致系统的最终压力都落在数据库之上。 而单一的数据节点,或者简单的主从架构,已经越来越难以承担。数据库的可用性,已成为整个系统的关键。
从运维成本方面考虑,当一个数据库实例中的数据达到阈值以上,对于 DBA 的运维压力就会增大。 数据备份和恢复的时间成本都将随着数据量的大小而愈发不可控。一般来讲,单一数据库实例的数据的阈值在 1TB 之内,是比较合理的范围。
在传统的关系型数据库无法满足互联网场景需要的情况下,将数据存储至原生支持分布式的 NoSQL 的尝试越来越多。 但 NoSQL 对 SQL 的不兼容性以及生态圈的不完善,使得它们在与关系型数据库的博弈中始终无法完成致命一击,而关系型数据库的地位却依然不可撼动。
数据分片指按照某个维度将存放在单一数据库中的数据分散地存放至多个数据库或表中以达到提升性能瓶颈以及可用性的效果。 数据分片的有效手段是对关系型数据库进行分库和分表。
- 分库和分表均可以有效的避免由数据量超过可承受阈值而产生的查询瓶颈。
- 除此之外,分库还能够用于有效的分散对数据库单点的访问量;
- 分表虽然无法缓解数据库压力,但却能够提供尽量将分布式事务转化为本地事务的可能,一旦涉及到跨库的更新操作,分布式事务往往会使问题变得复杂。 使用多主多从的分片方式,可以有效的避免数据单点,从而提升数据架构的可用性。
通过分库和分表进行数据的拆分来使得各个表的数据量保持在阈值以下,以及对流量进行疏导应对高访问量,是应对高并发和海量数据系统的有效手段。 数据分片的拆分方式又分为垂直分片和水平分片。
数据切分
数据切分,简单的说,就是通过某种条件,将我们之前存储在一台数据库上的数据,分散到多台数据库中,从而达到降低单台数据库负载的效果。数据切分,根据其切分的规则,大致分为两种类型,垂直切分和水平切分。
垂直切分
按照业务拆分的方式称为垂直分片,又称为纵向拆分,它的核心理念是专库专用。 在拆分之前,一个数据库由多个数据表构成,每个表对应着不同的业务。而拆分之后,则是按照业务将表进行归类,分布到不同的数据库中,从而将压力分散至不同的数据库。
下图展示了根据业务需要,将用户表和订单表垂直分片到不同的数据库的方案。
在架构设计中,各个功能模块之间的交互越统一、越少越好。这样,系统模块之间的耦合度会很低,各个系统模块的可扩展性、可维护性也会大大提高。这样的系统,实现数据的垂直切分就会很容易。
垂直分片往往需要对架构和设计进行调整。通常来讲,是来不及应对互联网业务需求快速变化的;而且,它也并无法真正的解决单点瓶颈。 垂直拆分可以缓解数据量和访问量带来的问题,但无法根治。如果垂直拆分之后,表中的数据量依然超过单节点所能承载的阈值,则需要水平分片来进一步处理。
下面我们来看看垂直切分的优缺点:
优点:
- 拆分后业务清晰,拆分规则明确;
- 系统之间容易扩展和整合;
- 数据维护简单
缺点:
- 部分业务表无法join,只能通过接口调用,提升了系统的复杂度;
- 跨库事务难以处理;
水平切分
水平分片又称为横向拆分。 相对于垂直分片,它不再将数据根据业务逻辑分类,而是通过某个字段(或某几个字段),根据某种规则将数据分散至多个库或表中,每个分片仅包含数据的一部分。 例如:根据主键分片,偶数主键的记录放入 0 库(或表),奇数主键的记录放入 1 库(或表),如下图所示。
水平分片从理论上突破了单机数据量处理的瓶颈,并且扩展相对自由,是数据分片的标准解决方案。
挑战
虽然数据分片解决了性能、可用性以及单点备份恢复等问题,但分布式的架构在获得了收益的同时,也引入了新的问题。
- 面对如此散乱的分片之后的数据,应用开发工程师和数据库管理员对数据库的操作变得异常繁重就是其中的重要挑战之一。 他们需要知道数据需要从哪个具体的数据库的子表中获取。
- 另一个挑战则是,能够正确的运行在单节点数据库中的 SQL,在分片之后的数据库中并不一定能够正确运行。 例如,分表导致表名称的修改,或者分页、排序、聚合分组等操作的不正确处理。
- 跨库事务也是分布式的数据库集群要面对的棘手事情。 合理采用分表,可以在降低单表数据量的情况下,尽量使用本地事务,善于使用同库不同表可有效避免分布式事务带来的麻烦。 在不能避免跨库事务的场景,有些业务仍然需要保持事务的一致性。 而基于 XA 的分布式事务由于在并发度高的场景中性能无法满足需要,并未被互联网巨头大规模使用,他们大多采用最终一致性的柔性事务代替强一致事务。
读写分离
背景
面对日益增加的系统访问量,数据库的吞吐量面临着巨大瓶颈。 对于同一时刻有大量并发读操作和较少写操作类型的应用系统来说,将数据库拆分为主库和从库,主库负责处理事务性的增删改操作,从库负责处理查询操作,能够有效的避免由数据更新导致的行锁,使得整个系统的查询性能得到极大的改善。
通过一主多从的配置方式,可以将查询请求均匀的分散到多个数据副本,能够进一步的提升系统的处理能力。 使用多主多从的方式,不但能够提升系统的吞吐量,还能够提升系统的可用性,可以达到在任何一个数据库宕机,甚至磁盘物理损坏的情况下仍然不影响系统的正常运行。
与将数据根据分片键打散至各个数据节点的水平分片不同,读写分离则是根据 SQL 语义的分析,将读操作和写操作分别路由至主库与从库。
读写分离的数据节点中的数据内容是一致的,而水平分片的每个数据节点的数据内容却并不相同。将水平分片和读写分离联合使用,能够更加有效的提升系统性能。
挑战
读写分离虽然可以提升系统的吞吐量和可用性,但同时也带来了数据不一致的问题。 这包括多个主库之间的数据一致性,以及主库与从库之间的数据一致性的问题。 并且,读写分离也带来了与数据分片同样的问题,它同样会使得应用开发和运维人员对数据库的操作和运维变得更加复杂。 下图展现了将数据分片与读写分离一同使用时,应用程序与数据库集群之间的复杂拓扑关系。
目标
透明化读写分离所带来的影响,让使用方尽量像使用一个数据库一样使用主从数据库集群。
应用场景
复杂的主从数据库架构
许多系统通过采用主从数据库架构的配置来提高整个系统的吞吐量,但是主从的配置也给业务的使用带来了一定的复杂性。
利用读写分离功能管理主从数据库,实现透明化的读写分离功能,让用户像使用一个数据库一样使用主从架构的数据库。
分库分表方案就是在多个单体数据库之前增加代理节点,本质上是增加了 SQL 路由功能。这样,代理节点首先解析客户端请求,再根据数据的分布情况,将请求转发到对应的单体数据库
显然,如果把每一次的事务写入都限制在一个单体数据库内,业务场景就会很受局限。因此,跨库事务成为必不可少的功能,但是单体数据库是不感知这个事情的,所以我们就要在代理节点增加分布式事务组件。
同时,简单的分库分表不能满足全局性的查询需求,因为每个数据节点只能看到一部分数据,有些查询运算是无法处理的,比如排序、多表关联等。所以,代理节点要增强查询计算能力,支持跨多个单体数据库的查询。
很多分库分表方案会演进到这个阶段,比如 MyCat。
分布式架构
客户端组件 + 单体数据库
通过独立的逻辑层建立数据分片和路由规则,实现单体数据库的初步管理,使应用能够对接多个单体数据库,实现并发、存储能力的扩展。其作为应用系统的一部分,对业务侵入较为深。
这种客户端组件的典型产品是 Sharding-JDBC。
代理中间件 + 单体数据库
以独立中间件的方式,管理数据规则和路由规则,以独立进程存在,与业务应用层和单体数据库相隔离,减少了对应用的影响。随着代理中间件的发展,还会衍生出部分分布式事务处理能力。
这种中间件的典型产品是 MyCat。
单元化架构 + 单体数据库
单元化架构是对业务应用系统的彻底重构,应用系统被拆分成若干实例,配置独立的单体数据库,让每个实例管理一定范围的数据。例如对于银行贷款系统,可以为每个支行搭建独立的应用实例,管理支行各自的用户,当出现跨支行业务时,由应用层代码通过分布式事务组件保证事务的 ACID 特性。
根据不同的分布式事务模型,应用系统要配合改造,复杂性也相应增加。例如 TCC 模型下,应用必须能够提供幂等操作。
在分布式数据库出现前,一些头部互联网公司使用过这种架构风格,该方案的应用系统的改造量最大,实施难度也最高。
看过这三种方案,我相信你能够明白,它们共同的特点是单体数据库仍然能够被应用系统感知到。相反,分布式数据库则是将技术细节收敛到产品内部,以一个整体面对业务应用。
我猜,看到这里你一定很想知道,分布式数据库的内部架构到底长什么样呢?它跟这三种方案有什么区别呢?回答这个复杂的问题,就是我们这门课的使命了。这里你也可以先记下自己的答案,等学完这门课以后再回过头来做个对比,也是对自己学习效果的一种检验。
分布式事务
Init
数据库事务需要满足 ACID(原子性、一致性、隔离性、持久性)四个特性。
- 原子性(Atomicity)指事务作为整体来执行,要么全部执行,要么全不执行;
- 一致性(Consistency)指事务应确保数据从一个一致的状态转变为另一个一致的状态;
- 隔离性(Isolation)指多个事务并发执行时,一个事务的执行不应影响其他事务的执行;
- 持久性(Durability)指已提交的事务修改数据会被持久保存。
在单一数据节点中,事务仅限于对单一数据库资源的访问控制,称之为本地事务。 几乎所有的成熟的关系型数据库都提供了对本地事务的原生支持。 但是在基于微服务的分布式应用环境下,越来越多的应用场景要求对多个服务的访问及其相对应的多个数据库资源能纳入到同一个事务当中,分布式事务应运而生。
关系型数据库虽然对本地事务提供了完美的 ACID 原生支持。 但在分布式的场景下,它却成为系统性能的桎梏。 如何让数据库在分布式场景下满足 ACID 的特性或找寻相应的替代方案,是分布式事务的重点工作。
什么是事务
事务可以理解为包含一系列操作的序列
什么是原子性
原子则代表不可分割的最小粒度。
那么事务的原子性就是让包含若干操作的事务表现得像一个最小粒度的操作。这个操作一旦被执行,只有“成功”或者“失败”这两种结果。这就好像比特(bit),只能代表 0 或者 1,没有其他选择。
为什么要让事务表现出原子性呢?
我想举个从 ATM 取款的例子。
现在,你走到一台 ATM 前,要从自己 50,000 元的账户上取 1,000 元现金。当你输入密码和取款金额后, ATM 会吐出 1,000 块钱,同时你的账户余额会扣减 1,000 元;虽然有些时候,ATM 出现故障,无法吐钞,系统会提示取款失败,但你的余额还会保持在 50,000 元。
总之,要么既吐钞又扣减余额,要么既不吐钞又不扣减余额,你拿到手的现金和账户余额总计始终是 50,000 元,这就是一个具有原子性的事务。
显然,吐钞和扣减余额是两个不同的操作,而且是分别作用在 ATM 和银行的存款系统上。当事务整合了两个独立节点上的操作时,我们称之为分布式事务,其达成的原子性也就是分布式事务的原子性。
关于事务的原子性,图灵奖得主、事务处理大师詹姆斯·格雷(Jim Gray)给出了一个更权威的定义:
Atomicity: Either all the changes from the transaction occur (writes, and messages sent), or none occur.
这句话说得很精炼,我再和你解释下。
原子性就是要求事务只有两个状态:
- 一是成功,也就是所有操作全部成功;
- 二是失败,任何操作都没有被执行,即使过程中已经执行了部分操作,也要保证回滚这些操作。
要做到事务原子性并不容易,因为多数情况下事务是由多个操作构成的序列。而分布式事务原子性的外在表现与事务原子性一致,但前者要涉及多个物理节点,而且增加了网络这个不确定性因素,使得问题更加复杂。
背景
随着业务规模和场景越趋于复杂与庞大。很多公司都进行了数据库拆分和服务化(SOA)。在这种情况下,完成某一个业务功能可能需要横跨多个服务,操作多个数据库。这就涉及到到了分布式事务,其中需要操作的资源位于多个资源服务器上,而应用需要保证对于多个资源服务器的数据的操作,要么全部成功,要么全部失败。
本质上来说,分布式事务就是为了保证不同资源服务器的数据一致性。
分布式事务的作用
比如在交易系统的业务逻辑中
正常情况下
一个商品在下单之前需要先调用库存服务,进行扣除库存,再调用订单服务,创建订单记录。
非正常情况下
有可能库存库存扣减失败或者订单插入失败,两边数据不一致
本质上来说,分布式事务就是为了保证不同资源服务器的数据一致性。
分布式事务的难点
事务的原子性
事务操作跨不同节点,当多个节点某一节点操作失败时,需要保证多节点操作的要么什么都不做,要么做全套(All or Nothing)的原子性。
事务的一致性
当发生网络传输故障或者节点故障,节点间数据复制通道中断,在进行事务操作时需要保证数据一致性,保证事务的任何操作都不会使得数据违反数据库定义的约束、触发器等规则。
事务的隔离性
事务隔离性的本质就是如何正确处理多个并发事务的读写冲突和写写冲突,因为在分布式事务控制中,可能会出现提交不同步的现象,这个时候就有可能出现“部分已经提交”的事务。
此时并发应用访问数据如果没有加以控制,有可能出现“脏读”问题。
事务的实现
基于XA规范实现分布式事务
什么是 XA规范
XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准。
XA 规范 描述了全局的事务管理器与局部的资源管理器之间的接口。 XA规范的目的是允许的多个资源(如数据库,应用服务器,消息队列等)在同一事务中访问,这样可以使 ACID 属性跨越应用程序而保持有效。
XA 规范 使用两阶段提交(2PC,Two-Phase Commit)来保证所有资源同时提交或回滚任何特定的事务。
XA 规范 在上世纪 90 年代初就被提出。目前,几乎所有主流的数据库都对 XA 规范 提供了支持。
XA模型
XA 事务采用的是 X/OPEN 组织所定义的 DTP 模型 所抽象的 AP(应用程序), TM(事务管理器)和 RM(资源管理器) 概念来保证分布式事务的强一致性。
其中 TM 与 RM 间采用 XA 的协议进行双向通信,通过两阶段提交实现。 与传统的本地事务相比,XA 事务增加了准备阶段,数据库除了被动接受提交指令外,还可以反向通知调用方事务是否可以被提交。 TM 可以收集所有分支事务的准备结果,并于最后进行原子提交,以保证事务的强一致性。
XA规范最主要的作用是,就是定义了RM-TM的交互接口,上图更加清晰了演示了XA规范在DTP模型中发挥作用的位置,从上图中可以看出来,XA仅仅出现在RM和TM的连线上。
- AP:AP 就是应用程序本身。
- RM:RM 是资源管理器,也就是事务的参与者,大部分情况下就是指数据库,一个分布式事务往往涉及到多个 RM。
- TM:TM 就是事务管理器,创建分布式事务并协调分布式事务中的各个子事务的执行和状态,子事务就是指在 RM 上执行的具体操作。(负责整体事务的提交与回滚的指令的触发,扮演事务的总体协调者角色)
XA执行流程
XA协议采用两阶段提交方式来管理分布式事务。XA还对分布式事务规定了两个阶段:Prepare阶段和Commit阶段。
Prepare阶段
在Prepare阶段,事务管理器(TM) 会通过xid(事务唯一标识,由业务或事务协调器生成)协调多个资源管理器(RM)执行子事务,所有子事务执行成功后会向事务协调器(TM)汇报。
这时的子事务执行成功是指事务内SQL执行成功,并没有执行事务的最终commit(提交),所有子事务是提交还是回滚,需要等事务协调器(TM)做最终决策。
Commit阶段
接着分布式事务进入Commit阶段:当事务管理器(TM)收到所有资源管理器(RM)成功执行子事务的消息后,会记录事务执行成功,并对子事务做真正提交。如果Prepare阶段有子事务失败,或者事务协调器(TM)在一段时间内没有收到所有子事务执行成功的消息,就会通知所有资源管理器(RM)对子事务执行回滚的操作。
上图所示,在协调两个服务Application 1和Application 2时,业务会先请求事务协调器创建全局事务,同时生成全局事务的唯一标识xid,然后再在事务协调器里分别注册两个子事务,生成每个子事务对应的xid。这里说明一下,xid由gtrid+bqual+formatID组成,多个子事务的gtrid是相同的,但其他部分必须区分开,防止这些服务在一个数据库下。
那么有了子事务的xid,被请求的服务会通过xid标识开启XA子事务,让XA子事务执行业务操作。当事务数据操作都执行完毕后,子事务会执行Prepare指令,将子事务标注为Prepared状态,然后以同样的方式执行xid2事务。
所有子事务执行完毕后,Prepared状态的XA事务会暂存在MySQL中,即使业务暂时断开,事务也会存在。这时,业务代码请求事务协调器通知所有申请的子事务全部执行成功。与此同时,TM会通知RM1和RM2执行最终的commit(或调用每个业务封装的提交接口)。
至此,整个事务流程执行完毕。而在Prepare阶段,如果有子事务执行失败,程序或事务协调器,就会通知所有已经在Prepared状态的事务执行回滚。
以上就是XA协议实现多个子系统的事务一致性的过程,可以说大部分的分布式事务都是使用类似的方式实现的。
基于Base理论实现分布式事务
BASE是指基本可用(Basically Available)、软状态( Soft State)、最终一致性( Eventual Consistency),核心思想是即使无法做到强一致性(CAP的一致性就是强一致性),但应用可以采用适合的方式达到最终一致性。
如果将实现了 ACID 的事务要素的事务称为刚性事务的话,那么基于 BASE 事务要素的事务则称为柔性事务。 BASE 是基本可用、柔性状态和最终一致性这三个要素的缩写。
- 基本可用(Basically Available)保证分布式事务参与方不一定同时在线;
- 柔性状态(Soft state)则允许系统状态更新有一定的延时,这个延时对客户来说不一定能够察觉;
- 最终一致性(Eventually consistent)通常是通过消息传递的方式保证系统的最终一致性。
在 ACID 事务中对隔离性的要求很高,在事务执行过程中,必须将所有的资源锁定。 柔性事务的理念则是在应用层通过业务逻辑将互斥锁操作从资源层面上移至业务层面。 通过放宽对强一致性要求,来换取系统吞吐量的提升。
基于Base理论实现分布式事务,有种实现方式叫做TCC(Try、Confirm 和 Cancel),在应用层通过代码完成资源的控制。具体操作完全依赖于业务编码实现,可以做针对性的设计,但是这也意味着业务侵入会比较深。
基于 ACID 的强一致性事务和基于 BASE 的最终一致性事务都不是银弹,只有在最适合的场景中才能发挥它们的最大长处。
- 基于XA 协议的事务则称为刚性事务
- 基于 BASE 理论要素的事务则称为柔性事务
总结
基于XA 协议的事务则称为刚性事务。这种实现方式是在数据库层面完成的。也就是说是数据库层支持的。业务系统只需要通过调用相应的接口就行了,基于 BASE 理论实现的分布式事务,需要在应用层通过代码完成资源的控制。具体操作完全依赖于业务编码实现,可以做针对性的设计,但是这也意味着业务侵入会比较深。
应用场景
在单机应用场景中,依赖数据库提供的事务即可满足业务上对事务 ACID 的需求。但是在分布式场景下,传统数据库解决方案缺乏对全局事务的管控能力,用户在使用过程中可能遇到多个数据库节点上出现数据不一致的问题。
基于XA规范实现分布式事务使用场景
对于 XA 事务,提供了分布式环境下,对数据强一致性的保证。但是由于存在同步阻塞问题,对性能会有一定影响。适用于对数据一致性要求非常高且对并发性能要求不是很高的业务场景。
基于Base理论实现分布式事务使用场景
对于 BASE 事务,提供了分布式环境下,对数据最终一致性的保证。由于在整个事务过程中,不会像 XA 事务那样全程锁定资源,所以性能较好。适用于对并发性能要求很高并且允许出现短暂数据不一致的业务场景。
基于XA规范实现分布式事务
两阶段提交(2PC)
JEE的XA协议根据两阶段提交来保证事务的完整性,并实现分布式服务化的强一致性。
两阶段提交协议把分布式事务分为两个阶段,
- 一个是准备阶段,
- 另一个是提交阶段。
准备阶段和提交阶段都是由事务管理器发起的。参与者为资源管理器。事务的发起者称协调者,事务的执行者称参与者。
两阶段提交协议的流程如下所述
- 准备(prepare)阶段:事务管理器(TM)向资源管理器(RM)发起指令,资源管理器(RM)评估自己的状态, 如果资源管理器(RM)评估指令可以完成,则会写redo或者undo日志(Write-Ahead Log的一种),然后锁定资源,执行操作,但是并不提交。
- 提交(commit)阶段:如果每个资源管理器(RM)明确返回准备成功,也就是预留资源和执行操作成功,则事务管理器(TM)向资源管理器(RM)发起提交指令,资源管理器(RM)提交资源变更的事务,释放锁定的资源;如果任何一个资源管理器(RM)明确返回准备失败, 也就是预留资源或者执行操作失败,则事务管理器(TM)向资源管理器(RM)发起中止指令, 资源管理器(RM)取消已经变更的事务,执行undo日志,释放锁定的资源。
2PC 方案实现起来简单,实际项目中使用比较少,主要因为以下问题:
- 阻塞:从上面的描述来看,对于任何一次指令都必须收到明确的响应,才会继续进行下一步,否则处于阻塞状态,占用的资源被一直锁定,不会被释放。
- 单点故障:如果协调者宕机,参与者没有协调者指挥,则会一直阻塞,尽管可以通过选举新的协调者替代原有协调者,但是如果协调者在发送一个提交指令后宕机,而提交指令仅仅被一个参与者接收,并且参与者接收后也宕机,则新上任的协调者无法处理这种情况。(比如在Commit阶段,如果事务协调器的提交操作被打断了,XA事务就会遗留在MySQL中)
- 脑裂:协调者发送提交指令,有的参与者接收到并执行了事务,有的参与者没有接收到事务就没有执行事务,多个参与者之间是不一致的。
- 没有超时机制:2PC的整体设计是没有超时机制的,如果长时间不提交遗留在MySQL中的XA子事务,就会导致数据库长期被锁表。
上面的所有问题虽然很少发生,但都需要人工干预处理,没有自动化的解决方案,因此两阶段提交协议在正常情况下能保证系统的强一致性,但是在出现异常的情况下,当前处理的操作处于错误状态,需要管理员人工干预解决,因此可用性不够好,这也符合CAP协议的一致性和可用性不能兼得的原理。
三阶段提交协议
三阶段提交协议是两阶段提交协议的改进版本。与2PC相比,3PC主要多了事务超时、多次重复尝试,以及提交check的功能。但因为确认步骤过多,很多业务的互斥排队时间会很长,所以3PC的事务失败率要比2PC高很多。
为了减少3PC因资源锁定等待超时导致的重复工作,3PC做了预操作,整体流程分成三个阶段:
- canCommit阶段:为了减少因等待锁定数据导致的超时情况,提高事务成功率,事务协调器会发送消息确认资源管理器的资源锁定情况,以及所有子事务的数据库锁定数据的情况。
- PreCommit阶段:执行2PC的Prepare阶段;
- DoCommit阶段:执行2PC的Commit阶段。
阶段 1:canCommit
协调者向参与者发送 commit 请求,参与者如果可以提交就返回 yes 响应(参与者不执行事务操作),否则返回 no 响应:
- 协调者向所有参与者发出包含事务内容的 canCommit 请求,询问是否可以提交事务,并等待所有参与者答复。
- 参与者收到 canCommit 请求后,如果认为可以执行事务操作,则反馈 yes 并进入预备状态,否则反馈 no。
- 这个阶段超时会导致中止。
阶段 2:preCommit
如果在canCommit阶段所有参与者都返回可以执行操作,则协调者向参与者发送预执行请求,然后参与者写redo和undo日志,执行操作但是不提交操作;如果在canCommit阶段任意参与者返回不能执行操作的 结果,则协调者向参与者发送中止请求,这里的逻辑与两阶段提交协议 的准备阶段是相似的。
阶段 3:do Commit
如果每个参与者在准备阶段返回准备成功,也就是说预留资源和执行操作成功,则协调者向参与者发起提交指令,参与者提交资源变更的事务,释放锁定的资源;如果任何参与者返回准备失败, 也就是说预留资源或者执行操作失败,则协调者向参与者发起中止指 令,参与者取消已经变更的事务,执行 undo 日志,释放锁定的资源, 这里的逻辑与两阶段提交协议的提交阶段一致。
流程
正常流程
canCommit有参与者出现异常
canCommit阶段任何一个参与者反馈 no,或者等待超时后协调者尚无法收到所有参与者的反馈,即中断事务,如下图:
- 协调者向所有参与者发出 abort 请求。
- 无论收到协调者发出的 abort 请求,或者在等待协调者请求过程中出现超时,参与者均会中断事务。
precommit 阶段出现异常
preCommit阶段任何一个参与者反馈 no,或者等待超时后协调者尚无法收到所有参与者的反馈,即中断事务,如下图:
- 如果协调者处于工作状态,向所有参与者发出 abort 请求。
- 参与者使用canCommit阶段 中的 undo 信息执行回滚操作,并释放整个事务期间占用的资源。
- 各参与者向协调者反馈 ack 完成的消息。
- 协调者收到所有参与者反馈的 ack 消息后,即完成事务中断。
在面对分布式事务的时候先考虑是不是可以避免使用分布式事务, 能避免就避免,不能避免再使用。
- 优点:相比二阶段提交,三阶段提交降低了阻塞范围,在等待超时后协调者或参与者会中断事务。避免了协调者单点问题,doCommit阶段中协调者出现问题时,参与者会继续提交事务。
- 缺点:数据不一致问题依然存在,当在参与者收到 preCommit 请求后等待 do commite 指令时,此时如果协调者请求中断事务,而协调者无法与参与者正常通信,会导致参与者继续提交事务,造成数据不一致。
总结
这个方案,我们很少用,一般来说系统内部如果出现跨多个库完成某个业务操作,是不合规的。
现在微服务,一个大的系统分成几十个甚至几百个服务。一般来说,我们的规定和规范,是要求每个服务只能操作自己对应的一个数据库。
如果你要操作别的服务对应的库,不允许直连别的服务的库,违反微服务架构的规范,你随便交叉胡乱访问,几百个服务的话,全体乱套,这样的一套服务是没法管理的,没法治理的,可能会出现数据被别人改错,自己的库被别人写挂等情况。
如果你要操作别人的服务的库,你必须是通过调用别的服务的接口来实现,绝对不允许交叉访问别人的数据库。
总体来说,3PC步骤过多,过程比较复杂,整体执行也更加缓慢,所以在分布式生产环境中很少用到它
基于Base理论实现分布式事务
TCC(最终一致性)
事实上,2PC和3PC都存在执行缓慢、并发低的问题,这里介绍一个性能更好的分布式事务TCC。
TCC(Try-Confirm-Cancel)从流程上来看,它比2PC多了一个阶段,也就是将Prepare阶段又拆分成了两个阶段:Try阶段和Confirm阶段。TCC可以不使用XA,只使用普通事务就能实现分布式事务。
TCC 是服务化的二阶段编程模型,其 Try、Confirm、Cancel 3 个方法均由业务编码实现:
- Try:业务代码会预留业务所需的全部资源,比如冻结用户账户100元(可以理解成预先在用户的账户上扣掉100元)、提前扣除一个商品库存、提前创建一个没有开始交易的订单等,这样可以减少各个子事务锁定的数据量。业务拿到这些资源后,
后续两个阶段操作就可以无锁进行了。 - Confirm:业务确认所需的资源都拿到后,子事务会并行执行这些业务。执行时可以不做任何锁互斥,也无需检查,直接执行Try阶段准备的所有资源就行。(请注意,
协议要求所有操作都是幂等的,以支持失败重试,因为在一些特殊情况下,比如资源锁争抢超时、网络不稳定等,操作要尝试执行多次才会成功。) - Cancel:如果子事务在Try阶段或Confirm阶段多次执行重试后仍旧失败,TM就会执行Cancel阶段的代码,并释放Try预留的资源,同时回滚Confirm期间的内容。注意,Cancel阶段的代码也要做幂等,以支持多次执行。
TCC 事务的 Try、Confirm、Cancel 可以理解为 SQL 事务中的 Lock、Commit、Rollback。
Try 阶段
- 业务系统发起创建订单操作
- 库存系统检查和更新真实库存,冻结库存+1,
- 订单系统创建订单,并将状态设置为“待确认”,并对外不可见。
Confirm/Cancel阶段
根据 Try 阶段服务是否全部正常执行,继续执行确认操作(Confirm)或取消操作(Cancel)。
Confirm 和 Cancel 操作满足幂等性,如果 Confirm 或 Cancel 操作执行失败,将会不断重试直到执行完成。
Confirm阶段
当 Try 阶段服务全部正常执行, 执行确认业务逻辑操作
- 库存系统,冻结库存-1
- 订单系统,订单状态更改成创建成功
Confirm 阶段也可以看成是对 Try 阶段的一个补充,Try+Confirm 一起组成了一个完整的业务逻辑。
Cancel阶段
当 Try 阶段存在服务执行失败, 进入 Cancel 阶段
Cancel 取消执行,释放 Try 阶段预留的业务资源,上面的例子中,Cancel 操作会把冻结的库存释放,并更新订单状态为取消。
从正常的流程上讲,这仍然是一个两阶段提交协议,但是在执行出现问题时有一定的自我修复能力,如果任何参与者出现了问题, 则协调者通过执行操作的逆操作来Cancel之前的操作,达到最终的一致状态。
面向业务层的TCC的优点
TCC 事务机制相对于传统事务机制(X/Open XA),TCC 事务机制相比于上面介绍的 XA 事务机制,有以下优点:
- 性能提升:具体业务来实现控制资源锁的粒度变小,不会锁定整个资源。
- 数据最终一致性:基于 Confirm 和 Cancel 的幂等性,保证事务最终完成确认或者取消,保证数据的一致性。
- 可靠性:解决了 XA 协议的协调者单点故障问题,由主业务方发起并控制整个业务活动,业务活动管理器也变成多点,引入集群。
面向业务层的TCC的缺点:
- TCC 的 Try、Confirm 和 Cancel 操作功能要按具体业务来实现,业务耦合度较高,提高了开发成本。
- 只适合短事务,不适合多阶段的事务;
- 不适合多层嵌套的服务;
- 相关事务逻辑要求幂等;
- 存在执行过程被打断时,容易丢失数据的情况。
TCC 本质上是补偿事务,它的核心思想是针对每个操作都要注册一个与其对应的确认操作和补偿操作(也就是撤销操作)。 它是一个业务层面的协议,你也可以将 TCC 理解为编程模型,TCC 的 3 个操作是需要在业务代码中编码实现的,为了实现一致性,确认操作和补偿操作必须是等幂的,因为这 2 个操作可能会失败重试。
另外,TCC 不依赖于数据库的事务,而是在业务中实现了分布式事务,这样能减轻数据库的压力,但对业务代码的入侵性也更强,实现的复杂度也更高。
但是这种方案说实话几乎很少人使用,但是也有使用的场景。因为这个事务回滚实际上是严重依于你自己写代码来回滚和补偿了,会造成补偿代码巨大,非常之恶心。
一般来说跟钱相关的,跟钱打交道的,支付、交易相关的场景,会用 TCC,严格保证分布式事务要么全部成功,要么全部自动回滚,严格保证资金的正确性,保证在资金上不会出现问题。
但是说实话,一般尽量别这么搞,自己手写回滚逻辑,或者是补偿逻辑,实在太恶心了,那个业务代码是很难维护的。
分布式事务DEMO
面向应用层的 TCC
TCC 是 Try、Confirm 和 Cancel 三个单词的缩写,它们是事务过程中的三个操作。
下面,我用一个转账的例子向你解释 TCC 处理流程。
小明和小红都是番茄银行的客户,现在小明打算给小红转账 2,000 元,这件事在番茄银行存款系统中是如何实现的呢?
我们先来看下系统的架构示意图:
显然,番茄银行的存款系统是单元化架构的。也就是说,系统由多个单元构成,每个单元包含了一个存款系统的部署实例和对应的数据库,专门为某一个地区的用户服务。比如,单元 A 为北京用户服务,单元 B 为上海用户服务。
单元化架构的好处是每个单元只包含了部分用户,这样运行负载比较小,而且一旦出现问题,也只影响到少部分客户,可以提升整个存款系统的可靠性。
不过这种架构也有局限性。那就是虽然单元内的客户转账非常容易,但是跨单元的转账需要引入额外的处理机制,而 TCC 就是一种常见的选择。
TCC 的整个过程由两类角色参与
- 一类是事务管理器,只能有一个;
- 另一类是事务参与者,也就是具体的业务服务,可以是多个,每个服务都要提供 Try、Confirm 和 Cancel 三个操作。
下面是 TCC 的具体执行过程。
小明的银行卡在北京的网点开户,而小红的银行卡是在上海出差时办理的,所以两人的账户分别在单元 A 和单元 B 上。现在小明的账户余额是 4,900 元,要给小红转账 2,000 元,一个正常流程是这样的。
- 第一阶段,事务管理器会发出 Try 操作,要求进行资源的检查和预留。也就是说,单元 A 要检查小明账户余额并冻结其中的 2,000 元,而单元 B 要确保小红的账户合法,可以接收转账。在这个阶段,两者账户余额始终不会发生变化。
- 第二阶段,因为参与者都已经做好准备,所以事务管理器会发出 Confirm 操作,执行真正的业务,完成 2,000 元的划转。
但是很不幸,小红账户是无法接收转账的非法账户,处理过程就变成下面的样子。
- 第一阶段,事务管理器发出 Try 指令,单元 B 对小红账户的检查没有通过,回复 No。而单元 A 检查小明账户余额正常,并冻结了 2,000 元,回复 Yes。
- 第二阶段,因为前面有参与者回复 No,所以事务管理器向所有参与者发出 Cancel 指令,让已经成功执行 Try 操作的单元 A 执行 Cancel 操作,撤销在 Try 阶段的操作,也就是单元 A 解除 2,000 元的资金冻结。
面向应用层TCC的优缺点
从上述流程可以发现,TCC 仅是应用层的分布式事务框架,具体操作完全依赖于业务编码实现,可以做针对性的设计,但是这也意味着业务侵入会比较深。
此外,考虑到网络的不可靠,操作指令必须能够被重复执行,这就要求 Try、Confirm、Cancel 必须是幂等性操作,也就是说,要确保执行多次与执行一次得到相同的结果。显然,这又增加了开发难度。
数据库领域最常用的 2PC
2PC 的处理过程也分为准备和提交两个阶段,每个阶段都由事务管理器(TM)与资源管理器(RM)共同完成。其中,
- 事务管理器作为事务的协调者只有一个
- 资源管理器作为参与者执行具体操作允许有多个
2PC 具体是如何运行的呢?我们还是说回小明转账的例子。
小明给小红转账没有成功,两人又到木瓜银行来尝试。
木瓜银行的存款系统采用了分库分表方案,系统架构大致是这样的:
在木瓜银行的存款系统中,所有客户的数据被分散存储在多个数据库实例中,这些数据库实例具有完全相同的表结构。业务逻辑部署在应用服务器上,通过数据库中间件访问底层的数据库实例。数据库中间件作为事务管理器,资源管理器就是指底层的数据库实例。
假设,小明和小红的数据分别被保存在数据库 D1 和 D2 上。
我们还是先讲正常的处理流程。
- 第一阶段是准备阶段,事务管理器首先向所有参与者发送待执行的 SQL,并询问是否做好提交事务的准备(Prepare);参与者记录日志、分别锁定了小明和小红的账户,并做出应答,协调者接收到反馈 Yes,准备阶段结束。
- 第二阶段是提交阶段,如果所有数据库的反馈都是 Yes,则事务管理器会发出提交(Commit)指令。这些数据库接受指令后,会进行本地操作,正式提交更新余额,给小明的账户扣减 2,000 元,给小红的账户增加 2,000 元,然后向协调者返回 Yes,事务结束。
那如果小明的账户出了问题,导致转账失败,处理过程会是怎样呢?
- 第一阶段,事务管理器向所有数据库发送待执行的 SQL,并询问是否做好提交事务的准备。由于小明之前在木瓜银行购买了基金定投产品,按照约定,每月银行会自动扣款购买基金,刚好这个自动扣款操作正在执行,先一步锁定了账户。数据库 D1 发现无法锁定小明的账户,只能向事务管理器返回失败。
- 第二阶段,因为事务管理器发现数据库 D1 不具备执行事务的条件,只能向所有数据库发出“回滚”(Rollback)指令。所有数据库接收到指令后撤销第一阶段的操作,释放资源,并向协调者返回 Yes,事务结束。小明和小红的账户余额均保持不变。
2PC 的三大问题
相比于 TCC,2PC 的优点是借助了数据库的提交和回滚操作,不侵入业务逻辑。但是,它也存在一些明显的问题:
同步阻塞
执行过程中,数据库要锁定对应的数据行。如果其他事务刚好也要操作这些数据行,那它们就只能等待。其实同步阻塞只是设计方式,真正的问题在于这种设计会导致分布式事务出现高延迟和性能的显著下降。
单点故障
事务管理器非常重要,一旦发生故障,数据库会一直阻塞下去。尤其是在第二阶段发生故障的话,所有数据库还都处于锁定事务资源的状态中,从而无法继续完成事务操作。
数据不一致
在第二阶段,当事务管理器向参与者发送 Commit 请求之后,发生了局部网络异常,导致只有部分数据库接收到请求,但是其他数据库未接到请求所以无法提交事务,整个系统就会出现数据不一致性的现象。比如,小明的余额已经能够扣减,但是小红的余额没有增加,这样就不符合原子性的要求了。
你可能会问:这些问题非常致命呀,2PC 到底还能不能用?
分布式数据库的两个 2PC 改进模型
NewSQL 阵营:Percolator
首先,我们要学习的是 NewSQL 阵营的 Percolator。
Percolator 来自 Google 的论文“Large-scale Incremental Processing Using Distributed Transactions and Notifications”,因为它是基于分布式存储系统 BigTable 建立的模型,所以可以和 NewSQL 无缝链接。
Percolator 模型同时涉及了隔离性和原子性的处理。今天,我们主要关注原子性的部分,在讲并发控制时,我再展开隔离性的部分。
使用 Percolator 模型的前提是事务的参与者,即数据库,要支持多版本并发控制(MVCC)。不过你不用担心,现在主流的单体数据库和分布式数据库都是支持的 MVCC。
在转账事务开始前,小明和小红的账户分别存储在分片 P1 和 P2 上。如果你不了解分片的含义,可以回到第 6 讲学习。当然,你也可以先用单体数据库来替换分片的概念,这并不会妨碍对流程的理解。
上图中的 Ming 代表小明,Hong 代表小红。在分片的账户表中各有两条记录,第一行记录的指针(write)指向第二行记录,实际的账户余额存储在第二行记录的 Bal. data 字段中。
Bal.data 分为两个部分,冒号前面的是时间戳,代表记录的先后次序;后面的是真正的账户余额。我们可以看到,现在小明的账户上有 4,900 元,小红的账户上有 300 元。
我们来看下 Percolator 的流程。
第一,准备阶段,事务管理器向分片发送 Prepare 请求,包含了具体的数据操作要求。
分片接到请求后要做两件事,写日志和添加私有版本。关于私有版本,你可以简单理解为,在 lock 字段上写入了标识信息的记录就是私有版本,只有当前事务能够操作,通常其他事务不能读写这条记录。
你可能注意到了,两个分片上的 lock 内容并不一样。
主锁的选择是随机的,参与事务的记录都可能拥有主锁,但一个事务只能有一条记录拥有主锁,其他参与事务的记录在 lock 字段记录了指针信息“primary@Ming.bal”,指向主锁记录。
准备阶段结束的时候,两个分片都增加了私有版本记录,余额正好是转账顺利执行后的数字。
第二,提交阶段,事务管理器只需要和拥有主锁的分片通讯,发送 Commit 指令,且不用附带其他信息。
分片 P1 增加了一条新记录时间戳为 8,指向时间戳为 7 的记录,后者在准备阶段写入的主锁也被抹去。这时候 7、8 两条记录不再是私有版本,所有事务都可以看到小明的余额变为 2,900 元,事务结束。
你或许要问,为什么在提交阶段不用更新小红的记录?
Percolator 最有趣的设计就是这里,因为分片 P2 的最后一条记录,保存了指向主锁的指针。其他事务读取到 Hong:7 这条记录时,会根据指针去查找 Ming.bal,发现记录已经提交,所以小红的记录虽然是私有版本格式,但仍然可视为已经生效了。
当然,这种通过指针查找的方式,会给读操作增加额外的工作。如果每个事务都照做,性能损耗就太大了。所以,还会有其他异步线程来更新小红的余额记录,最终变成下面的样子。
现在,让我们对比 2PC 的问题,来看看 Percolator 模型有哪些改进。
数据不一致
2PC 的一致性问题主要缘自第二阶段,不能确保事务管理器与多个参与者的通讯始终正常。
但在 Percolator 的第二阶段,事务管理器只需要与一个分片通讯,这个 Commit 操作本身就是原子的。所以,事务的状态自然也是原子的,一致性问题被完美解决了。
单点故障
Percolator 通过日志和异步线程的方式弱化了这个问题。
- 一是,Percolator 引入的异步线程可以在事务管理器宕机后,回滚各个分片上的事务,提供了善后手段,不会让分片上被占用的资源无法释放。
- 二是,事务管理器可以用记录日志的方式使自身无状态化,日志通过共识算法同时保存在系统的多个节点上。这样,事务管理器宕机后,可以在其他节点启动新的事务管理器,基于日志恢复事务操作。
Percolator 模型在分布式数据库的工程实践中被广泛借鉴。比如,分布式数据库 TiDB,完全按照该模型实现了事务处理;CockroachDB 也从 Percolator 模型获得灵感,设计了自己的 2PC 协议。
CockroachDB 的变化在于没有随机选择主锁,而是引入了一张全局事务表,所有分片记录的指针指向了这个事务表中对应的事务记录。单就原子性处理来说,这种设计似乎差异不大,但在相关设计上会更有优势,具体是什么优势呢,下一讲我来揭晓答案。
PGXC 阵营:GoldenDB 的一阶段提交
那么,分布式数据库的另一大阵营,PGXC,又如何解决 2PC 的问题呢?
GoldenDB 展现了另外一种改良思路,称之为“一阶段提交”。
GoldenDB 遵循 PGXC 架构,包含了四种角色:协调节点、数据节点、全局事务器和管理节点,其中协调节点和数据节点均有多个。GoldenDB 的数据节点由 MySQL 担任,后者是独立的单体数据库。
虽然名字叫“一阶段提交”,但 GoldenDB 的流程依然可以分为两个阶段。
第一阶段,GoldenDB 的协调节点接到事务后,在全局事务管理器(GTM)的全局事务列表中将事务标记成活跃的状态。这个标记过程是 GoldenDB 的主要改进点,实质是通过全局事务列表来申请资源,规避可能存在的事务竞争。
这样的好处是避免了与所有参与者的通讯,也减少了很多无效的资源锁定动作。
第二阶段,协调节点把一个全局事务分拆成若干子事务,分配给对应的 MySQL 去执行。如果所有操作成功,协调者节点会将全局事务列表中的事务标记为结束,整个事务处理完成。如果失败,子事务在单机上自动回滚,而后反馈给协调者节点,后者向所有数据节点下发回滚指令。
由于 GoldenDB 属于商业软件,公开披露信息有限,我们也就不再深入细节了,你只要能够理解上面我讲的两个阶段就够了。
GoldenDB 的“一阶段提交”,本质上是改变了资源的申请方式,更准确的说法是,并发控制手段从锁调度变为时间戳排序(Timestamp Ordering)。这样,在正常情况下协调节点与数据节点只通讯一次,降低了网络不确定性的影响,数据库的整体性能有明显提升。因为第一阶段不涉及数据节点的操作,也就弱化了数据一致性和单点故障的问题。