数据一致性理论
在介绍分布式系统的数据一致性问题之前,我们先来了解一下副本的概念。分布式系统会存在许多异常问题,例如机器死机、网络中断、高并发下I/O瓶颈、数据库存储空间不足等。为了提供高可用服务,一般会将数据或者服务部署到很多机器上,这些机器中的数据或服务可以称为副本。将数据复制成多份不仅可以增加存储系统高可用性,而且可以增加读操作的并发性,如果其中任何一台机器出现故障,用户可以访问其他机器上的数据或服务。如何保证这些节点上的副本数据或服务的一致性,是整个分布式系统需要解决的核心问题,也就是本章提到的数据一致性问题。一般来说,数据一致性可以分成三类: 时间点一致性、事务一致性、应用一致性。 1. 时间点一致性: 也叫作副本一致性,如果所有相关的数据副本在任意时刻都是一致的,那么可以称作时间点一致性。 2. 事务一致性: 是指在一个事务执行前和执行后数据库都必须处于一致性状态。如果事务成功完成,那么系统中所有变化将正确地应用,系统处于有效状态;如果在事务中出现错误,那么系统中的所有变化将自动地回滚,系统返回到原始状态。 3. 应用一致性: 事务一致性代表单一数据源或者狭义上的数据库;广义上的数据可能包括多个异构的数据源,比如数据源有多个数据库、消息队列、文件系统、缓存等,那么就需要应用一致性,这里也称作分布式事务一致性。
数据一致性是架构师在设计一个企业应用系统数据架构时必须考虑的一个重要问题,尤其在互联网分布式架构环境下,一个完整的业务被拆分到多个分布式的应用系统中,数据一致性的问题就显得尤为突出。分布式系统采用多机器分布式部署的方式提供服务,必然存在着数据的复制。在分布式系统引入复制机制后,由于网络延时等因素,不同的数据节点之间很容易产生数据不一致的情况。复制机制的目的是保证数据的一致性,但是复制数据面临的主要难题也是如何保证多个副本之间的数据一致性。
数据一致性模型
分布式系统一般通过复制数据来提高系统的可靠性和容错性,并且将数据的不同副本存放在不同的机器中。由于维护数据副本的一致性代价高,因此许多系统采用弱一致性来提高性能,一些不同的一致性模型也相继被提出。在CAP中,P(分区容错性)无法避免,A(可用性)是所有大型业务比较看重的,那么C(一致性)如何演变呢?按照不同的角度,一致性可以分为客户端和系统端。从客户端角度看,就是客户端读写操作是否符合某种特性;从系统端角度看,就是系统更新如何复制分布到整个系统,以确保数据最终一致。
如果从客户端角度看,一致性又可以分为以下3种。
1. 强一致性(strong consistency): 在任何时候,用户或节点都可以读到最近一次成功更新的副本数据。这种一致性肯定是我们最想要的,但是很遗憾,强一致性在实践中很难实现,而且一般会牺牲可用性。
2. 弱一致性(weak consistency): 某个进程更新了副本的数据,但是系统不能保证后续进程能够读取到最新的值。比如某些缓存、网络游戏、网络电话等系统。
3. 最终一致性(eventual consistency): 最终一致性是弱一致性的一种特例。A进程更新了副本的数据,如果没有其他进程更新这个副本的数据,系统最终一定能够保证在后续进程中能够读取到A进程写入的最新值。但是这个操作存在一个不一致的窗口,也就是从A进程写入数据到其他进程读取A所写数据所需的时间。根据更新数据后各进程访问数据的时间和方式的不同,最终一致性又可以分为以下5种。
- 因果一致性(causal consistency): 如果进程A通知进程B它已更新了一个数据项,那么进程B的后续访问将返回更新后的值,且一次写入将保证取代前一次写入。与进程A无因果关系的进程C的访问遵守一般的最终一致性规则。
- 读写一致性(read-your-writes consistency): 当进程A自己更新一个数据项之后,它总是访问到更新过的值,绝不会访问到旧值。这是因果一致性的一个特例。
- 会话一致性(session consistency): 这是读写一致性的实用版本,它把访问存储系统的进程放到会话的上下文中。只要会话还存在,系统就能保证读写一致性。如果由于某些失败情形令会话终止,就要建立新的会话,而且系统的保证不会延续到新的会话。
- 单调读一致性(monotonic read consistency): 如果进程已经看到过数据对象的某个值,那么任何后续访问都不会返回在那个值之前的值。
- 单调写一致性(monotonic write consistency): 系统保证来自同一个进程的写操作按顺序执行。
当然,还存在其他的一些一致性变体,这里不再赘述。在实践中,这5种一致性方案往往会结合使用,以构建一个具有最终一致性的分布式系统。实际上,不只是分布式系统使用最终一致性,关系型数据库在某个功能上也是使用最终一致性的,比如备份,数据库的复制过程是需要时间的,在复制过程中,业务读取到的值就是旧值。当然,最终还是达成了数据一致性,这也算是最终一致性的一个经典案例。
数据一致性原则
数据一致性实现指导
分布式系统架构的第一原则是不要分布,同理,分布式数据一致性的第一原则也是尽量避免分布式。分布式环境下的数据一致性问题(尤其是数据强一致性)向来是一个对业界充满挑战的技术难题,所以能通过业务方式避免的,尽可能通过业务方式或者管理流程解决,或者通过数据下沉、数据归集等方式避免分布式事务。
有些分布式事务问题看起来很重要,但实际上我们可以通过合理的设计或者将问题分解来规避。设计分布式事务系统并不需要考虑所有异常情况,不必过度设计各种回滚和补偿机制。如果硬要把时间花在解决问题本身上,实际上不仅效率低下,而且是一种浪费。如果系统要实现回滚机制,系统复杂度将可能大大提升,且很容易出现bug,估计出现bug的概率会比需要事务回滚的概率大很多。因此在设计系统时,我们需要衡量是否值得付出这么大的代价来解决这样一个出现概率非常小的问题,可以考虑当出现这个概率很小的问题时,能否采用人工的方式解决,这也是架构师在解决疑难问题时需要多多思考的地方。
核心业务逻辑长事务尽量改为短事务。因为长事务会长期占有行锁,对于高并发的业务来说会存在性能提升的瓶颈,任何类型的数据库都应该避免这种模式。
根据单机数据库和分布式数据库、系统对于数据一致性时效要求的不同,以及实际应用场景的不同,数据一致性的问题可分为下图所示的几种实现方案。
单库事务
单库事务通过关系型数据库本身自带的锁机制(表锁/行锁)来保证ACID特性,确保同一时间只有一个事务能够对特定的资源进行更新或读取。处理加锁的机制会让数据库产生额外的开销,增加产生死锁的机会,还会降低并行性。一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数据。锁的机制损失了并发性能,但对数据一致性是一种简单有效的实现方式。
数据库唯一索引,一般情况下应用程序会做数据判重校验,但程序难免会出差错,万一写入重复数据,就会污染数据,甚至导致故障或资损。建议在设计域模型和数据库模型时要尽可能地识别出唯一属性和属性组,将其设置为唯一索引,作为防止数据重复的最后一道屏障。
分布式数据一致性
分布式事务,严格意义上讲也是最终一致性的实现,目前主流分布式事务产品都是两阶段提交,第一阶段称为准备阶段,第二阶段称为提交/回滚阶段。模型中包含3个角色(或类似)。 1. AP: 事务发起者,一般是业务应用。 2. RM: 资源管理器,定义参与事务资源和行为。 3. TM: 事务管理器,协调RM的行为,例如准备、提交和回滚等。
最终一致性一般通过消息中间件对分布式事务进行解耦,利用消息的失败重试机制以及业务补偿机制来最终达到分布式应用的数据一致性。最终一致性的另一个实现手段是redo方案,主要解决调用下游系统失败时,特别是网络异常的情况下,本地数据状态不一致的问题。大体思路是将对外部系统的请求以redo数据的方式和本地业务数据在事务中落库,通过redo驱动外部系统的数据状态,达到最终一致。redo支持异常重试、超时告警、时序控制等,缺点是如果下游业务执行失败,不支持本地事务的回滚。
根据下游系统提供的接口方式选择消息或者redo方案,下游系统提供同步服务调用的建议优先选择消息队列异步解耦方案。如果要求时序,则选择redo方案;如果要求事务回滚,则选择分布式事务。
分布式系统中必须要保证API的调用幂等,本质是一个操作无论执行多少次,执行结果都是一致的,一般由调用方传入幂等号,最好是链路全局唯一序列号作为幂等号。
数据对账
对账是数据一致性的最终保障,一般分为实时对账、准实时对账和T+1全量对账,数据一致性要求我们选用不同的对账方案。以企业数据可用视角看,所有业务数据都应该保证数据的一致性,都需要做全量数据的对账,对于资金这一类数据正确性和一致性要求非常高的数据,需要做实时对账,以保证正确地产生每笔数据。
实时对账是由应用对链路上的数据进行核对,核对成功才会提交本地的数据,否则回滚并执行失败逻辑。如果是应用内部逻辑,则进行内部核对;如果需要跨系统核对,则调用第三方的对账平台进行核对,由第三方的对账平台调用其他应用收集数据并核对,并将核对结果返回调用方,调用方根据核对结果执行逻辑。
准实时对账,属于分钟级对账。当我们既不想影响业务流程和用户体验,又想尽快发现可能出现的问题时,可以采用准实时对账。准实时对账一般通过数据库的binlog触发专门的对账程序进行异步对账处理。
T+1全量数据对账,实时对账只能对应用产生的新数据进行对账。如果有非应用产生的数据(比如运维工具的数据订正、数据同步等),则无法保证数据的一致性,这就需要离线的全量数据对账来保证。
数据拆分原则
当数据存储选用MySQL(或Oracle)这种传统关系型数据库,而单机MySQL(或Oracle)无法满足存储数据的容量要求或性能要求时,就要考虑进行数据库拆分。数据库拆分的基本思想是把一个数据库拆分成多个部分放到不同的数据库上,从而缓解单一数据库的性能问题。 一般来说,拆分的方式有两类。 1. 垂直拆分。简单来说就是一个数据库的多张数据表拆分到多个数据库。垂直拆分的好处是隔离应用间的相互影响,减少数据间的强耦合;但坏处是需要关联操作数据时,可能会带来跨库事务,在性能上有所损耗,同时加大了关联查询的难度。 2. 水平拆分。简单来说就是一张数据表的数据拆分到同一个数据库的多张数据表或者多个数据库的多张数据表。水平拆分的好处是减少了单表数据量,有利于数据库表更新维护等,因此单表容量需要根据实际情况评估,不建议过大。
在企业级互联网架构中,这两种拆分方式经常会被使用。不严格地讲,对于海量数据的数据库,如果是因为表多而数据多,则适合使用垂直切分,即把关系紧密(比如同一模块)的表拆分出来放在一个数据库上。如果表并不多,但每张表的数据非常多,则适合水平切分,即把表的数据按某种规则(如按ID哈希)拆分到多个数据库上。当然,现实中更多的是这两种情况混杂在一起,这时需要根据实际情况做出选择,也可能会综合使用垂直拆分与水平拆分,从而将原有数据库拆分成类似于矩阵一样可以无限扩充的数据库阵列。将单表的大小控制在一个较低的水位上,这样做有利于表结构变更、数据库备份效率以及减小单机故障,同时合理的拆分也有利于后期性能的水平扩展,充分体现云数据库带来的技术红利。
读写分离
对于读多写少的场景,尽量采用数据读写分离的架构进行数据拆分,以保证高并发读压力下对于写操作的及时响应。
拆分键选择
数据库水平拆分前需要确定拆分字段(也可以称为“拆分键”),拆分键的选择应遵循以下原则。 1. 值分布均匀: 选择值分布均匀的字段作为拆分键,避免数据倾斜风险。 2. 使用高频: 选择高频使用的字段作为拆分键。根据查询重要程度或者查询性能要求,选择某列作为分库列,这样可以保证基于分库列的关联查询具有较好的性能。 3. 列值稳定: 选择列值稳定的列作为拆分键。拆分因子值不可直接修改,改变列值必须删除后新增插入。 4. 列值非空: 选择列值非空列作为拆分键,空值过多将导致数据不均衡、数据倾斜的问题。 5. 同事务同源: 同一业务事务的一组数据按不同拆分因子取模分库分表后应放在一起,以避免数据库事务跨物理库。例如某日常业务的数据事务涉及A表的α数据和B表的β数据,分库后α数据和β数据应该在同一个库中,即当其他分库异常时,不影响α数据和β数据相关业务。也就是说,避免数据库中的数据依赖另一数据库中的数据。
热点数据处理
短时间内对同一份数据的大量读写(如秒杀、春运抢票)会带来热点数据的问题。大量请求同时更新数据库中的同一个数据,update操作给表加上行锁,导致后面的请求全部排队等待前面一个update完成,释放行锁后才能处理下一个请求。大量后续请求等待占用了数据库的连接,一旦数据库连接数被占满,就会导致后续的全部请求因无法连接而超时,业务请求出现无法及时处理,数据库系统的RT会异常飙高,业务层由于等待出现超时,App层的连接耗尽等一系列雪崩效应!
按照产生原因,热点写的问题又可以细分为如下两类。 1. 数据库写的高并发请求量过大,导致数据库整体无法承受。 2. 数据库中某些更新特别频繁的热点数据,在加了排他锁的情况下(比如,在账务等系统中更新特别频繁的热点系统账户),在高并发的更新请求的情况下,由于锁竞争引起线程等待,导致系统响应变慢。 对于第1类问题,数据库整体并发写请求量过大,解决方案可以是数据库拆分。将写入的数据库由一个变为多个,以降低每个数据库承担的写请求量。
对于第2类问题,在进行数据架构设计时,一般实际应用场景采用锁拆分、缓冲更新两种解决方案。锁拆分的原理是将一个热点记录拆分成多个记录,降低数据库单行记录上的请求并发数,但更新请求总数是不变的。比如,账务系统中把某个热点账户拆分成收入户和支出户,就是使用的这种模式。缓冲更新模式,就是在一段时间内停止对于热点记录(一般如系统账户的账户余额字段)的update操作,而只做变更明细的insert操作,之后将变更明细汇总,更新系统账户。变"多次update"为"多次insert + 一次update",从而避免了热点账户的更新操作的锁竞争。
热点读的问题,其本质是数据库无法承受读的高并发请求。要解决热点读的问题,一般有两种解决方案。 1. 在数据源层面解决,利用数据库的读写分离,用多台只读数据库分担读的高并发请求。 2. 在应用层面解决,使用缓存来分担读的高并发请求。
锁机制
锁主要用来解决并发问题,是网络数据库中的一个非常重要的概念。当多个用户同时对数据库并发操作时,会带来数据不一致的问题,所以,锁主要用于多用户环境下保证数据库完整性和一致性。
悲观锁与乐观锁
通过锁的方式解决数据库并发问题有两种方式: - 一是悲观锁(独占锁), - 二是乐观锁(可重入锁)。
悲观锁
悲观锁就是MySQL中常用的for update,绝大部分数据库可以支持该特性,就是给数据表或记录加上独占锁,其他会话(session)想写这个数据时就会被挂起,只有获取这个数据独占锁的会话才有权限写入这个数据。悲观并发控制实际上是"先取锁再访问"的保守策略,为数据处理的安全提供了保障。这样就存在大量请求挂起,竞争锁的场景对于CPU是极大的消耗,效率比较低下,但这个方案实现非常简单,对并发要求不高的应用是不二选择,这种数据库独占锁的方式的一般原则为: 一锁二判三更新。悲观锁又可分为共享锁、排他锁和更新锁3种情况。
1. 共享锁又称为读锁,简称S锁。共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。
2. 排他锁又称为写锁,简称X锁。排他锁就是不能与其他锁并存,如果一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,只有获取排他锁的事务才可以对数据行读取和修改。
3. 更新锁,简称U锁。在修改操作的初始化阶段用来锁定可能要被修改的资源,这样可以避免使用共享锁造成的死锁现象。
乐观锁
乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况下不会冲突,所以在数据进行提交更新时,才会正式对数据的冲突与否进行检测。如果发现冲突了,则返回给用户错误消息,让用户决定如何去做。在对数据库进行处理时,乐观锁并不会使用数据库提供的锁机制。一般实现乐观锁的方式是记录数据版本,给数据添加一个版本标识,每当有线程对其进行修改,就把版本加1。这样当线程进行非原子操作时,一开始就保存了版本号,进行到修改数据时比较一下最新的版本号和旧版本号是否一样,一样就修改,不一样就重试或者失败。乐观并发控制相信事务之间数据竞争的概率是比较小的,因此尽可能直接执行下去,直到提交时才去锁定,这样不会产生任何锁和死锁。
在乐观锁的概念中其实已经阐述了它的具体实现细节,主要分为两个步骤: 冲突检测和数据更新。其比较典型的实现方式之一是CAS。CAS是一项乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,其他线程都失败,失败的线程并不会被挂起,而是被告知在这次竞争中失败,并可以再次尝试。
数据库锁
数据库锁是解决单库事务的主要工具,按锁的范围,分为表锁、行锁和页级锁3种。表锁的作用范围是整张表,行锁的作用范围是行级,页级锁是MySQL中锁定粒度介于行锁和表锁中间的一种锁。表锁速度快,但冲突多;行锁冲突少,但速度慢;页级锁是前两者的折中,一次锁定相邻的一组记录。BDB支持页级锁,页级锁的开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。
数据库能够确定在哪些行需要锁的情况下使用行锁,如果不知道会影响哪些行,就使用表锁。举个例子,一个用户表user,有主键id和用户生日birthday。当你使用update …where id=?这样的语句时,数据库明确知道会影响哪一行,它就会使用行锁;当你使用update … where birthday=?这样的语句时,因为事先不知道会影响哪些行,就可能会使用表锁。
在使用数据库锁来进行并发控制时,如果处理不好,经常会发生死锁的情况。死锁就是两个事务我等你,你又等我,双方就会一直等待下去。比如,T1锁住了数据R1,正请求对R2加锁,而T2锁住了R2,正请求对R1加锁,这样就会导致死锁。死锁没有完全解决的方法,只能尽量预防。
1. 一次加锁法: 指一次性把所需要的数据全部锁住,但这样会扩大加锁的范围,降低系统的并发度。
2. 顺序加锁法: 如果不同程序会并发存取多个表,那么尽量约定以相同的顺序访问表,这样可以大大减少死锁发生。顺序加锁是指事先对数据对象指定一个加锁顺序,要对数据进行加锁,只能按照规定的顺序来加锁,但是这对使用有限制。
3. 锁范围升级: 对于非常容易产生死锁的业务部分,可以尝试使用升级锁定粒度,通过表级锁定来降低死锁发生的概率。
分布式锁
数据库锁只能解决多个事务对同一个数据库的资源访问冲突问题,但现在主流的系统是分布式系统。单机多线程的同步锁机制(如ReentrantLock或synchronized)无法控制分布式环境下多个节点的并发,数据库也被垂直或水平拆分为多个库。此外,一次交易请求过程中,访问的资源可能不仅有数据库,还有缓存、消息队列、非结构化数据等,这些也是数据的不同格式。为了防止分布式系统中的多个进程之间相互干扰,我们需要一种分布式协调技术来对这些进程进行调度,分布式协调技术的核心就是实现这个分布式锁。分布式锁是控制分布式系统之间同步访问共享资源的一种方式,分布式锁应该具备以下这些条件。
1. 在分布式系统环境下,一个方法在同一时间内只能被一台机器的一个线程执行。
2. 高可用、高性能地获取锁与释放锁。
3. 具备可重入特性(避免死锁)。
4. 具备锁失效机制,防止程序异常退出且未及时释放锁。
5. 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。
分布式锁要解决3个核心要素: 加锁、解锁、锁超时。目前主流的分布式锁的实现主要有以下4种产品。
1. Memcached: 利用Memcached的add命令。此命令是原子性操作,只有在key不存在的情况下才能add成功,也就意味着线程得到了锁。
2. Redis: 和Memcached的方式类似,利用Redis的setnx命令。此命令同样是原子性操作,只有在key不存在的情况下,才能成功。
3. ZooKeeper: 利用ZooKeeper的顺序临时节点,来实现分布式锁和等待队列。ZooKeeper设计的初衷,就是为实现分布式锁服务的。
4. Chubby: 谷歌实现的粗粒度分布式锁服务,底层利用了Paxos一致性算法。
数据一致性解决方案
数据一致性分为多副本数据一致性和分布式事务数据一致性,两者的差别在于,多副本下不同节点之间的数据内容是一样的,而分布式事务下不同节点之间的数据内容是不一样的。数据多副本一般用于容灾及高可用,副本之间通过同步复制或者异步复制的方式达到数据一致。本节重点要介绍的是分布式事务下的数据一致性问题。
事务提供了一种机制,将一个活动涉及的所有操作纳入一个不可分割的执行单元,组成事务的所有操作只有在所有操作均能正常执行的情况下才能提交,其中任一操作执行失败,都将导致整个事务的回滚。按参与方的个数和性质,事务分为本地事务和分布式事务。
本地事务指数据库单机的事务处理,优点是支持严格的ACID特性:高效、可靠、状态可以只在资源管理器中维护、应用编程模型简单。但是本地事务不具备分布式事务的处理能力,隔离的最小单位受限于资源管理器。一般的关系型数据库可以较好实现本地事务的ACID特性。
分布式事务指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点上。简单来说,就是一次大的操作由不同的小操作组成,这些小操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。
事务的参与方可能不仅是数据库,还包括消息队列、缓存、对象存储等其他异构的数据源,当事务由全局事务管理器进行全局管理时成为全局事务,事务管理器负责管理全局的事务状态和参与的资源,协同资源的一致提交回滚。
强一致性解决方案
在强一致性的分布式环境下,一次交易请求对多个数据源的数据执行完整性以及一致性的操作,满足事务的特性,要么全部成功,要么全部失败,保证原子性以及可见性。强一致性通过锁定资源的方式确保分布式并发下数据不会产生脏读脏写的情况,但以牺牲性能为代价。一般来说,强一致性的分布式事务会比单机的本地事务性能下降一个数量级左右,因此在实际应用场景中使用时,需要谨慎评估业务上是否一定要求强一致性事务,可否在业务上做一些取舍和折中,或者改为性能更强一点的最终一致性方案。
XA分布式事务
说到强一致性方案,必须先了解数据库分布式事务中的XA协议。XA协议是全局事务管理器与资源管理器的接口。XA是由X/Open组织提出的分布式事务规范,该规范主要定义了全局事务管理器(TM)和本地资源管理器(RM)之间的接口,本地资源管理器往往由数据库实现。主流的数据库产品都提供了XA接口,XA接口是一个双向的系统接口,在事务管理器以及多个资源管理器之间作为通信桥梁。之所以需要XA,是因为在分布式系统中从理论上讲两台机器是无法达到一致性状态的,因此引入一个单点进行协调。由全局事务管理器管理和协调的事务可以跨越多个资源和进程,负责各个本地资源的提交和回滚。全局事务管理器一般使用XA二阶段提交(Two-Phase Commit,2PC)协议与数据库进行交互。XA实现分布式事务的原理如图所示。
XA协议用于在全局事务中协调多个资源的机制,TM和RM之间采取二阶段提交的方案来解决一致性问题。二阶段提交需要一个协调者(TM)来掌控所有参与者(RM)节点的操作结果,并且指引这些节点是否需要最终提交。总的来说,XA协议比较简单,而且一旦商业数据库实现了XA协议,使用分布式事务的成本也会降低。但是,XA协议也有致命的缺点,那就是性能不理想,XA协议无法满足高并发场景。XA协议目前对商业数据库的支持比较理想,对开源MySQL数据库的支持不太理想,MySQL的XA协议实现中没有记录准备阶段日志,主备切换会导致主库与备库数据不一致。许多NoSQL也没有支持XA协议,这让XA协议的应用场景受到很大的限制。
XA分布式事务协议,包含二阶段提交(2PC)和三阶段提交(Three-Phase Commit,3PC)这两种实现。
二阶段提交
- 准备阶段
事务协调者向所有事务参与者发送事务内容,询问是否可以提交事务,并等待参与者回复。事务参与者收到事务内容,开始执行事务操作,将
undo和redo信息记入事务日志中(但此时并不提交事务)。如果参与者执行成功,向协调者回复yes,表示可以进行事务提交;如果参与者执行失败,向协调者回复no,表示不可以提交。 - 提交阶段
如果协调者收到了参与者的失败信息或超时信息,直接给所有参与者发送回滚(
rollback)信息进行事务回滚;否则,发送提交(commit)信息。参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源(注意: 必须在最后阶段释放锁资源),并向协调者反馈应答消息(ack),协调者收到所有参与者反馈的ack消息后,即完成事务提交。
2PC方案实现起来简单,在实际项目中使用比较少,主要因为以下问题。
- 性能问题: 所有参与者在事务提交阶段处于同步阻塞状态,占用系统资源,容易导致性能瓶颈。
- 可靠性问题: 如果协调者存在单点故障问题,一旦协调者出现故障,那么参与者将一直处于锁定状态。
- 数据一致性问题: 在第二阶段中,如果发生局部网络问题,一部分事务参与者收到了提交消息,另一部分没收到提交消息,那么会导致节点之间数据的不一致。
三阶段提交
三阶段提交是在二阶段提交基础上的改进版本,主要是加入了超时机制,同时在协调者和参与者中都引入超时机制。三阶段将二阶段的准备阶段拆分为两个阶段,插入了一个preCommit阶段,以此来处理原先二阶段参与者准备后,参与者发生崩溃或错误,导致参与者无法知晓是否提交或回滚的不确定状态所引起的延时问题。三阶段提交处理流程如图所示。
- 第一阶段:
canCommit。 协调者向所有参与者发出包含事务内容的canCommit请求,询问是否可以提交事务,并等待所有参与者回复。参与者收到canCommit请求后,如果认为可以执行事务操作,则反馈yes并进入预备状态(参与者不执行事务操作),否则反馈no。 - 第二阶段:
preCommit。 协调者根据第一阶段canCommit中参与者的响应情况来决定是否可以进行基于事务的preCommit操作。根据响应情况,有以下两种处理情况。
只要有任何一个参与者反馈no,或者等待超时后协调者仍无法收到所有参与者的反馈,即中断事务,协调者向所有参与者发出abort请求。无论是收到协调者发出的abort请求,还是在等待协调者请求过程中出现超时,参与者均会中断事务。
如果所有参与者均反馈yes,那么协调者会向所有参与者发出preCommit请求,进入准备阶段。当参与者收到preCommit请求后,执行事务操作,将undo和redo信息记入事务日志中(但不提交事务)。各参与者向协调者反馈ack响应或no响应,并等待最终指令。
3. 第三阶段: doCommit。
该阶段进行真正的事务提交,也可以分为以下两种情况。
第二阶段中所有参与者均反馈ack响应,执行真正的事务提交,向所有参与者发出doCommit请求。当参与者收到doCommit请求后,会正式执行事务提交,并释放整个事务期间占用的资源。各参与者向协调者反馈ack完成的消息。协调者收到所有参与者反馈的ack消息后,即完成事务提交。
第二阶段中只要有任何一个参与者反馈no,或者等待超时后协调者仍无法收到所有参与者的反馈,即中断事务,向所有参与者发出abort请求。参与者使用第一阶段中的undo信息执行回滚操作,并释放整个事务期间占用的资源。各参与者向协调者反馈ack完成的消息,协调者收到所有参与者反馈的ack消息后,即完成事务中断。
注意,进入第三阶段后,无论是协调者出现问题,还是协调者与参与者网络出现问题,都会导致参与者无法接收到协调者发出的doCommit请求或abort请求。此时,参与者都会在等待超时之后,继续执行事务提交(因为异常的情况毕竟是极少数的)。
相比第二阶段提交,第三阶段提交降低了阻塞范围,在等待超时后协调者或参与者会中断事务。避免了协调者单点问题,当第三阶段中协调者出现问题时,参与者会继续提交事务。但数据不一致问题依然存在,当参与者在收到preCommit请求后等待doCommit指令时,此时如果协调者请求中断事务,而协调者无法与参与者正常通信,会导致参与者继续提交事务,造成数据不一致。目前主流分阶段提交协议实际上也很难做到百分百的数据一致性,最终还是要采用"异步校验+人工干预"的手段来保障。
弱一致性解决方案
数据一致性在严格意义上只有两种分法,强一致性与弱一致性。强一致性也叫作线性一致性,除此之外,其他所有的一致性都是弱一致性的特殊情况。所谓强一致性,即复制是同步的;弱一致性,即复制是异步的。最终一致性是弱一致性的一种特例,保证用户最终能够读取到某操作对系统特定数据的更新。
弱一致性主要针对数据读取而言,为了提升系统的数据吞吐量,允许一定程度上的数据"脏读"。某个进程更新了副本的数据,但是系统不能保证后续进程能够读取到最新的值。典型场景是读写分离,例如对于一主一备异步复制的关系型数据库,如果读的是备库(或者只读库),就可能无法读取主库已经更新过的数据,所以是弱一致性。由于数据库读写分离是一种比较成熟的方案,因此此处不再赘述弱一致性解决方案。
最终一致性解决方案
由于强一致性的技术实现成本很高,运行性能低,很难满足真实业务场景下高并发的要求,因此在实际生产环境中,通常采用最终一致性的解决方案。最终一致性不追求任意时刻系统都能满足数据完整且一致的要求,系统本身具有一定的"自愈"能力,通过一段业务上可接受的时间之后,系统能够达到数据完整且一致的目标。最终一致性有很多解决方案,如通过消息队列实现分布式订阅处理、数据复制、数据订阅、事务消息、尝试-确认-取消(Try-Confirm- Cancel,TCC)事务补偿等不同的方案。下面分别介绍几种典型的最终一致性方案,读者可以结合项目实际的应用场景选择合适的实现方案。
消息队列方案
如图所示,该方案的核心要点是建立两个消息topic,一个用来处理正常的业务提交,另一个用来处理异常冲正消息。请求服务往业务提交topic发送正常的业务执行消息,不同的业务模块各自订阅消费该topic并执行正常的业务逻辑。如果所有的业务执行都正常,数据自然也是完整一致的;如果业务执行过程中有任何异常,则向业务冲正topic发送异常冲正消息,通知其他的业务执行模块进行冲正回滚,以此来实现数据的最终一致性。该方案无法解决脏读、脏写等问题,使用时需要在业务上做一定的取舍。
事务消息方案
如图所示,该方案的核心要点是消息队列产品必须支持半事务消息(如RocketMQ)。半事务消息提供类似于X/Open XA二阶段提交的分布式事务功能,这样能够确保请求服务执行本地事务和发送消息在一个全局事务中,只有本地事务成功执行后,消息才会被投递。该方案不会产生脏读、脏写等问题,且实现起来比较简单,但要求消息队列产品必须支持半事务消息,且消息一旦投递,默认设计业务执行必须成功(通过重试机制)。如果因为某些异常导致业务执行最终失败,系统无法自愈,则只能通过告警的方式等待人工干预。
数据订阅方案
如图所示,该方案的核心要点是数据订阅产品[如阿里巴巴的数据传输服务(Data Transmission Service,DTS)、Data Replication Center(DRC)]能够接收数据库的更新日志(如MySQL的binlog、Oracle的归档日志)并转化为消息流供消费端进行订阅处理。业务执行模块消费数据变更消息并执行变更同步处理逻辑,以达到数据的最终一致性。由于数据订阅是异步的,会存在一定的消息延迟,且延迟时间依赖数据的变更量及数据订阅处理的性能。
TCC事务补偿
如图所示,TCC是服务化的二阶段编程模型,其尝试(try)、确认(confirm)、取消(cancel)这3个阶段均由业务编码实现。
1. try阶段: 尝试执行业务,完成所有业务的检查,实现一致性;预留必需的业务资源,实现准隔离性。
2. confirm阶段: 真正执行业务,不做任何检查,仅适用于try阶段预留的业务资源,confirm操作还要满足幂等性。
3. cancel阶段: 取消执行业务,释放try阶段预留的业务资源,cancel操作要满足幂等性。
TCC与2PC协议的区别是TCC位于业务服务层,而不是资源层,TCC没有单独的准备阶段,try操作兼具资源操作与准备的能力,TCC中try操作可以灵活地选择业务资源,锁定粒度。TCC的开发成本比2PC协议高,实际上TCC也属于2PC操作,但是TCC不等同于2PC操作,如图所示。
TCC事务补偿机制相对于传统事务二阶段提交机制(X/Open XA)有以下优点。
1. 性能提升: 具体业务来实现控制资源锁的粒度变小,不会锁定整个资源。
2. 数据最终一致性: 基于confirm和cancel的幂等性,保证事务最终完成确认或者取消,保证数据的一致性。
3. 可靠性: 解决了XA协议的协调者单点故障问题,由主业务方发起并控制整个业务活动,业务活动管理器也变成多点,引入集群。
缺点是
TCC的try、confirm和cancel操作功能要按具体业务来实现,业务耦合度较高,提高了开发成本。
Saga事务模式
如图所示,Saga事务源于1987年普林斯顿大学的Hecto和Kenneth发表的关于如何处理长活事务(long lived transaction)的论文。
Saga事务核心思想是将长事务拆分为多个本地短事务,由Saga事务协调器协调,如果正常结束,则正常完成;如果某个步骤失败,则根据相反顺序一次调用补偿操作,达到事务的最终一致性。Saga事务基本协议如下。
1. 每个Saga事务由一系列幂等的有序子事务(sub-transaction)Ti组成。
2. 每个Ti都有对应的幂等补偿动作Ci,补偿动作用于撤销Ti造成的结果。
可以看到,和TCC相比,Saga没有"预留"动作,它的Ti就是直接提交到库。Saga事务不能保证隔离性,需要在业务层控制并发,适合于业务场景事务并发操作同一资源较少的情况。相比TCC,Saga缺少预提交动作,导致补偿动作的实现比较麻烦,例如业务是发送短信,补偿动作则要再发送一次短信说明撤销,用户体验比较差。Saga事务较适用于补偿动作容易处理的场景。
Seata全局事务中间件
2019年1月,阿里巴巴中间件团队发起了开源项目Fescar,和社区一起共建开源分布式事务解决方案,这就是阿里云全局事务服务(Global Transaction Service,GTS)商业产品的开源版实现。Fescar的愿景是让分布式事务的使用像本地事务的使用一样简单和高效,并逐步解决开发者遇到的分布式事务方面的所有难题。Fescar开源后,蚂蚁金服加入Fescar社区参与共建,并在Fescar 0.4.0版本中贡献了TCC模式。为了打造更中立、更开放、生态更加丰富的分布式事务开源社区,经过社区核心成员的投票,大家决定对Fescar进行品牌升级,并更名为Seata——一套一站式分布式事务解决方案。
Seata融合了阿里巴巴和蚂蚁金服在分布式事务技术上的积累,并沉淀了新零售、云计算和新金融等场景下丰富的实践经验。Seata采用事务补偿机制,无须锁定资源,因此性能比XA事务的2PC机制有了很大的提升,如图所示。
Seata的补偿事务由TM在运行过程中自动生成,对应用的侵入性非常小。Seata主要由3个重要组件组成。
1. Transaction Coordinator(TC): 管理全局的分支事务的状态,用于全局性事务的提交和回滚。XID是全局事务的唯一标识,由ip:port:sequence组成。
2. Transaction Manager(TM): 事务管理器,用于开启全局事务,以及提交或者回滚全局事务,是全局事务的开启者。
3. Resource Manager(RM): 资源管理器,用于分支事务上的资源管理,向TC注册分支事务,上报分支事务的状态,接受TC的命令来提交或者回滚分支事务。