什么是事务

事务是逻辑上的一组操作,要么都执行,要么都不执行。

相信大家应该都能背上面这句话了,下面我结合我们日常的真实开发来谈一谈。

我们系统的每个业务方法可能包括了多个原子性的数据库操作,比如下面的 savePerson() 方法中就有两个原子性的数据库操作。这些原子性的数据库操作是有依赖的,它们要么都执行,要不就都不执行。

public void savePerson() {
    personDao.save(person);
    personDetailDao.save(personDetail);
}

另外,需要格外注意的是:事务能否生效数据库引擎是否支持事务是关键。比如常用的 MySQL 数据库默认使用支持事务的 InnoDB 引擎。但是,如果把数据库引擎变为 MyISAM,那么程序也就不再支持事务了!

事务最经典也经常被拿出来说例子就是转账了。假如小明要给小红转账 1000 元,这个转账会涉及到两个关键操作就是:

  1. 将小明的余额减少 1000 元。

  2. 将小红的余额增加 1000 元。

万一在这两个操作之间突然出现错误比如银行系统崩溃或者网络故障,导致小明余额减少而小红的余额没有增加,这样就不对了。事务就是保证这两个关键操作要么都成功,要么都要失败。

public class OrdersService {
    private AccountDao accountDao;
    public void setOrdersDao(AccountDao accountDao) {
        this.accountDao = accountDao;
    }

    @Transactional(propagation = Propagation.REQUIRED,
                 isolation = Isolation.DEFAULT, 
                 readOnly = false, timeout = -1)
    public void accountMoney() {
        // 小红账户多1000
        accountDao.addMoney(1000,xiaohong);
        // 模拟突然出现的异常,比如银行中可能为突然停电等等
        // 如果没有配置事务管理的话会造成,小红账户多了1000而小明账户没有少钱
        int i = 10 / 0;
        // 小王账户少1000
        accountDao.reduceMoney(1000,xiaoming);
    }
}

另外,数据库事务的 ACID 四大特性是事务的基础,下面简单来了解一下。

事务的特性(ACID)

  • 原子性(Atomicity):事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用如果事务中的任何一个操作失败,那么整个事务就会回滚,恢复到事务开始之前的状态。

  • 一致性(Consistency):执行事务前后,数据保持一致,例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的;一致性是指事务必须使数据库从一个一致性状态变换到另一个一致性状态,即事务执行前后,数据库中的数据都满足预定义的规则和约束,不会出现数据的矛盾和损坏。

  • 隔离性(Isolation)并发事务之间是相互隔离的,即一个事务的执行不会受到其他事务的影响,也不会影响其他事务的结果不同的隔离级别可以防止不同的并发问题,如脏读、不可重复读、幻读等。

  • 持久性(Durability)一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。

这里要额外补充一点:只有保证了事务的持久性、原子性、隔离性之后,一致性才能得到保障。也就是说 A、I、D 是手段,C 是目的! 想必大家也和我一样,被 ACID 这个概念被误导了很久! 我也是看周志明老师的公开课《周志明的软件架构课》才搞清楚的(多看好书!!!)。

另外,DDIA 也就是 《Designing Data-Intensive Application(数据密集型应用系统设计)》 的作者在他的这本书中如是说:

Atomicity, isolation, and durability are properties of the database, whereas consis‐ tency (in the ACID sense) is a property of the application. The application may rely on the database’s atomicity and isolation properties in order to achieve consistency, but it’s not up to the database alone.

