ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring事务失效陷阱:Closing non transactional SqlSession排查与修复

Spring事务失效陷阱:Closing non transactional SqlSession排查与修复 1. 从一个诡异的线上故障说起Closing non transactional SqlSession——这行日志第一次出现在我视野里的时候是一个再普通不过的周三下午。某个业务系统在批量处理数据时偶发地出现部分数据写入成功、部分数据回滚的诡异现象。日志里没有明显的异常堆栈只有这行看起来人畜无害的提示信息反复出现。当时团队里几个人的第一反应都是这不就是个日志级别的问题吗结果排查了整整两天才发现这行日志背后藏着一个非常典型的框架使用陷阱。这个问题的本质是在Spring管理的声明式事务中某段代码通过非事务方式获取了SqlSession导致后续操作脱离了事务管理器的控制范围。听起来有点绕但如果你用过MyBatis配合Spring做开发大概率踩过或者即将踩到这个坑。它最阴险的地方在于——不一定每次都报错。有时候数据正常提交有时候部分回滚有时候干脆静默失败让你在测试环境完全复现不出来。这篇文章适合所有使用Spring MyBatis技术栈的后端开发者尤其是那些正在处理批量数据、多数据源、或者手动管理SqlSession的同行。我会从问题现象、根因分析、复现步骤、排查手段、修复方案到预防措施完整地拆一遍。看完之后你至少能做到两件事第一遇到类似日志能快速定位方向第二在写代码时主动避开这个坑。2. 问题现象与根因深度拆解2.1 这个报错到底在说什么先把这个日志拆开看。Closing non transactional SqlSession来自MyBatis的SqlSessionTemplate类。当Spring整合MyBatis时通常会注册一个SqlSessionTemplate作为线程安全的SqlSession代理。这个模板在每次需要SqlSession时会先检查当前线程是否已经绑定了事务同步器TransactionSynchronizationManager。如果当前线程存在活跃的事务SqlSessionTemplate会从TransactionSynchronizationManager中取出已经绑定的SqlSession这样所有数据库操作都在同一个连接、同一个事务里。但如果当前线程没有活跃事务它就会创建一个全新的、独立的SqlSession用完就关。关闭的时候就会打印这行Closing non transactional SqlSession。关键点来了这行日志本身不是错误它只是一个DEBUG或INFO级别的提示。真正的问题是——当你的业务代码期望在事务中执行却因为某些原因走了非事务路径那么这段代码里的所有数据库操作都不会被事务管理器接管。该回滚的时候回滚不了该一起提交的时候各提交各的。2.2 为什么会出现非事务的SqlSession要理解这个问题得先搞清楚Spring事务的传播机制和SqlSession的绑定时机。正常情况下一个带有Transactional注解的方法被调用时Spring的事务拦截器会在方法执行前开启事务并把数据库连接绑定到当前线程。之后MyBatis执行任何操作都会复用这个连接。但以下几种情况会导致SqlSession脱轨第一种方法内部调用self-invocation。这是最经典的场景。同一个类中方法A调用了方法B方法B上标了Transactional。由于Spring的AOP代理机制这种内部调用不会经过代理对象所以方法B的事务注解完全失效。方法B里的数据库操作就会以非事务方式执行。第二种异步线程或线程池。事务绑定是基于ThreadLocal的。如果你在主线程开启了事务然后把数据库操作丢到另一个线程里执行那个新线程的ThreadLocal里什么都没有自然就是非事务的SqlSession。第三种手动获取SqlSession。有些开发者为了做批量操作或者动态SQL会直接注入SqlSessionFactory或者SqlSessionTemplate然后手动openSession()。如果打开时没有传入事务相关的参数或者没有正确参与Spring的事务同步就会产生独立的SqlSession。第四种多数据源切换时的绑定丢失。在使用动态数据源或者多数据源配置时如果事务管理器配置不当切换数据源后事务同步器里的绑定信息可能对应不上导致新数据源的操作走了非事务路径。2.3 事务失效带来的连锁反应一旦SqlSession脱离了事务管理后果不只是不回滚这么简单。我整理了几种实际遇到过的表现现象底层原因危险等级部分数据提交、部分回滚事务方法内的操作走了非事务连接独立提交高异常被吞掉数据静默丢失非事务SqlSession的提交不受事务管理器控制极高连接池耗尽每次非事务操作都新建连接且未及时释放高死锁概率上升同一业务逻辑使用了多个不同连接锁竞争加剧中主从延迟导致读不到刚写的数据写操作在非事务连接提交读操作在主库事务中中最让人头疼的是第一种和第二种。因为从业务角度看代码逻辑完全正确异常也抛了但数据就是没回滚。测试环境数据量小、并发低往往复现不出来一上生产就出问题。3. 复现步骤与排查手段实录3.1 最小化复现Demo为了让大家直观看到问题我写一个最小复现案例。假设有一个订单服务需要在同一个事务里插入订单主表和订单明细表。Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private OrderItemMapper orderItemMapper; public void createOrder(OrderDTO dto) { // 外部调用入口没有事务注解 doCreateOrder(dto); } Transactional(rollbackFor Exception.class) public void doCreateOrder(OrderDTO dto) { orderMapper.insert(dto.getOrder()); // 模拟业务异常 if (dto.getItems().isEmpty()) { throw new IllegalArgumentException(订单明细不能为空); } orderItemMapper.batchInsert(dto.getItems()); } }这段代码里createOrder调用了同类中的doCreateOrder。由于是内部调用Transactional不会生效。运行后你会看到日志里出现Closing non transactional SqlSession并且订单主表的数据被插入了但异常抛出后没有回滚。修复方式很简单把doCreateOrder的逻辑抽到另一个Spring Bean里或者通过AopContext.currentProxy()获取代理对象再调用。但实际项目中这种内部调用往往藏得很深可能跨越好几个方法层级。3.2 日志排查的正确姿势当你怀疑遇到这个问题时不要只盯着Closing non transactional SqlSession这一行。我通常按以下顺序排查第一步打开MyBatis和Spring事务的DEBUG日志。在配置里加上logging: level: org.mybatis.spring.SqlSessionUtils: DEBUG org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG org.springframework.transaction.interceptor: DEBUG这样你能看到事务的开启、提交、回滚以及SqlSession的获取和关闭。重点观察业务方法执行前后是否有Creating new transaction和Committing/rolling back的日志。如果没有说明事务根本没开起来。第二步确认事务方法的调用链路。用IDE的调用层级分析或者直接在事务方法入口打日志看它是否真的被代理拦截了。一个简单的判断方法在事务方法里打印TransactionSynchronizationManager.isActualTransactionActive()如果返回false基本可以确定事务没生效。第三步检查线程切换。如果业务逻辑里用了Async、CompletableFuture、或者手动提交到线程池检查这些异步任务里是否有数据库操作。有的话事务肯定不会跟着过去。第四步检查多数据源配置。如果项目用了多数据源确认Transactional注解有没有指定正确的事务管理器。不同数据源对应不同的事务管理器指定错了也会导致事务绑定失败。3.3 一个真实的排查案例之前遇到过一个更隐蔽的场景某个Service方法上标了Transactional日志里也能看到事务开启但部分数据库操作仍然走了非事务SqlSession。排查后发现这个方法里注入了一个工具类工具类内部通过SqlSessionTemplate手动执行了一段动态SQL。问题出在工具类的初始化时机上——它是在一个静态代码块里通过ApplicationContext获取的Bean而这个获取过程发生在Spring事务同步器初始化之前。结果就是工具类持有的SqlSessionTemplate在某些情况下会创建独立的SqlSession。这个案例告诉我们不要轻易在静态上下文或非Spring管理的对象里操作数据库。即使拿到了SqlSessionTemplate也要确保当前线程已经处于事务同步状态。4. 修复方案与代码实操4.1 方案一修复事务传播路径针对内部调用导致的事务失效最推荐的做法是把事务方法抽到独立的Bean中。这是最干净、最不容易出问题的方案。Service public class OrderService { Autowired private OrderTransactionService orderTransactionService; public void createOrder(OrderDTO dto) { orderTransactionService.doCreateOrder(dto); } } Service public class OrderTransactionService { Autowired private OrderMapper orderMapper; Autowired private OrderItemMapper orderItemMapper; Transactional(rollbackFor Exception.class) public void doCreateOrder(OrderDTO dto) { orderMapper.insert(dto.getOrder()); if (dto.getItems().isEmpty()) { throw new IllegalArgumentException(订单明细不能为空); } orderItemMapper.batchInsert(dto.getItems()); } }如果实在不想拆类可以用AopContext.currentProxy()但需要开启exposeProxy trueEnableAspectJAutoProxy(exposeProxy true)然后在方法里这样调用((OrderService) AopContext.currentProxy()).doCreateOrder(dto);注意AopContext.currentProxy()依赖ThreadLocal在异步线程里会失效。而且这种写法对代码侵入性较强我个人更倾向于拆Bean的方案。4.2 方案二手动管理SqlSession时正确参与事务有些场景确实需要手动操作SqlSession比如做大批量数据的流式处理。这时候必须确保SqlSession参与Spring的事务同步。错误写法Autowired private SqlSessionFactory sqlSessionFactory; public void batchProcess(ListData dataList) { try (SqlSession session sqlSessionFactory.openSession()) { DataMapper mapper session.getMapper(DataMapper.class); for (Data data : dataList) { mapper.insert(data); } session.commit(); } }这种写法完全绕开了Spring事务而且每次调用都新建连接。正确写法是使用SqlSessionTemplate它会自动参与当前线程的事务Autowired private SqlSessionTemplate sqlSessionTemplate; Transactional(rollbackFor Exception.class) public void batchProcess(ListData dataList) { DataMapper mapper sqlSessionTemplate.getMapper(DataMapper.class); for (Data data : dataList) { mapper.insert(data); } // 不需要手动commit事务管理器会处理 }SqlSessionTemplate的getMapper方法内部会调用SqlSessionUtils.getSqlSession它会检查当前线程是否有事务绑定。有的话复用没有的话才创建新的。所以只要外层有Transactional这里就不会出现non transactional的日志。4.3 方案三异步场景下的事务处理如果业务确实需要异步执行数据库操作不要指望事务能跨线程传播。正确的做法是在异步线程内部重新开启事务。Service public class AsyncDataService { Autowired private DataMapper dataMapper; Async Transactional(rollbackFor Exception.class) public void asyncInsert(ListData dataList) { for (Data data : dataList) { dataMapper.insert(data); } } }注意两点第一Async和Transactional同时使用时事务会在异步线程内重新开启这是符合预期的第二异步方法的调用方不能依赖异步方法的执行结果来做事务回滚决策因为两者已经解耦了。如果需要在异步任务失败时通知主流程可以用CompletableFuture配合回调或者引入消息队列做最终一致性保障。4.4 方案四多数据源下的事务管理器指定多数据源场景下每个数据源需要独立的事务管理器。使用Transactional时必须指定Transactional(transactionManager secondaryTransactionManager, rollbackFor Exception.class) public void doSomething() { // 操作从库 }如果项目里用了动态数据源比如通过AbstractRoutingDataSource切换要特别注意事务开启时确定的数据源在整个事务期间不会改变。如果在事务方法内部手动切换数据源切换后的操作会走非事务路径。这种场景下建议把不同数据源的操作拆到不同的事务方法里通过编程式事务或者事件机制来协调。5. 常见问题速查与避坑指南5.1 高频问题速查表问题描述可能原因排查方法解决方案日志出现Closing non transactional SqlSession数据未回滚事务方法内部调用检查调用链路是否经过代理拆Bean或用AopContext异步任务中数据库操作不回滚事务不跨线程检查是否有Async或线程池异步方法内重新开事务多数据源下部分操作不回滚事务管理器未指定或指定错误检查Transactional的transactionManager属性明确指定对应的事务管理器手动SqlSession操作导致连接泄漏未正确关闭或未参与事务同步检查是否用了SqlSessionFactory.openSession改用SqlSessionTemplate事务方法内捕获异常导致不回滚异常被吞掉检查try-catch是否重新抛出捕获后重新抛出或手动回滚5.2 几个容易忽略的细节细节一Transactional的rollbackFor默认只回滚RuntimeException。如果你抛的是受检异常比如IOException默认不会回滚。建议统一写成Transactional(rollbackFor Exception.class)。细节二事务方法不要用final或static修饰。Spring的AOP代理对final方法和static方法无效事务注解会直接失效。细节三事务方法尽量保持public。虽然Spring 4.0之后支持protected和package级别但为了兼容性和可读性还是用public最稳妥。细节四注意事务的超时时间。默认没有超时限制但长事务会占用数据库连接影响并发。建议根据业务设置合理的timeout值比如30秒。细节五嵌套事务要理解传播行为。REQUIRED是默认值表示有事务就加入没有就新建。REQUIRES_NEW会挂起当前事务新建一个独立事务。用错了会导致锁等待或者数据不一致。5.3 我踩过的三个坑第一个坑在事务方法里调用了this.xxx()。这个前面说过了但实际项目中往往不是直接调用而是通过一个工具类或者父类方法间接调用。排查时要把整个调用链都捋一遍。第二个坑用了Transactional但没配事务管理器。在纯Spring Boot 单数据源的项目里Spring Boot会自动配置DataSourceTransactionManager一般没问题。但如果你手动配置了多个数据源又没有指定Primary自动配置就会失效事务注解全部不生效。第三个坑在事务方法里做了远程调用。远程调用耗时不可控会导致事务长时间持有数据库连接。更严重的是如果远程调用成功但后续数据库操作失败回滚了远程那边的数据就变成了脏数据。这种场景应该用最终一致性方案而不是强事务。6. 预防措施与代码规范建议6.1 建立事务使用的代码规范与其等问题出现了再排查不如在团队里建立一套事务使用的规范。我总结了几个可落地的建议事务方法必须放在独立的Service类中禁止在同一个类里做事务方法的内部调用。所有Transactional注解必须显式指定rollbackFor Exception.class避免受检异常不回滚。事务方法内禁止做远程调用、文件IO、消息发送等耗时操作这些操作应该放在事务提交后执行。异步任务中如果需要数据库操作必须重新开启事务并在代码注释中说明。手动操作SqlSession时必须使用SqlSessionTemplate禁止直接使用SqlSessionFactory.openSession()。6.2 利用AOP做事务监控可以在项目中加一个切面监控所有Transactional方法的执行情况。如果发现某个方法执行了数据库操作但没有活跃事务就打印告警日志。Aspect Component public class TransactionMonitorAspect { Around(annotation(org.springframework.transaction.annotation.Transactional)) public Object monitor(ProceedingJoinPoint joinPoint) throws Throwable { boolean active TransactionSynchronizationManager.isActualTransactionActive(); if (!active) { log.warn(事务未生效: {}, joinPoint.getSignature().toShortString()); } return joinPoint.proceed(); } }这个切面能帮你快速发现那些以为有事务、实际没有的方法。上线前跑一遍能拦下不少隐患。6.3 测试环境的事务验证在测试环境建议对核心业务链路做一次异常注入测试在事务方法的中间步骤人为抛出异常然后检查数据库数据是否全部回滚。这个测试能有效验证事务配置是否正确。具体做法可以用Spring的Transactional测试支持配合Rollback注解SpringBootTest Transactional Rollback public class OrderServiceTransactionTest { Autowired private OrderService orderService; Test public void testCreateOrderRollback() { OrderDTO dto buildInvalidOrder(); assertThrows(IllegalArgumentException.class, () - { orderService.createOrder(dto); }); // 验证数据库中没有任何订单数据 assertEquals(0, orderMapper.countAll()); } }注意这种测试方法本身运行在事务中测试结束后会自动回滚不会污染数据库。但如果你的业务代码里有非事务的SqlSession操作这个测试就能暴露出来——因为非事务操作的数据不会被测试事务回滚会残留在数据库里。6.4 日志配置的长期建议生产环境的日志级别通常是INFO而Closing non transactional SqlSession这行日志在MyBatis里是DEBUG级别。这意味着生产环境默认看不到这个提示。如果你怀疑线上有事务问题可以临时把org.mybatis.spring.SqlSessionUtils的日志级别调到DEBUG观察一段时间后再调回去。但更推荐的做法是在应用启动时通过代码检查关键Service的事务配置是否正确。比如写一个ApplicationRunner扫描所有带Transactional的方法检查它们是否在Spring代理的管理范围内。这个检查可以在启动阶段就发现配置问题避免带到生产。7. 最后的经验分享这个问题我前前后后遇到过三次每次的场景都不太一样但根因都是事务绑定丢失。第一次是内部调用第二次是异步线程第三次是多数据源切换。每次排查都要花不少时间因为日志信息太隐晦了而且问题往往不是必现的。后来我养成了一个习惯任何涉及多表写入的业务方法上线前都要做一次异常回滚验证。具体就是在测试环境手动触发一次异常看数据是否干净回滚。这个习惯帮我拦下了至少两次潜在的生产事故。另外如果你正在做代码审查看到有人在Transactional方法里手动openSession()或者看到Async方法里直接操作数据库一定要多问一句事务怎么处理的。很多时候开发者自己也没意识到这里有问题。这个问题的本质其实是框架的隐式契约——Spring通过ThreadLocal和AOP帮你管理事务但这份便利是有前提的。一旦你绕过了代理、切换了线程、或者手动操作了底层资源这份契约就失效了。理解这一点比记住具体的修复方案更重要。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进