spring事务面试题

Spring和事务的关系

关系型数据库、某些消息队列等产品或中间件称为事务性资源,因为它们本身支持事务,也能够处理事务。

Spring很显然不是事务性资源,但是它可以管理事务性资源,所以Spring和事务之间是管理关系。

就像Jack Ma虽然不会写代码,但是他却管理者一大批会写代码的码农。

Spring事务三要素

数据源:表示具体的事务性资源,是事务的真正处理者,如MySQL等。

事务管理器:像一个大管家,从整体上管理事务的处理过程,如打开、提交、回滚等。

事务应用和属性配置:像一个标识符,表明哪些方法要参与事务,如何参与事务,以及一些相关属性如隔离级别、超时时间等。

Spring事务的注解配置

把一个DataSource(如DruidDataSource)作为一个@Bean注册到Spring容器中,配置好事务性资源。

把一个@EnableTransactionManagement注解放到一个@Configuration类上,配置好事务管理器,并启用事务管理。

把一个@Transactional注解放到类上或方法上,可以设置注解的属性,表明该方法按配置好的属性参与到事务中。

事务注解的本质

@Transactional这个注解仅仅是一些(和事务相关的)元数据,在运行时被事务基础设施读取消费,并使用这些元数据来配置bean的事务行为。

大致来说具有两方面功能,一是表明该方法要参与事务,二是配置相关属性来定制事务的参与方式和运行行为。

Spring声明式事务实现原理

声明式事务成为可能,主要得益于Spring AOP。使用一个事务拦截器,在方法调用的前后/周围进行事务性增强(advice),来驱动事务完成。

如何回滚一个事务

就是在一个事务上下文中当前正在执行的代码里抛出一个异常,事务基础设施代码会捕获任何未处理的异常,并且做出决定是否标记这个事务为回滚。

默认回滚规则

默认RuntimeException及其子类,Error导致回滚。Checked exceptions默认不导致回滚。这些规则和EJB是一样的。

如何配置回滚异常

使用@Transactional注解的rollbackFor/rollbackForClassName属性,可以精确配置导致回滚的异常类型,包括checked exceptions。

noRollbackFor/noRollbackForClassName属性,可以配置不导致回滚的异常类型,当遇到这样的未处理异常时,照样提交相关事务。

事务注解在类/方法上

@Transactional注解既可以标注在类上,也可以标注在方法上。当在类上时,默认应用到类里的所有方法。如果此时方法上也标注了,则方法上的优先级高。

事务注解在类上的继承性

@Transactional注解的作用可以传播到子类,即如果父类标了子类就不用标了。但倒过来就不行了。

子类标了,并不会传到父类,所以父类方法不会有事务。父类方法需要在子类中重新声明而参与到子类上的注解,这样才会有事务。

事务注解在接口/类上

@Transactional注解可以用在接口上,也可以在类上。在接口上时,必须使用基于接口的代理才行,即JDK动态代理。

事实是Java的注解不能从接口继承,如果你使用基于类的代理,即CGLIB,或基于织入方面,即AspectJ,事务设置不会被代理和织入基础设施认出来,目标对象不会被包装到一个事务代理中。

Spring团队建议注解标注在类上而非接口上。

只在public方法上生效?

当采用代理来实现事务时,(注意是代理),@Transactional注解只能应用在public方法上。当标记在protected、private、package-visible方法上时,不会产生错误,但也不会表现出为它指定的事务配置。可以认为它作为一个普通的方法参与到一个public方法的事务中。

如果想在非public方法上生效,考虑使用AspectJ(织入方式)。

目标类里的自我调用没有事务?

在代理模式中(这是默认的),只有从外部的方法调用进入通过代理会被拦截,这意味着自我调用(实际就是,目标对象中的一个方法调用目标对象的另一个方法)在运行时不会导致一个实际的事务,即使被调用的方法标有注解。

如果你希望自我调用也使用事务来包装,考虑使用AspectJ的方式。在这种情况下,首先是没有代理。相反,目标类被织入(即它的字节码被修改)来把@Transactional加入到运行时行为,在任何种类的方法上都可以。

事务与线程

和JavaEE事务上下文一样,Spring事务和一个线程的执行相关联,底层是一个ThreadLocal,就是每个线程一个map,key是DataSource,value是Connection。

逻辑事务与物理事务

事务性资源实际打开的事务就是物理事务,如数据库的Connection打开的事务。Spring会为每个@Transactional方法创建一个事务范围,可以理解为是逻辑事务。

在逻辑事务中,大范围的事务称为外围事务,小范围的事务称为内部事务,外围事务可以包含内部事务,但在逻辑上是互相独立的。每一个这样的逻辑事务范围,都能够单独地决定rollback-only状态。

那么如何处理逻辑事务和物理事务之间的关联关系呢,这就是传播特性解决的问题。

事务的传播特性

REQUIRED,SUPPORTS,MANDATORY,REQUIRES_NEW,NOT_SUPPORTED,NEVER,NESTED

REQUIRED

