尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Spring整合MyBatis事务管理:原理、配置与高频坑全解析
Spring整合MyBatis这事儿配置层面真不难难的是事务。我见过太多项目架子搭得漂漂亮亮Mapper写得工工整整一上线发现数据对不上——订单生成了库存没扣、用户注册了积分没发、转账扣了款对方没到账查来查去问题全出在事务上。作为这个系列的第三篇这篇专门聊Spring整合MyBatis与事务管理从Spring接管MyBatis事务的底层原理到Transactional的每一个关键属性再到多数据源、自调用失效、异常被吞这些高频坑最后用一个订单扣库存的完整案例把整条链路串起来。如果你已经会配置Spring Boot和MyBatis但总在事务上犯迷糊这篇就是给你写的。1. 先搞清楚没有Spring时MyBatis的事务管理长什么样1.1 原生SqlSession的事务困境很多同学直接上手Spring Boot对MyBatis原生的使用方式反而陌生结果就是“知其然不知其所以然”。我们先回到最原始的MyBatis用法看看。原生MyBatis里每一次数据库操作都围绕SqlSession展开// 早期MyBatis的典型用法 SqlSessionFactory factory new SqlSessionFactoryBuilder().build(inputStream); SqlSession session factory.openSession(); // 默认不自动提交 try { UserMapper mapper session.getMapper(UserMapper.class); mapper.insertUser(user); mapper.insertUserLog(log); session.commit(); // 手动提交事务 } catch (Exception e) { session.rollback(); // 出错了回滚 throw e; } finally { session.close(); }这里有几个问题非常明显。第一事务边界要靠开发人员手动控制。commit、rollback写在哪、什么时候调用全靠自觉漏一个commit数据就丢了漏一个rollback脏数据就进去了。项目一大人一多这种代码根本守不住。第二SqlSession本身不是线程安全的。它是数据库连接的一层封装每个SqlSession对应一个Connection。多线程环境里如果共享同一个SqlSession连接会被多个线程同时使用轻则报错重则数据错乱。所以正确的做法是“每次操作都开一个新的SqlSession”用完就关。可这样一来一个业务操作如果涉及多次数据库访问就要反复开关Session连接资源的开销非常大。第三也是最关键的一个业务方法里往往要操作多张表、多个Mapper。比如订单方法要插订单表、扣库存表、写操作日志这是三个数据库操作。如果每次Mapper调用都各自开启独立事务那么扣库存成功但写日志失败时前面的订单和库存操作根本不会跟着回滚——因为它们已经各自commit了。这就是原生MyBatis事务管理的窘境没有一个统一的机制把多个数据库操作放进同一个事务边界里事务控制完全靠手写、靠自觉。1.2 Spring介入后做了什么改变Spring整合MyBatis之后最大的变化不是省了几行代码而是改变了“事务控制”这件事的归属权。原来由开发人员手动控制的事务被统一移交给了Spring的声明式事务。具体到实现上核心是Spring的PlatformTransactionManager事务管理器接口。针对JDBC数据源Spring提供了DataSourceTransactionManager实现。这个管理器负责从事务同步管理器TransactionSynchronizationManager里绑定、获取当前线程对应的数据库连接在事务开始时把连接的自动提交autocommit关掉在事务提交或回滚时统一执行commit或rollback把同一个线程内所有数据库操作绑定到同一个Connection上形成真正的事务边界。这里最关键的一个词是“绑定”。Spring把“事务”抽象成一个和线程绑定的资源。同一个线程里所有被Spring管理的数据库组件——包括MyBatis的SqlSessionTemplate、JdbcTemplate、MyBatis的Mapper——拿到的都是同一个Connection操作都落在同一个事务里。等事务方法结束由Spring决定是整体提交还是整体回滚。MyBatis这边也做了对应的适配。Spring提供了一个SpringManagedTransaction它不自己管理事务而是从TransactionSynchronizationManager里获取当前线程绑定的Connection把连接的提交、回滚控制权完全交给Spring。所以整合后的MyBatis实际上就是个“数据访问工具”它只负责生成SQL、执行SQL再也不管事务了。我用一句话概括这个过程的本质Spring整合MyBatis的事务管理本质上是“Spring把数据库连接的获取、提交、回滚统一收编了”MyBatis的一切数据库操作都被纳入Spring事务的管辖范围。1.3 声明式事务的代码形态有了上面的基础声明式事务用起来就一句话加个注解Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private StockMapper stockMapper; Autowired private LogMapper logMapper; Transactional public void createOrder(OrderDTO dto) { orderMapper.insert(dto.getOrder()); stockMapper.decrease(dto.getProductId(), dto.getQuantity()); logMapper.insert(dto.getOrder().getOrderLog()); } }注意看这个方法的三个Mapper操作运行时拿到的其实是同一个数据库连接处在同一个事务中。任何一步抛异常前面已经执行的SQL都会在方法结束时被统一回滚。这个效果背后的链路是Spring容器启动时EnableTransactionManagementSpring Boot会自动配置注册了一个TransactionInterceptor拦截器配合BeanFactoryTransactionAttributeSourceAdvisor对所有标注了Transactional的Bean方法生成代理对象。当服务方法被调用时实际调用的是代理对象。代理先进入TransactionInterceptor通过Transactional上配置的属性传播行为、隔离级别、回滚规则等决定怎么开启事务。事务管理器执行doBegin从数据源拿到Connection关闭autocommit设置隔离级别把Connection绑定到当前线程。后续方法里的MyBatis操作通过SqlSessionTemplate拿到这个绑定的Connection执行SQL。方法正常结束时代理调用事务管理器的doCommit捕获到异常且符合回滚条件时调用doRollback。所以说白了Transactional就是一面旗子真正干活的还是那段“动态代理 事务管理器 线程绑定”的机制。理解了这条链路后面所有坑就都好理解了。注意Spring Boot 2.x 之后EnableTransactionManagement由自动配置完成不需要手动开启。但在SSMSpring SpringMVC MyBatis手动搭建的项目里必须记得在配置类上加上这个注解否则Transactional会被静默忽略这是SSM项目事务不生效的第一大原因。2. Spring与MyBatis事务整合的配置细节2.1 事务管理器从哪来Spring Boot自动配置在Spring Boot里加入spring-boot-starter-jdbc或mybatis-spring-boot-starter和对应的数据库驱动后根本不需要手动声明DataSourceTransactionManager。Spring Boot的自动配置类DataSourceTransactionManagerAutoConfiguration会在classpath下存在DataSource和PlatformTransactionManager的情况下自动创建一个DataSourceTransactionManager并注册到容器中。这个自动配置的逻辑相当直接Bean ConditionalOnMissingBean(PlatformTransactionManager.class) DataSourceTransactionManager transactionManager(DataSource dataSource) { return new DataSourceTransactionManager(dataSource); }也就是说只要容器里没有其他PlatformTransactionManagerSpring Boot就会拿你的数据源直接构建一个事务管理器。绝大多数单数据源项目这一行配置都不用写。那MyBatis和这个自动配置的事务管理器是怎么产生联系的靠的是MybatisAutoConfiguration。它注入的是SqlSessionFactory而SqlSessionFactory最终构建SqlSession时用的是SpringManagedTransactionFactory。这个工厂生成的SpringManagedTransaction从TransactionSynchronizationManager里找当前线程的Connection找到一个就是事务环境找不到就直接从数据源拿一个新的。逻辑很清晰有Spring事务就用Spring事务没有Spring事务就各自为政。2.2 非Boot环境下手动配置的完整写法如果你在SSM项目或者其他不使用Spring Boot的场合配置就得自己动手了。完整配置如下Configuration EnableTransactionManagement public class PersistenceConfig { Bean public DataSource dataSource() { DruidDataSource dataSource new DruidDataSource(); dataSource.setDriverClassName(com.mysql.cj.jdbc.Driver); dataSource.setUrl(jdbc:mysql://localhost:3306/mall?useUnicodetruecharacterEncodingutf8); dataSource.setUsername(root); dataSource.setPassword(password); return dataSource; } Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { SqlSessionFactoryBean factoryBean new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources(classpath:mapper/*.xml)); return factoryBean.getObject(); } Bean public MapperScannerConfigurer mapperScannerConfigurer() { MapperScannerConfigurer configurer new MapperScannerConfigurer(); configurer.setBasePackage(com.example.mapper); return configurer; } Bean public PlatformTransactionManager transactionManager(DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }这里面的几个细节值得说清楚。第一EnableTransactionManagement必须加它就是前面提到的“注册事务拦截器和代理创造器”的开关。不加这个注解容器里只有一个孤零零的DataSourceTransactionManager它并不会自动工作。第二DataSourceTransactionManager需要一个DataSource参数。它管理事务时从传入的这个数据源获取连接。如果你的项目中配置了多个DataSource就必须为每个数据源分别创建一个事务管理器并在Transactional注解里显式指定用哪个。第三SqlSessionFactoryBean必须设置数据源这个是MyBatis访问数据库的入口。它不影响事务管理器但事务管理器决定了它创建的连接是否处于事务中。2.3 多数据源与多事务管理器的选择多数据源是多模块项目、读写分离项目的常客。这里面的坑很典型配置了两个DataSource也配置了两个PlatformTransactionManager但调用Transactional时不做任何指定——然后发现事务总是失效或者操作了错误的数据源。原因很简单Transactional默认情况下注入的是容器里唯一的PlatformTransactionManager如果容器里有多个Spring就不知道选哪个了会直接抛出NoUniqueBeanDefinitionException。即使Spring后来自己选了一个喜欢的实际上不会也可能不是你要用的那个。解决办法有两种。方法一是用Transactional(transactionManagerName)指定事务管理器Bean public PlatformTransactionManager orderTransactionManager(Qualifier(orderDataSource) DataSource ds) { return new DataSourceTransactionManager(ds); } Bean public PlatformTransactionManager userTransactionManager(Qualifier(userDataSource) DataSource ds) { return new DataSourceTransactionManager(ds); }Transactional(userTransactionManager) public void updateUser(User user) { userMapper.updateById(user); }方法二是在不需要MySQL强一致性的场景里对其中一个数据源去掉事务管理器。读写分离的项目里如果从库只做查询压根不需要给它配事务管理器只给写库配一个就完了省得选来选去。另外提醒一句Transactional如果不传参数默认使用名称为transactionManager的Bean。所以如果你配了多个管理器最好养成习惯要么在建Bean时给每个管理器一个明确的名字要么在注解里显式指定名称别让Spring去猜。3. 声明式事务的七个关键细节3.1 事务传播行为怎么选Transactional的propagation属性是最容易被忽视的一项。它决定了一个事务方法被另一个事务方法调用时事务如何传播。Spring定义了几种传播行为我直接列个表传播行为含义实用场景REQUIRED默认当前有事务就加入没有就新建绝大多数业务方法REQUIRES_NEW无论如何都新建事务外层事务被挂起日志记录、异步通知即使失败不能影响主流程SUPPORTS有事务就加入没有就以非事务方式执行查询方法有事务就参与没有也无所谓NOT_SUPPORTED始终以非事务方式执行挂起当前事务大查询避免长事务占用连接MANDATORY必须在一个事务中运行否则抛异常不允许在无事务环境执行的内部方法NEVER必须在非事务环境下执行否则抛异常事务之外才能执行的独立操作NESTED基于保存点Savepoint的嵌套事务复杂业务中允许局部回滚不回滚整个事务这里重点说说REQUIRED和REQUIRES_NEW。默认的REQUIRED下如果methodA调用methodB而两个方法都有Transactional那么它们处于同一个事务中只要methodB抛异常导致外层方法也抛异常整个事务一起回滚。这在绝大多数业务中是想要的。但有些场景你必须用REQUIRES_NEW。比如业务方法里要写一条操作日志你绝不希望因为日志表满了、日志插入失败导致整个核心业务回滚。这时候日志写入方法应该用REQUIRES_NEW它会挂起外层事务另起一个独立事务把日志写进去。外层回滚日志依然保留。还有一个高频误区是NESTED。很多同学以为NESTED和REQUIRES_NEW一样其实完全不同。NESTED本质上是同一个事务里设置一个保存点内层出错时只回滚到保存点外层还可以决定是否继续提交。但底层实现依赖JDBC的SAVEPOINT支持MySQL、PostgreSQL都支持但要注意MySQL存储引擎中只有InnoDB支持保存点MyISAM不支持。用NESTED之前先确认数据库能力。3.2 隔离级别与只读事务的四个隔离级别JDBC都已经定义好了READ_UNCOMMITTED、READ_COMMITTED、REPEATABLE_READ、SERIALIZABLE。隔离级别脏读不可重复读幻读适用建议READ_UNCOMMITTED可能可能可能基本不用READ_COMMITTED不会可能可能很多互联网项目选择REPEATABLE_READMySQL默认不会不会可能InnoDB通过间隙锁基本可防MySQL默认适合大多数业务SERIALIZABLE不会不会不会强一致且低并发场景Transactional(isolation Isolation.DEFAULT)是默认值它表示使用数据库自身的默认隔离级别。MySQL默认是REPEATABLE_READPostgreSQL默认是READ_COMMITTED。一般来说除非有明确需求否则保持默认就是最稳妥的。强行修改隔离级别可能会引入不必要的锁竞争降低并发能力。只读事务readOnly true是很多团队容易漏掉的优化点。设置后Spring会把连接设置为只读模式底层会做一些优化例如MySQL的JDBC驱动在只读模式下不会执行一些不必要的锁申请。此外也能起到“人肉规范”的作用如果代码里不小心在这个方法里写了更新操作会直接报错。Transactional(readOnly true) public ListProduct listProducts() { return productMapper.selectList(); }一个认知误区是“查询只要加readOnlytrue能提高性能很多”。实际上对于单数据库、单连接的JDBC场景性能提升非常有限它最大的价值是防止误操作。真正该用只读事务的是那些有多个查询且要求数据一致的场景比如报表统计、对账任务。3.3 回滚规则默认回滚哪些异常Spring的Transactional默认回滚规则是只有抛出RuntimeException或Error时才回滚事务检查型异常checked exception默认不回滚。这个设计初衷是受检异常通常代表“业务可预期的问题”或“可恢复的错误”比如用户余额不足、库存不足、参数不合法框架不应该强制回滚。但实际开发中这条默认规则反而成了最大的坑之一。很多业务代码习惯把所有异常都声明为受检异常或者习惯把自定义异常定义成Exception的子类而不是RuntimeException的子类// 自定义异常这样定义 public class BusinessException extends Exception { ... }如果一个方法抛出了自定义的BusinessException即使它是在Transactional方法里抛出的事务也不会回滚。数据已经写到一半了Spring却认为“这是检查型异常按规则不回滚”。这不是Spring不智能而是它按规矩办事。解决办法有两个一个是回滚规则单独声明加上rollbackForTransactional(rollbackFor Exception.class) public void transfer(TransferDTO dto) throws BusinessException { accountMapper.decrease(dto.getFromId(), dto.getAmount()); accountMapper.increase(dto.getToId(), dto.getAmount()); }另一个是从根上消除误区自定义异常统一继承RuntimeException业务错误、数据校验失败都抛运行时异常。这样既省心又符合大多数实际项目对事务回滚的预期。另外提醒一下noRollbackFor属性。有些场景确实不需要回滚比如订单状态流转时某个步骤超时了但业务上希望保存已经修改的部分数据就可以针对特定异常指定不回滚。这个属性知道就行平时用得少。3.4 自调用失效问题同类内部方法调用为什么事务没了这是面试中几乎必问、实际开发中踩得最深的坑同一个类里一个方法调用另一个带Transactional的方法事务失效。Service public class OrderService { Transactional public void createOrder(OrderDTO dto) { // 直接调用同类方法 this.deductStock(dto.getProductId(), dto.getQuantity()); orderMapper.insert(dto.getOrder()); throw new RuntimeException(模拟异常); } Transactional(propagation Propagation.REQUIRES_NEW) public void deductStock(Long productId, Integer quantity) { stockMapper.decrease(productId, quantity); } }这段代码里调用createOrder时Spring代理对象会进入createOrder但方法内部this.deductStock(...)调用的是this引用的方法——也就是原始对象的方法而不是代理对象的方法。所以deductStock上的Transactional根本不会被事务拦截器感知。直观类比Spring代理就像是给原始对象套了一层“外壳”外面对壳的调用才会被事务拦截器处理。你一旦回到内部直接用this调自己的方法相当于绕开了那层壳直接触碰内部的真实对象拦截器完全不知情注解自然失效。解决办法有三种拆到另一个Spring管理的Bean里。把deductStock放进StockService由OrderService注入调用。这是最清晰、最推荐的做法。使用Lazy注入自身代理Service public class OrderService { Autowired Lazy private OrderService self; public void createOrder(OrderDTO dto) { self.deductStock(dto.getProductId(), dto.getQuantity()); // ... } }通过AopContext.currentProxy()获取当前代理对象需要配置EnableAspectJAutoProxy(exposeProxy true)((OrderService) AopContext.currentProxy()).deductStock(...);方案一最推荐因为它同时兼顾了类的职责划分也规避了后续可能出现的问题。方案二、方案三能用但多少有点“绕”团队的代码可维护性会受影响。3.5 事务方法内捕获异常自己处理吞掉异常的后果自调用失效说的“没事务”还有一类常见问题是“有事务但没回滚”根源是开发人员在Transactional方法里把异常捕获了、吞掉了Transactional public void createOrder(OrderDTO dto) { try { stockMapper.decrease(dto.getProductId(), dto.getQuantity()); orderMapper.insert(dto.getOrder()); } catch (Exception e) { // 日志里打了一行然后什么都不干 log.error(操作失败, e); } }这段代码的问题在于异常在方法内部被捕获后没有重新抛出TransactionInterceptor从头到尾没有感知到任何异常。方法“正常”结束于是Spring认为这是一次成功的事务执行了commit。后果就是库存扣减了、订单也插入了但程序告诉你“操作失败”——数据已经是脏的。正确写法是把异常重新抛出去由Spring事务拦截器捕获并触发回滚Transactional public void createOrder(OrderDTO dto) { try { stockMapper.decrease(dto.getProductId(), dto.getQuantity()); orderMapper.insert(dto.getOrder()); } catch (Exception e) { log.error(操作失败事务回滚, e); throw new RuntimeException(e); } }还有一种情况是错误地使用了“提前提交”的思路。比如一个事务方法里写了两个try...catch块第一块把异常吞了第二块正常执行结果第一块的数据保存了但第二块出了问题回滚——用户看到的是半成品数据。这种“局部容错”最正确的做法是用REQUIRES_NEW另开事务或者在事务外单独处理而不是在大事务里吞异常。3.6 同步异步方法的边界事务方法里调异步任务为什么失效Spring的事务是和“线程”绑定的。用ThreadLocal实现的TransactionSynchronizationManager把Connection存在当前线程里。如果你在事务方法里又开了一个新线程去执行数据库操作这个新线程压根没有继承外层线程的Connection绑定信息。Transactional public void afterCreateOrder(OrderDTO dto) { orderMapper.insert(dto.getOrder()); new Thread(() - { logMapper.insert(dto.getOrder().getOrderLog()); // 新线程不在此事务内 }).start(); }这段代码里logMapper.insert在新线程里执行它拿不到主线程事务绑定的Connection会自己从数据源取一个新连接在非事务状态下直接执行并auto-commit。一旦主线程的事务回滚这条日志已经写进去了数据不一致。同样的道理适用于Async注解的方法。Async方法本身会丢到线程池里执行如果该方法不在新的REQUIRES_NEW事务里它就没有事务环境。解决方法也很明确异步任务的数据库操作如果需要事务保证就把Transactional和Async放在同一个方法上并配上REQUIRES_NEW传播行为如果不需要事务就确保异步方法不参与核心链路并且业务上能够容忍失败。4. 实操订单创建与库存扣减的事务实现4.1 环境准备与数据表结构理论讲再多不如一个完整案例来得直观。这里我们搭一个最简的订单创建场景创建订单时往订单表插入一条数据同时扣减对应商品的库存如果扣库存失败库存不足整个创建订单的操作必须回滚。表结构就两张表CREATE TABLE t_order ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, product_id BIGINT NOT NULL, quantity INT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE t_stock ( id BIGINT NOT NULL AUTO_INCREMENT, product_id BIGINT NOT NULL, stock INT NOT NULL, version INT NOT NULL DEFAULT 0, PRIMARY KEY (id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; INSERT INTO t_stock(product_id, stock) VALUES (1, 100);项目依赖用最标准的组合dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency配置文件spring: datasource: url: jdbc:mysql://localhost:3306/mall?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.entity configuration: map-underscore-to-camel-case: true4.2 Mapper与Service代码实体类简单写public class Order { private Long id; private String orderNo; private Long productId; private Integer quantity; private LocalDateTime createTime; // getter / setter 省略 } public class Stock { private Long id; private Long productId; private Integer stock; private Integer version; // getter / setter 省略 }Mapper接口public interface OrderMapper { int insert(Order order); } public interface StockMapper { Stock selectByProductId(Long productId); int decreaseStock(Param(productId) Long productId, Param(quantity) Integer quantity); }对应的Mapper XMLinsert idinsert parameterTypecom.example.entity.Order INSERT INTO t_order(order_no, product_id, quantity) VALUES (#{orderNo}, #{productId}, #{quantity}) /insertselect idselectByProductId resultTypecom.example.entity.Stock SELECT id, product_id, stock, version FROM t_stock WHERE product_id #{productId} /select update iddecreaseStock UPDATE t_stock SET stock stock - #{quantity} WHERE product_id #{productId} AND stock #{quantity} /update注意decreaseStock里的stock #{quantity}这个条件。它是在SQL层面防止超卖的最后一道防线如果库存不够UPDATE影响的行数是0我们在Service层通过判断这个影响行数来决定是否抛出异常。Service层Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private StockMapper stockMapper; Transactional public void createOrder(OrderDTO dto) { // 1. 先尝试扣库存库存不够则影响行数为0抛出业务异常 int rows stockMapper.decreaseStock(dto.getProductId(), dto.getQuantity()); if (rows 0) { throw new RuntimeException(库存不足无法创建订单); } // 2. 插入订单 Order order new Order(); order.setOrderNo(UUID.randomUUID().toString().replace(-, )); order.setProductId(dto.getProductId()); order.setQuantity(dto.getQuantity()); orderMapper.insert(order); } }4.3 验证事务是否真的回滚写一个接口来测试RestController public class OrderController { Autowired private OrderService orderService; PostMapping(/order/create) public String create(RequestBody OrderDTO dto) { try { orderService.createOrder(dto); return success; } catch (Exception e) { return failed: e.getMessage(); } } }测试场景一库存充足productId1quantity10。调用接口订单表新增一条记录库存从100变为90。这是正常路径。测试场景二库存不足productId1quantity999。调用接口decreaseStock返回0行抛出RuntimeException。此时Spring事务拦截器捕获到异常执行回滚。由于decreaseStock的UPDATE本身没有生效而insert订单的代码还没来得及执行最终订单表也没有新增记录。结果符合预期。测试场景三在createOrder里人为制造一个异常放在扣库存成功之后、插订单成功之前Transactional public void createOrder(OrderDTO dto) { stockMapper.decreaseStock(dto.getProductId(), dto.getQuantity()); if (true) { throw new RuntimeException(模拟系统异常); } Order order new Order(); order.setOrderNo(UUID.randomUUID().toString()); // ... orderMapper.insert(order); }调用接口会发现库存扣了但订单没插入——不对如果事务生效库存也应该回到原来的值。实测结果应该是库存仍然是100订单表无记录。这就是Transactional的作用扣库存这个SQL虽然已经执行了但随着事务回滚被撤销了。一个很多人会忽略的验证细节上面测试场景三能回滚一个前提是createOrder方法必须是public、且被Spring容器管理、通过代理调用。如果你在测试代码里new OrderService()自己调用事务必然无效。验证事务是否生效时务必通过Spring容器获取Bean来调用。注意DataSourceTransactionManager管理的是JDBC事务它依赖数据库的InnoDB等支持事务的存储引擎。如果用MyISAM表Transactional怎么配置都不会生效因为存储引擎本身就不支持回滚。建表时务必确认存储引擎为InnoDB。5. 常见问题与排查技巧实录5.1 事务不生效的排查清单我在实际项目里排查过不少“事务不生效”的案例问题集中在这几类。直接做成清单排查的时候按顺序过一遍就行排查项具体检查内容容器是否管理方法所属的类是否被Spring扫描并注册为Bean是否通过getBean获取实例Transactional位置是否写在接口方法上但实现类没有注解JDK动态代理时可能失效CGLIB代理时接口注解无效方法可见性是否是非public方法。Spring AOP默认只代理public方法private/final/static都不行代理方式类内部this调用是否绕过了代理是否未拆Bean、未使用Lazy自注入回滚规则抛出的异常是否为RuntimeException或是否显式声明rollbackFor异常是否被吞方法内部是否用try...catch捕获异常后没有重新抛出传播行为外层无事务内层REQUIRED是否会新建事务MANDATORY在无事务环境会直接报错事务管理器多数据源时是否指定了正确的transactionManager事务管理开放非Boot项目是否加了EnableTransactionManagement其中最高频的三个自调用。同类里this调用直接绕过代理事务静默失效。异常被吞。异常出不了方法体Spring根本不知道出错了。自定义异常非运行时。rollbackFor没配受检异常默认不回滚。5.2 回滚不生效的经典场景还原还原一个真实的线上事故。某个支付回调服务里业务代码如下// 伪代码 Transactional public void handlePayCallback(PayResult result) { orderMapper.updateStatus(result.getOrderNo(), PAID); try { // 调用第三方库存服务扣减库存 boolean ok stockClient.deduct(result.getOrderNo()); if (!ok) { throw new ServiceException(库存服务调用失败); } } catch (ServiceException e) { // 只记录日志不抛出 log.error(库存扣减失败, e); } }现象订单状态更新为“PAID”了但库存没扣两边数据不一致。排查的时候直接看代码就明白了ServiceException抛出来了但被catch捕获后没有重新抛出。即使抛出来了如果ServiceException继承的是Exception而不是RuntimeExceptionrollbackFor没配置也一样不会回滚。两重错误叠在一起事务必然失效。正确写法Transactional(rollbackFor Exception.class) public void handlePayCallback(PayResult result) { orderMapper.updateStatus(result.getOrderNo(), PAID); boolean ok stockClient.deduct(result.getOrderNo()); if (!ok) { throw new ServiceException(库存扣减失败); } }不要再写try...catch来兜住一切把异常交给Spring统一处理是使用声明式事务最基本的原则。5.3 事务与连接池、SqlSession的深水坑最后补充两个比较隐蔽但对性能影响很大的点。第一个是“长事务”。Transactional方法里如果调用了外部接口、远程服务或大量非数据库操作事务会一直占着数据库连接。数据源的连接池是有限的比如默认HikariCP的maximumPoolSize是10。当10个请求都卡在外部调用上时第11个请求拿不到连接直接报Connection is not available。这种问题不会在开发环境暴露因为开发时并发低、外部接口响应快。到生产环境外部接口一慢整个数据库连接池就耗尽。排查时的典型现象是接口整体响应很慢数据库连接池监控显示活跃连接值一直打满日志里出现大量获取连接超时。对策很明确事务方法里只做数据库操作远程调用放到事务之前或事务之后或者把远程调用拆到REQUIRES_NEW事务之外用TransactionSynchronization.afterCommit回调来执行。第二个是“一个事务里的SqlSession到底几个”。Spring整合MyBatis后每个Mapper方法通过SqlSessionTemplate执行而SqlSessionTemplate是线程安全的、可以被多个线程共享。在事务环境下同一个线程第一次调用Mapper时会从SqlSessionUtils拿到一个与当前事务绑定的SqlSession后续所有Mapper操作都复用这个SqlSession直到事务结束。这意味着一个业务方法里执行了10条SQL底层其实是同一个SqlSession、同一个Connection。这不仅是事务的要求也减少了创建SqlSession的开销算是整合设计的精妙之处。但要注意REQUIRES_NEW会打断这个“绑定”。新事务开启后TransactionSynchronizationManager会在当前线程挂起原先的事务资源并绑定一组新的Connection。此时再执行Mapper操作拿到的是新的事务资源对应的新SqlSession。所以REQUIRES_NEW本质上是用“额外的一次连接”换取了独立性频繁使用它会增加数据库连接消耗实际业务中要克制。最后再说一点个人经验排查事务问题别急着看代码先看日志。在Spring Boot里把spring.jpa.show-sql如果用JPA或者MyBatis的SQL日志打开同时把日志级别调到DEBUG观察日志里有没有Coming back to the transaction、Rolling back之类的关键行。一旦看到Rolling back JDBC transaction说明Spring已经接管了回滚剩下的问题就是为什么异常没被抛出、或者为什么回滚条件没匹配上。顺着这条线绝大多数问题都能在三五分钟内定位。
RELATED

