Skip to content

略微探讨 Spring 事务

发表于: 时间 00:00
加载中...

1. 什么是事务

事务的定义

事物通常是指一组要被视为整体执行的活动或事件。

举个例子,比如银行转账,A 账户向 B 账户进行转账 100 元,此时要求下面两种操作:

这两个操作必须一起成功或一起失败。如果 A 账户余额减少了,B 账户没有增加,亦或者 A 账户余额没变,B 账户余额增加了,这就会出现数据不一致的问题,也就是银行系统出现了错误,账务记录不再可靠。

事务就是来解决类似于上述情况的问题,这便就是事务存在的意义:确保操作要么全部成功,要么全部失败,避免部分完成导致的数据错误,从而保证数据的一致性与完整性。

事务的性质

事务具有 ACID 四种特性,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。

原子性:事务不可分割。

一致性:事务前后数据保持有效状态。

隔离性:多个事务互不干扰。

持久性:提交后数据永久保存。

2. MySQL 中的事务

注意事项

MySQL 并非所有存储引擎都支持事务:

使用事务前,请确保表是 InnoDB 引擎

基本事务操作

在 MySQL 中,我们可以使用 START TRANSACTIONBEGIN 进行开启事务,然后执行 SQL 操作。然后使用 COMMIT 进行事务的提交或使用 ROLLBACK 进行事务的回滚。

下面我们以上面的银行转账进行举例,首先创建一张简化的用户余额数据表,并初始化一定的数据:

CREATE TABLE accounts (
    id CHAR(1) PRIMARY KEY,
    balance DECIMAL(10,2) NOT NULL
) ENGINE=InnoDB;

INSERT INTO accounts (id, balance) VALUES
('A', 1000.00),
('B', 500.00);

image.png

接下来我们在 console 中输入 START TRANSACTION 开启事务。

image.png

现在我们我们创建一个转账的操作,并执行:

-- 扣减 A 账户余额
UPDATE accounts
SET balance = balance - 100
WHERE id = 'A';

-- 增加 B 账户余额
UPDATE accounts
SET balance = balance + 100
WHERE id = 'B';

image.png

此时我们使用 SELECT * FROM accounts 来查看当前的表状态:

image.png

可以发现已经操作成功了,A 与 B 的余额发生了变化,但是我们此时再回到 IDEA 的数据库工具面板中查看表格数据,却发现余额没有变化:

image.png

这便是事务隔离性的体现。这些修改只在事务内部可见。在 同一个会话中,事务未提交前对数据的修改是可见的,但对于其他会话,这些修改仍然不可见,直到事务提交后才会对外生效。我们在 IDEA 中打开数据库工具面板查看表格数据,这相当于开启了一个新的会话,这和刚才的事务是相互独立的,因此数据并没有发生变化。

此时如果我们使用 COMMIT 提交事务,此时数据才正式的写到数据库表中去持久化,这便是事务的持久性

image.png

此时,我们就会发现在 IDEA 的数据库工具中查看表格数据就被修改了:

image.png

同理,使用 ROLLBACK 就可以撤销事务中所有未提交的操作,将数据恢复到事务开始之前的状态。这样,即使在事务内进行了多次修改,也不会影响数据库的实际数据,实现 原子性一致性

image.png

image.png

3. Spring 中的事务

示例 Demo

我们编写如下代码:

@Mapper
public interface AccountsMapper {
    @Update("UPDATE accounts " +
            "SET balance = balance + #{delta} " +
            "WHERE id = #{id}")
    void updateByDelta(@Param("id") Character id, @Param("delta") BigDecimal delta);
}
@Service
public class AccountsService {
    @Autowired
    private AccountsMapper accountsMapper;

    public void transfer(Character id1, Character id2, BigDecimal delta) {
        accountsMapper.updateByDelta(id1,BigDecimal.ZERO.subtract(delta));
        System.out.println(1 / 0);
        accountsMapper.updateByDelta(id2,delta);
    }

}

AccountsService 中我们定义了一个简易的 transfer 转账方法。在该方法中,我们故意写了一个 1/0,让其抛出运行时异常,运行后我们查看抛出异常后,数据库中的数据有没有发生变化。

我们创建一个测试单元:

@SpringBootTest
class DemoApplicationTests {
    @Autowired
    private AccountsService accountsService;
    @Test
    void  testTransaction(){
        accountsService.transfer('A','B',new BigDecimal("100"));
    }
}

运行后我们发现,抛出了一个异常:

image.png

此时我们去查看数据库表,我们发现,出问题了:

image.png

A 账户的余额减少了 100 元,B 账户的余额却没有增加,总的余额 1500 缺无缘无故少了 100 元,这就对数据的一致性与完整性造成了问题。

