Spring事务基础:你在入门时踩过的所有坑

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; // 重新抛出原始异常
        }
    }

🚩陷阱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 vs REQUIRES_NEW的区别

REQUIRED REQUIRES_NEW
调用 methodA ↓ 检查是否存在事务 ↓ 如果无事务 → 创建事务 A ↓ 调用 methodBmethodB 加入事务 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.RuntimeExceptionjava.lang.Error。 不需要显式处理,但如果不处理,可能会导致程序崩溃。 通常用于表示程序逻辑错误或不可恢复的系统错误。

  • 常见示例

    运行时异常(RuntimeException):

    NullPointerException:空指针异常。 ArrayIndexOutOfBoundsException:数组越界异常。 IllegalArgumentException:非法参数异常。

    错误(Error):

    OutOfMemoryError:内存溢出错误。 StackOverflowError:栈溢出错误。

两者的区别

特性 受检异常(Checked Exception) 非受检异常(Unchecked Exception
继承关系 继承自 Exception,但不包括 RuntimeException 继承自 RuntimeExceptionError
编译时检查 编译器强制要求处理 编译器不要求显式处理
使用场景 可恢复的业务逻辑错误或外部系统问题 程序逻辑错误或不可恢复的系统错误
是否需要声明抛出
典型示例 IOException, SQLException NullPointerException, ArithmeticException
相关推荐
why技术38 分钟前
在我眼里,这就是天才般的算法!
后端·面试
绝无仅有39 分钟前
Jenkins+docker 微服务实现自动化部署安装和部署过程
后端·面试·github
程序视点43 分钟前
Escrcpy 3.0投屏控制软件使用教程:无线/有线连接+虚拟显示功能详解
前端·后端
hqxstudying1 小时前
mybatis过渡到mybatis-plus过程中需要注意的地方
java·tomcat·mybatis
lichkingyang1 小时前
最近遇到的几个JVM问题
java·jvm·算法
zhuyasen1 小时前
当Go框架拥有“大脑”,Sponge框架集成AI开发项目,从“手写”到一键“生成”业务逻辑代码
后端·go·ai编程
ZeroKoop1 小时前
多线程文件下载 - 数组切分,截取文件名称
java
Monly211 小时前
IDEA:控制台中文乱码
java·ide·intellij-idea
叫我阿柒啊2 小时前
从全栈开发到微服务架构:一次真实的Java面试实录
java·redis·ci/cd·微服务·vue3·springboot·jwt
东皋长歌2 小时前
SpringBoot集成ELK
spring boot·后端·elk