强制要求要有一个物理事务。如果没有已经存在的事务,就专门打开一个事务用于当前范围。或者参与到一个已存在的更大范围的外围事务中。在相同的线程中,这是一种很好的默认方式安排。(例如,一个service外观/门面代理到若干个仓储方法,所有底层资源必须参与到service级别的事务里)

在标准的REQUIRED行为情况下,所有这样的逻辑事务范围映射到同一个物理事务。因此,在内部事务范围设置了rollback-only标记,确实会影响外围事务进行实际提交的机会。

注:默认,一个参与到外围事务的事务,会使用外围事务的特性,安静地忽略掉自己的隔离级别,超时值,只读标识等设置。当然可以在事务管理器上设置validateExistingTransactions标识为true,这样当你自己的事务和参与到的外围事务设置不一样时会被拒绝。

REQUIRES_NEW

与REQUIRED相比,总是使用一个独立的物理事务用于每一个受影响的逻辑事务范围,从来不参与到一个已存在的外围事务范围。这样安排的话,底层的事务资源是不同的,因此,可以独立地提交或回滚。外围事务不会被内部事务的回滚状态影响。这样一个独立的内部事务可以声明自己的隔离级别,超时时间和只读设置,并不继承外围事务的特性。

NESTED

使用同一个物理事务,带有多个保存点,可以回滚到这些保存点,可以认为是部分回滚,这样一个内部事务范围触发了一个回滚,外围事务能够继续这个物理事务,尽管有一些操作已经被回滚。典型地,它对应于JDBC的保存点,所以只对JDBC事务资源起作用。

SUPPORTS

支持当前事务。如果当前有事务,就参与进来,如果没有,就以非事务的方式运行。这样的一个逻辑事务范围,它背后可能没有实际的物理事务,此时的事务也成为空事务。

NOT_SUPPORTED

不支持当前事务。总是以非事务方式运行。当前的事务会被挂起,并在适合的时候恢复。

MANDATORY

支持当前事务。如果当前没有事务存在,就抛出异常。

NEVER

不支持当前事务。如果当前有事务存在,就抛出异常。

事务的隔离级别

DEFAULT,READ_UNCOMMITTED,READ_COMMITTED,REPEATABLE_READ,SERIALIZABLE

  1. 脏读(Dirty Reads):一个事务正在对一条记录做修改,在这个事务并提交前,这条记录的数据就处于不一致状态;这时,另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些尚未提交的脏数据,并据此做进一步的处理,就会产生未提交的数据依赖关系。这种现象被形象地叫做 “脏读”。
  2. 不可重复读(Non-Repeatable Reads):一个事务在读取某些数据已经发生了改变、或某些记录已经被删除了!这种现象叫做“不可重复读”。
  3. 幻读(Phantom Reads):一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为 “幻读”。

事务失效的7种情况

  1. 未启用spring事务管理功能
  2. 方法不是public类型的
  3. 数据源未配置事务管理器
  4. 自身调用问题
  5. 异常类型错误
  6. 异常被吞了
  7. 业务和spring事务代码必须在一个线程中

未启用spring事务管理功能

@EnableTransactionManagement注解用来启用spring事务自动管理事务的功能,这个注解千万不要忘记写了。

方法不是public类型的

@Transaction 可以用在类上、接口上、public方法上,如果将@Trasaction用在了非public方法上,事务将无效。

数据源未配置事务管理器

spring是通过事务管理器了来管理事务的,一定不要忘记配置事务管理器了,要注意为每个数据源配置一个事务管理器:

@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) {
    return new DataSourceTransactionManager(dataSource);
}

自身调用问题

spring是通过aop的方式,对需要spring管理事务的bean生成了代理对象,然后通过代理对象拦截了目标方法的执行,在方法前后添加了事务的功能,所以必须通过代理对象调用目标方法的时候,事务才会起效。

看下面代码,大家思考一个问题:当外部直接调用m1的时候,m2方法的事务会生效么?

@Component
public class UserService {
    public void m1(){
        this.m2();
    }

    @Transactional
    public void m2(){
        //执行db操作
    }
}

显然不会生效,因为m1中通过this的方式调用了m2方法,而this并不是代理对象,this.m2()不会被事务拦截器,所以事务是无效的,如果外部直接调用通过UserService这个bean来调用m2方法,事务是有效的,上面代码可以做一下调整,如下,@1在UserService中注入了自己,此时m1中的m2事务是生效的

@Component
public class UserService {
    @Autowired //@1
    private UserService userService;
    
    public void m1() {
        this.userService.m2();
    }

    @Transactional
    public void m2() {
        //执行db操作
    }
}

重点:必须通过代理对象访问方法,事务才会生效。

异常类型错误

spring事务回滚的机制:对业务方法进行try catch,当捕获到有指定的异常时,spring自动对事务进行回滚,那么问题来了,哪些异常spring会回滚事务呢?

并不是任何异常情况下,spring都会回滚事务,默认情况下,RuntimeException和Error的情况下,spring事务才会回滚。

也可以自定义回滚的异常类型:

@Transactional(rollbackFor = {异常类型列表})

异常被吞了

当业务方法抛出异常,spring感知到异常的时候,才会做事务回滚的操作,若方法内部将异常给吞了,那么事务无法感知到异常了,事务就不会回滚了。

