Spring 的本地事务

什么是事务?

事务的定义:事务是数据库管理系统(DBMS)执行过程中的一个逻辑单位,由一个或多个数据库操作(通常是 SQL 语句)序列组成。这些操作要么全部执行成功,要么全部撤销(回滚),它使数据库从一种一致性状态变更为另一种一致性状态。事务有四个最基本的特性,即所谓的 ACID,以最经典的转账业务为例:

原子性 (Atomicity) —— “要么全成,要么全败”

业务场景:转账包含两步:A 扣钱,B 加钱。

  • 代码体现:如果在 A 扣钱成功后,程序抛出了异常(比如金额过大),Spring 会捕捉到这个异常并通知数据库 Rollback(回滚)。
  • 结果:数据库会撤销 A 扣钱的操作。在外界看来,这两步操作就像一个不可分割的 “原子”,不存在 “A 扣了钱但 B 没收到” 的中间状态。


一致性 (Consistency) —— “能量守恒”

业务场景:转账前后,系统必须处于一个合法的合规状态。

  • 代码体现:我们使用了 “ge(Account::getBalance, amount)”。
  • 结果:一致性保证了数据库完整性约束。如果 A 只有 100 元却想转 200 元,SQL 检查失败,result 为 0,我们抛出异常触发回滚。
  • 宏观视角:转账前 A+B 的总额是 1000 元,转账后无论成功还是失败,A+B 的总额必须依然是 1000 元,钱不会凭空消失或产生。


隔离性 (Isolation) —— “互不干扰”

业务场景:当张三给李四转账的同时,王五也给李四转账。

  • 代码体现:这是由数据库(如 MySQL)的隔离级别(默认通常是 Repeatable Read)和锁机制保证的。
  • 结果:当我们的 update 语句执行时,MySQL 会给对应的行加排他锁(X锁)。
  • 直观感受:即使有 100 个线程同时改李四的余额,数据库也会让它们 “排队” 执行,确保每一个事务读取到的余额都是准确的,不会产生 “脏读” 或 “覆盖更新”。


持久性 (Durability) —— “落盘为安”

业务场景:转账成功的一瞬间,银行大楼突然断电。

  • 底层体现:一旦 transfer 方法执行完毕,Spring 提交了事务,MySQL 会确保数据已经写入了 Redo Log(重做日志) 并刷入磁盘。
  • 结果:哪怕数据库服务器下一秒就炸了,重启后通过日志恢复,张三扣掉的钱和李四增加的钱依然存在。

以上四个特性,C 一致性才是真正的目的,A、I、D 只是手段。 我们之所以追求原子性、隔离性和持久性,最终都是为了保证数据库里的数据是准确一致的。


事务的隔离级别

事务的隔离性是为了解决多个线程同时操作同一份数据时产生的 “打架” 问题,它本质上是数据库引擎(如 MySQL 的 InnoDB)层面的实现。Spring 的 @Transactional 只是一个“遥控器”,只负责把你的指令发送给数据库,真正去锁表、去记录版本、去排队的,全是底层的数据库引擎。

为了平衡性能与安全,SQL 标准定义了 4 种隔离级别。我们可以用 “防偷窥、防干扰” 的程度来通俗理解:

  • 读未提交 (Read Uncommitted):

    • 可以理解成 “草稿可见”,别人正在改的数据,哪怕还没存盘(Commit),你也能看到。
    • 底层机制:数据库不对读操作加任何锁。
    • 存在脏读现象。比如小王给李四转账 500 元(还没点确定),李四查账发现多了 500 元,结果小王取消了转账。李四看到的就是“脏”数据。
    • 安全等级最低,几乎不用。
  • 读已提交 (Read Committed)

    • 可以理解成 “盖章后可见”。只有别人正式存盘(Commit)的数据,你才能查到。
    • 底层机制:采用 MVCC(多版本并发控制)。在每次执行 select 语句时,数据库都会生成一个新的快照(Read View)。
    • 解决了脏读,但存在不可重复读的问题。比如你正在查工资,第一次查是 5000 元;这时财务改成了 6000 元并点提交;你再点一次刷新,变成了 6000 元。同一次事务里,两次读的结果不一样。
    • 这是 Oracle、SQL Server 的默认级别。
  • 可重复读 (Repeatable Read)

    • 只要事务一开启,我看到的数据就定格在这一刻。无论别人怎么改并提交,我查到的永远是一开始的样子,就像给数据拍了个快照一样,同一个事务之内,看到的数据总是一样的。
    • 底层机制:同样使用 MVCC,但与 RC 不同,它在事务开始后的第一条查询时生成快照,并在整个事务期间一直复用这个快照。
    • 解决了不可重复读,但在标准的 SQL 定义中它无法解决幻读。比如你查账户列表有 10 个人,这时别人新增加了一个人。你虽然查不到这个人,但你尝试创建一个同名账户时却提示 “已存在”,就像见鬼了(幻觉)一样。
    • 这是MySQL 的默认级别(MySQL 通过间隙锁解决了大部分幻读)。
  • 串行化 (Serializable)

    • 所有的事务必须一个一个排队执行。如果你在读这行数据,别人连改的权利都没有,必须等你读完。
    • 底层机制:数据库会对读取的每一行数据都自动加上共享锁(S-Lock)。如果此时有其他事务尝试修改这些数据,必须等待当前事务释放锁。
    • 存在性能很差的问题,因为完全没有并发可言。
    • 安全等级最高,仅用于对安全性要求变态的场景。


