Spring事务基础:你在入门时踩过的所有坑
Spring事务基础:你在入门时踩过的所有坑
开篇:用问题引起共鸣
-
场景化提问:
@Service public class OrderService {@Transactionalpublic void createOrder() {insertOrder(); // 插入订单updateStock(); // 更新库存(这里抛出异常)} }
-
灵魂拷问:
“为什么上面的代码事务不回滚?你遇到过多少种类似的情况?”
事务的概念:
Spring
事务管理是 Spring
框架的核心功能之一,用于在业务逻辑中保证数据的一致性和完整性。事务的本质是将一组操作(如数据库增删改查)封装为一个原子性操作,确保这些操作要么全部成功,要么全部失败。
事务的特性:
🧩 原子性(Atomicity
):事务中的所有操作要么全部完成,要么全部回滚。
🔗一致性(Consistency
):事务执行前后,数据的状态保持一致。
🚧隔离性(Isolation
):多个事务并发执行时,彼此之间互不干扰。
💾持久性(Durability
):事务一旦提交,数据的修改就是永久性的。
ACID特性在Spring中的体现:
ACID属性 | Spring实现方式 | 常见破坏场景 |
---|---|---|
原子性 | 回滚机制(Rollback ) | 异常被捕获未抛出 |
一致性 | 业务逻辑+约束 | 手动修改数据库绕过事务 |
隔离性 | @Transactional(isolation=?) | 脏读/幻读(隔离级别设置不当) |
持久性 | 数据库提交 | 未提交事务时服务重启 |
🚩陷阱1:异常被吞掉
-
错误示例:
@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)异常
@Transactional public void method() {try {jdbcTemplate.update("INSERT ...");} catch (Exception e) {log.error("出错", e);throw new RuntimeException(e); // 手动抛出运行时异常} }
- 或配置
@Transactional(rollbackFor = Exception.class)
,显式声明对所有异常回滚。
@Transactional(rollbackFor = Exception.class) public void method() {try {jdbcTemplate.update("INSERT ...");} catch (Exception e) {log.error("出错", e);throw e; // 重新抛出原始异常} }
- 在catch中手动抛出
🚩陷阱2:非public方法
-
错误示例:
@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 能够通过代理对象拦截到该方法的调用,从而应用事务管理逻辑。
@Transactional
public void innerMethod() {// 数据库操作
}
🚩陷阱3:自调用问题
-
经典错误:
@Service public class UserService {public void updateUser() {this.innerMethod(); // 自调用导致AOP失效}@Transactionalpublic void innerMethod() { /* ... */ } }
-
问题分析
原理:在 Spring
中,事务管理是通过 AOP
(面向切面编程)实现的。AOP
的核心机制是通过动态代理拦截目标方法的调用,并在方法执行前后添加事务管理逻辑。然而,当一个类中的方法通过this
调用另一个方法时,这种调用会绕过代理对象,直接调用目标方法,从而导致 AOP
逻辑(如事务管理)失效。
-
为什么事务失效?
-
Spring AOP
的代理机制
Spring AOP
默认使用动态代理(JDK
动态代理或CGLIB
)来实现事务管理。动态代理会在目标对象外部创建一个代理对象,所有对目标方法的调用都会经过代理对象,从而触发事务管理逻辑。 -
自调用绕过代理对象
在上述代码中,
updateUser()
方法通过this.innerMethod()
直接调用了innerMethod()
,而不是通过代理对象调用。这种调用方式会绕过代理对象,导致事务管理逻辑无法生效。
-
-
解决方案:
- 将事务方法移动到另一个类中,确保调用是通过代理对象进行的。
@Service
public class UserService {@Autowiredprivate InnerService innerService;public void updateUser() {innerService.innerMethod(); // 通过代理对象调用}
}
@Service
public class InnerService {@Transactionalpublic void innerMethod() {// 数据库操作}
}
- 通过
AopContext.currentProxy()
获取当前代理对象,并通过代理对象调用事务方法。
@Service
public class UserService {public void updateUser() {((UserService) AopContext.currentProxy()).innerMethod(); // 通过代理对象调用}@Transactionalpublic void innerMethod() {// 数据库操作}
}@Configuration
@EnableAspectJAutoProxy(exposeProxy = true)//启用暴露代理对象的功能
public class AppConfig {
}
🚩陷阱4:错误的事务传播机制
-
经典错误:
@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:多数据源配置错误
- 经典错误
@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();}// 错误:事务管理器绑定了主数据源,但业务逻辑使用了从数据源@Beanpublic PlatformTransactionManager transactionManager(@Qualifier("primaryDataSource") DataSource dataSource) {return new DataSourceTransactionManager(dataSource);}
}@Service
public class UserService {@Autowiredprivate JdbcTemplate primaryJdbcTemplate;@Autowiredprivate JdbcTemplate secondaryJdbcTemplate;@Transactionalpublic 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 注解生效时,事务管理器只会管理主数据源的操作,而从数据源的操作不会被事务管理。 -
数据一致性问题
如果主数据源的更新成功,但从数据源的日志插入失败,事务管理器无法回滚从数据源的操作,导致数据不一致。
-
-
修复方案
- 需要为每个数据源配置独立的事务管理器,并通过注解指定事务管理器。
@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:异步方法调用
-
错误示范:
@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
,确保每次调用都会创建一个新的事务。异步线程中的事务独立于主线程的事务,避免事务上下文丢失。
- 将事务逻辑封装到异步方法中,确保事务管理器能够正确管理异步线程中的操作,在
@Servicepublic class UserService {@Transactionalpublic void mainMethod() {asyncTask(); // 调用异步方法}@Async@Transactional(propagation = Propagation.REQUIRES_NEW)public void asyncTask() {// 数据库操作jdbcTemplate.update("INSERT INTO user (name) VALUES ('Alice')");}}
- 如果异步任务需要跨多个服务或数据源操作,可以使用分布式事务框架(如
Seata、Atomikos
)来保证数据一致性。分布式事务框架会协调多个服务或数据源的操作,确保所有操作在一个全局事务中完成。即使异步任务失败,也可以通过回滚机制保证数据一致性。
//引入分布式事务框架依赖:
<dependency><groupId>io.seata</groupId><artifactId>seata-spring-boot-starter</artifactId><version>1.5.0</version>
</dependency>/**
配置分布式事务:
定义全局事务 ID。
使用框架提供的注解(如 @GlobalTransactional)管理事务。
*/@Servicepublic class UserService {@GlobalTransactionalpublic void mainMethod() {asyncTask(); // 异步方法内操作通过分布式事务管理}@Asyncpublic void asyncTask() {// 数据库操作jdbcTemplate.update("INSERT INTO user (name) VALUES ('Alice')");}}
- 如果无法使用分布式事务,可以采用事件补偿机制,在异步任务失败时手动回滚或重试。通过记录操作日志和定义补偿逻辑,可以在异步任务失败时手动修复数据不一致问题。
@Servicepublic class UserService {@Transactionalpublic void mainMethod() {// 主方法逻辑jdbcTemplate.update("INSERT INTO user (name) VALUES ('Alice')");// 异步任务失败时触发补偿try {asyncTask();} catch (Exception e) {compensateTask();}}@Asyncpublic 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 {
@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),增加了内存和磁盘的开销。
-
-
危害:连接池耗尽、死锁风险
-
解决方案
- 将事务范围缩小到最小必要的范围,避免在事务中执行耗时操作。
@Servicepublic class UserService {@Autowiredprivate 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();}}}@Transactionalpublic void insertUser(String name) {userMapper.insert(name);}}
-
检测工具:
-- 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 |