一、本地事务
1.1 概念
本地事务必须具备ACID原则
原子性(Atomicity):事务中的操作要么全部完成,要么全部不做。事务出错会全部回滚。
一致性(Consistency):事务执行必须保证系统的一致性,事务前后,数据库完整性没有被破坏。如转账事务,转账前后总金额不变。
隔离性(Isolation):事务之间不相互影响。
事务隔离级别:
1.读未提交:事务读不阻塞其他事务读和写,事务写阻塞其他事务写但不阻塞读。可以通过写操作加“持续-X锁”实现。
脏读
2.读已提交:事务读不会阻塞其他事务读和写,事务写会阻塞其他事务读和写。可以通过写操作加“持续-X”锁,读操作加“临时-S锁”实现。
不可重复读
3.可重复读:事务读会阻塞其他事务写但不阻塞读,事务写会阻塞其他事务读和写。
可以通过写操作加“持续-X”锁,读操作加“持续-S锁”实现。
脏读
4.可串行化:事务读写会阻塞其他事务对整张表的写但不阻塞读,事务写会阻塞其他事务对整张表。使用表级锁。持久性(Durability):事务一旦提交,事务对所有的变更就完全保存在了数据库中,即使系统宕机也不丢失。
数据库锁
共享锁和排他锁都是悲观锁的实现:
共享锁(-S锁、读锁):持有S锁的事务只读不可写。如果事务A对数据D加上S锁后,其它事务只能对D加上S锁而不能加X锁。
排他锁(-X锁、写锁):持有X锁的事务可读可写。如果事务A对数据D加上X锁后,其它事务不能再对D加锁,直到A对D的锁解除。
加锁实例:
set autocommit=0; #设置mysql为非自动提交
SELECT * from city where id = "1" lock in share mode; #加共享锁
commit; #提交
update,insert,delete语句会自动加排它锁
1.2 ACID实现原理
原子性和持久性通过undo日志和redo日志实现;一致性通过代码逻辑实现;
Undo Log和Redo Log
在操作任何数据之前,将数据备份到Undo Log缓存中,操作数据后记录到Redo Log缓存中。将Undo Log和Redo Log写入到磁盘中。提交事务后,异步将Redo Log日志写入到数据库中。
A. 事务开始
B. 记录A=1到undo log buffer
C. 修改A=3
D. 记录A=3到redo log buffer
E. 记录B=2到undo log buffer
F. 修改B=4
G. 记录B=4到redo log buffer
H. 将redo log和undo log同时写入到磁盘
I. 事务提交(开启线程将日志写入数据库)
情况一:如果在H之前宕机,那么事务异常,内存中数据丢失,数据库不变。
情况二:如果在H和I之间宕机,那么事务未提交且数据已经落盘,那么系统恢复后通过undo log日志将数据回滚。
性能问题:Redo和Undo日志通过顺序写写入到磁盘中,进行一次IO,保证了性能;如果将数据直接写入到数据库,因为聚簇索引是B+树结构,是随机写操作,IO速度较慢。
1.3 Springboot本地事务
1.3.1 注解@Transactional
@Transactional
- isolation = Isonlation.REPEATABLE_READ 指定隔离级别,mysql默认为可重复读;
- propagation = Propagation.REQUIRED 指定传播行为,REQUIRED表示该方法被另一个事务方法调用,会共用一个事务,即会一起回滚;PROPAGATION_REQUIRES_NEW 表示不会共用一个事务;
- timeout = 2 指定事务执行超时时间,超时回滚;
- rollback 指定抛出异常回滚的异常类型
手动回滚的方法:currentTransactionStatus().setRollbackOnly();
注:传播行为分类如下
事务传播行为类型 | 说明 |
---|---|
PROPAGATION_REQUIRED | 如果当前没有事务,就新建一个事务,如果已经存在一个事务中,加入到这个事务中。这是最常见的选择。 |
PROPAGATION_SUPPORTS | 支持当前事务,如果当前没有事务,就以非事务方式执行。 |
PROPAGATION_MANDATORY | 使用当前的事务,如果当前没有事务,就抛出异常。 |
PROPAGATION_REQUIRES_NEW | 新建事务,如果当前存在事务,把当前事务挂起。 |
PROPAGATION_NOT_SUPPORTED | 以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。 |
PROPAGATION_NEVER | 以非事务方式执行,如果当前存在事务,则抛出异常。 |
PROPAGATION_NESTED | 如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则执行与PROPAGATION_REQUIRED类似的操作。 |
1.3.2 注意点
@Transactional(propagation = Propagation.REQUIRED,timeout = 2)
public void a(){
b();
}
@Transactional(propagation = Propagation.REQUIRED,timeout = 2)
public void b(){
}
在同一个类中不使用容器对象调用方法,而是直接调用本类中的方法会导致被调用的方法的事务失效。
本地事务失效:同一个对象内事务方法互调默认失效,原因是绕过了代理对象,事务使用代理对象来控制。但是不能在类中注入自己,会产生循环依赖。
解决方案:
- 引入aop-starter(spring-boot-starter-aop),引入了aspectj。
- 开启@EnableAspectJAutoProxy(exposeProxy=true);开启aspectj动态代理功能并对外暴露代理对象。以后所有的动态代理都是aspectj创建的。(即是没有接口也可以创建动态代理)
- 本类互调用调用对象
二、分布式事务
2.1 分布式事务场景
- 从张三转账给李四,需要保证数据一致性,在同一个表中,可以使用Spring的@Transaction注解保证数据一致性;如果数据库过大,进行了水平分割,就变成了跨数据库的事务操作,此时就需要分布式事务保证数据一致性。
- 有订单服务和库存服务这两个微服务,下订单后需要远程调用库存服务进行减库存操作,此时也需要分布式事务保证数据一致性。
异常情况
- 远程服务假失败:远程服务成功,但是因为网络等原因返回失败。
- 远程服务执行完成:多个远程服务,如果后续的服务因为网络原因失败,已完成的远程服务无法回滚。
2.2 分布式理论
2.2.1 CAP定理
一致性(Consistency):请求所有节点获取的数据都是一致的。即分布式中一个节点的数据发送变化,其他节点需要进行同步。
可用性(Availability):所有的节点都是可用的。
分区容错性(Partition tolerance):不同区域的机房通讯会产生网络问题。
因为网络问题不可避免,所以分区容错性是必须具备的。
一致性和可用性不可能同时做到,因为保证一致性需要进行同步,同步未完成服务不可用。保证可用性导致接受请求时节点间数据未完成同步。
2.2.1.1 Eureka(满足AP)
eureka 保证了可用性,实现最终一致性。
Eureka各个节点都是平等的,几个节点挂掉不会影响正常节点的工作,剩余的节点依然可以提供注册和查询服务。而Eureka的客户端在向某个Eureka注册或时如果发现连接失败,则会自动切换至其它节点,只要有一台Eureka还在,就能保证注册服务可用(保证可用性),只不过查到的信息可能不是最新的(不保证强一致性),其中说明了,eureka是不满足强一致性,但还是会保证最终一致性。
2.2.1.2 Zookeeper(满足CP)
Zookeeper使用了Zab一致性选法:在选举leader时,会停止服务,直到选举成功之后才会再次对外提供服务,这个时候就说明了服务不可用,但是在选举成功之后,因为一主多从的结构,zookeeper在这时还是一个高可用注册中心,只是在优先保证一致性的前提下,zookeeper才会顾及到可用性。
2.2.2 Base理论
BASE 理论是对 CAP 中一致性和可用性权衡的结果:
- 基本可用(Basically Available):分布式系统在出现不可预知故障的时,允许损失部分可用性。如响应时间和功能上的损失(降级)。
- 软状态(Soft state):允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许同步存在延时。
- 最终一致性(Eventually consistent):最终数据会同步完成。
CP方式:满足事务强一致,就需要在订单服务数据库锁定时,对库存服务、用户服务数据资源同时锁定。等待三个服务业务全部处理完成,才可以释放资源。如果此时有其他请求要操作被锁定的资源就会阻塞。
AP方式:事务满足高可用,三个服务对应的数据库各自独立执行自己的业务,执行本地事务,不要求相互锁定资源。但是访问不同节点的服务会发生数据不一致的情况。最终数据会满足一致性,这就是高可用、弱一致(最终一致)。
2.2.3 实现思路分类
2.2.3.1 刚性事务和柔性事务
满足ACID的是刚性事务,如2PC
满足Base理论的是柔性事务,如TCC、本地消息表等。
2.2.3.2 补偿型和通知型
补偿型事务又分TCC、Saga,通知型事务分事务消息、最大努力通知型。
补偿型事务都是同步的,通知型事务都是异步的。
2.3 具体实现
由以上理论延伸出的分布式事务解决方案:
- XA
- TCC
- Saga
- AT
- 可靠消息最终一致(包括本地消息表和事务消息)
- 最大努力通知
2.3.1 2PC 两阶段提交(不适合高并发)
2.3.1.1 原理
遵循XA协议的数据库支持2PC分布式事务。
两阶段提交(Tow-phase Commit,2PC),通过引入协调者(Coordinator)来协调参与者的行为,并最终决定这些参与者是否要真正执行事务。原理类似一致性算法raft,但是需要所有子事务都提交完成。
该模型包括的角色:
- 应用程序(AP):微服务
- 事务管理器(TM):全局事务管理者
- 资源管理器(RM):一般是数据库
- 通信资源管理器(CRM):是TM和RM间的通信中间件
分布式事务拆分为许多本地事务运行在AP和RM上。每个本地事务的ACID很好实现,全局的事务需要本地事务之间进行通讯。XA就是X/Open DTP中通信中间件CRM与事务管理器TM间联系的接口规范,定义了用于通知事物开始、提交、中止、回滚等接口,各个数据库厂商都必须实现这些接口。
二阶段提交协议:
- 阶段一:准备阶段,各个本地事务完成本地事务的准备工作。
-
阶段二:执行阶段,各个本地事务根据上一阶段执行结果,进行提价或回滚。
coordinator是协调者,voter是参与者。如果参与者都成功执行了操作,通知协调者进行所有事务的提交。如果其中一个参与者发生错误,则所有事务进行回滚。
2.3.1.2 存在的问题
- 同步阻塞:准备阶段和提交阶段都会为数据库上锁,直到分布式事务结束。无法满足高并发场景。
- 单点故障:如果协调者coordinator宕机了,那么等待提交事务的参与者voter就会给数据库上锁,导致后续访问数据库的线程阻塞。
- 数据不一致:在阶段二,如果协调者只发送了部分Commit消息,此时网络发生异常,那么只有部分参与者接收到Commit消息并提交了事务,导致数据不一致。
- 太过保守,一个节点异常就会导致整个事务失败,没有完善的容错机制。
- 许多数据库不支持或支持不理想。如mysql的XA实现,没有记录prepare阶段日志,主备切换会导致主库与备库数据不一致。
2.3.2 TCC补偿事务 ( 严选,阿里,蚂蚁金服)
2.3.2.1 原理
TCC其实就是采用补偿机制,核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作,分为三阶段:
- 准备阶段(try):资源的监测和预留。
- Confirm阶段主要是对业务系统做确认提交,Try阶段执行成功并开始执行Confirm阶段时,默认Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。
- Cancel阶段主要在业务执行错误,需要回滚状态下执行的业务取消,预留资源释放。
实例如下
对账号A进行扣款
- 准备阶段(try):检查余额是否充足,添加冻结资金到冻结资金字段中,提交事务并通知协调者。
-
执行阶段(confirm/cancel):
*confirm提交:真正扣款,把冻结资金从余额中扣除,冻结资金清空。
*cancel取消:将冻结金额清空。
2.3.2.2 优缺点
优势
TCC执行的每个阶段的每个事务都会提交本地事务并释放锁,无需等待其他事务的执行结果,执行效率高。
缺陷
- 代码入侵:需要人为编写代码实现try、confirm、cancel。
- 开发成本高:一个业务需要拆分为三步,即冻结(try)、扣除冻结(confirm)、释放冻结(cancel)。
2.3.3 Saga模式
2.3.3.1 原理
Saga 是一种补偿协议,在 Saga 模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作。
分布式事务执行过程中,依次执行各参与者的正向操作,如果所有正向操作均执行成功,那么分布式事务提交。如果任何一个正向操作执行失败,那么分布式事务会退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态。
Saga 正向服务与补偿服务也需要业务开发者实现。因此是业务入侵的。
Saga 模式下分布式事务通常是由事件驱动的,各个参与者之间是异步执行的,Saga 模式是一种长事务解决方案。
Saga 模式使用场景
Saga 模式适用于业务流程长且需要保证事务最终一致性的业务系统,Saga 模式一阶段就会提交本地事务,无锁、长流程情况下可以保证性能。
事务参与者可能是其它公司的服务或者是遗留系统的服务,无法进行改造和提供 TCC 要求的接口,可以使用 Saga 模式。
2.3.3.2 优缺点
优点
- 一阶段提交本地数据库事务,无锁,高性能;
- 参与者可以采用事务驱动异步执行,高吞吐
- 补偿服务即正向服务的“反向”,易于理解,易于实现;
缺点
Saga 模式由于一阶段已经提交本地数据库事务,且没有进行“预留”动作,所以不能保证隔离性。后续会讲到对于缺乏隔离性的应对措施。
注意
与TCC实践经验相同的是,Saga 模式中,每个事务参与者的冲正、逆向操作,需要支持:
- 空补偿:逆向操作早于正向操作时;
- 防悬挂控制:空补偿后要拒绝正向操作
- 幂等
参考文章:https://www.jianshu.com/p/e4b662407c66
2.3.4 AT模式
Seata开源的AT模式,无侵入式分布式事务解决方案。是对TCC和2PC的模型优化,解决TCC模式中代码侵入、编码复制等问题。
流程
一阶段:执行本地事务并返回执行结果。Seata会拦截业务SQL并解析,将需要修改的记录保存成before image,然后执行业务SQL,将业务更新后的记录存储为after image,最后获取全局行锁,提交事务。这些都是本地事务,保证了原子性。
二阶段:
- 如果是提交的话,只需要删除日志和删除行锁即可。
-
如果是回滚,Seata需要根据before image日志回滚数据,回滚前需要读取数据库记录和after image日志对比保证没有其他事务对记录进行操作,两份数据一致,说明没有脏写,还原业务数据,如果不一致,需要转人工处理。
详情见https://www.jianshu.com/writer#/notebooks/44681510/notes/69583976
2.3.5 可靠消息一致性 (注册送积分,登录送优惠券)
两种实现
- 本地消息表
- 事务消息
2.3.5.1 本地消息表
2.3.5.1.1 原理
它的核心思想是将需要分布式处理的任务通过消息或者日志的方式来异步执行,消息或日志可以存到本地文件、数据库或消息队列,再通过业务规则进行失败重试,它要求各服务的接口是幂等的。
本地消息表与业务数据表处于同一个数据库中,这样就能利用本地事务来保证在对这两个表的操作满足事务特性,并且使用了消息队列来保证最终一致性
在分布式事务操作的一方完成写业务数据的操作之后向本地消息表发送一个消息,本地事务能保证这个消息一定会被写入本地消息表中;
之后将本地消息表中的消息转发到 Kafka 等消息队列中,如果转发成功则将消息从本地消息表中删除,否则继续重新转发;
消息消费方处理这个消息,并完成自己的业务逻辑。此时如果本地事务处理成功,表明已经处理成功了,如果处理失败,那么就会重试执行。如果是业务上面的失败,可以给生产方发送一个业务补偿消息,通知生产方进行回滚等操作(冻结或者补偿);
2.3.5.1.2 优缺点
优点
与TCC相比,实现方式简单,开发成本低。
缺陷
- 被动业务执行时间不确定,时效性差;
- 需要处理被动业务方的幂等问题;
- 被动业务失败不会导致主动业务回滚,需要额外策略来回滚主动业务;
- 代码耦合性较高,需要消息表和消息确认回调方法。
2.3.5.1.3 实例
流程:
- 可靠生产(确保消息投递到MQ中):
①创建冗余业务数据表,将需要事务修改的数据添加到冗余表中并设置状态为0,然后发送消息到MQ中。
②利用RabbitMQ的publisher/Confirm机制开启确认机制后,如果消息正常发送到MQ中就会获取到回执信息,然后将状态修改为1;
③定义一个定时器循环检查冗余表中未确认的信息并发送给RabbitMQ,直到超过最大重试次数。@EnableScheduling和@Scheduled(cron="")创建定时任务。 - 可靠消费(确保消息)
①通过异常拒收,然后进入死信队列再次消费,如果再次异常则进行报警。两次消费确保可靠消费。
②可能存在重复消费导致幂等性失效的情况,需要保证幂等性。通过数据库唯一索引或分布式锁来保证幂等性。
2.3.5.2 MQ事务消息
2.3.5.2.1 原理
原理类似本地消息表,但是需要MQ支持半消息机制或者类似特性(如RocketMQ)。
简单原理:本地消息表通过冗余表重复投递未确认的消息,而MQ事务消息会在消息到达MQ时进行本地事务操作,如果成功则继续发送,否则撤销发送。这样可以省下消息表和重复发送消息。
半消息:指的是暂不能投递的消息,发送方已经将消息成功发送到了 MQ 服务端,但是服务端未收到生产者对该消息的二次确认。
消息回查:由于网络闪断、生产者应用重启等原因,导致某条事务消息的二次确认丢失,MQ 服务端通过扫描发现某条消息长期处于“半消息”时,需要主动向消息生产者询问该消息的最终状态(Commit 或是 Rollback),该过程即消息回查。
- 事务发起方首先发送半消息到MQ;
- MQ通知发送方消息发送成功;
- 在发送半消息成功后执行本地事务;
- 根据本地事务执行结果返回commit或者是rollback;
- 如果消息是rollback, MQ将丢弃该消息不投递;如果是commit,MQ将会消息发送给消息订阅方;
- 订阅方根据消息执行本地事务;
- 订阅方执行本地事务成功后再从MQ中将该消息标记为已消费;
- 如果执行本地事务过程中,执行端挂掉,或者超时,MQ服务器端将不停的询问producer来获取事务状态;
- Consumer端的消费成功机制有MQ保证;
2.3.5.2.1 MQ事务消息对比本地消息表
①DB本地消息表:
- 使用了数据库来存储事务消息,降低了对MQ的要求,但是增加了存储成本;
- 事务消息使用了异步投递,增大了消息重复投递的可能性;
②MQ事务消息:
- 需要MQ支持半消息机制或者类似特性,在重复投递上具有比较好的去重处理;
- 具有比较大的业务侵入性,需要业务方进行改造,提供对应的本地操作成功的回查功能;
2.3.6 最大努力通知(银行通知、支付结果通知)
2.3.6.1 原理
按规律进行通知,不保证消息一定通知成功,但会提供可查询操作接口进行核对。这种方案主要用在与第三方系统通讯时,如调用第三方支付平台(微信或支付宝)支付后的支付结果异步返回。
- 业务活动的主动方,在完成业务处理之后,向业务活动的被动方发送消息,允许消息丢失。
- 主动方可以设置时间阶梯型通知规则,在通知失败后按规则重复通知,直到通知N次后不再通知。
- 主动方提供校对查询接口给被动方按需校对查询,用于恢复丢失的业务消息。
- 业务活动的被动方如果正常接收了数据,就正常返回响应,并结束事务。
- 如果被动方没有正常接收,根据定时策略,向业务活动主动方查询,恢复丢失的业务消息。
特点
- 用到的服务模式:可查询操作、幂等操作;
- 被动方的处理结果不影响主动方的处理结果;
- 适用于对业务最终一致性的时间敏感度低的系统;
- 适合跨企业的系统间的操作,或者企业内部比较独立的系统间的操作,比如银行通知、商户通知等;
2.3.6.2最大努力通知对比DB本地消息表和MQ事务消息
- 最大努力通知 允许发起通知方处理业务失败,发起通知方需提供查询执行情况接口。需要知道消息的相关信息,可以在超时未收到消息时通过接口进行查询。
- DB本地消息表和MQ事务消息 是为了保证了分布式业务能够顺利执行。
2.4 总结:
属性 | 2PC | TCC | Saga | 本地消息表 | 事务消息 | 最大努力通知 |
---|---|---|---|---|---|---|
事务一致性 | 强一致 | 最终一致 | 最终一致 | 最终一致 | 最终一致 | 最终一致 |
性能 | 低 | 中 | 高 | 高 | 高 | 高 |
业务侵入性 | 小 | 大 | 小 | 中 | 中 | 中 |
复杂性 | 中 | 高 | 中 | 低 | 低 | 低 |
使用局限性 | 大 | 大 | 中 | 小 | 中 | 中 |
维护成本 | 低 | 高 | 中 | 低 | 中 | 中 |
总结 XA、TCC、Saga、AT 模式分析:
- XA模式是分布式强一致性的解决方案,但性能低而使用较少。
- TCC 模式是高性能分布式事务解决方案,适用于核心系统等对性能有很高要求的场景。
- Saga 模式是长事务解决方案,适用于业务流程长且需要保证事务最终一致性的业务系统,Saga 模式一阶段就会提交本地事务,无锁,长流程情况下可以保证性能,多用于渠道层、集成层业务系统。事务参与者可能是其它公司的服务或者是遗留系统的服务,无法进行改造和提供 TCC 要求的接口,也可以使用 Saga 模式。
- AT 模式是无侵入的分布式事务解决方案,适用于不希望对业务进行改造的场景,几乎0学习成本。
测试结果:
在单机环境下使用 jmeter 进行压测,
Seata AT模式:在100并发下成功数是 23、29,成功率不到30%,在1000并发下成功数是276、260,成功率同样是不到30%;
Seata TCC模式:在100并发下成功数 98、80,500并发下成功数是304,在1000并发下成功数是481;