本地事务的含义

1. 物理边界:无法跨越 “进程” 与 “网络”

本地事务依赖于底层 JDBC Connection。一个事务本质上绑定在一个数据库连接上。在微服务架构中,如果 OrderService(订单业务) 通过 RPC(如 Feign 或 Dubbo)调用 InventoryService(库存业务),@Transactional 只能保证 OrderService 内部数据库操作的回滚,无法控制远程服务的数据库。所以它不能解决分布式事务问题。


2. 资源边界:无法跨越 “异构数据源”

即便是在单体应用中,@Transactional 也有软肋。本地事务默认由 DataSourceTransactionManager 管理,只能管辖同一个数据库连接池中的连接。如果你同时操作 MySQL 和 Oracle,普通的 @Transactional 只能保其中一个。如果你在代码里先发了条 MQ 消息,然后更新 Redis,最后写 MySQL。即便 MySQL 事务回滚了,MQ 消息已经发出了,Redis 里的缓存也已经改了。所以它也无法实现跨物理介质的原子性(除非引入极其厚重的 JTA/XA 二阶段提交协议)。


3. 线程边界:无法跨越 “多线程”

这是最容易忽略的陷阱。Spring 的事务同步机制 TransactionSynchronizationManager 是基于 ThreadLocal 实现的。如果你在 @Transactional 方法内部开启了子线程(如 new Thread() 或 @Async)去执行数据库操作,这些操作将不在父线程的事务控制之下。子线程报错,父线程是不会回滚的;父线程回滚,子线程已提交的操作也无法撤回。


当本地事务搞不定时

当本地事务 @Transactional 搞不定时,通常我们会引入以下方案:

  1. 分布式事务框架:如 Seata(提供 AT、TCC、Saga 模式)。
  2. 最终一致性:利用 MQ 事务消息 或 “本地消息表” 实现。
  3. 编程式补偿:手动写 try-catch,在失败时调用逆向操作(如退款接口)。


本地事务用例演示

本例中,我们使用 spring + mybatis plus + druid + mysql 全注解开发形式进行演示。相关依赖如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.27</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>

配置类 MyConfig:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import com.alibaba.druid.pool.DruidDataSource;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.apache.ibatis.logging.stdout.StdOutImpl;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import javax.sql.DataSource;

@Configuration
@ComponentScan("com.demo") // 组件扫描
@EnableTransactionManagement // 开启事务管理
@MapperScan("com.demo.mapper") // Mapper扫描
public class MyConfig {

@Bean
@Primary // 数据源
public DataSource dataSource() {
DruidDataSource dataSource = new DruidDataSource();
dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
dataSource.setUrl("jdbc:mysql://xxx:3306/zdemo");
dataSource.setUsername("xxx");
dataSource.setPassword("xxx");
return dataSource;
}

@Bean // sqlSessionFactory
public MybatisSqlSessionFactoryBean sqlSessionFactory(DataSource dataSource) {
MybatisSqlSessionFactoryBean factoryBean = new MybatisSqlSessionFactoryBean();
factoryBean.setDataSource(dataSource);
//
MybatisConfiguration configuration = new MybatisConfiguration();
configuration.setLogImpl(StdOutImpl.class);
factoryBean.setConfiguration(configuration);
return factoryBean;
}

@Bean // transactionManager:同样持有 DataSource 用来控制连接的 commit 和 rollback
public PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}

业务演示类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@Data
@TableName("t_account")
@Alias("Account")
public class Account {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private BigDecimal balance;
}

@Mapper
public interface AccountMapper extends BaseMapper<Account> {
// 继承 BaseMapper 后,自动拥有了增删改查能力
}

@Service
public class BankService {

@Autowired
private AccountMapper accountMapper;

@Transactional(rollbackFor = Exception.class, isolation = Isolation.REPEATABLE_READ)
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 校验:金额必须大于 0
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("转账金额必须大于 0");
}

// 出账操作:UPDATE t_account SET balance = balance - ? WHERE id = ? AND balance >= ?
int result = accountMapper.update(null, new LambdaUpdateWrapper<Account>()
.eq(Account::getId, fromId)
.ge(Account::getBalance, amount) // 数据库层面校验余额是否充足
.setSql("balance = balance - " + amount));
if (result == 0) {
throw new RuntimeException("转账失败:账户不存在或余额不足!");
}