相关推荐

软考中级信息安全工程师备考:考点拆解与案例分析指南

软考中级信息安全工程师备考:考点拆解与案例分析指南

软考中级信息安全工程师,备考圈里常被叫做“信息安全中级”,全称是“计算机技术与软件专业技术资格(水平)考试——信息安全工程师”。很多朋友第一次听到这个名字,都会先问一句:这和网络安全工程师有什么不…

📅 2026/10/9 7:02:29
Python入门高频问题全解析:环境配置、导包、语法与并发

Python入门高频问题全解析:环境配置、导包、语法与并发

刚装好 Python 的新手,大多数会在同一个地方翻车:软件装完了,双击 .py 文件要么闪一下就关掉,要么在终端里跑一行import numpy直接给你一个ModuleNotFoundError,然后就开始在搜索框里疯狂输入“python安装教程”“pyth…

📅 2026/10/9 6:57:29
Ubuntu 20.04 WiFi 连接故障排查与 netplan/nmcli 实战配置

Ubuntu 20.04 WiFi 连接故障排查与 netplan/nmcli 实战配置

简介:本资源是一份面向Ubuntu 20.04初学者与系统运维人员的Wi-Fi连接故障排障指南,聚焦解决“无Wi-Fi图标”“无法识别无线网卡”等典型驱动缺失或配置错误问题。内容系统梳理两种主流解决方案:一是通过有线网络安装Broadcom芯片专用驱动&…