如下代码,事务操作2发生了异常,但是被捕获了,此时事务并不会被回滚

@Transactional
public void m1(){
    事务操作1
    try{
        事务操作2,内部抛出了异常
    }catch(Exception e){
    }
}

业务和spring事务代码必须在一个线程中

spring事务实现中使用了ThreadLocal,ThreadLocal大家应该知道吧,可以实现同一个线程中数据共享,必须是同一个线程的时候,数据才可以共享,这就要求业务代码必须和spring事务的源码执行过程必须在一个线程中,才会受spring事务的控制,比如下面代码,方法内部的子线程内部执行的事务操作将不受m1方法上spring事务的控制,这个大家一定要注意

@Transactional
public void m1() {
    new Thread() {
        一系列事务操作
    }.start();
}

如何快速定位事务相关bug?

2种方式

方式1:看日志

如果你使用了logback或者log4j来输出日志,可以修改一下日志级别为debug模式,可以看到事务的详细执行日志,帮助你定位错误

方式2:调试代码

如果你对源码比较了解,那么你会知道被spring管理事务的业务方法,执行的时候都会被TransactionInterceptor拦截器拦截,会进入到它的invoke方法中,咱们可以在invoke方法中设置一些断点,可以看到详细的执行过程,排错也就比较容易了。

微服务下如何保证事务的一致性

前言背景:

本地事务通过 ACID 保证数据的强一致性。ACID 是 Atomic(原子性)、Consistency(一致性)、 Isolation(隔离性)和 Durability(持久性)的缩写 。在实际开发过程中,我们或多或少都有使用到本地事务。例如,MySQL 事务处理使用到 begin 开始一个事务,rollback 事务回滚,commit 事务确认。这里,事务提交后,通过 redo log 记录变更,通过 undo log 在失败时进行回滚,保证事务的原子性。Spring 使用 @Transactional 注解就可以搞定事务功能。事实上,Spring 封装了这些细节,在生成相关的 Bean 的时候,在需要注入相关的带有 @Transactional 注解的 bean 时候用代理去注入,在代理中为我们开启提交 / 回滚事务。

由于业务逐渐复杂,数据量增多,例如,上千万甚至上亿的数据,查询一次所花费的时间会变长,甚至会造成数据库的单点压力,此时对于数据库可以通过两种方式:垂直分表、水平分表;

  • 水平分表:对表的行进行拆分。因为表的行数超过几百万行时,就会变慢,这时可以把一张表的数据拆成多张表来存放。水平拆分,有许多策略,例如,取模分表,时间维度分表等。这种场景下,虽然我们根据特定规则分表了,我们仍然可以使用本地事务,因为我们可以将对于相同数据的读写路由到相同的数据库实例上;
  • 垂直分表:对表的列进行拆分。垂直分表则会将原有表中的数据列按照领域的维度拆分为多个表,并且多个表会部署在不同微服务对应的不同的数据库中,但是这多个表之间可能仍然存在数据一致性的要求,而在分布式场景中每个数据库只能够保证自身本地事务的ACID特性,并且微服务间只可以通过网络通信了解到其他事务的执行状态,因此需要提供额外的机制负责维护微服务间的分布式事务;

因为单体架构->微服务架构的过程中,原本的本地进程调用->远程过程调用/消息队列通信,因此对于服务可用性、数据一致性的讨论需要加入网络这个因素;

此外,不仅仅在跨库调用存在本地事务无法解决的问题,随着微服务的落地中,每个服务都有自己的数据库,并且数据库是相互独立且透明的。那如果服务 A 需要获取服务 B 的数据,就存在跨服务调用,如果遇到服务宕机,或者网络连接异常、同步调用超时等场景就会导致数据的不一致,这个也是一种分布式场景下需要考虑数据一致性问题。

引入分布式事务:

我们可以简单地理解,它就是为了保证不同数据库的数据一致性的事务解决方案。

这里,我们有必要先来了解下 CAP 原则和 BASE 理论。CAP 原则是 Consistency(一致性)、Availablity(可用性)和 Partition-tolerance(分区容错性)的缩写,它是分布式系统中的平衡理论。在分布式系统中,一致性要求所有节点每次读操作都能保证获取到最新数据;可用性要求无论任何故障产生后都能保证服务仍然可用;分区容错性要求被分区的节点可以正常对外提供服务。事实上,任何系统只可同时满足其中二个,无法三者兼顾。对于分布式系统而言,分区容错性是一个最基本的要求。那么,如果选择了一致性和分区容错性,放弃可用性,那么网络问题会导致系统不可用。如果选择可用性和分区容错性,放弃一致性,不同的节点之间的数据不能及时同步数据而导致数据的不一致。

BASE 理论针对一致性和可用性提出了一个方案,BASE 是 Basically Available(基本可用)、Soft-state(软状态)和 Eventually Consistent(最终一致性)的缩写,它是最终一致性的理论支撑。简单地理解,在分布式系统中,允许损失部分可用性,并且不同节点进行数据同步的过程存在延时,但是在经过一段时间的修复后,最终能够达到数据的最终一致性。BASE 强调的是数据的最终一致性。相比于 ACID 而言,BASE 通过允许损失部分一致性来获得可用性。

