多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Spring Boot @Transactional事务管理:原理、失效场景与排查实战

Spring Boot @Transactional事务管理:原理、失效场景与排查实战 接手 Spring Boot 项目事务管理是绕不开的一环。尤其是使用注解的方式配置事务看起来就是一行的功夫但真正跑到生产环境踩过的坑一个比一个经典事务悄悄失效、异常被吞、自调用导致不回滚、多数据源下注解不生效……这些问题不搞明白线上数据对不上账的时候代价就不只是加班能补回来的了。这篇内容就围绕Transactional注解在 Spring Boot 里的实际用法展开把原理、配置、踩坑和排查方法一次性讲透适合刚开始接触声明式事务的开发者也适合那些用了一段时间但总觉得“事务时灵时不灵”的同学拿去对照检查。1. 项目概述与事务管理场景拆解1.1 为什么业务代码需要事务管理先从一个最典型的业务场景说起下单功能。用户点击“提交订单”时后端通常要同时执行多条写操作比如扣减库存、生成订单记录、更新用户账户余额甚至还要写入日志表。这些操作要么全部成功要么全部失败。如果扣库存成功但订单表插入挂了用户会发现自己钱没了但订单不存在这种数据不一致问题就是灾难。事务就是用来保证这一组操作具备原子性的机制它让多个数据库操作绑定成一个整体要么一起提交要么一起回滚。在单体应用阶段数据库的事务能力足够支撑这种保障到了微服务或分布式环境事务的概念被进一步外延到分布式事务但无论怎么扩展单机数据库事务仍然是所有方案的基石。Spring Boot 提供了声明式事务的支持核心就是Transactional注解用注解描述“哪些方法需要事务能力”把事务边界从业务代码中抽离出来交给框架去控制。在实际项目里绝非只有“下单”这类显性写操作需要事务。比如批量导入数据、同步缓存与数据库、更新主表的同时维护冗余字段只要存在多条写操作且它们之间存在业务上的关联性事务就应该被明确启用。很多新手容易犯的错是把事务只放在 Controller 层甚至在 Repository 接口的每个方法上直接加Transactional这种设计往往导致事务边界过宽或过窄难以维护。1.2 Spring Boot 下的事务注解解决什么问题Transactional的本质是 AOP面向切面编程的典型应用。它在目标方法执行之前开启事务在方法正常返回之后提交事务在方法抛出异常的时候回滚事务。开发者不需要手写connection.setAutoCommit(false)、connection.commit()、connection.rollback()这些样板代码只需在方法或类上标注注解框架的代理机制就会自动插入事务逻辑。Spring Boot 之所以让配置更简单是因为它的自动配置机制帮我们默认装配了事务管理器。只要项目依赖了spring-boot-starter-jdbc或spring-boot-starter-data-jpa并且配置了数据源Spring Boot 就会自动创建一个DataSourceTransactionManager或JpaTransactionManager然后在启动时扫描Transactional注解动态生成事务代理。这省去了传统 Spring XML 里繁琐的tx:annotation-driven配置。但“自动配置”也容易让人误以为自己什么都不用管。实际上事务管理器是绑定了特定数据源的如果你的项目里有多个数据源默认的自动配置就会失效必须手动定义每个数据源对应的事务管理器并且显式指定Transactional使用哪个事务管理器。这一点在后面的实操部分我会单独展开。1.3 这个能力适合谁参考不管你是刚用 Spring Boot 写后端的新人还是已经带项目的老手事务管理都值得认真吃透。新人最容易掉进“注解没生效”的坑老手则可能在多数据源、事务传播行为、嵌套事务优化上反复纠结。这篇文章从注解参数的含义开始一直讲到真实故障的排查思路中间穿插了大量实际项目中的代码示例和经验总结。看完之后你可以直接回到自己的项目里查一遍事务写法大概率能发现几个潜在风险点。2. 核心概念与注解工作原理2.1 事务的 ACID 特性与 Spring 的抽象事务的四大特性是 ACID原子性Atomicity、一致性Consistency、隔离性Isolation、持久性Durability。数据库天然支持这些特性但应用层的“一致性”往往还包含业务规则比如“库存不能为负数”“订单金额必须大于零”这些规则写在业务代码里事务确保这些规则验证和写入操作在同一个原子边界内完成。Spring 的PlatformTransactionManager接口是事务抽象的顶层接口它定义了三类操作获取事务、提交事务、回滚事务。Spring Boot 自动配置中最常用的事务管理器有DataSourceTransactionManager配合 MyBatis、JdbcTemplate 或 Spring JDBC 使用基于java.sql.Connection。JpaTransactionManager配合 Spring Data JPA/Hibernate 使用。JtaTransactionManager用于 JTA 全局事务现实中微服务项目用得比较少。Transactional注解与事务管理器之间通过切点匹配连接。当 Spring 容器启动时内部会查找所有Transactional标注的方法或类为它们创建动态代理对象。外部调用者拿到的是代理对象而不是真实目标对象这样当方法被调用时代理会先在方法前开启事务然后在方法后决定提交还是回滚。2.2 Transactional 注解的属性和语义Transactional可以标注在类或接口方法上。放在类上表示该类所有公有方法都启用事务放在方法上表示仅该方法生效。如果类和方法同时标注方法级别优先这一点和 Spring MVC 的RequestMapping合并规则类似。以下属性在实际项目中非常关键propagation事务传播行为默认是Propagation.REQUIRED。意思是当前方法调用时如果已有事务则加入没有则新建。其他常用的还有REQUIRES_NEW挂起旧事务开启全新事务、NESTED嵌套事务依赖数据库的 savepoint 机制、MANDATORY强制要求已有事务否则异常。注意REQUIRED和REQUIRES_NEW的语义差异直接影响回滚范围很多人踩过坑。isolation事务隔离级别默认使用数据库自身隔离级别Isolation.DEFAULT。可以指定READ_COMMITTED、REPEATABLE_READ、SERIALIZABLE等。隔离级别越高并发性能越低需要根据业务场景权衡。timeout事务超时时间单位秒。如果事务执行时间超过设定值会自动回滚。默认值 -1 表示使用底层数据库的默认超时策略。rollbackFor指定哪些异常触发回滚。默认情况下只有RuntimeException和Error触发回滚受检异常checked exception比如IOException不会触发回滚这是一个最容易踩的坑。noRollbackFor指定哪些异常不触发回滚即使它是RuntimeException。readOnly将事务标记为只读。只读事务可以提高性能比如告诉数据库连接可以忽略某些锁机制但前提是方法内确实只做查询。如果对只读事务执行了写操作数据库可能抛异常也可能不报错但行为不保证。2.3 事务代理机制与自调用陷阱Spring 的声明式事务基于动态代理而动态代理有一个天然限制它只能拦截“通过代理对象发起的外部调用”。如果在一个 Bean 的内部某个方法调用同类中的另一个带Transactional的方法这个调用是this.method()直接调用的没有经过代理对象因此事务注解不会生效。这就是业界常说的“自调用失效”问题。举一个很常见的场景Service public class OrderService { Transactional public void createOrder(OrderDTO dto) { orderDao.insert(dto); updateStock(dto.getProductId()); // 本类方法调用事务不生效 } Transactional(propagation Propagation.REQUIRES_NEW) public void updateStock(Long productId) { stockDao.decrease(productId); } }上面代码里createOrder内部直接调用updateStock本质是this.updateStock(...)绕过了 Spring 生成的代理对象。因此updateStock上的Transactional不会起作用它只是在createOrder的事务里执行一段普通代码。如果你期望的是两条记录各自独立事务就会大失所望如果你期望updateStock失败不影响createOrder同样会落空。解决办法有三种把需要独立事务的方法拆分到另一个 Service Bean 中然后注入该 Bean 调用。在自身注入ApplicationContext通过context.getBean(OrderService.class)获取代理对象后再调用。使用AopContext.currentProxy()但需要在启动类上开启EnableAspectJAutoProxy(exposeProxy true)此办法略 hack不推荐常规使用。2.4 动态代理方式JDK 动态代理与 CGLIBSpring 生成代理时如果目标类实现了接口默认使用 JDK 动态代理如果目标类没有实现接口则使用 CGLIB 代理Spring Boot 2.x 默认是spring.aop.proxy-target-classtrue也就是总是使用 CGLIB。这不影响Transactional的业务使用但需要注意一个细微问题如果使用 JDK 动态代理那么切面只能通过接口暴露的方法进行拦截如果你把Transactional写在实现类的非接口方法上即使外部拿的是接口引用也无法触发事务。在实际排查时如果你发现自己加了注解但事务一直不生效可以先看一眼 Spring Boot 启动日志中关于代理类型的提示或者 debug 看看注入的 Bean 实际类型是不是$$EnhancerBySpringCGLIB$$之类的代理子类。确认代理存在才能继续排查后面的事务边界问题。3. 实操过程在 Spring Boot 中配置和验证 Transactional3.1 环境准备与基础项目结构这里我按最常见的组合来演示Spring Boot 2.7.x MyBatis MySQL Maven。如果你的项目用的是 JPA 或别的 ORM事务管理器的差别我会单独说明。首先在pom.xml中加入以下关键依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency然后在application.yml里配置数据源以及开启事务管理需要的相关设置spring: datasource: url: jdbc:mysql://localhost:3306/trans_demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver这里不需要额外配置事务管理器Spring Boot 会自动装配DataSourceTransactionManager。如果项目里有多个数据源才需要自己写配置类。建议在建表时把存储引擎设置为 InnoDB因为 MyISAM 不支持事务。我实际见过有人用默认建表配置结果一直不能回滚最后发现表引擎是 MyISAM这种低级问题排查起来最浪费时间。建表语句里再加上ENGINEInnoDB DEFAULT CHARSETutf8mb4最稳妥。3.2 准备演示用的数据表与实体为了把事务效果讲清楚我们模拟一个“转账”业务。表结构如下CREATE TABLE account ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL, balance decimal(10,2) NOT NULL DEFAULT 0.00, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO account (name, balance) VALUES (张三, 1000.00), (李四, 1000.00);对应的实体类public class Account { private Long id; private String name; private BigDecimal balance; // getter/setter 省略 }对应的 Mapper 接口Mapper public interface AccountMapper { Update(UPDATE account SET balance balance - #{amount} WHERE id #{id}) int decreaseBalance(Param(id) Long id, Param(amount) BigDecimal amount); Update(UPDATE account SET balance balance #{amount} WHERE id #{id}) int increaseBalance(Param(id) Long id, Param(amount) BigDecimal amount); }这里使用注解 SQL 是为了演示简洁真实项目中一般会用 XML 文件维护复杂 SQL效果一样。3.3 Service 层添加 Transactional 注解在 Service 类中定义转账方法Service public class TransferService { private final AccountMapper accountMapper; public TransferService(AccountMapper accountMapper) { this.accountMapper accountMapper; } Transactional(rollbackFor Exception.class) public void transfer(Long fromId, Long toId, BigDecimal amount) { int fromResult accountMapper.decreaseBalance(fromId, amount); if (fromResult 0) { throw new RuntimeException(转出账户不存在或余额不足); } int toResult accountMapper.increaseBalance(toId, amount); if (toResult 0) { throw new RuntimeException(转入账户不存在); } } }这里我特意写了rollbackFor Exception.class原因下面讲。如果没有这个属性当方法内抛出自定义受检异常时Spring 默认不会回滚事务会导致“钱已经扣了但事务没回滚”的严重问题。在 Controller 中调用 ServiceRestController public class TransferController { private final TransferService transferService; public TransferController(TransferService transferService) { this.transferService transferService; } PostMapping(/transfer) public String transfer(RequestParam Long fromId, RequestParam Long toId, RequestParam BigDecimal amount) { transferService.transfer(fromId, toId, amount); return success; } }运行项目调用接口后查询数据库正常情况张三减少李四增加。3.4 验证事务回滚是否生效关键验证在于如果转入账户不存在转出账户的余额是否也会回滚。我可以直接模拟这个场景把toId传成一个不存在的 id例如 999。此时increaseBalance返回 0抛出RuntimeException。因为transfer方法上有Transactional而RuntimeException会触发默认回滚规则所以accountMapper.decreaseBalance的更新也会被回滚。查询数据库后张三的余额仍然保持 1000.00。你可能会问为什么我明明写了Transactional但抛异常后数据还是变了最常见的原因有两个一是方法内部异常被 try-catch 吞掉了没有向外传播代理感知不到异常就不会回滚二是调用事务方法的外层方法本身没有捕获异常不对实际上只要异常抛出到代理层代理就会处理。真正的坑在于“异常没有被抛到代理层”。举个例子Transactional public void transfer(...) { try { // 数据库操作... if (toResult 0) { throw new RuntimeException(转入账户不存在); } } catch (RuntimeException e) { log.error(记录错误但不回滚, e); } }这段代码把RuntimeExceptioncatch 住了代理层看不到异常自然就提交了。所以在编写事务方法时除非你有明确的“吞掉异常但要回滚”的策略否则不要让异常在事务方法内部被静默处理。如果确实需要捕获异常后回滚可以手动回滚TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();这是编程式事务的一种补充手段后续我会详细对比。3.5 配置自定义事务管理器与多数据源如果你只有一个数据源上面那种“自动装配”的方式就够用。但现实项目常常有多个库比如业务主库和报表库。此时 Spring Boot 不再自动装配唯一的DataSourceTransactionManager因为多个DataSource并存会导致容器无法决定用哪个。你需要为每个数据源分别配置事务管理器。假设你配置了两个数据源Configuration public class DataSourceConfig { Bean Primary ConfigurationProperties(prefix spring.datasource.primary) public DataSource primaryDataSource() { return DataSourceBuilder.create().build(); } Bean ConfigurationProperties(prefix spring.datasource.secondary) public DataSource secondaryDataSource() { return DataSourceBuilder.create().build(); } Bean Primary public PlatformTransactionManager primaryTransactionManager( Qualifier(primaryDataSource) DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } Bean public PlatformTransactionManager secondaryTransactionManager( Qualifier(secondaryDataSource) DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }然后在需要使用的事务方法上显式指定事务管理器Transactional(transactionManager secondaryTransactionManager, rollbackFor Exception.class) public void writeReport(...) { // 只操作报表库 }注意如果方法需要跨两个数据源操作单机DataSourceTransactionManager无法保证原子性因为它是基于单个 Connection 的。跨库事务需要 JTA/JTA 实现比如 Atomikos或者分布式事务中间件。这个话题非常大本文不展开但你至少要知道Transactional不是万能的跨库解决方案。3.6 声明式事务与编程式事务的取舍Transactional是声明式事务通过注解声明由 AOP 代理执行。编程式事务则是通过代码直接控制事务边界。Spring 提供了TransactionTemplate适合在方法内部手动定义事务范围比如只希望某一段代码在事务内而不是整个方法Service public class SomeService { private final TransactionTemplate transactionTemplate; public SomeService(PlatformTransactionManager transactionManager) { this.transactionTemplate new TransactionTemplate(transactionManager); } public void bizMethod() { // 非事务操作 transactionTemplate.execute(status - { try { // 事务操作 return doDbWrite(); } catch (Exception e) { status.setRollbackOnly(); throw e; } }); // 非事务操作 } }编程式事务更适合那种事务边界不固定、需要动态判断的场景。但绝大多数后端业务是固定在一个方法内完成一组操作用Transactional更简单可读性也更好。我的建议是默认用Transactional遇到因自调用或复杂流程导致的失效时优先拆分 Service而不是全部改成编程式事务。4. 常见问题与排查技巧实录4.1 事务不回滚的经典原因速查在实际项目中我几乎每周都能在代码审查里看到一些事务隐患。下面列出的问题基本上覆盖了常见的“Transactional失效”场景问题现象根本原因建议解决方式方法抛异常但数据未回滚异常类型是受检异常且未指定rollbackFor在注解中设置rollbackFor Exception.class方法抛异常但数据未回滚异常在方法内被 try-catch 捕获改为不捕获或捕获后手动设置 rollback-only方法抛异常且通过this调用同类方法自调用绕过代理拆分 Bean 或注入代理对象被调用的方法不生效Transactional标注在 private 方法上Spring 无法拦截 private 方法移动到公有方法使用了 MyISAM 表底层存储引擎不支持事务切换到 InnoDB代理未生效Bean 没有被 Spring 管理比如手动 new 出来的确保对象由 Spring 容器创建没有配置事务管理器多数据源时使用默认自动配置导致找不到管理器显式配置每个数据源对应的PlatformTransactionManager方法在同一个类内被调用且该类使用 JDK 动态代理非接口方法无法拦截通过接口调用或强制使用 CGLIB4.2 自调用失效的解决实例结合前面的OrderService例子正确的做法是拆出一个StockServiceService public class StockService { Transactional(propagation Propagation.REQUIRES_NEW) public void updateStock(Long productId) { // 独立事务 } } Service public class OrderService { private final StockService stockService; public OrderService(StockService stockService) { this.stockService stockService; } Transactional public void createOrder(OrderDTO dto) { orderDao.insert(dto); stockService.updateStock(dto.getProductId()); // 通过代理调用事务生效 } }这种拆分不仅解决了事务失效问题还让各个服务类职责更清晰。如果你遇到的是自己写的代码直接在内部调用另一个方法重构成本并不高。4.3 异常被吞掉导致的消息不一致我见过一个比较隐蔽的场景某同步任务循环处理一批数据每条数据处理都调用一个带Transactional的方法。因为其中一条数据的insert失败方法抛出了DataIntegrityViolationException但外层循环在这条数据上有 try-catch它负责记录这条数据处理失败后继续处理下一条。这本身没什么问题关键是外层并不是事务方法而内层事务方法抛出的异常已经被内层方法吞掉了其实不是吞掉而是异常从内层事务方法抛出到外层内层事务会因为异常而回滚这是对的。但如果内层方法把异常catch后返回一个默认值那内层事务就会提交导致坏数据落库。所以一个原则是事务方法内部尽量不要捕获异常让异常经由代理层统一处理。如果确实需要吞掉异常并保留业务结果必须确保这个操作是非事务的或者你手动控制回滚。对于批量任务我倾向于单独写一个非事务的调度方法内部循环调用事务方法让异常抛出到外层由外层记录失败并继续下一个这样每个数据项的事务边界又清楚又安全。4.4 事务超时与数据库锁等待使用Transactional时如果某个事务长时间持有数据库连接会影响连接池复用严重时甚至导致连接池耗尽。默认的timeout是 -1意味着事务可以无限等待下去。在核心写接口上建议显式加上超时时间比如Transactional(timeout 5) public void doUpdate() { // 数据库操作 }超时时间为 5 秒如果超过则自动回滚。另外也要关注数据库层面的锁等待超时设置比如 MySQL 的innodb_lock_wait_timeout默认是 50 秒和事务超时是两个层面一个是应用层事务执行时长一个是数据库等待锁的时长。排查线上“请求卡住”时可以利用如下 SQL 查询当前正在运行的事务SELECT * FROM information_schema.INNODB_TRX; SELECT * FROM sys.innodb_lock_waits;如果发现某个事务长时间不提交说明可能是事务边界过大也可能是代码里的事务方法包含耗时的外部调用比如远程 HTTP 请求。把远程调用放在事务外是降低事务持锁时间的一个有效优化。4.5 代理对象与启动日志排查方法排查事务是否生效最直接的方法是看对象类型。比如在 Controller 中注入 Service 后打印它的类名log.info(transferService.getClass().getName());如果输出结果是com.example.TransferService$$EnhancerBySpringCGLIB$$8f2e3a说明它是 CGLIB 代理对象事务拦截器很可能已生效。如果输出的是原生类名说明容器里放的不是代理对象那就要检查是不是手动 new 了对象或者类有没有被 Spring 扫描到。另一个排查点是启动日志。Spring Boot 开启 debug 日志后可以看到事务相关的初始化信息logging: level: org.springframework.transaction: DEBUG org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG这样在运行事务方法时日志会输出类似“Creating new transaction with name ...”“Initiating transaction commit”“Rolling back transaction”的信息。通过日志能直观判断开启和回滚发生在哪个方法上也能发现自调用时根本没有“Creating new transaction”记录的问题。4.6 事务方法必须通过 public 调用Transactional只能作用于公有方法。这不是 Spring 的缺陷而是底层代理机制决定的JDK 动态代理和 CGLIB 都无法代理私有方法。IDE 里如果注解标在 private 方法上通常只会给出警告但运行时不会生效。我见过有人把Transactional写在一个包内可见方法上还以为是事务开启成功了结果查了很久才发现方法权限是 default。将事务方法都定义为public同时避免在同类内部直接调用是铁律。4.7 与 MyBatis 一级缓存或 JPA Session 的交互细节如果你用 MyBatis要注意事务范围内一级缓存的存在可能导致某些 query 查询不到最新数据因为一级缓存是 SqlSession 级别的。这个问题一般在事务内先查后改会暴露。更常见的场景是事务方法里先查一次实体修改字段后不调用 update但期望在事务提交时自动 flush——这是 JPA 的行为MyBatis 不会这样。所以不要混淆 ORM 框架的事务行为和持久化上下文实际开发中明确调用更新语句才最靠谱。4.8 事务管理在测试中的落地写单元测试验证事务回滚是一个很有效的自检手段。用 Spring Boot Test 时可以注入一个PlatformTransactionManager或者直接使用Transactional测试注解让测试方法包裹在事务内。但要注意Transactional放在测试方法上默认会在测试结束后回滚如果你希望验证“提交后数据落库”就需要使用Commit注解。还有一个稳妥的验证办法调用事务方法后查询数据库确认数据没有变化。我自己经常在集成测试里用Sql初始化多条数据然后通过断言数据变化来确认事务回滚。这样能避免把耗时问题带到生产环境。5. 深入理解事务传播行为与隔离级别5.1 常用的传播行为对比propagation属性在日常代码中出现频率极高。重点区分REQUIRED和REQUIRES_NEW。REQUIRED如果外层方法有事务则内层方法加入同一个事务如果外层没有则新建事务。这种传播行为强调“共享一个事务”内层方法异常抛出后整个事务都会回滚。REQUIRES_NEW无论外层是否存在事务内层方法都会挂起当前事务开启一个全新事务。内层事务独立提交或回滚不会受外层事务回滚影响但外层事务如果在外层方法后续抛异常回滚之前已提交的内层事务数据不会跟着回滚因此要谨慎评估数据一致性需求。NESTED用 savepoint 模拟嵌套事务。内层回滚只会回滚到保存点不会影响外层已执行的更新。MySQL 的 InnoDB 支持 savepoint所以NESTED在 Spring MySQL 下可用。但它的语义和REQUIRES_NEW有本质不同REQUIRES_NEW物理上开启新连接新事务NESTED还在同一个连接上只是逻辑回滚点不同。下面用一个表格总结常用传播行为传播行为是否加入已有事务是否有独立提交点典型使用场景REQUIRED是无则新建否默认适合多数单事务场景REQUIRES_NEW否挂起旧事务是独立审计日志、发送 MQ 后各自保证原子性NESTED是逻辑嵌套有保存点复杂业务流程中允许局部回滚MANDATORY必须已有事务否则异常否强制要求调用方开启事务NEVER必须无事务否则异常否禁止在事务中执行的操作SUPPORTS有则加入无则不开启否低要求查询方法中使用较多5.2 REQUIRES_NEW 的典型误用场景很多开发者喜欢在日志表插入时用REQUIRES_NEW期望无论主业务是否回滚日志都能保存下来。这个思路本身没问题但要注意REQUIRES_NEW会挂起外层事务如果外层事务持有某些行的锁而内层事务又要更新同一行就可能出现锁等待甚至死锁。举个例子主事务写订单表同时更新库存内层日志事务也要更新同一订单表的状态就可能死锁。所以使用REQUIRES_NEW时尽量让内层事务操作独立的表避免与主事务竞争相同资源。另外REQUIRES_NEW开启的是物理新连接至少对DataSourceTransactionManager来说是这样如果连接池较小大量并发请求会导致连接池耗尽。所以使用前要考虑连接池配置大小比如hikari.maximum-pool-size需要适当放大。我在实际项目中观察过一些小并发接口里加入REQUIRES_NEW后连接池活跃连接数明显上涨幸好量小没出事但压测一定不能漏掉这种场景。5.3 隔离级别与脏读、不可重复读、幻读数据库隔离级别定义了并发事务之间可见多少未提交/已提交的数据。MySQL 默认的隔离级别是REPEATABLE_READPostgreSQL 默认是READ_COMMITTED。Spring 注解中如果没有显式配置isolation就使用数据库默认级别。理解最典型的三个并发问题脏读事务 A 读取了事务 B 未提交的数据如果 B 回滚A 读到的就是脏数据。READ_UNCOMMITTED会出现脏读。不可重复读事务 A 内两次读取同一行数据因为事务 B 在期间提交了更新两次结果不一致。READ_COMMITTED允许不可重复读。幻读事务 A 内执行两次范围查询因为事务 B 在期间插入/删除了行两次结果集不一致。REPEATABLE_READ在 MySQL InnoDB 下通过间隙锁可以在一定程度上避免幻读但并非所有数据库都保证。在业务层面最容易出现的场景是“先插入后查询统计”。如果两个并发调用同时插入相同唯一键记录后一个会因为唯一索引冲突而崩溃这是数据库保证正确性的最后一道防线。因此不要把隔离级别当做唯一保障业务上尽可能使用唯一索引来兜底。5.4 只读事务的优化与注意事项readOnly true到底有用吗答案是对于 Hibernate/JPAreadOnly会做一些优化比如跳过实体脏检查对于 JDBC/MyBatis它主要影响是向数据库连接发送setReadOnly(true)请求某些数据库驱动会启用只读模式减少锁开销。但不要指望它能让查询变快它更多的是一种语义约束和规范提示。如果把增删改操作放进一个readOnly true的事务中Hibernate 可能会抛出FlushMode.NEVER相关的异常或者干脆不 flush。JDBC 层面MySQL 的setReadOnly(true)并不是严格禁止写操作所以不报错但行为可能不符合预期。我总是把readOnly用在明确的查询接口上比如报表查询、详情查询但查询使用事务的意义本身又值得斟酌一个方法只执行查询其实没必要开事务因为单条 SQL 已经有原子性。只有需要多次查询且要求彼此隔离时才值得开只读事务。6. 事务使用经验总结与更高阶的思考6.1 事务方法设计的“六不要”原则踩了太多坑后我总结了一个“六不要”每次代码评审都会看一眼不要在事务方法内做长耗时的外部调用例如 HTTP 请求、RPC、文件上传。这些操作会长时间占用数据库连接拖垮整个连接池。不要用this调用同类中的事务方法所有跨事务调用都通过注入的 Bean 完成。不要把Transactional注解加在 Controller 的方法上。Controller 属于展示层事务是服务层的职责放在 Controller 会导致事务边界过宽还会让 AOP 无法在多层代理下正确生效。不要吞掉事务方法中的异常。如果需要记录日志先把真实异常抛出在外层统一记录或者在 catch 块中显式setRollbackOnly()。不要图省事把所有 Service 类都加默认Transactional。只给真正包含多步写操作的方法加事务避免无谓的开销。不要在业务代码中直接操作事务管理器除非你的场景确实需要编程式事务。否则一旦和 Spring AOP 混用很容易弄混事务边界。6.2 事务与连接池之间的关系事务的本质是获取一个数据库连接把它的autoCommit设为 false执行一篮子 SQL最后提交或回滚并归还连接。Spring 的事务管理器只是对连接资源进行管理所以事务越久连接被占用的时间越长。在高并发下如果同时有几十个请求各自开了长事务连接池很容易被打满。建议核心接口的数据库操作尽量控制在几十毫秒内完成事务超时时间要小于连接池等待时间否则多个请求堆积起来最后表现为“整个服务卡死”。另一方面查询操作可以不开启事务就不开启查询务必开启事务的场景也要控制范围和方法内耗时。6.3 事务日志分析与问题定位当线上数据出现异常时最快的排查方式是先看事务日志。如果服务里有如下日志片段基本说明事务已提交Creating new transaction with name [com.example.service.AccountService.transfer] ... Initiating transaction commit Committing JDBC transaction如果看到Rolling back transaction说明事务回滚了。排查方向转为“为什么业务代码没有回滚”此时需要查看异常栈确认异常类型、异常是否被捕获、注解上rollbackFor是否覆盖了该异常类型。如果连“Creating new transaction”都没有说明代理根本没有拦截到这个方法重点排查自调用、类未被 Spring 管理、方法非 public、事务管理器配置缺失等。日志是定位这类问题的第一入口请在测试环境就开始关注。6.4 从单事务到分布式事务的演进当服务拆分为多个微服务原本在一个事务方法里完成的“扣库存发订单”可能分散在多个服务各自都有独立的数据库连接单机事务已经无法保证跨服务一致性。这也是很多人对Transactional产生误解的根源它管不了分布式场景。业界常见方案包括本地消息表、事务消息、TCC、Saga以及 Seata 等分布式事务框架。但我还是要提醒99% 的单体项目根本不需要分布式事务框架优先把应用范围内的数据库事务用对、用规范就已经避免了绝大多数数据问题。过早引入分布式事务中间件反而会把复杂度集中在基础设施上不利于快速交付。一些想对你说的话前面这些内容写下来其实都是我从一个“事务时灵时不灵”的菜鸟阶段一点点踩过来的。现在再回看当时的代码最大的问题是只把Transactional当成一个“标记”没有搞清楚标记背后的代理机制、异常传播规则和连接管理逻辑。如果你现在正准备在自己的项目里加事务我的建议是先把最小示例跑通确认回滚行为符合预期再往复杂场景上套。比如先在本地 MySQL 里建一张表写一个只有两个更新语句的方法故意让第二步失败观察第一步是否回滚。这一步验证通过比看十篇文档都管用。事务管理本质上是一个“边界控制”的艺术方法粒度、异常粒度、连接粒度都要拿捏到位。别迷信一个注解解决所有问题也别因为事务失效就排斥声明式事务。用对Transactional配合上rollbackFor、timeout、propagation这几个关键属性再记住自调用和异常吞入的坑平时开发就够用了。如果后续项目真的需要跨库强一致那时候再认真评估分布式事务方案也不迟。希望这些经历过实战的细节能让你少走几条弯路。
返回列表