翻译过来的意思是:原子性,隔离性和持久性是数据库的属性,而一致性(在 ACID 意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。因此,字母 C 不属于 ACID 。

《Designing Data-Intensive Application(数据密集型应用系统设计)》这本书强推一波,值得读很多遍!豆瓣有接近 90% 的人看了这本书之后给了五星好评。另外,中文翻译版本已经在 GitHub 开源,地址:https://github.com/Vonng/ddia

详谈 Spring 对事务的支持

⚠️ 再提醒一次:你的程序是否支持事务首先取决于数据库 ,比如使用 MySQL 的话,如果你选择的是 InnoDB 引擎,那么恭喜你,是可以支持事务的。但是,如果你的 MySQL 数据库使用的是 MyISAM 引擎的话,那不好意思,从根上就是不支持事务的。

这里再多提一下一个非常重要的知识点:MySQL 怎么保证原子性的?

我们知道如果想要保证事务的原子性,就需要在异常发生时,对已经执行的操作进行回滚,在 MySQL 中,恢复机制是通过 回滚日志(undo log) 实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后再执行相关的操作。如果执行过程中遇到异常的话,我们直接利用 回滚日志 中的信息将数据回滚到修改之前的样子即可!并且,回滚日志会先于数据持久化到磁盘上。这样就保证了即使遇到数据库突然宕机等情况,当用户再次启动数据库的时候,数据库还能够通过查询回滚日志来回滚之前未完成的事务。

数据库事务比较简单,就只有开启、回滚和关闭,Spring 的事务管理器完成了对数据库事务的包装,原理就是拿一个数据连接,根据 Spring 的事务配置,操作这个数据连接,对数据库进行事务开启、回滚或关闭操作。但是 Spring 除了实现这些,还配合 Spring 的传播行为和隔离级别等,对事务进行了更广泛的管理。

想搞清楚 Spring 如何对事务进行管理的,就必须先弄明白数据库事务的概念,想象一下我们平时是否有过这样的操作,当我们打开一个 MySQL 客户端的会话时,写了一个插入语句,执行插入语句后,需要我们手动提交才能将操作入库,而在提交之前,我们在另一个会话(新的连接)去查询这个表,并不能查到当前插入的数据,只有在同一个事务中才能查到。这就体现出了事务的概念,事务一旦提交整个事务就结束了,结束就意味着已经将变更的结果存到了数据库文件中,整个过程就是事务的提交过程。事务在提交之前是可以回滚的,当我们执行完插入语句,而不点提交时,这时候会在数据库中存一条日志,数据库会根据这条日志进行回滚或提交,当我们回滚后,在当前事务中就查不到最新插入的记录了。下面来给出一个测试案例:测试工具为 MySQL 官方提供的 MySQL Workbench

当我们在第一个连接中执行插入语句时:

insert into sys_user(user_id, username) values (13, 'zhangsan1');

当我们不点提交,这时在第二个连接查询当前表时,USER_ID = 13 这个人是查不到的。这就说明了不同线程事务的隔离特性,同时也说明 MySQL 达到了读已提交的事务隔离级别,也就是说只有一个事务提交了,在另一个事务中才能看到新更新的数据。我们称一次数据库的连接就是一个事务。当然我们也可以这样理解,在 Spring 框架中,我们代码每次执行一次数据库的操作都会建立一个新的连接,这一次新的连接就是一个独立的事务,而不和其他连接共享这个事务。如果有两个人同时操作数据库,在事务提交之前,这两个人对数据库的操作是相互不可知的,只有当一个人提交了本次事务,另一个人才会看到更新的数据。通过案例我们可以发现,一次数据库的连接中如果有两个更新操作,那么这两个更新操作要么就同时成功要么就同时失败,只要这两个更新操作在一个事务中。所以我们平时使用的 Spring 事务就是来管理这个的,总结起来就是一句话:事务就是通过 Spring 框架来管理数据库的连接,用来确定哪些操作可以放到同一个事务中去执行,从而满足数据库数据的一致性,一致性就是本来账户有 100 块钱,经过各种操作后,比如今天买了一双袜子,明天发给老婆一个红包,那么经过计算系统的总钱数应该还是 100,而不能丢失,这就是一致性。而在同一个事务中还具备其他特性,下面我们详细说明。

注意:上述使用的是 Workbench 进行的测试,如果您使用其他工具,比如 plsql 客户端,一个 query tab 就是一个单独的事务(连接,会话)。

事物的原理

事务在项目中应用的场景非常多,当两个数据库表更新操作有业务关联,并且要保证必须同时成功,那么就需要用到事务,比如我们常说的银行转账功能,又或者是订单支付成功更新订单状态和插入支付记录这两个操作必须同时成功或失败等等。

然而,要想使用好事务,我们还需要了解很多知识技能,比如,事务如何和异常结合使用、事务的隔离级别怎么定义、事务的传播特性是什么?事务什么时候会失效?事务对并发锁又会产生怎样的影响等。这些场景在使用事务时都需要认真考虑,才能保证应用程序的正确性和完整性。

在 Spring Boot 项目中,只要我们引用一个注解,就可简单的给业务方法或类添加上事务,这个注解为 @Transactional 此注解是 Spring 框架提供的一种声明式事务管理的方式,它可以让你的业务方法在一个安全的环境中执行,并能够保证数据的一致性和完整性。

其实现原理是基于 AOP(面向切面编程)的,它会为业务类创建一个动态代理对象,然后在代理对象中添加事务处理的逻辑。下面大概来说下 @Transactional 注解的实现步骤及原理:

首先,我们需要一个事务管理器,它主要用来管理事务的对象,并可以根据不同的数据源和技术选择不同的实现类,如 DataSourceTransactionManagerJpaTransactionManagerJtaTransactionManager 等。在 Spring Boot 项目中,配置事务管理器的通常使用自动配置功能,而不需要显示使用 @EnableTransactionManagement 注解来开启事务,这源于 Spring Boot 的自动装配特性。比如,项目中就有一个数据源,Spring Boot 会根据依赖(spring-boot-starter-jdbc 或 spring-boot-starter-data-jpa 依赖)自动注入 DataSourceTransactionManagerJpaTransactionManager 实例,并且会在 Spring 容器中创建一个 TransactionAutoConfiguration 类,该类上有 @EnableTransactionManagement 注解 。

开启了事务后,就可以使用 @Transactional 了,Spring 会自动扫描带有此注解的类,并为此业务类创建一个代理对象,并将事务管理器和事务属性注入到代理对象中。

当调用业务方法时,实际上是调用代理对象的方法,代理对象会在方法执行前开启事务,在方法执行后提交或回滚事务,这样就实现了事务的自动管理。

事物的传播特性

事务的传播特性是指在多个事务方法相互调用时,一个事务方法应该如何加入或创建事务。不同的传播特性会影响事务的执行和回滚。Spring 框架提供了七种事务的传播特性,分别是:

  • REQUIRED:如果当前没有事务,就新建一个事务,如果已经存在一个事务中,加入到这个事务中。这是最常见的选择。

  • SUPPORTS:支持当前事务,如果当前没有事务,就以非事务方式执行。

  • MANDATORY:使用当前的事务,如果当前没有事务,就抛出异常。

  • REQUIRES_NEW:新建事务,如果当前存在事务,把当前事务挂起。

  • NOT_SUPPORTED:以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。

  • NEVER:以非事务方式执行,如果当前存在事务,则抛出异常。

  • NESTED:如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则执行与REQUIRED类似的操作。

使用案例:

@Service
public class A {
    @Autowired
    private B b;

    @Transactional(propagation = Propagation.REQUIRED)
    public void a1() {
        // do something
        b.b1();
        // do something
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void a2() {
        // do something
        b.b2();
        // do something
    }
}

@Service
public class B {
    @Transactional(propagation = Propagation.REQUIRED)
    public void b1() {
        // do something
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void b2() {
        // do something
    }
}

现在我们分析以下几种情况:

  • 如果直接调用 a1 方法,那么 a1 会创建一个新的事务,然后调用 b1b1 会加入到 a1 的事务中,这样 a1b1 都在同一个事务中执行,如果其中任何一个方法发生异常,整个事务都会回滚。

  • 如果直接调用 a2 方法,那么 a2 会创建一个新的事务,然后调用 b2b2 会创建一个新的事务,并把 a2 的事务挂起,这样 a2b2 都在各自的事务中执行,如果 b2 发生异常,只会回滚 b2 的事务,不会影响 a2 的事务,如果 a2 发生异常,会回滚 a2 的事务,但不会影响 b2 的事务。

  • 如果先调用 a1 方法,然后在 a1 方法中调用 a2 方法,那么 a1 会创建一个新的事务,然后调用 a2a2 会创建一个新的事务,并把 a1 的事务挂起,这样 a1a2 都在各自的事务中执行,如果 a2 发生异常,只会回滚 a2 的事务,不会影响 a1 的事务,如果 a1 发生异常,会回滚 a1 的事务,但不会影响 a2 的事务。

  • 如果先调用 a2 方法,然后在 a2 方法中调用 a1 方法,那么 a2 会创建一个新的事务,然后调用 a1a1 会加入到 a2 的事务中,这样 a2a1 都在同一个事务中执行,如果其中任何一个方法发生异常,整个事务都会回滚。

需要注意的是,异常必须是 RuntimeException 事务才会生效。

事物的隔离级别

事务的隔离级别一般是在并发场景下才会出现,它是指在并发事务中,一个事务对数据的修改如何影响其他事务的访问。不同的隔离级别有不同的并发性能和一致性保证。一般来说,隔离级别越高,一致性越好,但并发性越差,反之亦然。

SQL 标准定义了四种事务的隔离级别,分别如下:

  • Read Uncommitted(读未提交):最低的隔离级别,允许一个事务读取另一个事务未提交的数据,可能导致脏读、不可重复读和幻读。

  • Read Committed(读已提交):较低的隔离级别,只允许一个事务读取另一个事务已提交的数据,可以避免脏读,但仍然可能出现不可重复读和幻读。

  • Repeatable Read(可重复读):较高的隔离级别,保证一个事务在执行过程中,对同一行数据的多次读取结果都相同,可以避免脏读和不可重复读,但仍然可能出现幻读。

  • Serializable(可串行化):最高的隔离级别,保证一个事务在执行过程中,对数据的访问不受其他事务的影响,可以避免脏读、不可重复读和幻读,但并发性能最差。

不同的数据库系统可能有不同的默认隔离级别,也可能支持不同的隔离级别。

MySQL 默认的隔离级别是可重复读(Repeatable Read),当启动一个事务时,MySQL 会使用可重复读来执行事务,这有助于避免不可重复读问题。MySQL 支持所有四种隔离级别,可以通过 SET TRANSACTION ISOLATION LEVEL 命令来设置。而 Oracle 默认的隔离级别是读已提交(Read Committed),当执行一个 SQL 语句时,Oracle 会使用读已提交来执行语句,这有助于避免脏读问题。Oracle 只支持读已提交和可串行化两种隔离级别。

我们有一个广为流传的表格会说明这四种隔离级别分别会带来什么样的问题,如下图所示:

隔离级别

脏读

不可重复读

幻读

读未提交

读已提交

可重复读

串行化

下面来说明一下这三种问题的案例解释

  1. 脏读(Dirty read):假设有两个事务 AB,事务 A 修改了一条记录,但还没有提交,事务 B 读取了这条记录,然后事务 A 回滚了,这样事务 B 就读到了一个不存在的数据,这就是脏读。如果使用读未提交的隔离级别,就可能出现这种情况,但如果使用读已提交或更高的隔离级别,就可以避免这种情况。

  2. 不可重复读(Unrepeatable read):假设有两个事务 AB,事务 A 读取了一条记录,然后事务 B 修改了这条记录并提交,事务 A 再次读取这条记录,发现数据已经变了,这就是不可重复读。如果使用读已提交的隔离级别,就可能出现这种情况,但如果使用可重复读或更高的隔离级别,就可以避免这种情况。

  3. 幻读(Phantom read):假设有两个事务 AB,事务 A 读取了满足某个条件的所有记录,然后事务 B 插入了一条满足这个条件的记录并提交,事务 A 再次读取满足这个条件的所有记录,发现多了一条记录,这就是幻读。如果使用可重复读的隔离级别,就可能出现这种情况,但如果使用可串行化的隔离级别,就可以避免这种情况。

通常,在 Spring 中,可以通过 @Transactional 注解来设置事务的隔离级别。@Transactional 注解提供了一个 isolation 属性,用于指定事务的隔离级别。隔离级别的取值可以是以下常量之一:

  • Isolation.DEFAULT:使用默认的数据库隔离级别,一般是 Read Committed。

  • Isolation.READ_UNCOMMITTED:使用 Read Uncommitted 隔离级别,可能出现脏读、不可重复读和幻读。

  • Isolation.READ_COMMITTED:使用 Read Committed 隔离级别,可以避免脏读,但可能出现不可重复读和幻读。

  • Isolation.REPEATABLE_READ:使用 Repeatable Read 隔离级别,可以避免脏读和不可重复读,但可能出现幻读。

  • Isolation.SERIALIZABLE:使用 Serializable 隔离级别,可以避免脏读、不可重复读和幻读,但并发性能最差。

例如,如果想让一个方法使用 Repeatable Read 隔离级别,可以这样写:

@Transactional(isolation = Isolation.REPEATABLE_READ)
public void doSomething() {
    // 业务逻辑
}

实际上,我们在项目中很少会设置隔离级别,都会使用数据库默认的隔离级别。

大家可以考虑一个问题。刚刚说,隔离级别是在并发条件下才会产生,而在并发的场景下,通常我们会和锁一起使用,那么锁和事务在一起使用时,事务会影响到锁么?这个计划在后面的文章中进行描述。

事物失效的几大场景

事务使用虽然简单,但是如果稍有不慎就会导致事务不生效,下面列举一些我实际开发中遇到的一些场景来记录一下:

  • 失效场景一:业务类没有被 Spring 管理,前面刚刚说了,事务的原理是 AOP,使用 Spring 管理的类才会生成代理对象,否则无法管理事务。

  • 失效场景二:添加 @Transactional 注解的方法中使用了 try...catch 块,捕获了异常却没有抛出给方法,或者抛出的异常不是 RuntimeException 异常。如下案例:

    @Transactional
    public String updateOrder(){
        try {
    
        } catch (Exception e) {
            log.error("error")!
        }
    }
    
    @Transactional
    public String updateOrder() {
        try {
    
        } catch(Exception e) {
            throw new "非RuntimeException"
        }
    }

    所以,要想被 Spring 容器捕获到异常,要不就不使用 try...catch 块,交给程序自动处理异常。要么就使用 try...catch 块并向上抛出运行时异常。

  • 失效场景三:在业务方法内部调用了另一个有 @Transactional 注解的方法。比如在业务类中有两个方法,一个是没有 @Transactional 注解的方法 A,一个是有 @Transactional 注解的方法 B,如果方法 A 内部调用了方法 B,那么方法 B 的事务就会失效,因为 Spring 的代理对象只能拦截外部的方法调用,而不能拦截内部的方法调用。

    解决方式就是要将方法 A 和方法 B 都加上 @Transactional 注解。

    @Service
    public class UserService {
        @Autowired
        private UserDao userDao;
        
        // 没有 @Transactional 注解的方法 A
        public void createUser(User user) {
            // 保存用户到数据库
            userDao.save(user);
            // 内部调用有 @Transactional 注解的方法 B
            sendEmail(user.getEmail());
        }
        
        // 有 @Transactional 注解的方法 B
        @Transactional
        public void sendEmail(String email) {
            // 发送邮件
            emailService.send(email);
            // 模拟异常
            int i = 1 / 0;
        }
    }

    在这个案例中,如果 sendEmail() 方法发生异常,我们期望的结果是用户数据和邮件数据都不会保存,事务会回滚。但是实际的结果是用户数据已经保存到数据库,而邮件数据没有保存,事务没有回滚。这是因为 Spring 的代理对象只能拦截外部的方法调用,而不能拦截内部的方法调用,所以 createUser() 方法和 sendEmail() 方法不在同一个事务中,也就无法保证事务的一致性。所以,解决方案就是在 A 方法上面也添加上事务注解。如下:

    @Service
    public class UserService {
        @Autowired
        private UserDao userDao;
        
        @Transactional
        public void createUser(User user) {
            // 保存用户到数据库
            userDao.save(user);
            // 内部调用有 @Transactional 注解的方法 B
            sendEmail(user.getEmail());
        }
        
        // 有 @Transactional 注解的方法 B
        @Transactional
        public void sendEmail(String email) {
            // 发送邮件
            emailService.send(email);
            // 模拟异常
            int i = 1 / 0;
        }
    }
  • 失效场景四:接上述失效场景三的描述,这种场景实际是在失效场景三的延申。假设期望 A 方法内部调用 B 方法,A 方法上有事务注解,B 方法开启新事物,及 B 方法抛出的异常不会影响 A 方法的回滚。即:

    @Service
    public class TestService {
        @Transactional
        public void A(User user) {
            // 保存用户到数据库操作
            userDao.save(user);
            // 内部调用有@Transactional注解的方法B
            B(user.getEmail());
        }
        
        // B方法,有@Transactional注解,开启新事务
        @Transactional(propagation = Propagation.REQUIRES_NEW)
        public void B(String email) {
            // 发送邮件
            emailService.send(email);
            // 模拟异常
            int i = 1 / 0;
        }
    }

    如果想实现这种场景,B 方法的开启的新事务是失效的。原因同场景三,但是我们可以使用代理对象来调用 B 方法,解决方案如下:

    @Service
    public class TestService {
        @Transactional
        public void A(User user) {
            // 保存用户到数据库操作
            userDao.save(user);
            // 这里修改一下,调用 B 方法时使用代理对象来调用
            TestService proxy_o = (TestService ) AopContext.currentProxy();
            proxy_o.B(user.getEmail());
        }
        
        // B 方法,有 @Transactional 注解,开启新事务
        @Transactional(propagation = Propagation.REQUIRES_NEW)
        public void B(String email) {
            // 发送邮件
            emailService.send(email);
            // 模拟异常
            int i = 1 / 0;
        }
    }

    上面的解决方案中,首先获取到当前类的代理对象,通过代理对象的方式来调用 B 方法,这时 B 方法的事务就会生效了。 TestService proxy_o = (TestService ) AopContext.currentProxy(); 当然,除了这种解决方式,我们还可以将 AB 方法拆到两个不同类中,都交由 Spring 容器进行管理,这种方案是我们开发项目时常用的方案,因为项目设计之处就应该要使得类遵循单一职责的设计原则。

  • 失效场景五: 整个事务中间使用了多线程或异步线程池。比如在业务方法使用了 @Async 注解或者 ExecutorService 来执行异步或者多线程的任务,那么这些任务就不会受到 Spring 事务的管理,因为它们是在另一个线程中执行的,而 Spring 事务是基于线程绑定的。这种在项目种比较少见,但是为了保证复杂业务的更新性能,这种场景还是存在的,下面以一个简单案例来说明一下:

    假设有一个订单服务和一个库存服务,需要在订单服务中异步调用库存服务来扣减库存,如果库存不足,则要回滚订单。如下案例

    // 订单服务
    @Service
    public class OrderService {
        @Autowired
        private OrderDao orderDao;
        @Autowired
        private StockService stockService;
        @Autowired
        private ExecutorService executorService;
        
        // 使用@Transactional注解来开启事务
        @Transactional
        public void createOrder(Order order) {
            // 保存订单到数据库
            orderDao.save(order);
            // 异步调用库存服务扣减库存
            executorService.submit(() -> {
                stockService.reduceStock(order.getProductId(), order.getAmount());
            });
        }
    }
    
    // 库存服务
    @Service
    public class StockService {
        @Autowired
        private StockDao stockDao;
        
        // 使用@Transactional注解来开启事务
        @Transactional
        public void reduceStock(Long productId, Integer amount) {
        // 查询库存
        Stock stock = stockDao.findByProductId(productId);
        // 判断库存是否足够
        if (stock.getQuantity() < amount) {
            // 库存不足,抛出异常,触发事务回滚
            throw new RuntimeException("库存不足");
        }
        // 扣减库存
        stock.setQuantity(stock.getQuantity() - amount);
            stockDao.save(stock);
        }
    }

    期望的结果是,如果库存不足,那么订单和库存都不会变化,事务会回滚。但是实际的结果是,订单已经保存到数据库,而库存没有扣减,事务没有回滚。这是因为订单服务和库存服务使用了不同的线程来执行,而 Spring 事务是基于线程绑定的,所以它们不在同一个事务中,也就无法保证事务的一致性。

    其实上述场景我们可以延申到更多的场景,比如微服务之间的调用,这就涉及到了分布式事务。那么上述场景应该如何解决呢?

    我们刚刚说了,订单服务和库存服务是两个不同的事务。即使在库存服务不使用 @Transactional 注解,订单服务的事务也不会传递到库存服务中。就变成了订单服务有事务,库存服务没事务。如下写法:

    //@Transactional 这里注释掉事务注解
    // 注释后,订单服务的事务不会传递到库存方法中。
    public void reduceStock(Long productId, Integer amount)

    若库存方法和订单方法都有事务,且是两个不同的事务,库存方法出现异常,那么想让订单服务回滚该如何做呢?

    可以在订单方法内部使用 try...catch 块来包住库存服务的调用过程,并在库存服务抛出运行时异常,订单服务接收到异常后,并在订单服务捕获异常抛给 Spring 事务管理器即可实现订单方法的操作回滚。如下代码:

    // 订单服务
    @Service
    public class OrderService {
        @Autowired
        private OrderDao orderDao;
        @Autowired
        private StockService stockService;
        @Autowired
        private ExecutorService executorService;
        
        // 使用@Transactional注解来开启事务
        @Transactional(rollbackFor=Exception.class)
        public void createOrder(Order order) {
            try {
                // 保存订单到数据库
                orderDao.save(order);
            
                // 异步调用库存服务扣减库存
                executorService.submit(() -> {
                  stockService.reduceStock(order.getProductId(), order.getAmount());
                });
            } catch() {
                throw new RuntimeException("出错");
            }
        }
    }
    
    // 库存服务
    @Service
    public class StockService {
        @Autowired
        private StockDao stockDao;
        
        // 使用@Transactional注解来开启事务
        @Transactional
        public void reduceStock(Long productId, Integer amount) {
            try {
                // 查询库存
                Stock stock = stockDao.findByProductId(productId);
                // 判断库存是否足够
                if (stock.getQuantity() < amount) {
                    // 库存不足,抛出异常,触发事务回滚
                    throw new RuntimeException("库存不足");
                }
                // 扣减库存
                stock.setQuantity(stock.getQuantity() - amount);
                stockDao.save(stock);
            } catch(Exception e) {
               throw new RuntimeException("更新库存出错");
            }
        
        }
    }

    然而,微服务之间也是一样的,假设你把上述案例当作订单服务和库存服务,订单服务通过 feign 或 openfeign 或 loadbalance 的方式进行调用库存服务,那么也归于刚刚说的场景。

    注意:rollbackFor 的这个异常范围尽量设大,因为如果使用默认值,一旦程序抛出了 Exception,事务不会回滚,这会出现很大的问题。建议一般情况下,将该参数设置成ExceptionThrowable

    除了使用这种方案,当然还有其他方案,比如,使用分布式框架 Seata 或使用 TCC,来管理异步或多线程的事务。这些解决方案可以通过不同的机制,如二阶段提交、补偿、本地消息等,来保证分布式系统中的事务的一致性。

  • 其他失效场景:使用 publicfinalstatic 修饰的方法,这一类方法上添加事务注解都不会使事务生效。

嵌套事物

刚刚在传播特性一小节中提到了嵌套事务,(NESTED)是一种事务传播行为,表示如果当前存在事务,就在嵌套事务内执行,如果当前没有事务,就按 REQUIRED 属性执行。嵌套事务是外部事务的一个子事务,它可以独立于外部事务进行提交或回滚,而不影响外部事务的状态。嵌套事务的实现依赖于数据库的保存点(savepoint)机制,每个嵌套事务都会创建一个保存点,如果嵌套事务失败,就回滚到该保存点,如果嵌套事务成功,就删除该保存点。

大白话来说就是:外部事物出现异常会影响内部事务,内部事务出现异常不会影响外部事务,但是,当内部方法向上抛出异常了,外部方法捕获又抛给了 Spring 管理器,则同时会回滚外部事物。

举个例子来说明一下嵌套事务的使用

第一种情况:内外部都出现异常:

// 外部方法,开启一个事务
@Transactional
public void outerMethod() {
    // 调用内部方法,加入嵌套事务
    nestedMethod();
    // 执行其他业务逻辑
    // ...
    // 抛出异常,回滚外部事务
    throw new RuntimeException();
}

// 内部方法,开启一个嵌套事务
@Transactional(propagation = Propagation.NESTED)
public void nestedMethod() {
    // 执行业务逻辑
    // ...
    // 抛出异常,回滚嵌套事务
    throw new RuntimeException();
}

上面代码的执行过程:

  • 当外部方法被调用时,会开启一个事务,然后调用内部方法。

  • 当内部方法被调用时,会在嵌套事务内执行,创建一个保存点,然后执行业务逻辑。

  • 如果内部方法执行成功,会删除保存点,然后返回外部方法。

  • 如果内部方法执行失败,会回滚到保存点,然后抛出异常,不影响外部方法的事务。

  • 当外部方法继续执行时,会执行其他业务逻辑,然后抛出异常,回滚外部事务。

  • 最终,外部方法的事务和内部方法的嵌套事务都会回滚,数据库中的数据不会被修改。

第二种情况:内部事务成功,外部方法出现异常:

// 外部服务类,开启一个外部事务
@Service
@Transactional
public class OuterService {

    @Autowired
    private InnerService innerService;

    // 外部方法,执行外部事务逻辑
    public void outerMethod() {
        // 执行一些数据库操作
        // ...
        // 调用内部方法,开启一个嵌套事务
        innerService.innerMethod();
        // ...
        // 抛出异常,回滚外部事务
        throw new RuntimeException("Outer transaction failed");
    }
}

// 内部服务类,开启一个内部事务
@Service
public class InnerService {

    // 内部方法,执行内部事务逻辑
    @Transactional(propagation = Propagation.NESTED)
    public void innerMethod() {
        // 执行一些数据库操作
        // ...
        // 不抛出异常,正常结束内部事务
    }
}

执行过程如下:

  • 当外部方法被调用时,它会开启一个外部事务,然后执行一些数据库操作。

  • 当外部方法调用内部方法时,它会在嵌套事务中执行,创建一个保存点,然后执行一些数据库操作。

  • 如果内部方法执行成功,它会删除保存点,然后返回外部方法。

  • 当外部方法继续执行时,它会执行一些数据库操作,然后抛出异常,回滚外部事务。

  • 最终,外部方法的事务和内部方法的嵌套事务都会回滚,数据库中的数据不会被修改。

所以得出一个结论:如果内部方法执行成功,它会删除保存点,然后返回外部方法。这种情况下,如果外部方法抛出异常了,内部方法会回滚么?答案是 会。因为内部方法和外部方法是父子关系,外部方法的回滚会影响内部方法的状态

写到这里,我突然想起一个问题:嵌套事务 NESTED 和新事务 REQUIRES_NEW 的区别是什么呢?

以刚刚的代码案例我们按二者的定义扩展一下代码,案例如下:

// 外部方法,开启一个事务
@Transactional
public void outerMethod() {
    // 执行外部事务逻辑
    // ...
    // 调用内部方法
    innerMethod();
    // ...
    // 抛出异常,回滚外部事务
    throw new RuntimeException();
}

// 内部方法,开启一个嵌套事务
@Transactional(propagation = Propagation.NESTED)
public void innerMethod() {
    // 执行内部事务逻辑
    // ...
    // 创建一个保存点
    Savepoint savepoint = TransactionAspectSupport.currentTransactionStatus().createSavepoint();
    try {
        // ...
        // 抛出异常,回滚到保存点
        throw new RuntimeException();
    } catch (Exception e) {
        TransactionAspectSupport.currentTransactionStatus().rollbackToSavepoint(savepoint);
    }
    // 删除保存点
    TransactionAspectSupport.currentTransactionStatus().releaseSavepoint(savepoint);
}

// 内部方法,开启一个新的事务
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void innerMethod() {
    // 执行内部事务逻辑
    // ...
    // 挂起外部事务,开启一个新的事务
    TransactionStatus status = TransactionAspectSupport.currentTransactionStatus();
    status.setSuspended(true);
    TransactionStatus newStatus = transactionManager.getTransaction(new DefaultTransactionDefinition());
    try {
        // ...
        // 抛出异常,回滚新的事务
        throw new RuntimeException();
    } catch (Exception e) {
        transactionManager.rollback(newStatus);
    }
    // 提交新的事务
    transactionManager.commit(newStatus);
    // 恢复外部事务
    status.setSuspended(false);
}

从上面的代码案例可以看出,嵌套事务和 required_new 的区别主要包括以下几点,我们来做个简单的总结:

  • 嵌套事务是在外部事务内部创建一个子事务,而required_new 是在外部事务外部创建一个独立的事务 。

  • 嵌套事务不会挂起外部事务,而 required_new 会挂起外部事务 。

  • 嵌套事务依赖于保存点机制,而 required_new 依赖于事务管理器 。

  • 嵌套事务的提交和回滚受到外部事务的影响,而 required_new 的提交和回滚不受到外部事务的影响 。

循环体内处理异常和循环体外处理异常的区别

写到异常对事务的影响,下面也扩展一下团队成员问过我的一些开发问题,比如循环内抛异常和循环外抛异常是什么区别,项目中该如何做。就这个问题,这里也做一下总结和记录:

如果是循环体内处理异常,则在每次循环中都使用 try-catch 语句来捕获和处理可能发生的异常。

这种方式的优点是可以保证循环的继续执行,即使某次循环发生了异常,也不会影响后续的循环。缺点是会增加循环的开销,因为每次循环都需要创建和销毁 try-catch 语句,而且如果异常频繁发生,会影响程序的性能和可读性。

而循环体外处理异常,优点是可以减少循环的开销,因为只需要创建和销毁一次 try-catch 语句,而且可以提高程序的性能和可读性。但是无法保证循环的继续执行,如果某次循环发生了异常,会导致整个循环终止,无法处理后续的循环。

// 循环体内处理异常
public void innerTryCatch() {
    for (int i = 0; i < 10; i++) {
        try {
            // 模拟可能发生异常的操作
            if (i == 5) {
                throw new RuntimeException("Exception at i = 5");
            }
            // 打印正常的循环结果
            System.out.println("i = " + i);
        } catch (Exception e) {
            // 处理异常
            e.printStackTrace();
        }
    }
}

// 循环体外处理异常
public void outerTryCatch() {
    try {
        for (int i = 0; i < 10; i++) {
            // 模拟可能发生异常的操作
            if (i == 5) {
                throw new RuntimeException("Exception at i = 5");
            }
            // 打印正常的循环结果
            System.out.println("i = " + i);
        }
    } catch (Exception e) {
        // 处理异常
        e.printStackTrace();
    }
}

执行结果如下:

// 循环体内处理异常的执行结果
i = 0
i = 1
i = 2
i = 3
i = 4
java.lang.RuntimeException: Exception at i = 5
    at com.example.AppTest.innerTryCatch(AppTest.java:14)
    at com.example.AppTest.main(AppTest.java:6)
i = 6
i = 7
i = 8
i = 9

// 循环体外处理异常的执行结果
i = 0
i = 1
i = 2
i = 3
i = 4
java.lang.RuntimeException: Exception at i = 5
    at com.example.AppTest.outerTryCatch(AppTest.java:26)
    at com.example.AppTest.main(AppTest.java:7)

从执行结果可以看出,循环体内处理异常的方式可以继续执行后续的循环,而循环体外处理异常的方式会终止整个循环。

Spring 支持两种方式的事务管理

编程式事务管理

通过 TransactionTemplate 或者 TransactionManager 手动管理事务,实际应用中很少使用,但是对于你理解 Spring 事务管理原理有帮助。

使用 TransactionTemplate 进行编程式事务管理的示例代码如下:

@Autowired
private TransactionTemplate transactionTemplate;
public void testTransaction() {
    transactionTemplate.execute(new TransactionCallbackWithoutResult() {
        @Override
        protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) {

            try {

                // ....  业务代码
            } catch (Exception e){
                //回滚
                transactionStatus.setRollbackOnly();
            }

        }
    });
}

使用 TransactionManager 进行编程式事务管理的示例代码如下:

@Autowired
private PlatformTransactionManager transactionManager;

public void testTransaction() {

    TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
    try {
       // ....  业务代码
      transactionManager.commit(status);
    } catch (Exception e) {
      transactionManager.rollback(status);
    }
}

声明式事务管理

推荐使用(代码侵入性最小),实际是通过 AOP 实现(基于 @Transactional 的全注解方式使用最多)。

使用 @Transactional 注解进行事务管理的示例代码如下:

@Transactional(propagation = Propagation.REQUIRED)
public void aMethod {
  //do something
  B b = new B();
  C c = new C();
  b.bMethod();
  c.cMethod();
}

事物和锁

有时,在高并发场景下,我们项目里会同时使用事务和锁,锁就是为了保证在同一时刻,代码块只有一个线程可以执行,事务就是为了控制业务数据的逻辑正确,从而保证数据库数据的一致性。

有些时候,使用了事务,就会导致锁失效。原因在于,如果你得数据库使用的是 MySQL,MySQL 的隔离级别默认为可重复读,说白了,就是多个线程都走这段代码块时,每个线程的数据互不影响,在同一个线程内多次读取都是同一个值。

举个例子:假如这段代码块中有两个操作,一个操作是读取竞价表中最高价字段的值,另一个操作是更新竞价表的最高价字段的值,则在这段代码块中获取的最高价的值都是当前线程的值,而不受其他线程更新最高价的影响。这就是可重复读的隔离级别,这个隔离级别也是很高了。

如果这段代码块加了锁,和事务,如果代码块是这样的:

@Transactional(rollbackFor = Exception.class)
@Override
public void entryTransAction(int newMoney,String threadName){
    RLock bidLock = redissonClient.getLock("bidLock");
    try {
        bidLock.lock();
        //执行业务代码-查询最高价
        Integer maxMoney =  this.queryMax();

        if (newMoney <= maxMoney) {
            log.warn("出价低于最高价,无法出价");
            return;
        }
        //根据最高价更新数据库
        this.updateMoney(newMoney,threadName);

    } catch (Exception e) {
        e.printStackTrace();
        throw new RuntimeException("cuowu");
    } finally {
        bidLock.unlock();
    }
}

上面的代码块中锁放到了事务的内部,锁内部执行了查询最大值的操作,然后更新最大值,如果不加锁就会出现多个线程同时进入到这个代码块中,同时处理最大值,从而导致最大值不正确。加了锁后,同一时刻只有一个线程执行查询和更新操作,当前线程释放锁之后才能允许下一个线程进入执行。

而如果将事务放到了锁的外面时,只有等事务提交后当前线程的执行数据库的操作才会更新,而此时恰好在当前线程释放锁之后、事务提交之前有其他线程执行 Integer maxMoney = this.queryMax(); 这个代码时,就会出现读到的数据不正确,从而导致锁失效。

所以为了解决这种情况,保证锁生效,我们一定要将锁放到事务的外面,也就是这样写:

@Override
public void userBidTest1(int newMoney,String threadName) throws Exception{
    //获取锁对象
    RLock lock = redissonClient.getLock(lockKey);
    //尝试获取一个锁,如果获取成功,向队列中添加出价信息,如果获取失败,抛出异常
    if(lock.tryLock(2,3,TimeUnit.SECONDS)){
        try {

            //调用另一个方法,从队列中取出出价的信息,并更新数据库
           TestBidZsServiceImpl o = (TestBidZsServiceImpl) AopContext.currentProxy();
            o.entryTransAction(newMoney,threadName);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //释放锁
            lock.unlock();
        }
    } else {
        //获取锁失败,抛出异常
        throw new RuntimeException("获取锁失败");
    }
}


@Transactional(rollbackFor = Exception.class)
@Override
public void entryTransAction(int newMoney,String threadName){
    try{
        //执行业务代码-查询最高价
        Integer maxMoney =  this.queryMax();
        if (maxMoney ==null) {
            // 根据最高价更新数据库
            this.updateMoney(newMoney,threadName);
            // int b = 1 / 0;
            return;
        }

        if (newMoney <= maxMoney){
            log.warn("出价低于最高价,无法出价");
            return;
        }
        // 根据最高价更新数据库
        this.updateMoney(newMoney,threadName);
        // int a =  1 / 0;

    } catch (Exception e) {
        e.printStackTrace();
        throw new RuntimeException("cuowu");
    } finally {

    }
}

Spring 事务管理接口介绍

Spring 框架中,事务管理相关最重要的 3 个接口如下:

  • PlatformTransactionManager:(平台)事务管理器,Spring 事务策略的核心。

  • TransactionDefinition:事务定义信息(事务隔离级别、传播行为、超时、只读、回滚规则)。

  • TransactionStatus:事务运行状态。

我们可以把 PlatformTransactionManager 接口可以被看作是事务上层的管理者,而 TransactionDefinition TransactionStatus 这两个接口可以看作是事务的描述。

PlatformTransactionManager 会根据 TransactionDefinition 的定义比如事务超时时间、隔离级别、传播行为等来进行事务管理 ,而 TransactionStatus 接口则提供了一些方法来获取事务相应的状态比如是否新事务、是否可以回滚等等。