现在,业内比较常用的分布式事务解决方案,包括强一致性的两阶段提交协议,三阶段提交协议,以及最终一致性的可靠事件模式、补偿模式,阿里的 TCC 模式

强一致性解决方案

二阶段提交协议(2PC)

在分布式系统中,每个数据库只能保证自己的数据可以满足 ACID 保证强一致性,但是它们可能部署在不同的服务器上,只能通过网络进行通信,因此无法准确的知道其他数据库中的事务执行情况。因此,为了解决多个节点之间的协调问题,就需要引入一个协调者负责控制所有节点的操作结果,要么全部成功,要么全部失败。其中,XA 协议是一个分布式事务协议,它有两个角色:事务管理者和资源管理者。这里,我们可以把事务管理者理解为协调者,而资源管理者理解为参与者。

XA 协议通过二阶段提交协议保证强一致性。

二阶段提交协议具有两个阶段:第一阶段准备,第二阶段提交。事务管理者(协调者)主要负责控制所有节点的操作结果,包括准备流程和提交流程。
第一阶段,事务管理者(协调者)向资源管理者(参与者)发起准备指令,询问资源管理者(参与者)预提交是否成功。如果资源管理者(参与者)可以完成,就会执行操作,并不提交,最后给出自己响应结果,是预提交成功还是预提交失败。
第二阶段,如果全部资源管理者(参与者)都回复预提交成功,资源管理者(参与者)正式提交命令。如果其中有一个资源管理者(参与者)回复预提交失败,则事务管理者(协调者)向所有的资源管理者(参与者)发起回滚命令。

举个案例,现在我们有一个事务管理者(协调者),三个资源管理者(参与者),那么这个事务中我们需要保证这三个参与者在事务过程中的数据的强一致性。首先,事务管理者(协调者)发起准备指令预判它们是否已经预提交成功了,如果全部回复预提交成功,那么事务管理者(协调者)正式发起提交命令执行数据的变更。

二阶段提交协议的问题:

  • 同步通信的性能问题: 由协调者节点同步通信维护分布式事务的状态,依赖参与者节点本地事务维护了节点间数据的强一致性;但是在分布式事务运行的整个过程中,本地事务一直处于未提交的状态、对应的业务数据处于锁定状态,因此在高并发或者说该分布式事务涉及到的参与者节点较多时,会降低系统可承载的并发量;因此最直观的优化的方案为同步->异步,并基于消息事件进行实现;即可靠事件模式;
  • 同步阻塞问题: 上述2PC整个流程是同步的,事务管理者(协调者)必须等待每一个资源管理者(参与者)返回操作结果后才能进行下一步操作,这样就非常容易造成同步阻塞问题;因此3PC将准备阶段拆分为预备和准备阶段用于在预备阶段提前发现挂掉的节点而提前终止事务,并引入了超时机制解决协调者因为网络因素一直处于阻塞状态的问题;
  • 引入超时机制的问题: 因为网络延迟不固定的原因,可能存在协调者节点发起prepare()调用的网络包因为网络延迟在超时时间内尚未到达服务节点,因此协调者节点会发起cancel()调用用于事务回滚,因此可能存在cancel()包已经到达而prepare()包尚未到达的情况,因此服务需要支持空回滚;但是更可靠的方式是设置过期时间>>网络通信时间,并设置基于网络平均延迟的网络包指数级避让重发的机制。
  • 协调者单点故障问题: 二阶段提交亦会有单点故障的问题,虽然3PC通过引入超时机制,并将二阶段提交的准备过程拆分成两个步骤,但是仍然无法避免协调者的单点故障问题;而基于服务协同的可靠事件模式可以有效避免单点故障问题。
三阶段提交协议(3PC)

三阶段提交协议是二阶段提交协议的改良版本,它与二阶段提交协议不同之处在于,引入了超时机制解决同步阻塞问题,此外加入了预备阶段尽可能提早发现无法执行的资源管理者(参与者)并且终止事务,如果全部资源管理者(参与者)都可以完成,才发起第二阶段的准备和第三阶段的提交。否则,其中任何一个资源管理者(参与者)回复执行,或者超时等待,那么就终止事务。总结一下,三阶段提交协议包括:第一阶段预备,第二阶段准备,第二阶段提交。

三阶段提交协议的问题:极小概率的场景下可能会出现数据的不一致性。因为三阶段提交协议引入了超时机制,如果出现资源管理者(参与者)超时场景会默认提交成功,但是如果其没有成功执行,或者其他资源管理者(参与者)出现回滚,那么就会出现数据的不一致性。

最终一致性解决方案

TCC 模式

二阶段提交协议和三阶段提交协议很好的解决了分布式事务的问题,但是在极端情况下仍然存在数据的不一致性,此外它对系统的开销会比较大,引入事务管理者(协调者)后,比较容易出现单点瓶颈,以及在业务规模不断变大的情况下,系统可伸缩性也会存在问题。注意的是,它是同步操作,因此引入事务后,直到全局事务结束才能释放资源,性能可能是一个很大的问题。因此,在高并发场景下很少使用。因此,阿里提出了另外一种解决方案:TCC 模式。注意的是,很多读者把二阶段提交等同于二阶段提交协议,这个是一个误区,事实上,TCC 模式也是一种二阶段提交。

