10.软件架构设计:大型网站技术架构与业务架构融合之道 --- 事务一致性
Posted enlyhua
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了10.软件架构设计:大型网站技术架构与业务架构融合之道 --- 事务一致性相关的知识,希望对你有一定的参考价值。
第10章 事务一致性
一致性问题分为两类:
1.事务一致性
2.多副本一致性
10.1 随处可见的分布式事务问题
分布式时代,数据库的单机机制不管用了,因为数据库本身只能保证单机事务,对于分布式事务,只能靠业务系统解决。
10.2 分布式事务解决方案汇总
10.2.1 2PC
1.2PC理论
在讲mysql binlog 和 redo log 的一致性问题时,已经提到2pc。当然,那个场景只是内部的分布式事务问题,只涉及单机的两个日志文件之间的数据一致性;
2pc是应用在两个数据库或两个系统之间的。
2pc有两个角色:事务协调者和事务参与者。具体到数据库的实现来说,每一个数据库就是一个参与者,调用方也就是协调者。2pc是指事务的提交分为两个阶段。
a) 准备阶段
协调者向各个参与者发起询问,说要执行一个事务,各参与者可能回复yes,no或者超时。
b) 提交阶段
如果所有参与者都回复的是yes,则事务协调者向所有参与者发起事务提交操作,即Commit操作,所有参与者各自执行事务,然后发送ack。
如果有一个参与者回复的是no,或者超时了,则事务协调者向所有参与者发起事务回滚操作,所有参与者各自回滚事务,然后发送ack。
2.2PC的实现
通过分析发现,要实现2PC,所有参与者都要实现三个接口:Prepare,Commit,Rollback,这也就是XA协议。
3.2PC的问题
问题1:性能问题。
在阶段1,锁定资源之后,要等所有节点返回,然后才能一起进入阶段2,不能很好的应对高并发场景。
问题2:阶段1完成之后,如果在阶段2事务协调者宕机,则所有的参与者接收不到Commit或Rollback指令,将处于"悬而不决"的状态。
问题3:阶段1完成之后,在阶段2,事务协调者向所有的参与者发送了Commit指令,但其中一个参与者超时或者出错了(没有正确返回ack),则其他参与者提交还是
回滚呢?也不能确定。
为了解决2PC问题,又引入了3PC。
2PC除了本身的算法局限外,还有一个使用上的限制,就是它主要是在两个数据库之间(数据库实现了XA协议)。但以支付宝为例,是两个系统之间的转账,而不是底层两个
数据库之间直接交互,所以没办法使用2PC。
10.2.2 终一致性(消息中间件)
一般的思路是通过消息中间件来实现"最终一致性"。
系统A收到用户的转账请求,系统A先自己扣钱,也就是更新DB1;然后通过消息中间件给系统B发送一条加钱的消息,系统B收到此消息,对自己的账户进行加钱,也就是更新
DB2。
这里有一个关键的问题:
系统A给消息中间件发消息,是一次网络交互;更新DB1,也是一次网络交互。系统A是先更新DB1,后发送消息,还是先发送消息,后更新DB1?
来看下最终一致性的几种具体实现思路:
1.最终一致性:错误的方案 0
有人可能会想,可以把"发送加钱消息"这个网络调用和更新db1放在同一个事务里,如果发消息失败,更新db自动回滚。这样不就可以保证两个操作的原子性吗?
这个方案看似正确,其实是错误的,原因有2个:
a) 网络2将军问题:发消息失败,发送方并不知道是消息中间件没有收到消息,还是消息收到了,只是返回response的时候失败了?
如果已经收到消息了,而发送端却认为没有收到,执行update db的回滚操作,会导致账户A的钱没有扣,账户B的钱却被加了。
b) 把网络调用方在数据库事务里,可能会因为网络的延迟导致数据库长事务。验证的会影响阻塞整个数据库,风险很大。
2.最终一致性:第1种实现方式(业务方自己实现)
假设消息中间件没有提供 "事务消息",比如用的是kafka。如何解决这个问题?
实现如下:
1.系统A增加一张消息表,系统A不再直接给消息中间件发送消息,而是把消息写到这张消息表里。把DB1的扣钱操作(表1)和写入消息表(表2)这2个操作放
在同一个数据库事务里,保证两者的原子性。
2.系统A准备一个后台程序,源源不断的把消息表中的消息传递给消息中间件。如果失败了,也不断尝试重传。因为网络2将军的问题,系统A发送给消息中间件
的消息网络超时了,消息中间件可能已经收到消息,也可能没有收到。系统A会再次尝试发送该消息,直到消息中间件返回成功。所以,系统A允许消息重复,
但消息不会丢失,顺序也不会打乱。
3.通过上面两个步骤,系统A保证了消息不丢失,但消息可能重复。系统B对消息的消费要解决下面两个问题:
问题1:丢失消费。
系统B从消息中间件取出消息(此时还在内存中),如果处理了一半,系统B宕机并再次重启,此时这条消息未处理成功,怎么办?
答案是通过消息中间件的ack机制,凡是发送ack的消息,系统B重启之后消息中间件不会再次推送;凡是没有发送ack的消息,系统B重启之后消息
中间件会再次推送。
但这又会引起新问题,就是下面的重复消费:即使系统B把消息处理成功了,但是正要发送ack的时候宕机了,消息中间件以为这条消息没有处理成功,
系统B再次重启的时候又会收到这条消息,系统B就会重复消费这条消息(对应加钱场景,就会加2次)。
问题2:重复消息。
除了ack机制,可能会引起重复消费;系统A的后台任务也可能给消息中间件重复发送消息。
为了解决重复消费消息,系统B增加了一张判重表。判重表记录了处理成功的消息ID和消息中间件对应的offset(以kafka为例),系统B宕机重启,
可以定位到offset位置,从这之后开始继续消费。
每次接收到消息,先通过判重表进行判重,实现业务的幂等。同样,对DB2的加钱操作和消息写入判重表两个操作,要在一个DB的事务里面完成。
这里需要补充的是,消息的判重不止判重表一种办法。如果业务本身就具有业务数据,可以判断出消息是否重复了,就不需要判重表了。
通过上面三个步骤,实现了消息在发送方的不丢失,在接收方的不重复,联合起来就是消息的不漏不重,严格实现了系统A和系统B的最终一致性。
但这个方案有一个缺点:系统A需要增加消息表,同时还需要一个后台任务,不断扫描此消息表,会导致消息的处理和业务逻辑耦合,额外增加业务方的开发负担。
3.最终一致性:第2种实现方式(基于RocketMQ事务消息)
为了能通过消息中间件解决该问题,同时又不和业务耦合,RocketMQ提出了"事务消息"的概念。
RocketMQ不是提供一个单一的"发送"接口,而是把消息的发送拆成了两个阶段,Prepare阶段(消息发送)和Confirm阶段(确认发送)。具体使用方法如下:
步骤1:系统A调用Prepare接口,预发送消息。此时消息只是保存在消息中间件里面,但消息中间件不会把消息给消息消费方,消息只是暂存在那里。
步骤2:系统A更新数据库,进行扣钱操作。
步骤3:系统A调用Confirm接口,确认发送消息。此时消息中间件才会把消息给消费方进行消费。
这里显然有2个异常场景:
场景1:步骤1成功了,步骤2成功,步骤3失败了,怎么处理?
场景2:步骤1成功了,步骤2失败或者超时了,步骤3不会执行,怎么处理?
这里就设计RocketMQ的关键点:RocketMQ会定期(1min)扫描所有预发送但还没确认的消息,回调给发送方,询问这条消息是要发送出去,还是取消。
发送方根据自己的业务数据,知道这条消息应该发送出去(DB更新成功了),还是取消(DB更新失败)。
对比最终一致性的两种实现方案你会发现,RocketMQ最大的改变其实是把 "扫描消息表" 这件事情不让业务方做,而是让消息中间件做了。
至于消息表,其实还是没有省略掉。因为消息中间件要询问发送方事务是否执行成功了,还需要一个 "变相的本地消息表",记录事务执行状态和消息发送状态。
同时对于消费方,还是没有解决系统重启可能导致的重复消费问题,这只能由消费方解决。需要设计判重机制,实现消息消费的幂等。
4.人工介入
无论是方案1,还是方案2,发送端把消息成功放入队列中,但如果消费端消费失败怎么办?
如果消费失败了,则可以重试,但还一直失败怎么办?是否要自动回滚整个流程?
答案是人工介入。从工程实践的角度看,这种整个流程自动回滚的代价是非常巨大的,不但实现起来很复杂,还会引入新问题。比如自动回滚失败,怎么办?
对应这种概率极低的事件,采取人工介入处理会比实现一个高复杂的自动化回滚系统更加可靠和简单。
10.2.3 TCC
2PC 通常用来解决两个数据库之间的分布式事务问题,比较局限。现代企业采用的是各式各样的SOA服务,更需要解决的是两个服务之间的分布式事务问题。
为了解决SOA系统中分布式事务问题,支付宝提出了TCC。TCC是 Try,Confirm,Cancel 三个单词的缩写,其实是一个应用层的2PC协议,Confirm对应2PC中的
事务提交操作,Cancel对应2PC的事务回滚操作。
1.准备阶段
调用方调用所有服务方提供的Try接口,该阶段各调用方做资源检查和资源锁定,为接下来的阶段2做准备。
2.提交阶段
如果所有服务方都返回yes,则进入提交阶段,调用方调用各服务方的Confirm接口,各服务方进行事务提交。如果有一个服务方在阶段1返回no或者超时了,则
调用方调用各服务方的Cancel接口。
这里有一个关键问题:TCC既然也借鉴2PC的思路,那么它是如何解决2PC的问题的呢?也就是说,在阶段2,调用方发生宕机,或者某个服务超时了,如何处理呢?
答案是:不断重试。不管是Confirm失败了,还是Cancel失败了,都不断重试。这就要求Confirm和Cancel都必须是幂等的操作。注意,这里的重试是由TCC的框架来
执行的,而不是让业务方自己去做。
举例:假设有三个账号A,B,C,通过SOA提供的转账服务操作。A,B同时向C转30元,50元,最后的C的账号+80元,A,B各自减30,50。
阶段1:分别对账号A,B,C 执行Try操作,A,B,C 3个账号在三个不同的SOA服务里面,也就是分别调用三个服务方的Try接口。具体来说,就是账号A锁定30元,账号B
锁定50元,检查账号C的合法性,比如账号C是否违法被冻结,账号C是否被注销等。
所以,在这个场景中,对应的"扣钱"的Try操作就是"锁定",对应的"加钱"的Try操作就是检查账号合法性,为的就是保证接下来的阶段2扣钱可扣,加钱可加。
阶段2:A,B,C 的Try都成功了,执行Confirm操作,即分别调用三个SOA服务的Confirm接口。A,B扣钱,C加钱。如果一个失败了,就不断重试,直到成功。
从案例可以看出,Try操作主要是为了 "保证业务操作的前置条件都得到满足",然后在Confirm阶段,因为前置条件都满足了,所以可以不断的重试保证成功。
10.2.4 事务状态表 + 调用方重试 + 接收方幂等
同样以转账为例,介绍一种类似tcc的方法。tcc的方法通过tcc框架内部来做,下面介绍的是业务方自己实现的。
调用方调用一张 事务状态表(或者说事务日志,日志流水),在每次调用之前,落盘一条事务流水,生成一个全局事务ID。事务的状态表的表结构如下:
事务ID :ID1
事务内容:操作1,账号A减30;操作2,账号B减去50;操作3,账号C减去80;
事务状态(枚举类型):状态1,初始;状态2,操作1成功;状态3,操作1,2成功;状态4,操作1,2,3成功
初始状态1,每次成功调用1个服务则更新1次状态,最后所有系统调用成功,状态更新到4。状态2,3是中间状态,当然,也可以不保存中间状态,只设置2个状态,
Begin和End。事务开始之前的状态是 Begin,全部结束之后的状态是End。如果某个事务一直停留在Begin,则说明该事务没有执行完毕。
然后后台有一个任务,扫描状态表,在过了一段时间后(假设1次事务执行成功通常最多花费30s),状态没有变为最终的状态4,说明这条事务没有执行成功。于是
重新调用系统A,B,C。保证这条流水的最终状态是4(或者End状态)。当然,系统A,B,C 根据全局的事务ID做幂等操作,所以即使重复调用也没有关系。
补充说明:
1.如果后台任务重试多次仍然不成功,要为状态表加一个Error状态,通过人工介入干预。
2.对于调用方的同步调用,如果部分成功,此时给客户端返回什么?
答案是不确定的,或者说暂时不确定。只能告诉用户这笔转账超时了,请稍后确认。
3.对于同步调用,调用方调用A或者B失败的时候,可以重试3次,如果重试3次还不成功,则放弃操作,再交由后台任务执行。
10.2.5 对账
不止事务有状态,系统中的各种数据对象也都有状态,或者说都有各自完整的生命周期,同时数据与数据之间还存在着联系。我们可以很好的利用这种完整的生命周期
和数据之间的关联关系,来实现系统的一致性,这就是"对账"。
前面的注意力都放在了 "过程",而在 "对账" 的思路中,将把注意力转移到 "结果" 中。什么意思呢?在前面的方案中,无论是最终一致性,还是tcc,事务状态表,
都是为了保证"过程的原子性",也就是多个系统操作(或系统调用),要么全部成功,要么全部失败。
但所有的 "过程" 都必然产生 "结果",过程就是我们说的 事务,"结果" 就是业务数据。一个过程如果部分执行成功,部分执行失败,则意味着结果是不完整的,
从结果也可以反推出过程出了问题了,从而对数据进行修补,这就是 "对账" 的思路。
案例1:电商网站的履约系统。
一张订单从 "已支付",到 "下发给仓库",到 "出仓库"。假定从 已支付 到 下发给仓库最多用1小时;从 下发给仓库 到 出仓完成,最多8小时。意味着只要
发现1个订单的状态过去了1小时还是 已支付 状态,就认为订单下发没有成功,就需要重新下发,也就是 重试。
这个案例跟事务的状态很相似,一旦发现系统的某个数据对象过了一个限定的时间生命周期仍然没有走完,仍然处在中间状态,就说明系统不一致了,就要进行某种
补偿。
案例2:微博的关注关系。
需要2张表,一张关注表,一张是粉丝表,这两张表各自都是分库分表的。假设A关注了B,需要先以A为主键进行分库,存入关注表;再以B为主键进行分库,存入
粉丝表。也就是说,一次业务操作,需要向两个库写2条数据,如何保证原子性?
案例3:电商的订单系统也是分库分表的。订单通常有2个常用的查询维度,一个是买家,一个是买家。通常会把订单数据冗余一份,按买家进行分库分表存一份,按卖家
分库分表存一份。同案例2,如何保证原子性?
如果把案例2,案例3的问题看做一个分布式事务的话,可以用最终一致性,TCC,事务状态表,但这些方法都太重,一个简单的方法是 "对账"。
因为2个库是冗余的,可以先保证一个库的数据是准确的,以该库为基准校对另外一个库。
对账又分为 全量对账和增量对账:
1.全量对账
比如每晚运作一个定时任务,对比两个数据库。
2.增量对账
可以基于一个定时任务,基于数据库的更新时间。也可以基于消息中间件。
10.2.6 妥协方案:弱一致性 基于状态的补偿
可以发现:
1."最终一致性"是一种异步的方法,数据有一定的延迟;
2.TCC 是一种同步的方法,但tcc需要两个阶段,性能损耗较大;
3.事务状态表也是一种同步的方法,但每次要记事务流水,要更新事务状态,很繁琐,性能也有损耗;
4."对账" 也是一个事后的过程。
电商的下单至少需要2个操作:创建订单和扣库存。
如果采用最终一致性方案,因为是异步操作,如果库存扣减不及时会导致超卖,因此最终一致性方案不可行;如果用TCC方案,则意味着一个用户请求要调用
2次(Try和Confirm)订单服务,2次(Try和Confirm)库存服务,性能达不到要求。如果用事务状态表,要写事务状态,也存在性能问题。
对于电商的购物来说:允许少卖,但不能超卖。
方案1:先扣库存,后创建订单。
方案2:先创建订单,后扣库存。
无论是方案1,还是方案2,只要保证库存可以多扣,不能少扣即可。但是,库存扣多了,数据不一致,怎么办?
库存每扣一次,都会生成一条流水记录,这条记录的初始状态是"占用",等订单支付成功后,会把状态改成"释放"。
对于那些过来很长一段时间一直是占用,而不释放的库存,要么是因为前面多扣造成的,要么是因为用户下了单,但没支付造成的。
通过对比,得到库存系统的"占用又没有释放的库存流水"与订单系统的未支付的订单,就可以回收这些库存,同时把对应的订单取消掉。类似12306,过一段时间未支付,
订单会取消,库存会释放。
10.2.7 妥协方案:重试 回滚 报警 人工修复
上文介绍了 基于订单的状态 + 库存流水的状态 做补偿(或者叫对账)。如果业务很复杂,状态的维护也很复杂,可以采用下面更加妥协而简单的办法。
按方案1,先扣库存,后创建订单。不做状态补偿,为库存系统提供一个 回滚接口,创建订单失败了,先重试。如果重试还不成功,则回滚库存的扣减。如回滚也失败,
则报警,进行人工干预修复。
总之,根据业务逻辑,通过三次重试或回滚的方法,最大限度的保证一致。实在不一致,就发报警,让人工介入。只要日志流水完整,人工肯定修复。
10.2.8 总结
总结了实践中比较可靠的7种办法:两种最终一致性的方案,两种妥协办法,两种基于 状态 + 重试 + 幂等的方法(TCC,状态机 + 重试 + 幂等),还有一种对账。
在实现层面,妥协和对账 最容易,最终一致性次之,TCC最复杂。
以上是关于10.软件架构设计:大型网站技术架构与业务架构融合之道 --- 事务一致性的主要内容,如果未能解决你的问题,请参考以下文章
《软件架构设计:大型网站技术架构与业务架构融合之道》思维导图
3.软件架构设计:大型网站技术架构与业务架构融合之道 --- 语言
14.软件架构设计:大型网站技术架构与业务架构融合之道 --- 业务架构思维
13.软件架构设计:大型网站技术架构与业务架构融合之道 --- 业务意识