1. 什么是事务
事务的定义
事物通常是指一组要被视为整体执行的活动或事件。
举个例子,比如银行转账,A 账户向 B 账户进行转账 100 元,此时要求下面两种操作:
-
A 账户余额减少 100 元
-
B 账户余额增加 100 元
这两个操作必须一起成功或一起失败。如果 A 账户余额减少了,B 账户没有增加,亦或者 A 账户余额没变,B 账户余额增加了,这就会出现数据不一致的问题,也就是银行系统出现了错误,账务记录不再可靠。
事务就是来解决类似于上述情况的问题,这便就是事务存在的意义:确保操作要么全部成功,要么全部失败,避免部分完成导致的数据错误,从而保证数据的一致性与完整性。
事务的性质
事务具有 ACID 四种特性,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。
原子性:事务不可分割。
一致性:事务前后数据保持有效状态。
隔离性:多个事务互不干扰。
持久性:提交后数据永久保存。
2. MySQL 中的事务
注意事项
MySQL 并非所有存储引擎都支持事务:
- 支持事务:InnoDB(默认)
- 不支持事务:MyISAM(不支持事务,数据操作无法回滚)
使用事务前,请确保表是 InnoDB 引擎。
基本事务操作
在 MySQL 中,我们可以使用 START TRANSACTION 或 BEGIN 进行开启事务,然后执行 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);
- 注意:正常项目中主键通常用整数类型,这里为了符合上面的题目例子,方便解释设置为 char 类型。

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

现在我们我们创建一个转账的操作,并执行:
-- 扣减 A 账户余额
UPDATE accounts
SET balance = balance - 100
WHERE id = 'A';
-- 增加 B 账户余额
UPDATE accounts
SET balance = balance + 100
WHERE id = 'B';

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

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

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

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

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


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"));
}
}
运行后我们发现,抛出了一个异常:

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

A 账户的余额减少了 100 元,B 账户的余额却没有增加,总的余额 1500 缺无缘无故少了 100 元,这就对数据的一致性与完整性造成了问题。
在 AccountsService 代码中,首先成功运行了 accountsMapper.updateByDelta(id1,BigDecimal.ZERO.subtract(delta));,但是之后抛出异常后,下面的 accountsMapper.updateByDelta(id2,delta); 就不执行了,因此就造成了上述问题。而我们希望的是,这两个 update 要么同时成功,要么同时失败才可以。因此,必须把这两个 update 封成一个事务去执行,一旦其中一个失败,则全部 ROLLBACK 回滚。
@Transactional 的基本用法
在 Spring 框架中,事务管理是通过 Spring 的事务抽象来实现的,它可以让我们在业务代码中方便地管理事务,而不需要直接操作数据库的 START TRANSACTION、COMMIT 或 ROLLBACK。
在 Spring 框架中,我们往往使用 @Transactional 注解来声明某个方法或类需要在事务中执行。Spring 会在方法执行前自动开启事务,方法执行成功后提交事务,如果在执行过程中出现 RuntimeException 或 Error,事务会自动回滚,从而保证操作的原子性和一致性。
@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。

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

刷新后,可以看到,数据没有任何变动修改,这说明事务成功回滚了。当 transfer 方法执行到 1/0 抛出 ArithmeticException 异常时,Spring 检测到运行时异常,自动触发了事务回滚,撤销了之前对 A 账户的扣款操作,保证了数据的一致性。
如果我们此时把 1/0 的代码删掉,然后再去运行,此时没有任何异常,因此会 COMMIT 成功,刷新数据库表如下图所示。

@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);
}
}
}
运行单元测试后刷新并查看数据库表我们发现,又出问题了:

还是和最初始的那样,即使加了 @Transactional,也出现了之前的情况,A 的余额减少,B 的余额没增加,这是因为什么?
就像我们刚才所说的,@Transactional 默认只能对运行时异常自动回滚,因为 ArithmeticException 是 RuntimeException 的子类,因此可以自动回滚,但是 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,再去进行单元测试,结果如下:

这下就非常完美了,异常也抛出了,数据也没有被修改,说明 ROLLBACK 回滚了。
事务传播行为
好,但是我们又有一个问题。我们在项目中,往往会有储存操作日志的业务逻辑,下面我们创建一个 accounts_log 表。
CREATE TABLE accounts_log (
id BigInt PRIMARY KEY AUTO_INCREMENT,
message TEXT NOT NULL
) ENGINE=InnoDB;
- 请注意,我这是简化的 log 表,正常项目开发没这么简单,还有很多别的字段,如日志类型等。
然后创建一个实体类:
@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));
}
}
}
我们要求转账不论是否成功,都要记录日志,如果只这样写,我们运行单元测试后发现:

异常抛出了,但是 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);
}
}
此时我们再去运行单元测试查看数据库表结果:

相当完美,我们的日志添加成功了。对于当前这个例子,在进行内层事务时,外层事务会挂起,等待内层事务声明周期结束后,才重新唤醒外层事务。
但是在正式的项目开发中,我们大部分情况是使用默认的传播方式的(约 85 % ~ 90 % 的情景),其次使用较多的就是 REQUIRES_NEW (约 5 % ~10 %),其他传播方式使用的概率不是很大,感兴趣的小伙伴可以自行去了解。