TCC 模式将一个任务拆分三个操作:Try、Confirm、Cancel。假如,我们有一个 func() 方法,那么在 TCC 模式中,它就变成了 tryFunc()、confirmFunc()、cancelFunc() 三个方法。

tryFunc();

confirmFunc();

cancelFunc();

在 TCC 模式中,主业务服务负责发起流程,而从业务服务提供 TCC 模式的 Try、Confirm、Cancel 三个操作。其中,还有一个事务管理器的角色负责控制事务的一致性。例如,我们现在有三个业务服务:交易服务,库存服务,支付服务。用户选商品,下订单,紧接着选择支付方式进行付款,然后这笔请求,交易服务会先调用库存服务扣库存,然后交易服务再调用支付服务进行相关的支付操作,然后支付服务会请求第三方支付平台创建交易并扣款,这里,交易服务就是主业务服务,而库存服务和支付服务是从业务服务。

TCC 模式的流程:第一阶段主业务服务调用全部的从业务服务的 Try 操作,并且事务管理器记录操作日志。第二阶段,当全部从业务服务都成功时,再执行 Confirm 操作,否则会执行 Cancel 逆操作进行回滚。

现在,我们针对 TCC 模式说说大致业务上的实现思路。首先,交易服务(主业务服务)会向事务管理器注册并启动事务。其实,事务管理器是一个概念上的全局事务管理机制,可以是一个内嵌于主业务服务的业务逻辑,或者抽离出的一个 TCC 框架。事实上,它会生成全局事务 ID 用于记录整个事务链路,并且实现了一套嵌套事务的处理逻辑。当主业务服务调用全部的从业务服务的 try 操作,事务管理器利用本地事务记录相关事务日志,这个案例中,它记录了调用库存服务的动作记录,以及调用支付服务的动作记录,并将其状态设置成“预提交”状态。这里,调用从业务服务的 Try 操作就是核心的业务代码。那么, Try 操作怎么和它相对应的 Confirm、Cancel 操作绑定呢?其实,我们可以编写配置文件建立绑定关系,或者通过 Spring 的注解添加 confirm 和 cancel 两个参数也是不错的选择。当全部从业务服务都成功时,由事务管理器通过 TCC 事务上下文切面执行 Confirm 操作,将其状态设置成“成功”状态,否则执行 Cancel 操作将其状态设置成“预提交”状态,然后进行重试。因此,TCC 模式通过补偿的方式保证其最终一致性。

