开发中事务的实现
事务是什么?
了解过数据库的知道事务满足 A(原子性)、C(一致性)、I(隔离性)、D(独立性) 四个性质。在业务中,事务是一个程序执行的单元(由一组操作组成),保证里面的所有操作要么全部执行成功,要么全部执行失败。那么业务开发中要怎么实现事务呢?文章中将会介绍 单体系统下数据库本地事务的实现 和 分布式系统下分布式事务的实现。
开发中使用事务的场景
以电商系统的订单业务为例,在下单操作中包括生成订单、锁定库存、更新用户积分等操作,并且考虑到这些操作都必须是要么执行成功,要么一个业务出现异常,需要全部回滚。
如上图,最直观的地方就是服务的数据库,单体系统使用单一数据库,而分布式系统的各个服务都有自己的数据库。
在单体系统中下单操作的所有业务可能都在一个方法中实现,使用数据库本地事务即可保证事务操作(后面介绍使用SpringBoot的事务实现本地事务操作)。
在分布式系统下(随着业务量的增长,业务服务拆分,业务间相互隔离,可能下单操作需要远程调用库存服务、用户服务等服务),由于本地事务只能保证自己服务内的事务回滚(保证自己服务的数据一致性),即本地事务无法保证下单操作对远程调用的服务进行事务回滚(无法保证远程调用的服务数据一致性),所以就需要分布式事务。下面将依次介绍两种模式的实现方式。
1. 本地事务的实现方式
基于 SpringBoot 实现事务的两种方式
- 编程式实现(使用
TransactionTemplate
编程式实现事务) - 声明式实现(添加
@Transactional
注解声明式实现事务)
先看一个小问题:
@EnableTransactionManagement
用于开启事务支持,那么不写行吗?
答案是可以
项目启动时会通过@SpringBootApplication
注解中的@EnableAutoConfiguration
注解加载AutoConfigurationImportSelector
类,这个类会将META-INF/spring.factories
中的TransactionAutoConfiguration
类自动加载,到这里就自动完成了对事务的支持。所以在启动类中其实无需声明@EnableTransactionManagement
也可以使用事务。
1.1 编程式实现事务
通过
TransactionTemplate
的execute(TransactionCallback<T> action)
方法实现事务
从容器中拿出TransactionTemplate
的实例,通过TransactionTemplate
的execute
执行事务,通过try-catch包裹需要保证原子性的业务方法,如果执行中遇到异常,使用setRollbackOnly()
方法手动回滚事务。
/**
* 订单服务
* @author 单程车票
*/
@Service
public class OrderServiceImpl extends implements OrderService {
// 从容器中获取transactionTemplate
@Resource
private TransactionTemplate transactionTemplate;
/**
* 提交订单
*/
public SubmitOrderResponse submitOrder(OrderSubmitVo vo) {
// 从这里开启并执行事务
return transactionTemplate.execute(transactionStatus -> {
try{
// 业务代码
// TODO 锁定库存
// TODO 生成订单
// TODO 更新会员积分
// TODO ...
}catch (Exception e) {
// 出现异常回滚事务
transactionStatus.setRollbackOnly();
}
// 返回结果
return submitOrderResponse;
});
}
}
1.2 声明式实现事务
添加
@Transactional
在类上或方法上即可实现事务
添加@Transactional
在方法上,在方法执行前会开启事务,当方法正常执行结束,会自动提交事务,如果出现异常,则会自动回滚事务。
/**
* 订单服务(由于本身的业务代码过于冗长,这里将非核心代码省略)
* @author 单程车票
*/
@Service
public class OrderServiceImpl extends implements OrderService {
/**
* 提交订单
*/
@Transactional
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
// 下面业务只要存在异常就会全部回滚
// 业务代码
// TODO 锁定库存
// TODO 生成订单
// TODO 更新会员积分
// TODO ...
return submitOrderResponseVo;
}
}
@Transactional
的可选属性
propagation
:用于设置事务传播属性。该属性类型为Propagation
枚举,默认值为Propagation.REQUIRED
(如果当前没有事务,就创建一个新事务,如果当前存在事务,就加入该事务)。isolation
:用于设置事务的隔离级别。该属性类型为Isolation
枚举,默认值为Isolation.DEFAULT
。readOnly
:用于设置该方法对数据库的操作是否是只读的。该属性为boolean
,默认值为 false。timeout
:用于设置本操作与数据库连接的超时时限。单位为秒,类型为int
,默认值为-1,即没有时限。rollbackFor
:指定需要回滚的异常类。类型为Class[]
,默认值为空数组。若只有一个异常类时,可以不使用数组。rollbackForClassName
:指定需要回滚的异常类类名。类型为String[]
,默认值为空数组。若只有一个异常类时,可以不使用数组。noRollbackFor
:指定不需要回滚的异常类。类型为Class[]
,默认值为空数组。若只有一个异常类时,可以不使用数组。noRollbackForClassName
:指定不需要回滚的异常类类名。类型为String[]
,默认值为空数组。若只有一个异常类时,可以不使用数组。
@Transactional
的三个坑点
-
如果
@Transactional
添加在方法上,要求该方法一定要是public
。- 原因:
@Transactional
使用Spring AOP实现的,而@Transactional
在生成代理对象时会判断是否是public
,不是则无法生成代理对象,自然无法执行事务。
- 原因:
-
如果使用
@Transactional
时,内部不要使用try-catch
代码块包裹业务。- 只有在方法执行中出现异常,
@Transactional
才会执行事务回滚,如果捕获异常,会导致@Transactional
无法识别,自然不会回滚事务。
- 只有在方法执行中出现异常,
-
同一个类里事务方法互相调用会导致事务失效。
// 例子: 同一个类中事务方法互相调用的失效问题 @Service public class AService { @Transactional public void a() { // 调用b(),此时b()的事务会失效,原因是这里调用b()是通过this.b(),绕过了代理对象 b(); } @Transactional public void b() { } }
- 原因:同样是因为是通过Spring AOP实现的,需要使用动态代理,那么就需要使用代理对象完成,而内部调用使用的是this对象,这样就绕过了代理对象,就会导致事务失效。
针对同一个类中事务方法互相调用的失效问题解决方法
通过上面的分析,是因为使用的是this对象完成的b()
方法的调用,如果一定要在类内部调用事务方法,可以通过AspectJ动态代理的方式调用b()
即可。
步骤:
- 引入spring-aop依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
- 开启
@EnableAspectJAutoProxy(exposeProxy = true)
注解
//开启了AspectJ动态代理模式,并且对外暴露代理对象
@EnableAspectJAutoProxy(exposeProxy = true)
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
- 使用
AopContext.currentProxy()
创建代理对象调用内部事务方法
@Service
public class AService {
@Transactional
public void a() {
// 创建代理对象
AService aService = (AService) AopContext.currentProxy();
// 调用b()
aService.b();
}
@Transactional
public void b() {
}
}
2. 分布式事务的实现方式
了解分布式系统的理论基础:CAP 理论 与 BASE 理论
CAP 理论
- 一致性(Consistency):在某个节点更新或删除(写操作)成功后,所有节点都可以访问同一份最新且正确的数据(同一时刻数据是否保持一致)。
- 可用性(Availability):不会因为一部分节点故障失效而导致一直等待,集群整体对数据的更新具有高可用性。
- 分区容错性(Partition tolerance):可以容忍网络分区,在网络断开的情况下,导致节点分区,此时被分割开的节点依旧能正常对外提供服务。
CAP理论其实基本上是三选二的状态,三者不能共有。选择CP则放弃了可用性,要求一致性和分区容错性,选择AP则放弃了强一致性,要求高可用和分区容错性。分布式系统是不可放弃分区容错性的,但是其实P出现的几率很小,所以在设计时其实还是需要保证一致性和可用性的。
BASE 理论
- 基本可用(Basically Available):当分布式系统出现故障宕机时,允许损失部分可用性(响应时间上的损失、功能上的损失)
- 软状态(Soft state):允许系统数据存在中间状态,并认为该状态不会影响系统整体可用性。分布式系统中数据一般会有多个副本,允许不同副本同步的延时就是软状态的体现(即允许系统在不同节点的数据副本同步的过程存在延时)
- 最终一致性(Eventually consistent):系统中所有数据副本在经过一段时间后,最终能够达到一致的状态。
BASE理论是对CAP理论的AP的拓展,核心思想是即使无法做到强一致性,即可以牺牲强一致性来获得高可用性,数据允许在一段时间内是不一致的,即最终一致性。
根据上面两种理论,在分布式系统的场景中,对于不同需求的一致性要求是不同的,即根据不同的要求选用不同的解决方案。所以分布式事务解决方案可以分为 要求数据强一致性 和 要求数据最终一致性 两种。
- 要求数据强一致性:遵循数据库本地事务的ACID性质,CAP理论的CP。
- 要求数据最终一致性(柔性事务):遵循分布式的BASE理论,CAP理论的AP。
下面根据这两种分类介绍五种解决方案。
2.1 强一致性解决方案:2PC
XA 协议
XA协议是一个基于数据库层面的分布式事务协议,引入事务管理器和本地资源管理器,以事务管理器作为一个全局调度者,负责对每个本地资源管理器统一管理事物的提交和回滚。
2PC (两端提交)
2PC(两段提交)由XA协议衍生而来,通过引入协调者(事务管理器)统一管理参与者(本地资源管理器)的操作结果,并通过反馈的结果来管理参与者是否最终提交结果的操作。当出现本地资源管理器操作失败的结果返回后,协调者会根据结果进行中止操作,号令所有本地资源管理器回滚事务。
2PC流程图:
2PC两段提交:
- 第一阶段(准备阶段):事务管理器向每个涉及到事务的数据库(本地资源管理器)发送预提交,询问是否准备好,本地资源管理器反映给事务管理器是否可以提交(PREPARED 或 NO)。
- 第二阶段(提交阶段):事务管理器根据反映来要求每个本地资源管理器提交数据(COMMIT)或者回滚数据(ROLLBACK)。
2PC 的优缺点
- 优点:尽量保证了数据的强一致性,由于主流数据库Oracle、MYSQL都实现了XA协议,所以实现成本较低,且XA协议简单易懂。
- 缺点:
- 单点故障风险:事务管理器起着非常重要的作用,一旦出现故障宕机,而此时如果刚好在第二阶段,本地资源处于阻塞状态的话,会导致本地资源管理器一直处于阻塞,导致数据库无法使用。
- 网络抖动造成数据不一致性:如果在第二阶段事务管理器发送提交命令时出现网络抖动导致一部分本地资源管理器无法收到提交命令时,会导致一部分数据提交成功,另一部分数据没提交成功,造成数据的不一致性。
- 超时导致的同步阻塞问题:在就绪之后,本地资源会处于阻塞状态,直到提交成功,此时如果出现通信超时情况,会导致占用资源无法释放。
小结
由于过多的缺点(单点故障等),以及无法支持高并发(同步阻塞的原因),所以实际开发中很少使用2PC,但是需要了解。
2.2 柔性事务解决方案:TCC
TCC 事务补偿型方案
TCC 分为Try、Confirm、Cancel三个阶段
- Try阶段:尝试执行,检查业务所需资源,并预留业务资源(隔离性)。
- Confirm阶段:确认真正的去执行业务(不再检查资源),直接使用Try阶段预留的业务资源。
- Cancel阶段:当Try阶段出现问题(预留资源出现问题),则取消执行,释放Try阶段预留的所有业务资源。
TCC 流程图
TCC相对于前面的2PC方案的优势
- 解决了协调者的单点故障风险:不再由单一的协调者进行统一管理,而是交给业务处理者自身发起并完成。
- 不再因为超时而同步阻塞:当出现超时情况时,会进行补偿,不再锁死业务资源。
- 数据保持着一致性:资源统一由协调者管理,协调者控制着数据的一致性。
TCC 注意点
- 幂等性问题:由于网络异常或服务器故障超时等原因,需要在Confirm阶段和Cancel阶段加上重试机制,加入重试机制就意味着会遇到幂等性问题。需要设计方案保证幂等性,可以增加事务执行状态,在每次调用Confirm或Cancel接口时判断执行状态是否一致,从而保证幂等性。
- 执行Cancel接口前,先判断Try阶段是否执行过,未执行过Try,则无需执行Cancel方法,避免出现空回滚的情况。
- 执行Try接口前,判断Confirm或Cancel接口是否执行过,执行过则无需再调用Try方法,避免后续Try预留的资源无法释放。
- 针对2,3问题中如何判断是否执行过方法,可以创建一张记录事务的表,在启动事务时生成一条事务记录,执行过Try方法或Confirm方法或Cancel方法则记录在这条事务记录中,以便后续检查是否执行过方法时使用。
2.3 柔性事务解决方案:本地消息表
本地消息表方案的核心在于把大事务(分布式事务)转化为小事务(本地事务)
分布式系统下之所以会存在事务的实现困难,很大原因是虽然各个服务之间可以互相调用服务,但是服务之间无法直接感知别的服务的业务是否正常执行完成。
而本地消息表方案不使用直接调用从服务的方式处理业务,而是通过主业务处理业务同时把需要远程调用的从服务的业务通过消息的方式存入数据库的消息表中,并通过定时任务扫描消息表发送消息给消息队列,从业务通过消费消息执行自身服务需要执行的业务(其实就是异步执行远程服务任务)。这样的方式可通过消息队列传递的消息使得服务之间可以互相感知(即通过消息反馈自身服务需要回滚或执行成功)。
消息队列即可完成异步执行,为什么还需要先把业务存储到消息表中? 为了防止消息发送失败时,可以通过定时任务的方式扫描消息表的状态做到重新发送消息的效果。
本地消息表方案流程图
根据流程图描述一下步骤:
- 主业务服务在本地事务的状态下处理业务和将需要远程服务处理的业务写入消息表。
- 主业务服务会消息表中状态为未执行成功的消息发送给消息队列,从业务服务通过订阅消息队列消费主业务服务通知的消息并处理业务。
- 从业务服务会将执行的结果通过消息反馈给主业务服务。
- 主业务服务消费消息来更新消息表中对于消息的状态。
本地消息表方案是如何使事务回滚的
- 情况一:步骤1主业务服务执行业务(写业务数据)时出现异常,直接通过本地事务回滚,此时后续操作还没有进行。
- 情况二:步骤3发送从服务业务消息失败时,会通过定时扫描的方式检查消息表的消息状态,从而做到重新发送消息。
- 情况三:从业务服务执行业务失败时,会先通过自身本地事务进行回滚,同时把失败结果通过消息队列的方式反馈给主服务,主服务通过反馈结果手动回滚事务。
- 情况四:从服务已经完成业务,但主服务后续业务中出现问题,此时主服务可以通过自身本地事务先自身事务回滚,再通过消息的方式通知从服务手动回滚事务。
小结
通过上面的流程可以清楚的发现本地消息表方案采用的是数据最终一致性的方式保持事务的。
通过本地消息表的方式使得消息数据更可靠,但是同时也因为这个原因,导致业务数据和消息数据在同一个数据库,会导致占用业务资源,并且对业务代码的耦合性强。
2.4 柔性事务解决方案:MQ事务
MQ事务流程图
通过流程图可以发现基于MQ的分布式事务方案其实是对本地消息表方案的封装,类似于把本地消息表封装进了MQ中,虽然类似,但从步骤中也可以看出大不相同,MQ内部的处理尤为重要。
下面描述一下流程:
- 主服务先向消息队列MQ发送一条消息,消息队列收到消息后将其持久化,此时并不暴露给从服务。
- 消息队列MQ返回ack应答给主服务。
- 主服务开始执行自己的业务,并根据执行结果(完成or异常)情况发送不同请求给消息队列(这里无论发送什么请求,主业务也无需阻塞等待后续结果,后续全为异步执行):
- 情况一:主业务处理完成,向消息队列中发送COMMIT请求。(接着看后续执行)
- 情况二:主业务处理失败,向消息队列中发送ROLLBACK请求。消息队列收到请求后,直接丢弃该消息。(到这里结束,丢弃信息不会再投递给从服务,从服务也无需再执行业务)
- 消息队列收到请求为COMMIT后,向从服务投递该消息,触发从服务执行业务(投递消息后MQ进入阻塞等待状态)。
- 从服务业务完成后,从服务向消息队列MQ返回一个ack应答表示确认消费该消息,到此结束。
超时回查机制
针对主服务向MQ发送COMMIT/ROLLBACK时消息丢失问题,消息队列采用超时回查机制处理。
超时回查机制:当消息队列收到一条事务型消息后便会开启计时,如果超时了还没有收到COMMIT/ROLLBACK请求时,会主动调用事务查询接口回查主服务的事务状态,根据事务状态重新发送请求。(① 提交状态则发送COMMIT ② 回滚状态则发送ROLLBACK ③ 处理中状态则继续等待)
超时重传机制
针对消息队列MQ给从服务投递消息丢失 和 从服务响应消息队列MQ的ack应答丢失问题,消息队列采用超时重传机制解决该类问题。
超时重传机制:当消息队列投递消息给从服务器时,开始计时,遇到投递消息丢失或者返回应答ack丢失时,等到超时时会进行重新投递,直到从服务器能够返回应答为止(根据时间间隔不断尝试重新投递消息)。
小结
MQ事务同样再后续投递消息给从服务是异步执行,所以对应也是数据的最终一致性。
不同于本地消息表,MQ事务可以起到消息数据独立存储,降低了业务数据与消息数据的耦合度,并且具有更高吞吐量(因为主服务后续发送完请求后剩余操作属于异步执行,并且超时回查机制更好的降低了阻塞时间,提高了并发度)。
转载自:https://juejin.cn/post/7199254597914951741