📅 2026/10/9 6:57:29
MORE NEWS

更多资讯

📰

从暴力循环到数位DP:梦中的统计P1554数字计数优化实战

1. 这题到底在问什么:梦里的奶牛在数数《梦中的统计》(Dream Counting)是USACO 2006年12月赛季的一道银牌题,编号P1554。题目本身很短,核心诉求一句话就能说清:给定两个非负整数N和M(通常N ≤ M…

📰

Python零基础入门:变量、数据类型与运算规则全解析

我最初学Python时,最崩溃的不是语法看不懂,而是看懂了每个单词却不知道代码为什么报错。尤其是刚接触"数据存储"这个概念的时候,我一度分不清数字和字符串,屏幕上明明显示的是1,可一相加结果却是"11&qu…

📰

Agent定时任务跑偏根因与触发补跑规则工程化设计

1. 定时任务跑偏的根因拆解1.1 为什么 Agent 场景下的定时任务更容易失控做过传统后端定时任务的人,第一次把定时逻辑搬到 Agent 上,大概率会经历一个“怎么又跑偏了”的阶段。传统 Cron 任务面对的是确定性逻辑:到点执行一段代码&#xff0c…

📰

从原型到生产:数据科学工作流的持续交付实践

这几年我一直在做数据科学平台相关的事,接触过的项目大多有一个共同点:原型很漂亮,生产很痛苦。算法同学在 Notebook 里把模型跑得风生水起,模型推到线上却像换了个人;数据特征对不上、依赖版本漂移、训练和推理逻辑分…

📰

NASA审计报告揭示的真相:为什么成本可控,风险却始终在累积

这几年我养成了一个不太好的习惯:只要NASA监察长办公室(OIG)发布和登月计划(Artemis)相关的审计报告,我都会第一时间找来看。别人看这类报告是为了吃瓜,我看它是把它当“大型复杂项目病历本”—…

📰

Python 3.11被SELinux拦截?自定义策略模块全攻略

在 CentOS 8 / Anolis 8 上把 Python 3.11 装好,再顺手把一个服务用 systemd 拉起来,然后看着它报Permission denied,这种场景我一年里至少碰到三四回。很多人的第一反应是去查文件权限、属主,折腾半天无果;其实十有八…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