TCC 的实现框架有很多成熟的开源项目,例如 tcc-transaction 框架。(关于 tcc-transaction 框架的细节,可以阅读:https://github.com/changmingxie/tcc-transaction)。

tcc-transaction 框架主要涉及 tcc-transaction-core、tcc-transaction-api、tcc-transaction-spring 三个模块。其中,tcc-transaction-core 是 tcc-transaction 的底层实现,tcc-transaction-api 是 tcc-transaction 使用的 API,tcc-transaction-spring 是 tcc-transaction 的 Spring 支持。tcc-transaction 将每个业务操作抽象成事务参与者,每个事务可以包含多个参与者。参与者需要声明 try / confirm / cancel 三个类型的方法。这里,我们通过 @Compensable 注解标记在 try 方法上,并定义相应的 confirm / cancel 方法。

// try 方法
@Compensable(confirmMethod = "confirmRecord", cancelMethod = "cancelRecord", transactionContextEditor = MethodTransactionContextEditor.class)
@Transactional
public String record(TransactionContext transactionContext, CapitalTradeOrderDto tradeOrderDto) {}
 
// confirm 方法
@Transactional
public void confirmRecord(TransactionContext transactionContext, CapitalTradeOrderDto tradeOrderDto) {}
 
// cancel 方法
@Transactional
public void cancelRecord(TransactionContext transactionContext, CapitalTradeOrderDto tradeOrderDto) {}

对于 tcc-transaction 框架的实现,我们来了解一些核心思路。tcc-transaction 框架通过 @Compensable 切面进行拦截,可以透明化对参与者 confirm / cancel 方法调用,从而实现 TCC 模式。这里,tcc-transaction 有两个拦截器。

  • org.mengyun.tcctransaction.interceptor.CompensableTransactionInterceptor,可补偿事务拦截器。
  • org.mengyun.tcctransaction.interceptor.ResourceCoordinatorInterceptor,资源协调者拦截器。

这里,需要特别关注 TransactionContext 事务上下文,因为我们需要远程调用服务的参与者时通过参数的形式传递事务给远程参与者。在 tcc-transaction 中,一个事务org.mengyun.tcctransaction.Transaction可以有多个参与者org.mengyun.tcctransaction.Participant 参与业务活动。其中,事务编号 TransactionXid 用于唯一标识一个事务,它使用 UUID 算法生成,保证唯一性。当参与者进行远程调用时,远程的分支事务的事务编号等于该参与者的事务编号。通过事务编号的关联 TCC confirm / cancel 方法,使参与者的事务编号和远程的分支事务进行关联,从而实现事务的提交和回滚。事务状态 TransactionStatus 包含 :尝试中状态 TRYING(1)、确认中状态 CONFIRMING(2)、取消中状态 CANCELLING(3)。此外,事务类型 TransactionType 包含 :根事务 ROOT(1)、分支事务 BRANCH(2)。当调用 TransactionManager#begin() 发起根事务时,类型为 MethodType.ROOT,并且事务 try 方法被调用。调用 TransactionManager#propagationNewBegin() 方法,传播发起分支事务。该方法在调用方法类型为 MethodType.PROVIDER 并且 事务 try 方法被调用。调用 TransactionManager#commit() 方法提交事务。该方法在事务处于 confirm / cancel 方法被调用。类似地,调用 TransactionManager#rollback() 方法,取消事务。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QyuxfvNS-1685347830675)(.\assets\事务\format,png-1684490548710-15.png)]

此外,对于事务恢复机制,tcc-transaction 框架基于 Quartz 实现调度,按照一定频率对事务进行重试,直到事务完成或超过最大重试次数。如果单个事务超过最大重试次数时,tcc-transaction 框架不再重试,此时需要手工介入解决。

这里,我们要特别注意操作的幂等性。幂等机制的核心是保证资源唯一性,例如重复提交或服务端的多次重试只会产生一份结果。支付场景、退款场景,涉及金钱的交易不能出现多次扣款等问题。事实上,查询接口用于获取资源,因为它只是查询数据而不会影响到资源的变化,因此不管调用多少次接口,资源都不会改变,所以是它是幂等的。而新增接口是非幂等的,因为调用接口多次,它都将会产生资源的变化。因此,我们需要在出现重复提交时进行幂等处理。那么,如何保证幂等机制呢?事实上,我们有很多实现方案。其中,一种方案就是常见的创建唯一索引。在数据库中针对我们需要约束的资源字段创建唯一索引,可以防止插入重复的数据。但是,遇到分库分表的情况是,唯一索引也就不那么好使了,此时,我们可以先查询一次数据库,然后判断是否约束的资源字段存在重复,没有的重复时再进行插入操作。注意的是,为了避免并发场景,我们可以通过锁机制,例如悲观锁与乐观锁保证数据的唯一性。这里,分布式锁是一种经常使用的方案,它通常情况下是一种悲观锁的实现。但是,很多人经常把悲观锁、乐观锁、分布式锁当作幂等机制的解决方案,这个是不正确的。除此之外,我们还可以引入状态机,通过状态机进行状态的约束以及状态跳转,确保同一个业务的流程化执行,从而实现数据幂等。

补偿模式(重试机制)

上节,我们提到了重试机制。事实上,它也是一种最终一致性的解决方案:我们需要通过最大努力不断重试,保证数据库的操作最终一定可以保证数据一致性,如果最终多次重试失败可以根据相关日志并主动通知开发人员进行手工介入。注意的是,被调用方需要保证其幂等性。重试机制可以是同步机制,例如主业务服务调用超时或者非异常的调用失败需要及时重新发起业务调用。重试机制可以大致分为固定次数的重试策略与固定时间的重试策略。除此之外,我们还可以借助消息队列和定时任务机制。消息队列的重试机制,即消息消费失败则进行重新投递,这样就可以避免消息没有被消费而被丢弃,例如 RocketMQ 可以默认允许每条消息最多重试 16 次,每次重试的间隔时间可以进行设置。定时任务的重试机制,我们可以创建一张任务执行表,并增加一个“重试次数”字段。这种设计方案中,我们可以在定时调用时,获取这个任务是否是执行失败的状态并且没有超过重试次数,如果是则进行失败重试。但是,当出现执行失败的状态并且超过重试次数时,就说明这个任务永久失败了,需要开发人员进行手工介入与排查问题。

除了重试机制之外,也可以在每次更新的时候进行修复。例如,对于社交互动的点赞数、收藏数、评论数等计数场景,也许因为网络抖动或者相关服务不可用,导致某段时间内的数据不一致,我们就可以在每次更新的时候进行修复,保证系统经过一段较短的时间的自我恢复和修正,数据最终达到一致。需要注意的是,使用这种解决方案的情况下,如果某条数据出现不一致性,但是又没有再次更新修复,那么其永远都会是异常数据。

定时校对也是一种非常重要的解决手段,它采取周期性的进行校验操作来保证。关于定时任务框架的选型上,业内比较常用的有单机场景下的 Quartz,以及分布式场景下 Elastic-Job、XXL-JOB、SchedulerX 等分布式定时任务中间件。关于定时校对可以分为两种场景,一种是未完成的定时重试,例如我们利用定时任务扫描还未完成的调用任务,并通过补偿机制来修复,实现数据最终达到一致。另一种是定时核对,它需要主业务服务提供相关查询接口给从业务服务核对查询,用于恢复丢失的业务数据。现在,我们来试想一下电商场景的退款业务。在这个退款业务中会存在一个退款基础服务和自动化退款服务。此时,自动化退款服务在退款基础服务的基础上实现退款能力的增强,实现基于多规则的自动化退款,并且通过消息队列接收到退款基础服务推送的退款快照信息。但是,由于退款基础服务发送消息丢失或者消息队列在多次失败重试后的主动丢弃,都很有可能造成数据的不一致性。因此,我们通过定时从退款基础服务查询核对,恢复丢失的业务数据就显得特别重要了。

可靠事件模式

在分布式系统中,消息队列在服务端的架构中的地位非常重要,主要解决异步处理、系统解耦、流量削峰等场景。多个系统之间如果同步通信很容易造成阻塞,同时会将这些系统会耦合在一起。因此,引入了消息队列,一方面解决了同步通信机制造成的阻塞,另一方面通过消息队列进行业务解耦。

可靠事件模式是基于队列实现服务协同通信的最终一致性解决方案;与2PC将分布式事务分为准备/提交两个阶段不同,可靠事件模式则是将一个分布式长事务拆分成多个事务,通过向消息队列投递事件异步触发事务流程。

这里,请读者思考,是否只要引入了消息队列就可以解决问题了呢?事实上,只是引入消息队列并不能保证其最终的一致性,因为分布式部署环境下都是基于网络进行通信,而网络通信过程中,上下游可能因为各种原因而导致消息丢失。

其一,主业务服务发送消息时可能因为消息队列无法使用而发生失败。对于这种情况,我们可以让主业务服务(生产者)发送消息,再进行业务调用来确保。一般的做法是,主业务服务将要发送的消息持久化到本地数据库,设置标志状态为“待发送”状态,然后把消息发送给消息队列,消息队列收到消息后,也把消息持久化到其存储服务中,但并不是立即向从业务服务(消费者)投递消息,而是先向主业务服务(生产者)返回消息队列的响应结果,然后主业务服务判断响应结果执行之后的业务处理。如果响应失败,则放弃之后的业务处理,设置本地的持久化消息标志状态为“结束”状态。否则,执行后续的业务处理,设置本地的持久化消息标志状态为“已发送”状态。

public void doServer(){
    // 发送消息
    send();
    // 执行业务
    exec();
    // 更新消息状态
    updateMsg();
}

此外,消息队列发生消息后,也可能从业务服务(消费者)宕机而无法消费。绝大多数消息中间件对于这种情况,例如 RabbitMQ、RocketMQ 等引入了 ACK 机制。注意的是,默认的情况下,采用自动应答,这种方式中消息队列会发送消息后立即从消息队列中删除该消息。所以,为了确保消息的可靠投递,我们通过手动 ACK 方式,如果从业务服务(消费者)因宕机等原因没有发送 ACK,消息队列会将消息重新发送,保证消息的可靠性。从业务服务处理完相关业务后通过手动 ACK 通知消息队列,消息队列才从消息队列中删除该持久化消息。那么,消息队列如果一直重试失败而无法投递,就会出现消息主动丢弃的情况,我们需要如何解决呢?聪明的读者可能已经发现,我们在上个步骤中,主业务服务已经将要发送的消息持久化到本地数据库。因此,从业务服务消费成功后,它也会向消息队列发送一个通知消息,此时它是一个消息的生产者。主业务服务(消费者)接收到消息后,最终把本地的持久化消息标志状态为“完成”状态。说到这里,读者应该可以理解到我们使用“正反向消息机制”确保了消息队列可靠事件投递。当然,补偿机制也是必不可少的。定时任务会从数据库扫描在一定时间内未完成的消息并重新投递。

注意的是,因为从业务服务可能收到消息处理超时或者服务宕机,以及网络等原因导致而消息队列收不到消息的处理结果,因此可靠事件投递并且消息队列确保事件传递至少一次。这里,从业务服务(消费者)需要保证幂等性。如果从业务服务(消费者)没有保证接口的幂等性,将会导致重复提交等异常场景。此外,我们也可以独立消息服务,将消息服务独立部署,根据不同的业务场景共用该消息服务,降低重复开发服务的成本。

了解了“可靠事件模式”的方法论后,现在我们来看一个真实的案例来加深理解。首先,当用户发起退款后,自动化退款服务会收到一个退款的事件消息,此时,如果这笔退款符合自动化退款策略的话,自动化退款服务会先写入本地数据库持久化这笔退款快照,紧接着,发送一条执行退款的消息投递到给消息队列,消息队列接受到消息后返回响应成功结果,那么自动化退款服务就可以执行后续的业务逻辑。与此同时,消息队列异步地把消息投递给退款基础服务,然后退款基础服务执行自己业务相关的逻辑,执行失败与否由退款基础服务自我保证,如果执行成功则发送一条执行退款成功消息投递到给消息队列。最后,定时任务会从数据库扫描在一定时间内未完成的消息并重新投递。这里,需要注意的是,自动化退款服务持久化的退款快照可以理解为需要确保投递成功的消息,由“正反向消息机制”和“定时任务”确保其成功投递。此外,真正的退款出账逻辑在退款基础服务来保证,因此它要保证幂等性,及出账逻辑的收敛。当出现执行失败的状态并且超过重试次数时,就说明这个任务永久失败了,需要开发人员进行手工介入与排查问题。

总结一下,引入了消息队列并不能保证可靠事件投递,换句话说,由于网络等各种原因而导致消息丢失不能保证其最终的一致性,因此,我们需要通过“正反向消息机制”确保了消息队列可靠事件投递,并且使用补偿机制尽可能在一定时间内未完成的消息并重新投递。

编排式Saga方案

开源项目中对分布式事务的应用有很多值得我们学习与借鉴的地方。本节,我们就来对其实现进行解读。

RocketMQ

Apache RocketMQ 是阿里开源的一款高性能、高吞吐量的分布式消息中间件。在历年双 11 中,RocketMQ 都承担了阿里巴巴生产系统全部的消息流转,在核心交易链路有着稳定和出色的表现,是承载交易峰值的核心基础产品之一。RocketMQ 同时存在商用版 MQ 可在阿里云上购买,阿里巴巴对于开源版本和商业版本,主要区别在于:会开源分布式消息所有核心的特性,而在商业层面,尤其是云平台的搭建上面,将运维管控、安全授权、深度培训等纳入商业重中之重。

Apache RocketMQ 4.3 版本正式支持分布式事务消息。RocketMQ 事务消息设计主要解决了生产者端的消息发送与本地事务执行的原子性问题,换句话说,如果本地事务执行不成功,则不会进行 MQ 消息推送。那么,聪明的你可能就会存在疑问:我们可以先执行本地事务,执行成功了再发送 MQ 消息,这样不就可以保证事务性的?但是,请你再认真的思考下,如果 MQ 消息发送不成功怎么办呢?事实上,RocketMQ 对此提供一个很好的思路和解决方案。RocketMQ 首先会发送预执行消息到 MQ,并且在发送预执行消息成功后执行本地事务。紧接着,它根据本地事务执行结果进行后续执行逻辑,如果本地事务执行结果是 commit,那么正式投递 MQ 消息,如果本地事务执行结果是 rollback,则 MQ 删除之前投递的预执行消息,不进行投递下发。注意的是,对于异常情况,例如执行本地事务过程中,服务器宕机或者超时,RocketMQ 将会不停的询问其同组的其他生产者端来获取状态。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ot8lp7wu-1685347830676)(.\assets\事务\format,png-1684491210112-27.png)]