AccountsService 代码中,首先成功运行了 accountsMapper.updateByDelta(id1,BigDecimal.ZERO.subtract(delta));,但是之后抛出异常后,下面的 accountsMapper.updateByDelta(id2,delta); 就不执行了,因此就造成了上述问题。而我们希望的是,这两个 update 要么同时成功,要么同时失败才可以。因此,必须把这两个 update 封成一个事务去执行,一旦其中一个失败,则全部 ROLLBACK 回滚。

@Transactional 的基本用法

在 Spring 框架中,事务管理是通过 Spring 的事务抽象来实现的,它可以让我们在业务代码中方便地管理事务,而不需要直接操作数据库的 START TRANSACTIONCOMMITROLLBACK

在 Spring 框架中,我们往往使用 @Transactional 注解来声明某个方法或类需要在事务中执行。Spring 会在方法执行前自动开启事务,方法执行成功后提交事务,如果在执行过程中出现 RuntimeExceptionError,事务会自动回滚,从而保证操作的原子性一致性

@Transactional 既可以加在方法上,又可以加在类上。如果加在方法上,则只对该方法生效,事务仅在执行该方法时开启;如果加在类上,则作用于类中所有公共方法,相当于给每个公共方法都加了事务。但在实际开发中,通常是只加在需要用的方法上,而不是直接加在类上。

下面我们把刚才的 AccountsService 中的 transfer 方法上,加上 @Transactional 的注解,如下:

@Service
public class AccountsService {
    @Autowired
    private AccountsMapper accountsMapper;

    @Transactional
    public void transfer(Character id1, Character id2, BigDecimal delta) {
        accountsMapper.updateByDelta(id1,BigDecimal.ZERO.subtract(delta));
        System.out.println(1 / 0);
        accountsMapper.updateByDelta(id2,delta);
    }

}

为了对比,我先把数据库的数据还原成 900 与 600。

image.png

再次运行单元测试,抛出异常后,查看数据库结果:

image.png

刷新后,可以看到,数据没有任何变动修改,这说明事务成功回滚了。当 transfer 方法执行到 1/0 抛出 ArithmeticException 异常时,Spring 检测到运行时异常,自动触发了事务回滚,撤销了之前对 A 账户的扣款操作,保证了数据的一致性。

如果我们此时把 1/0 的代码删掉,然后再去运行,此时没有任何异常,因此会 COMMIT 成功,刷新数据库表如下图所示。

image.png

@Transactional 的高级用法

rollBackFor 属性

OK,刚才前面说的是遇到运行时异常时自动回滚,那如果异常不是运行时异常,还会不会自动回滚?我们来测试一下。我们修改 transfer 代码如下:

@Service
public class AccountsService {
    @Autowired
    private AccountsMapper accountsMapper;
    @Transactional
    public void transfer(Character id1, Character id2, BigDecimal delta) throws Exception {
        accountsMapper.updateByDelta(id1,BigDecimal.ZERO.subtract(delta));
        if(true){
            throw new Exception("手动抛出异常~");
        }
        accountsMapper.updateByDelta(id2,delta);
    }
}

使用 if(true) 可以让编译器认为下面的语句可能会被执行到,因此不会编译报错,相当于“骗”了一下编译器。

单元测试里我们捕获异常,如下:

