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