ServiceComb

ServiceComb 基于华为内部的 CSE(Cloud Service Engine) 框架开源而来,它提供了一套包含代码框架生成,服务注册发现,负载均衡,服务可靠性(容错熔断,限流降级,调用链追踪)等功能的微服务框架。其中,ServiceComb Saga 是一个微服务应用的数据最终一致性解决方案。

Saga 拆分分布式事务为多个本地事务,然后由 Saga 引擎负责协调。如果整个流程正常结束,那么业务成功完成;如果在这过程中实现出现部分失败,那么 Saga 引擎调用补偿操作。Saga 有两种恢复的策略 :向前恢复和向后恢复。其中,向前恢复对失败的节点采取最大努力不断重试,保证数据库的操作最终一定可以保证数据一致性,如果最终多次重试失败可以根据相关日志并主动通知开发人员进行手工介入。向后恢复对之前所有成功的节点执行回滚的事务操作,这样保证数据达到一致的效果。

Saga 与 TCC 不同之处在于,Saga 比 TCC 少了一个 Try 操作。因此,Saga 会直接提交到数据库,然后出现失败的时候,进行补偿操作。Saga 的设计可能导致在极端场景下的补偿动作比较麻烦,但是对于简单的业务逻辑侵入性更低,更轻量级,并且减少了通信次数。