@SpringBootTest
class DemoApplicationTests {
    @Autowired
    private AccountsService accountsService;
    @Test
    void  testTransaction(){
        try {
            accountsService.transfer('A','B',new BigDecimal("100"));
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

运行单元测试后刷新并查看数据库表我们发现,又出问题了:

image.png

还是和最初始的那样,即使加了 @Transactional,也出现了之前的情况,A 的余额减少,B 的余额没增加,这是因为什么?

就像我们刚才所说的,@Transactional 默认只能对运行时异常自动回滚,因为 ArithmeticExceptionRuntimeException 的子类,因此可以自动回滚,但是 Exception 不是运行时异常,因此无法自动回滚。

怎么办,难道我们就没有办法解决这个问题了吗?我们马上去看一下 @Transactional 的源码,来看看这个注解里有没有什么属性我们可以设置。

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Transactional {
    @AliasFor("transactionManager")
    String value() default "";

    @AliasFor("value")
    String transactionManager() default "";

    String[] label() default {};

    Propagation propagation() default Propagation.REQUIRED;

    Isolation isolation() default Isolation.DEFAULT;

    int timeout() default -1;

    String timeoutString() default "";

    boolean readOnly() default false;

    Class<? extends Throwable>[] rollbackFor() default {};

    String[] rollbackForClassName() default {};

    Class<? extends Throwable>[] noRollbackFor() default {};

    String[] noRollbackForClassName() default {};
}

我们发现了 Class<? extends Throwable>[] rollbackFor() default {};,听这名字一看 rollbackFor,为…而回滚,这不正是我们想要的吗?他的传入值是一个 Class 数组的形式,我们立马回到我们的 transfer 方法,添加上这个属性,如下:

@Service
public class AccountsService {
    @Autowired
    private AccountsMapper accountsMapper;

    @Transactional(rollbackFor = {Exception.class})
    public void transfer(Character id1, Character id2, BigDecimal delta) throws Exception {
        accountsMapper.updateByDelta(id1,BigDecimal.ZERO.subtract(delta));
        if(true){
            throw new Exception("手动抛出异常~");
        }
        accountsMapper.updateByDelta(id2,delta);
    }
}

然后我们把数据库数据还原成 800 和 700,再去进行单元测试,结果如下:

image.png

这下就非常完美了,异常也抛出了,数据也没有被修改,说明 ROLLBACK 回滚了。

事务传播行为

好,但是我们又有一个问题。我们在项目中,往往会有储存操作日志的业务逻辑,下面我们创建一个 accounts_log 表。

CREATE TABLE accounts_log (
    id BigInt PRIMARY KEY AUTO_INCREMENT,
    message TEXT NOT NULL
) ENGINE=InnoDB;

然后创建一个实体类:

@Data
@AllArgsConstructor
@NoArgsConstructor
public class AccountsLog {
    private Long id;
    private String message;
}

创建一个 Mapper:

@Mapper
public interface AccountsLogMapper {
    @Insert("INSERT INTO accounts_log VALUES(#{id},#{message})")
    void insert(AccountsLog accountsLog);
}

创建一个 Service:

@Service
public class AccountsLogService {
    @Autowired
    private AccountsLogMapper accountsLogMapper;
    
    public void insert(AccountsLog accountsLog) {
        accountsLogMapper.insert(accountsLog);
    }

}

改造 transfer 代码:

@Service
public class AccountsService {
    @Autowired
    private AccountsMapper accountsMapper;

    @Autowired
    private AccountsLogService accountsLogService;

    @Transactional(rollbackFor = {Exception.class})
    public void transfer(Character id1, Character id2, BigDecimal delta) throws Exception {
        String message = id1 + "给"
                + id2 + "发起转账" + delta + "元, ";
        try {
            accountsMapper.updateByDelta(id1,BigDecimal.ZERO.subtract(delta));
            if(true){
                throw new Exception("手动抛出异常~");
            }
            accountsMapper.updateByDelta(id2,delta);
            message = "成功了!";
        } catch (Exception e){
            message += "但是失败了!";
            throw e;//继续抛出异常,要不然 @Transactional 检测不到
        }finally {
            accountsLogService.insert(new AccountsLog(null,message));
        }
    }

}

我们要求转账不论是否成功,都要记录日志,如果只这样写,我们运行单元测试后发现:

image.png

异常抛出了,但是 accounts_log 表里却没有数据,这是因为虽然 accountsLogService.insert 是在 finally 里被调用,但是该操作仍然在同一个事务方法下被约束,只要有异常,所有修改都会被回滚,包括 finally 中的操作。那么我们如何解决这个问题呢?

Transactional 的源码中我们看到有一个属性 Propagation propagation() default Propagation.REQUIRED;,这个属性用于控制事务的传播行为,即当一个事务方法被另一个事务方法调用时,如何处理事务的问题。

Spring 定义了 7 种事务传播行为:

传播行为说明
REQUIRED (默认)如果当前存在事务,则加入该事务;如果没有事务,则创建一个新事务
REQUIRES_NEW总是创建新事务,如果当前存在事务,则将当前事务挂起
SUPPORTS如果当前存在事务,则加入该事务;如果没有事务,则以非事务方式执行
NOT_SUPPORTED以非事务方式执行,如果当前存在事务,则将当前事务挂起
MANDATORY如果当前存在事务,则加入该事务;如果没有事务,则抛出异常
NEVER以非事务方式执行,如果当前存在事务,则抛出异常
NESTED如果当前存在事务,则在嵌套事务内执行;如果没有事务,则创建一个新事务

对于我们的日志记录需求,应该使用 Propagation.REQUIRES_NEW,它会创建一个完全独立的新事务,不受外层事务回滚的影响。

我们修改 AccountsLogService 如下:

@Service
public class AccountsLogService {
    @Autowired
    private AccountsLogMapper accountsLogMapper;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void insert(AccountsLog accountsLog) {
        accountsLogMapper.insert(accountsLog);
    }

}

此时我们再去运行单元测试查看数据库表结果:

image.png

相当完美,我们的日志添加成功了。对于当前这个例子,在进行内层事务时,外层事务会挂起,等待内层事务声明周期结束后,才重新唤醒外层事务。

但是在正式的项目开发中,我们大部分情况是使用默认的传播方式的(约 85 % ~ 90 % 的情景),其次使用较多的就是 REQUIRES_NEW (约 5 % ~10 %),其他传播方式使用的概率不是很大,感兴趣的小伙伴可以自行去了解。


上一篇文章
Modalities collaboration and granularities interaction for fine–grained sketch-based image retrieval 的解读
下一篇文章
Spring Bean 的作用域与生命周期