// 模拟异常
if (amount.compareTo(new BigDecimal("10000")) > 0) {
throw new RuntimeException("金额超过 10000,触发风控回滚!");
}

// 入账操作:UPDATE t_account SET balance = balance + ? WHERE id = ?
result = accountMapper.update(null, new LambdaUpdateWrapper<Account>()
.eq(Account::getId, toId)
.setSql("balance = balance + " + amount));
if (result == 0) {
throw new RuntimeException("转账失败:入账账户不存在!");
}
}
}

测试单元:

1
2
3
4
5
6
7
8
9
10
11
@SpringJUnitConfig(classes = MyConfig.class)
public class AccountTest {

@Autowired
private BankService bankService;

@Test
public void test01() {
bankService.transfer(1L, 2L, new BigDecimal(10001));
}
}

结果你会发现:在 @Transactional 不加之前,业务执行的过程中如果出现异常,数据库的数据是无法保证一致性的。加上 @Transactional 之后 ,如果中间有异常,数据总会回滚到最初的状态。


事务的传播属性演示

事务的传播属性(Propagation)定义的是:当一个开启了事务的方法被另一个事务方法调用时,这个事务该如何运转。Spring 共有 7 种传播行为:

我们增加一个 LogService,模拟 “无论转账成败,都要记录转账尝试日志” 的场景。

第一步:创建 LogService

1
2
3
4
5
6
7
8
9
10
11
@Service
public class LogService {
@Autowired
private LogMapper logMapper;

// 关键点:使用 REQUIRES_NEW
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(String content) {
logMapper.insert(new Log(content));
}
}

第二步:在 BankService 中调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
public class BankService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private LogService logService;

@Transactional(propagation = Propagation.REQUIRED) // 默认传播
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 1. 调用日志服务
logService.saveLog("尝试转账 " + amount);

// 2. A 扣钱
accountMapper.update(null, new LambdaUpdateWrapper<Account>()
.eq(Account::getId, fromId)
.setSql("balance = balance - " + amount));

// 3. 故意制造异常
if (true) throw new RuntimeException("转账中途崩溃!");

// 4. B 加钱
// ...
}
}

测试并验证结果:

测试场景 A:验证 REQUIRED (默认)

  • 设置:将 LogService.saveLog 的传播属性设为 REQUIRED。
  • 现象:transfer 报错后,account 表余额没变,log 表也没有新记录。
  • 结论:saveLog 加入了 transfer 的事务,一损俱损。

测试场景 B:验证 REQUIRES_NEW

  • 设置:将 LogService.saveLog 的传播属性设为 REQUIRES_NEW。
  • 现象:transfer 报错后,account 表余额没变(回滚了),但 log 表新增了一条记录。
  • 结论:saveLog 开启了一个独立的新事务,transfer 的回滚不会影响到它。

测试场景 C:验证 NESTED (嵌套事务)

  • 设置:将 LogService.saveLog 设为 NESTED,并在 BankService 里用 try-catch 包裹 logService.saveLog。
  • 现象:如果 saveLog 内部报错,它可以被 catch 住,转账继续成功。但如果 transfer 报错,saveLog 也会被连带回滚。
  • 结论:子事务可以独立回滚,但必须受父事务的总体控制。

如果你把 saveLog 方法写在 BankService 类内部,然后直接调用 this.saveLog(),那么传播属性会完全失效!

  • 原因:Spring 的事务是通过 AOP 代理对象实现的。当你调用 this.saveLog() 时,是直接对象内部调用,没有经过代理对象,事务增强逻辑(拦截器)根本没起作用。
  • 解决方法:
    1. 像上面的例子一样,分拆到两个不同的 Service 类中。
    2. 或者在同一个类中,通过 AopContext 获取当前的代理对象进行调用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 第一步:默认情况下,Spring 为了性能不会把代理对象放进上下文,你必须在配置类上手动开启。
@Configuration
@EnableTransactionManagement
@EnableAspectJAutoProxy(exposeProxy = true) // 设置 exposeProxy 为 true
public class AppConfig {
// ... 其他配置
}

// 第二步:假设我们在 BankService 内部有一个记录日志的方法,我们希望它在独立事务中运行。
@Service
public class BankService {
@Autowired
private AccountMapper accountMapper;

@Transactional(propagation = Propagation.REQUIRED)
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 直接调用:this.saveLog(...) 事务传播会失效!

// 正确做法:通过 AopContext 获取当前类的代理对象
BankService proxy = (BankService) AopContext.currentProxy();
proxy.saveLog("尝试转账操作");

// 执行转账逻辑...
accountMapper.update(...);

if (amount.compareTo(new BigDecimal("1000")) > 0) {
throw new RuntimeException("转账失败,触发回滚");
}
}

// 这个方法定义在同一个类里
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(String msg) {
System.out.println("正在独立事务中记录日志: " + msg);
// 执行插入日志 SQL...
}
}