Spring事务基础:你在入门时踩过的所有坑
开篇:用问题引起共鸣
-
场景化提问:
scss@Service public class OrderService { @Transactional public void createOrder() { insertOrder(); // 插入订单 updateStock(); // 更新库存(这里抛出异常) } }
-
灵魂拷问: "为什么上面的代码事务不回滚?你遇到过多少种类似的情况?"
事务的概念:
Spring
事务管理是 Spring
框架的核心功能之一,用于在业务逻辑中保证数据的一致性和完整性。事务的本质是将一组操作(如数据库增删改查)封装为一个原子性操作,确保这些操作要么全部成功,要么全部失败。
事务的特性:
🧩 原子性(Atomicity
):事务中的所有操作要么全部完成,要么全部回滚。 🔗一致性(Consistency
):事务执行前后,数据的状态保持一致。 🚧隔离性(Isolation
):多个事务并发执行时,彼此之间互不干扰。 💾持久性(Durability
):事务一旦提交,数据的修改就是永久性的。
ACID特性在Spring中的体现:
ACID属性 | Spring实现方式 | 常见破坏场景 |
---|---|---|
原子性 | 回滚机制(Rollback ) |
异常被捕获未抛出 |
一致性 | 业务逻辑+约束 | 手动修改数据库绕过事务 |
隔离性 | @Transactional(isolation=?) |
脏读/幻读(隔离级别设置不当) |
持久性 | 数据库提交 | 未提交事务时服务重启 |
🚩陷阱1:异常被吞掉
-
错误示例:
java@Transactional public void method() { try { jdbcTemplate.update("INSERT ..."); } catch (Exception e) { log.error("出错", e); // 事务不回滚! } }
-
问题分析
原理:在 Spring
事务管理中,事务的回滚是基于异常触发的。默认情况下,只有 RuntimeException
和 Error
会触发事务回滚,而受检异常(Checked Exception
)不会触发回滚。因此,如果在代码中捕获了异常但没有重新抛出,Spring
就无法感知到异常的发生,从而不会触发事务回滚。
为什么事务不回滚? 异常被捕获并"吞掉" 在 catch 块中,异常被记录日志后没有重新抛出。Spring 的事务管理器通过代理机制监控方法执行过程中是否抛出了异常。如果没有异常抛出,事务管理器会认为操作成功,进而提交事务。 默认回滚规则限制 Spring
默认只对 RuntimeException
和 Error
触发回滚。即使你手动抛出了一个受检异常(如 SQLException
),事务也不会回滚。
-
解决方案:
- 在catch中手动抛出
throw new RuntimeException(e)
,捕获后抛出非受检(Unchecked)异常
java@Transactional public void method() { try { jdbcTemplate.update("INSERT ..."); } catch (Exception e) { log.error("出错", e); throw new RuntimeException(e); // 手动抛出运行时异常 } }
- 或配置
@Transactional(rollbackFor = Exception.class)
,显式声明对所有异常回滚。
java@Transactional(rollbackFor = Exception.class) public void method() { try { jdbcTemplate.update("INSERT ..."); } catch (Exception e) { log.error("出错", e); throw e; // 重新抛出原始异常 } }
- 在catch中手动抛出
🚩陷阱2:非public方法
-
错误示例:
java@Transactional private void innerMethod() { // 事务失效! // 数据库操作 }
-
问题分析
原理:在 Spring 框架中,事务管理是基于 AOP(面向切面编程)实现的。AOP 的核心机制是通过动态代理拦截目标方法的调用,并在方法执行前后添加事务管理逻辑。然而,Spring AOP 默认只能代理 public 方法,因此如果事务方法是非 public 的(如 private、protected 或包级私有),事务将无法生效。
为什么事务失效?
Spring AOP 的限制 Spring AOP 使用动态代理(JDK 动态代理或 CGLIB)来实现事务管理。动态代理只能拦截 public 方法的调用。对于非 public 方法(如 private、protected 或包级私有),代理对象无法拦截到这些方法的调用,因此事务管理逻辑不会生效。
- 解决方案
- 将方法改为 public 后,Spring AOP 能够通过代理对象拦截到该方法的调用,从而应用事务管理逻辑。
java
@Transactional
public void innerMethod() {
// 数据库操作
}
🚩陷阱3:自调用问题
-
经典错误:
java@Service public class UserService { public void updateUser() { this.innerMethod(); // 自调用导致AOP失效 } @Transactional public void innerMethod() { /* ... */ } }
-
问题分析
原理:在 Spring
中,事务管理是通过 AOP
(面向切面编程)实现的。AOP
的核心机制是通过动态代理拦截目标方法的调用,并在方法执行前后添加事务管理逻辑。然而,当一个类中的方法通过this
调用另一个方法时,这种调用会绕过代理对象,直接调用目标方法,从而导致 AOP
逻辑(如事务管理)失效。
-
为什么事务失效?
-
Spring AOP
的代理机制Spring AOP
默认使用动态代理(JDK
动态代理或CGLIB
)来实现事务管理。动态代理会在目标对象外部创建一个代理对象,所有对目标方法的调用都会经过代理对象,从而触发事务管理逻辑。 -
自调用绕过代理对象
在上述代码中,
updateUser()
方法通过this.innerMethod()
直接调用了innerMethod()
,而不是通过代理对象调用。这种调用方式会绕过代理对象,导致事务管理逻辑无法生效。
-
-
解决方案:
- 将事务方法移动到另一个类中,确保调用是通过代理对象进行的。
java
@Service
public class UserService {
@Autowired
private InnerService innerService;
public void updateUser() {
innerService.innerMethod(); // 通过代理对象调用
}
}
@Service
public class InnerService {
@Transactional
public void innerMethod() {
// 数据库操作
}
}
- 通过
AopContext.currentProxy()
获取当前代理对象,并通过代理对象调用事务方法。
java
@Service
public class UserService {
public void updateUser() {
((UserService) AopContext.currentProxy()).innerMethod(); // 通过代理对象调用
}
@Transactional
public void innerMethod() {
// 数据库操作
}
}
@Configuration
@EnableAspectJAutoProxy(exposeProxy = true)//启用暴露代理对象的功能
public class AppConfig {
}
🚩陷阱4:错误的事务传播机制
-
经典错误:
java@Transactional(propagation = Propagation.REQUIRED) // 默认 public void methodA() { methodB(); // 不同传播行为的结果差异 } @Transactional(propagation = Propagation.REQUIRES_NEW) public void methodB() { /* ... */ }
-
问题分析
在 Spring 中,事务传播机制定义了事务方法之间的调用关系。不同的传播行为会导致事务的行为和结果产生显著差异。
-
传播行为详解
- REQUIRED(默认传播行为):如果当前存在事务,则加入该事务。如果当前不存在事务,则创建一个新事务。
- REQUIRES_NEW:总是创建一个新事务。如果当前存在事务,则挂起当前事务。
-
结论 :用流程图展示
REQUIRED
vsREQUIRES_NEW
的区别
REQUIRED | REQUIRES_NEW |
---|---|
调用 methodA ↓ 检查是否存在事务 ↓ 如果无事务 → 创建事务 A ↓ 调用 methodB ↓ methodB 加入事务 A ↓ methodB 完成 ↓ methodA 完成 → 提交事务 A |
调用 methodA ↓ 检查是否存在事务 ↓ 如果无事务 → 创建事务 A ↓ 调用 methodB ↓ 挂起事务 A ↓ 创建事务 B ↓ methodB 完成 → 提交事务 B ↓ 恢复事务 A ↓ methodA 完成 → 提交事务 A |
可能的报错原因总结
- 事务传播行为选择错误 如果需要独立事务但使用了 REQUIRED,可能会导致事务回滚影响范围过大。 如果需要共享事务但使用了 REQUIRES_NEW,可能会导致事务隔离性问题。
- 事务嵌套复杂性 过多的事务嵌套可能导致性能问题或难以调试的事务行为。
- 事务挂起和恢复开销 REQUIRES_NEW 会挂起当前事务并创建新事务,增加了系统开销。
🚩陷阱5:多数据源配置错误
- 经典错误
java
@Configuration
public class DataSourceConfig {
@Bean(name = "primaryDataSource")
@ConfigurationProperties(prefix = "spring.datasource.primary")
public DataSource primaryDataSource() {
return DataSourceBuilder.create().build();
}
@Bean(name = "secondaryDataSource")
@ConfigurationProperties(prefix = "spring.datasource.secondary")
public DataSource secondaryDataSource() {
return DataSourceBuilder.create().build();
}
// 错误:事务管理器绑定了主数据源,但业务逻辑使用了从数据源
@Bean
public PlatformTransactionManager transactionManager(@Qualifier("primaryDataSource") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
@Service
public class UserService {
@Autowired
private JdbcTemplate primaryJdbcTemplate;
@Autowired
private JdbcTemplate secondaryJdbcTemplate;
@Transactional
public void updateUser() {
// 使用主数据源更新用户信息
primaryJdbcTemplate.update("UPDATE user SET name = 'Alice' WHERE id = 1");
// 使用从数据源记录日志
secondaryJdbcTemplate.update("INSERT INTO log (message) VALUES ('User updated')");
}
}
- 问题分析
原理:在 Spring
应用中,如果项目需要操作多个数据源(如主库和从库、不同业务数据库等),必须正确配置事务管理器和数据源。如果事务管理器绑定到了错误的数据源,可能会导致以下问题: 数据一致性问题:事务无法正确管理目标数据源的操作。 SQL 执行失败:事务管理器尝试对未绑定的数据源执行事务操作。
-
错误原因分析
-
事务管理器绑定错误
transactionManager
方法中绑定了主数据源(primaryDataSource
),但updateUser
方法中同时操作了主数据源和从数据源。 当 @Transactional 注解生效时,事务管理器只会管理主数据源的操作,而从数据源的操作不会被事务管理。 -
数据一致性问题
如果主数据源的更新成功,但从数据源的日志插入失败,事务管理器无法回滚从数据源的操作,导致数据不一致。
-
-
修复方案
- 需要为每个数据源配置独立的事务管理器,并通过注解指定事务管理器。
java@Configuration public class DataSourceConfig { @Bean(name = "primaryDataSource") @ConfigurationProperties(prefix = "spring.datasource.primary") public DataSource primaryDataSource() { return DataSourceBuilder.create().build(); } @Bean(name = "secondaryDataSource") @ConfigurationProperties(prefix = "spring.datasource.secondary") public DataSource secondaryDataSource() { return DataSourceBuilder.create().build(); } // 配置主数据源的事务管理器 @Bean(name = "primaryTransactionManager") public PlatformTransactionManager primaryTransactionManager(@Qualifier("primaryDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } // 配置从数据源的事务管理器 @Bean(name = "secondaryTransactionManager") public PlatformTransactionManager secondaryTransactionManager(@Qualifier("secondaryDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } } @Service public class UserService { @Autowired @Qualifier("primaryJdbcTemplate") private JdbcTemplate primaryJdbcTemplate; @Autowired @Qualifier("secondaryJdbcTemplate") private JdbcTemplate secondaryJdbcTemplate; // 指定主数据源的事务管理器 @Transactional("primaryTransactionManager") public void updateUser() { // 使用主数据源更新用户信息 primaryJdbcTemplate.update("UPDATE user SET name = 'Alice' WHERE id = 1"); // 手动调用从数据源操作(非事务) recordLog(); } // 指定从数据源的事务管理器 @Transactional("secondaryTransactionManager") public void recordLog() { secondaryJdbcTemplate.update("INSERT INTO log (message) VALUES ('User updated')"); } }
🚩陷阱6:异步方法调用
-
错误示范:
java@Transactional public void mainMethod() { asyncTask(); // 异步方法内操作不回滚 } @Async public void asyncTask() { // 数据库操作 jdbcTemplate.update("INSERT INTO user (name) VALUES ('Alice')"); }
-
问题分析
-
原理:在 Spring 中,事务管理是基于线程绑定的(通过
ThreadLocal
实现)。当一个事务方法被异步调用时,事务上下文无法传递到异步线程中,从而导致事务管理失效。 -
错误原因分析
-
事务上下文未传递
Spring
事务是基于线程绑定的,事务上下文存储在当前线程的ThreadLocal
中。 当asyncTask()
方法被异步调用时,它会在一个新的线程中执行,而新线程无法访问原线程的事务上下文。 -
事务失效
asyncTask()
方法中的数据库操作不会被事务管理器管理,因此即使发生异常,也不会触发回滚。
-
数据一致性问题
如果mainMethod()
的事务提交成功,但 asyncTask()
的操作失败,会导致数据不一致。
-
解决方案
- 将事务逻辑封装到异步方法中,确保事务管理器能够正确管理异步线程中的操作,在
asyncTask()
方法上添加@Transactional
注解,并指定传播行为为REQUIRES_NEW
,确保每次调用都会创建一个新的事务。异步线程中的事务独立于主线程的事务,避免事务上下文丢失。
- 将事务逻辑封装到异步方法中,确保事务管理器能够正确管理异步线程中的操作,在
java
@Service
public class UserService {
@Transactional
public void mainMethod() {
asyncTask(); // 调用异步方法
}
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void asyncTask() {
// 数据库操作
jdbcTemplate.update("INSERT INTO user (name) VALUES ('Alice')");
}
}
- 如果异步任务需要跨多个服务或数据源操作,可以使用分布式事务框架(如
Seata、Atomikos
)来保证数据一致性。分布式事务框架会协调多个服务或数据源的操作,确保所有操作在一个全局事务中完成。即使异步任务失败,也可以通过回滚机制保证数据一致性。
java
//引入分布式事务框架依赖:
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.5.0</version>
</dependency>
/**
配置分布式事务:
定义全局事务 ID。
使用框架提供的注解(如 @GlobalTransactional)管理事务。
*/
@Service
public class UserService {
@GlobalTransactional
public void mainMethod() {
asyncTask(); // 异步方法内操作通过分布式事务管理
}
@Async
public void asyncTask() {
// 数据库操作
jdbcTemplate.update("INSERT INTO user (name) VALUES ('Alice')");
}
}
- 如果无法使用分布式事务,可以采用事件补偿机制,在异步任务失败时手动回滚或重试。通过记录操作日志和定义补偿逻辑,可以在异步任务失败时手动修复数据不一致问题。
java
@Service
public class UserService {
@Transactional
public void mainMethod() {
// 主方法逻辑
jdbcTemplate.update("INSERT INTO user (name) VALUES ('Alice')");
// 异步任务失败时触发补偿
try {
asyncTask();
} catch (Exception e) {
compensateTask();
}
}
@Async
public void asyncTask() {
// 数据库操作
jdbcTemplate.update("INSERT INTO log (message) VALUES ('User created')");
if (Math.random() > 0.5) { // 模拟失败
throw new RuntimeException("异步任务失败");
}
}
public void compensateTask() {
// 补偿逻辑
jdbcTemplate.update("DELETE FROM user WHERE name = 'Alice'");
}
}
🚩陷阱7:长事务问题
- 错误案例
@Service public class UserService {
csharp
@Transactional
public void longRunningTask() {
// 模拟长时间运行的操作
for (int i = 0; i < 1000; i++) {
jdbcTemplate.update("INSERT INTO user (name) VALUES ('User" + i + "')");
try {
Thread.sleep(100); // 模拟耗时操作
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}
-
问题分析
-
错误原因分析
-
事务范围过大
整个
longRunningTask
方法被包裹在一个事务中,事务持续时间长达 100 秒(1000 次循环 × 100 毫秒)。在此期间,数据库连接被长时间占用,可能导致连接池耗尽。 -
资源锁定
如果方法中涉及对表的写操作,可能会锁定大量行或表,导致其他事务等待或死锁。
-
性能问题 数据库需要维护未提交事务的中间状态(如 Undo Log),增加了内存和磁盘的开销。
-
-
危害:连接池耗尽、死锁风险
-
解决方案
- 将事务范围缩小到最小必要的范围,避免在事务中执行耗时操作。
java
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
public void shortRunningTask() {
// 将事务范围缩小到每次插入操作
for (int i = 0; i < 1000; i++) {
insertUser("User" + i);
try {
Thread.sleep(100); // 模拟耗时操作
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
@Transactional
public void insertUser(String name) {
userMapper.insert(name);
}
}
-
检测工具:
sql-- MySQL查看长事务 SELECT * FROM information_schema.innodb_trx WHERE TIME_TO_SEC(timediff(now(),trx_started)) > 60;
总结:事务使用Checklist
- ✅ 检查方法是否为
public
- ✅ 检查异常是否未被捕获
- ✅ 检查是否跨数据源/跨线程
- ✅ 检查传播行为是否符合预期
- ✅ 检查
@Transactional
注解是否被同类方法调用
附件:异常的分类
1、 受检异常(Checked Exception
)
- 定义 受检异常是指在编译时就必须处理的异常。如果方法中可能抛出受检异常,则必须通过
try-catch
块捕获异常,或者通过throws
关键字将异常向上抛出。 - 特点 继承自
java.lang.Exception
类,但不包括RuntimeException
及其子类。 编译器会强制要求开发者处理这些异常。 通常用于表示可恢复的业务逻辑错误或外部系统问题。 - 常见示例
IOException
:输入输出操作异常。SQLException
:数据库操作异常。FileNotFoundException
:文件未找到异常。
2、非受检异常(Unchecked Exception
)
-
定义 非受检异常是指在编译时不需要显式处理的异常。即使方法中可能抛出非受检异常,编译器也不会强制要求开发者捕获或声明抛出。
-
特点 继承自
java.lang.RuntimeException
或java.lang.Error
。 不需要显式处理,但如果不处理,可能会导致程序崩溃。 通常用于表示程序逻辑错误或不可恢复的系统错误。 -
常见示例
运行时异常(
RuntimeException
):NullPointerException
:空指针异常。ArrayIndexOutOfBoundsException
:数组越界异常。IllegalArgumentException
:非法参数异常。错误(Error):
OutOfMemoryError
:内存溢出错误。StackOverflowError
:栈溢出错误。
两者的区别
特性 | 受检异常(Checked Exception) | 非受检异常(Unchecked Exception |
---|---|---|
继承关系 | 继承自 Exception ,但不包括 RuntimeException |
继承自 RuntimeException 或 Error |
编译时检查 | 编译器强制要求处理 | 编译器不要求显式处理 |
使用场景 | 可恢复的业务逻辑错误或外部系统问题 | 程序逻辑错误或不可恢复的系统错误 |
是否需要声明抛出 | 是 | 否 |
典型示例 | IOException , SQLException |
NullPointerException , ArithmeticException |