ServiceComb Saga 在其理论基础上进行了扩展,它包含两个组件:alpha 和 omega。alpha 充当协调者,主要负责对事务的事件进行持久化存储以及协调子事务的状态,使其得以最终与全局事务的状态保持一致。omega 是微服务中内嵌的一个 agent,负责对网络请求进行拦截并向 alpha 上报事务事件,并在异常情况下根据 alpha 下发的指令执行相应的补偿操作。在预处理阶段,alpha 会记录事务开始的事件;在后处理阶段,alpha 会记录事务结束的事件。因此,每个成功的子事务都有一一对应的开始及结束事件。在服务生产方,omega 会拦截请求中事务相关的 id 来提取事务的上下文。在服务消费方,omega 会在请求中注入事务相关的 id 来传递事务的上下文。通过服务提供方和服务消费方的这种协作处理,子事务能连接起来形成一个完整的全局事务。注意的是,Saga 要求相关的子事务提供事务处理方法,并且提供补偿函数。这里,添加 @EnableOmega 的注解来初始化 omega 的配置并与 alpha 建立连接。在全局事务的起点添加 @SagaStart 的注解,在子事务添加 @Compensable 的注解指明其对应的补偿方法。使用案例:https://github.com/apache/servicecomb-saga/tree/master/saga-demo

@EnableOmega
public class Application{
  public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
  }
}

@SagaStart
public void xxx() { }
 
@Compensable
public void transfer() { }

业务流程图:

再看下该篇文章:面试官:浅析微服务下保证事务数据一致性的几种方案 - 知乎 (zhihu.com)

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值