分布式事务全攻略
1.什么是分布式事务
1.1.本地事务
本地事务,是传统的的单机数据库事务,必须具备ACID原则;
原子性(A)
在整个事务中的所有操作,要么全部完成,要么全部不做,没有中间状态。对于事务在执行中发生错误,所有的操作都会被回滚,整个事务就像从没被执行过一样。
一致性(C)
事务执行必须保证系统的一致性,在事务开始之前和事务结束之后,数据库的完整性没有被破坏,就拿转账为例,A有500元,B有500元,如果在一个事务里A成功转给B50元,那么不管发生什么,最后A账户和B账户的数据之和必须是1000元。
隔离性(I)
事务和事务之间不会相互影响,一个事务的中间状态不会被其他事务感知。数据库保证隔离性包括四种不同的隔离级别:
- Read Uncommitted(读取未提交内容)
- Read Committed(读取提交内容)
- Repeatable Read(可重读)
- Serializable(可串行化)
持久性(D)
一旦事务提交了,那么事务对数据做的变更就完全保存在数据库中,即使发生停电,系统宕机也是如此。
因为在传统项目中,项目部署基本是但单点式:即单个服务和单个数据库。这种情况下,数据库本身的事务机制就能保证ACID的原则,这样的事务都是本地事务。
概括来讲,单个服务与单个数据库的架构中,产生的事务都是本地事务。
其中原子性和持久性就要靠undo和redo日志来实现。
1.2.undo和redo
在数据库系统中,既有存放数据的文件,也有存放日志的文件。日志在内存中也是有缓存Log buffer,也有磁盘文件log file。
MySQL中的日志文件,有那么两种与事务有关:undo日志与redo日志。
1.2.1.undo日志
数据库事务具备原子性(Atomicity),如果事务执行失败,需要把数据回滚。
事务同时还具备持久性(Durability),事务对数据所做的变更就完全保存在了数据库,不能因为故障而丢失。
原子性可以利用undo日志来实现。
Undo Log的原理很简单,为了满足事务的原子性,在操作任何数据之前,首先将数据备份到Undo Log。然后进行数据的修改。如果出现错误或者用户执行rollback语句,系统可以利用Undo Log中的备份将
数据恢复到事务开始之前的状态。
数据库写入数据到磁盘前,会把数据先存在内存中,事务提交时才会写入磁盘中。
用Undo Log实现原子性和持久化的事务简化过程:
假设有A、B两个数据,值分别时1,2。
- 事务开始
- 记录A=1到undo log。
- 修改A=3
- 记录B=2到undo log
- 修改B=4
- 将undo log写到磁盘
- 将数据写到磁盘
- 提交事务
- 如何保证持久性?
事务提交前,会把修改数据到磁盘前,也就是说只要事务提交了,数据就肯定持久化了。
- 如何保证原子性?
每次对数据库修改,都会把修改前数据记录在undo log,那么需要回滚时,可以读undo log,恢复数据。
若在7和8之间崩溃,此时事务未提交,需要回滚。而undo log已经被持久化,可以根据undo log恢复数据。
若系统在7之前崩溃,此时数据并未持久化到硬盘,依然保持在事务之前的状态。
缺陷:每个事务提交前将数据和Undo Log写入磁盘,这样会导致大量的磁盘IO,因此性能很低。
如果能够将数据缓存一段时间,就能减少IO提高性能。但是这样就会丧失事务的持久性。因此引入了另外一种机制来实现持久化,即Redo Log。
1.2.2.redo日志
和Undo Log相反,Redo Log记录的是新数据的备份。在事务提交前,只要将Redo Log持久化即可,不需要将数据持久化,减少了IO的次数。
先看下基本原理:
Undo + Redo事务的简化过程
假设有A、B两个数据,值分别为1,2
- 事务开始
- 记录A=1到undo log buffer
- 修改A=3
- 记录A=3到redo log buffer
- 记录B=2到undo log buffer
- 修改B=4
- 记录B=4到redo log buffer
- 将undo log写入磁盘
- 将redo log写入磁盘
- 事务提交
安全和性能问题
- 如何保证原子性?
如果在事务提交前故障,通过undo log日志恢复数据,如果undo log都还没写入,那么数据就尚未持久化,无需回滚。
- 如何保证持久化?
因为数据已经写入redo log ,而redo log已经持久化到了硬盘,因此只要到了步骤9以后,事务是可以提交的。
- 内存中的数据库数据何时持久化到硬盘?
因为redo log 已经持久化,因此数据库数据写入磁盘与否影响不大,不过为了避免出现脏数据(内存中与磁盘不一致),事务提交以后也会将内存数据刷入磁盘(也可以按照固定的频率刷新内存数据到磁盘中)
- redo log何时写入磁盘
redo log会在事务提交之前,或者redo log buffer 满了的时候写入磁盘。
- 性能是如何提高的?
将数据库数据写入磁盘是随机写(在磁盘上先寻址,再写入,寻址往往耗费更多的时间),将redo log写入磁盘写入磁盘是顺序写(在磁盘上开辟一条连续的空间写入数据,减少了寻址所需的消耗)。实际上undo log并不是直接写入磁盘,而是先写入到redo log buffer中,当redo log持久化时,undo log就同时持久化到硬盘了。
因此事务提交前,只需要对redo log持久化即可。
另外,redo log并不是写一次就持久化一次,redo log在内存中也有自己的缓冲池:redo log buffer,每次写redo log 都是写入到buffer,在提交时一次性持久化到磁盘,减少IO次数。
- redo log中记录的数据,有可能包含尚未提交事务,如果此时数据库崩溃,那么如何完成数据恢复?
数据恢复有两种策略:
恢复时,只重做已经提交了的事务
恢复时,重做所有事务包括为提交和回滚了的事务。然后通过Undo Log回滚那些未提交的事务
Inodb引擎采用的是第二种方案,因此undo log要在redo log前持久化
1.2.3.总结
- undo log 记录更新前数据,用于保证事务原子性
- redo log记录更新后数据,用于保证事务持久性
- redo log有自己的内存buffer,先写入到buffer,事务提交时写入磁盘
- redo log 持久化之后,意味着事务是可提交的
1.3.分布式事务
分布式事务,就是指不是在单个服务或单个数据库架构下,产生的事务:
- 跨数据源的分布式事务
- 跨服务的分布式事务
- 综合情况
1)跨数据源
随着业务数据规模的快速发展,数据量越来越大,单库表逐渐成为瓶颈。所以我们对数据库进行水平拆分,将原单库表拆分成数据库分片,于是就产生了跨数据事务问题。
2)跨服务
在业务初期,“一块大饼”的单业务系统架构,能满足基本业务需求。但是随着业务的快速发展,系统的访问量和业务复杂程度在快速增长,但系统架构逐渐成为业务发展瓶颈,解决业务系统的高耦合/可伸缩问题的需求越来越强烈。
如图所示,按照面向服务(SOA)的架构设计原则,将单业务系统拆分成多个业务系统,降低了各系统之间的耦合度,使不同的业务系统专注于自身业务,更有利于业务的发展和系统的容量的伸缩。
3)分布式系统的数据一致性问题
在数据库水平拆分、服务垂直拆分之后,一个业务操作通常要跨多个数据库、服务才能完成。z在分布式网络环境下,我们无法保障所有服务、数据库都百分百可用一定会出现部分服务/数据库执行成功,另一部分执行失败的问题。
当出现部分业务操作成功、部分业务操作失败时,业务数据就会出现不一致。
例如电商行业中比较常见的下单付款案例,包括下面几个行为:
- 创建订单
- 扣减商品库存
- 从用户账户余额扣款
完成上面的操作要访问三个不同的微服务和三个不同的数据库。
在分布式环境下,肯定会出现部分操作成功,部分操作失败的问题,比如:订单生成了,库存也扣减了,但是用户余额不足,这就造成了数据不一致
订单的创建、库存的扣减、账户扣款在每一个服务和一个数据库内是一个本地事务,可用保证ACID原则。
但是当我们把三件事情看做一个事情时,要满足保证“业务”的原子性,要么所有操作全部成功,要么全部失败,不允许部分成功部分失败的现象出现,这就是分布式系统下的事务。
此时ACID难以满足,这是分布式 事务要解决的问题。
2.解决分布式事务的思路
为社么分布式系统下,事务的ACID原则难以满足?
这得从CAP定理和BASE理论说起。
2.1.CAP定理
本节内容参考:阮一峰的博客CAP定理的含义
2.2.Base理论
BASE时三个单词的缩写:
- Basically Available(基本可用)
- Soft state(软状态)
- Eventually consistent(最终一致性)
而我们解决分布式事务,就是根据上述理论来实现。
还是以上面的下单减库存和扣款为例:
订单服务、库存服务、用户服务及他们对应的数据库就是分布式应用中的三个部分。
●CP方式:现在如果要满足事务的强一 致性,就必须在订单服务数据库锁定的同时,对库存服务、用户服务数据资源同时锁定。等待三个服务业务全部处理完成,才可以释放资源。此时如果有其他请求想要操作被锁定的资源就会被阻塞,这样就是满足了CP。
这就是强一致,弱可用
●AP方式:三个服务的对应数据库各自独立执行自己的业务,执行本地事务,不要求互相锁定资源。但是这个中间状态下,我们去访问数据库,可能遇到数据不一致的情况, 不过我们需要做一些后补措施,保证在经过一-段时间后, 数据最终满足-致性。
这就是高可用,但弱一致(最终一致)。
由上面的两种思想,延伸出了很多的分布式事务解决方案:
- XA
- TCC
- 可靠消息最终一致
- TA
2.4.分阶段提交
2.4.1.DTP和XA
分布式事务的解决手段之.一,就是两阶段提交协议 (2PC: Two-Phase Commit)
那么到底什么是两阶段提交协议呢?
1994年,X/Open组织(即现在的Open Group )定义了分布式事务处理的DTP模型。该模型包括这样,几个角色:
●应用程序( AP) :我们的微服务
●事务管理器( TM) ;全局事务管理者
●资源管理器(RM) : - -般是数据库
●通信资源管理器( CRM) :是TM和RM间的通信中间件
在该模型中,一个分布式事务(全局事务)可以被拆分成许多个本地事务,运行在不同的AP和RM上。每个本地事务的ACID很好实现,但是全局事务必须保证其中包含的每一个本地事务都能同时成功,若有一个本地事务失败,则所有其它事务都必须回滚。但问题是,本地事务处理过程中,并不知道其它事务的运行状态。因此,就需要通过CRM来通知各个本地事务,同步事务执行的状态。
因此,各个本地事务的通信必须有统- -的标准,否则不同数据库间就无法通信。XA就是X/Open DTP中通信中间件与TM间联系的接口规范,定义了用于通知事务开始、提交、终止、回滚等接口,各个数据库厂商都必须实现这些接口。
2.4.2.二阶段提交
本节内容参考:漫话分布式系统共识协议: 2PC/3PC篇
2.4.3.使用场景
对事务有强一致性要求,对事务执行效率不敏感,并且不希望有太多代码侵入。
2.5.TCC
TCC模式可以解决2PC中的资源锁定和阻塞问题,减少资源锁定时间。
2.5.1.基本原理
它本质是一种补偿的思路。事务运行过程包括三个方法,
●Confirm: 执行的业务操作提交;要求Try成功Confirm一定要能成功;
●Cancel: 预留资源释放。
执行分两个阶段:
准备阶段(try) :资源的检测和预留;
●执行阶段(confirm/cancel) :根据上一 步结果,判断下面的执行方法。如果上一步中所有事务参与者都成功,则这里执行confirm。 反之,执行cancel
●try、 confirm、 cancel都是独立的事务,不受其它参与者的影响,不会阻塞等待它人
2.5.2实例
●一阶段(Try) : 余额检查,并冻结用户部分金额,此阶段执行完毕,事务已经提交
。检查用户余额是否充足,如果充足,冻结部分余额
。在账户表中添加冻结金额字段,值为30,余额不变
●二阶段
。提交 (Confirm) :真正的扣款, 把冻结金额从余额中扣除, 冻结金额清空
■修改冻结金额为0,修改余额为100-30= 70元
。补偿(Cancel) :释放之前冻结的金额,并非回滚
■余额不变,修改账户冻结金额为0
2.5.3.优势与缺点
TCC执行的每一个阶段都会提交本地事务并释放锁, 并不需要等待其它事务的执行结果。而如果其它事务执行失败,最后不是回滚,而是执行补偿操作。这样就避免了资源的长期锁定和阻塞等待,执行效率比较高,属于性能比较好的分布式事务方式。
●缺点
。代码侵入:需要人为编写代码实现try、confirm、 cancel, 代码侵入较多
。开发成本高: 一个业务需要拆分成3个步骤,分别编写业务实现,业务编写比较复杂
2.5.4.使用场景
●开发人员具备较高的编码能力和幂等处理经验
2.6.可靠消息服务
这种实现方式的思路,其实是源于ebay,其基本的设计思想是将远程分布式事务拆分成一系列的本地事务
2.6.1.基本原理
一般分为事务的发起者A和事务的其它参与者B:
●事务发起者A执行本地事务
●事务发起者A通过MQ将需要执行的事务信息发送给事务参与者B
●事务参与者B接收到消息后执行本地事务
如图:
这个过程有点像你去学校食堂吃饭:
●拿着钱去收银处,点一份红烧牛肉面,付钱
●收银处给你发-个小票,还有一个号牌,你别把票弄丢了
●你凭小票和号牌一定能领到一份红烧牛肉面,不管需要多久
几个注意事项:
●事务发起者A必须确保本地事务成功后,消息-定发送成功
●MQ必须保证消息正确投递和持久化保存
●事务参与者B必须确保消息最终一 定能消费, 如果失败需要多次重试
●事务B执行失败,会重试,但不会导致事务A回滚
2.6.2.本地消息表
为了避免消息发送失败或丢失,我们可以把消息持久化到数据库中。实现时有简化版本和解耦合版本两种方式。
1)简化版本
原理图:
●事务发起者:
。开启本地事务
。执行事务相关业务
。发送消息到MQ
。把消息持久化到数据库,标记为已发送
。提交本地事务
●事务接收者:
。接收消息
。开启本地事务
。处理事务相关业务
。修改数据库消息状态为已消费
。提交本地事务
●额外的定时任务
。定时扫描表中超时未消费消息,重新发送
●与tcc相比,实现方式较为简单,开发成本低。
缺点:
●数据一致性完全依赖于消息服务,因此消息服务必须是可靠的。
●被动业务失败不会导致主动业务的回滚,而是重试被动的业务
2)独立消息服务
一次消息发送的时序图:
事务发起者A的基本执行步骤:
●开启本地事务
●通知消息服务,准备发送消息(消息服务将消息持久化,标记为准备发送)
●执行本地业务,
。执行失败则终止,通知消息服务,取消发送(消息服务修改订单状态)
。执行成功则继续,通知消息服务,确认发送(消息服务发送消息、修改订单状态)
●提交本地事务
消息服务本身提供下面的接口:
●准备发送:把消息持久化到数据库,并标记状态为准备发送
●取消发送:把数据库消息状态修改为取消
●确认发送:把数据库消息状态修改为确认发送。尝试发送消息,成功后修改状态为已发送
●确认消费:消费者已经接收并处理消息,把数据库消息状态修改为已消费
●定时任务:定时扫描数据库中状态为确认发送的消息,然后询问对应的事务发起者,事务业务执行是否成功,结果:
。业务执行成功:尝试发送消息,成功后修改状态为已发送
。业务执行失败:把数据库消息状态修改为取消
事务参与者B的基本步骤:
●接收消息
●开启本地事务
●执行业务
●通知消息服务,消息已经接收和处理
●提交事务
●解除了事务业务与消息相关业务的耦合
●实现起来比较复杂
2.6.3.RocketMQ事务消息
RocketMQ本身自带了事务消息,可以保证消息的可靠性,原理其实就是自带了本地消息表,与我们上面讲的思路类似。
2.6.4.RabbitMQ的消息确认
●生产者确认机制:确保消息从生产者到达MQ不会有问题
。消息生产者发送消息到RabbitMQ时, 可以设置一个异步的监听器,监听来自MQ的ACK
。MQ接收到消息后,会返回一个回执给生产者:
■消息到达交换机后路由失败,会返回失败ACK
■消息路由成功,持久化失败,会返回失败ACK
■消息路由成功,持久化成功,会返回成功ACK
。生产者提前编写好不同回执的处理方式
■失败回执:等待一定时间后重新发送
■成功回执:记录日志等行为
●消费者确认机制:确保消息能够被消费者正确消费
。消费者需要在监听队列的时候指定手动ACK模式
。RabbitMQ把消息投递给消费者后,会等待消费者ACK,接收到ACK后才删除消息,如果没有接收到ACK消息会一直保留在服务端, 如果消费者断开连接或异常后,消息会投递给其它消费者。
。消费者处理完消息,提交事务后,手动ACK。如果执行过程中抛出异常,则不会ACK,业务处理失败,等待下一条消息
2.6.5.消息事务优缺点
●优点:
。业务相对简单,不需要编写三个阶段业务
。是多个本地事务的结合,因此资源锁定周期短,性能好
●缺点:
。代码侵入
。依赖于MQ的可靠性
。消息发起者可以回滚,但是消息参与者无法引起事务回滚
。事务时效性差,取决于MQ消息发送是否及时,还有消息参与者的执行情况
2.7.AT模式
2019年1月份,Seata开源了AT模式。AT 模式是-种无侵入的分布式事务解决方案。可以看做是对TCC或者二阶段提交模型的一种优化,解决了TCC模式中的代码侵入、编码复杂等问题。
●与2PC相比:每个分支事务都是独立提交,不互相等待,减少了资源锁定和阻塞时间
缺点:
●与TCC相比, 需要动态生成二阶段的反向补偿操作,执行性能略低于TCC