尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MyBatis 中手动 openSession 导致 Spring 事务失效的根因与正确写法

MyBatis 中手动 openSession 导致 Spring 事务失效的根因与正确写法 做Java后端这些年谁还没被“事务不生效”折磨过几回。尤其是在MyBatis和Spring集成项目里你明明在方法上写了Transactional该抛的异常也抛了进到数据库一看脏数据照样躺在表里。我第一次接到这种报障时对方斩钉截铁说是框架的坑结果打开代码一眼就看见了问题有人在业务方法里直接调了sqlSessionFactory.openSession()拿到SqlSession后操作数据库。这其实就是最经典的“事务不生效”事故现场。这篇文章我打算把这条链路从头到尾拆一遍为什么openSession拿出来的会话不归Spring管、手动会话和SqlSessionTemplate到底差在哪、怎么用日志和代码实锤问题以及最终正确的几种写法。凡是项目里还在用openSession()的都建议对照着自查一下。1. 从一次联调事故说起异常抛了数据却写进去了1.1 一段很典型的“问题代码”当时的代码长这样我给它精简成了一小段Service public class OrderService { Autowired private SqlSessionFactory sqlSessionFactory; Transactional(rollbackFor Exception.class) public void createOrder(OrderDTO dto) { try (SqlSession session sqlSessionFactory.openSession()) { OrderMapper mapper session.getMapper(OrderMapper.class); mapper.insertOrder(dto); } // 模拟后面业务校验失败 throw new IllegalArgumentException(库存不足); } }功能需求很简单插入订单后续校验失败就整个方法回滚。你指望Transactional把前面的insert一起撤销但实际结果是——订单记录稳稳当当落在数据库里异常也抛了回滚却像没发生过一样。1.2 违背直觉的地方到底在哪大多数人的第一反应都是怀疑Transactional配置错了于是去查rollbackFor、查EnableTransactionManagement、查DataSourceTransactionManager查了一圈发现配置全是好的。真正违背直觉的地方在于同一个数据源并不等于同一个受Spring管理的事务。一个方法上标注了TransactionalSpring只会去管理它自己“发了号、记了账”的连接。而sqlSessionFactory.openSession()在方法内部另起炉灶自己从数据源拿了一个新的连接这个连接根本没有向Spring的TransactionSynchronizationManager报到。两个连接各干各的Spring想回滚也够不着另一边的已经提交或未提交的操作。1.3 这种问题比“不回滚”更隐蔽的地方如果你以为症状永远是“数据必然写入”那又天真了。手动openSession这个场景实际表现受你写法和连接池行为影响会出现好几种情况手动调用过session.commit()之后业务抛异常——数据一定残留因为提交动作已经发生Spring回滚管不到它。用了try (SqlSession session ...)但没手动commit方法正常结束——MyBatis在close时不会自动帮你commit但不代表万事大吉最终取决于连接池归还连接时对未提交事务的处理方式不同版本可能行为不一样。没手动commit方法抛异常session在finally里close——某些环境看起来“碰巧回滚了”数据库里没有脏数据于是你以为只是虚惊一场下次换了个连接池或换了个环境问题再次爆发。症状随环境漂移没有稳定规律这才是最折磨人的地方。你甚至可能花一整天去怀疑MySQL隔离级别、怀疑ORM框架缓存就是想不到是openSession脱离了Spring事务治理。2. 根因openSession() 拿到的 SqlSession 根本没在 Spring 事务的管辖名单里2.1 Spring 的 Transactional 到底在管理什么先梳理一遍Spring事务管理的核心动作。当一个带Transactional的方法被调用时实际上是DataSourceTransactionManager在背后执行了这几个步骤从配置好的数据源里拿一个连接把连接的自动提交关掉autoCommit false将这个连接绑定到当前线程的TransactionSynchronizationManager资源Map里方法正常执行完对连接执行commit()方法抛出异常对连接执行rollback()并清理绑定关系。关键在第3步连接必须被注册到当前线程的事务同步器里后续所有DAO操作才能感知“我正处在一个事务中”。某个数据库操作如果自己另开了一个连接那么它执行的SQL自然不在Spring这份事务的控制范围内。Spring既不知道这个连接的存在也没法替它回滚。2.2 DefaultSqlSession 是自己开辟的一亩三分地sqlSessionFactory.openSession()底层走的是DefaultSqlSessionFactory.openSessionFromDataSource(...)。这条链路内部做的事情大概是从MyBatis的Environment配置中取出数据源通过这个数据源获取一个新的数据库连接通常会走连接池比如HikariCP、Druid用这个连接构造一个JdbcTransaction由这个事务构造出Executor最终生成DefaultSqlSession。这套流程完全绕过了Spring的DataSourceTransactionManager。它管理事务的方式是通过JdbcTransaction直接对刚才那个物理连接做setAutoCommit(false)、commit()、rollback()。Spring的注解它看不见也根本不打算配合。这就解释了为什么你在Transactional方法里手动openSession操作会“失控”。2.3 SpringManagedTransaction 与 JdbcTransaction 的本质差别在mybatis-spring集成环境下真正承担事务职责的是SpringManagedTransaction。它有一个关键动作获取连接时调用的是DataSourceUtils.getConnection(dataSource)。这个工具方法会先去TransactionSynchronizationManager里查当前线程有没有已经绑定的连接如果有直接复用如果没有才新建一个连接并且这个新建也会被注册到事务同步器里。而openSession()走的JdbcTransaction不会做这一步它直接dataSource.getConnection()拿新连接不问Spring也不登记。这个差别翻译成大白话就是Spring事务像一个“全局指挥官”所有要参与本次行动的资源都必须先到指挥官那里报到、领号牌。SqlSessionTemplate和Mapper代理都是通信员它们从指挥官那里领连接、报告状态而手动openSession()相当于你自己另开了一辆车指挥官压根不知道你出了门等车队要集体掉头回滚时你这辆已经开出去的车自然不在召回名单里。2.4 两个连接并发数据状态一定会乱有人可能还会心存侥幸既然数据源是同一个会不会连接池刚好把同一个连接分给了手动SqlSession和Spring事务这种“撞车”是有可能的但恰恰更危险。同一个物理连接同时被两套机制使用Spring以为这个连接已经关了自动提交、归自己管MyBatis手动会话也在这条连接上执行自己的一套commit/rollback逻辑。双方状态互相覆盖轻则事务提交策略混乱重则连接复用后残留未提交操作下一个拿到这条连接的业务就被莫名其妙地影响。这种不可控的并发使用比“两个不同连接”更让人头疼。3. 展开代码链路SqlSessionTemplate 和 Mapper 代理是怎样保证事务生效的3.1 Mapper 接口的代理调用链在Spring集成项目里你注入的OrderMapper并不是一个普通实现类而是一个JDK动态代理对象。Spring启动时通过MapperFactoryBean或MapperScannerConfigurer扫描Mapper接口为每个接口生成代理。调用orderMapper.insertOrder(dto)时的真实调用链是这样的OrderMapper 代理 - MapperProxy.invoke(...) - sqlSessionTemplate.insert(...) - SqlSessionUtils.getSqlSession(...) - 判断当前线程是否已有 SqlSessionHolder - 有则复用无则新建并注册事务同步链路的终点是一个由SqlSessionTemplate动态代理包装过的SqlSession对象全程受Spring事务同步器控制。3.2 SqlSessionUtils.getSqlSession 如何找到“当前事务的会话”看SqlSessionUtils的简化逻辑就能理解它有多“懂事”SqlSessionHolder holder (SqlSessionHolder) TransactionSynchronizationManager.getResource(sessionFactory); if (holder ! null holder.isSynchronizedWithTransaction()) { return holder.getSqlSession(); // 当前线程已有事务会话直接复用 } SqlSession session sessionFactory.openSession(executorType); registerSessionHolder(session, sessionFactory, executorType, exceptionTranslator); return session; // 没有就新建并登记到当前事务当我们处在某个Transactional方法中当前线程的TransactionSynchronizationManager里已经绑定了SqlSessionFactory对应的资源所以getSqlSession()拿到的就是Spring事务范围内固定的那个SqlSession。同一个事务方法里无论你调用几个Mapper接口、执行多少条SQL用的都是同一个SqlSession、同一个连接。3.3 SpringManagedTransaction 如何保证连接一致SqlSessionTemplate拿到的这个SqlSession最终执行SQL时事务由SpringManagedTransaction管理。它的getConnection()方法是这样的public Connection getConnection() throws SQLException { if (this.connection null) { this.connection DataSourceUtils.getConnection(this.dataSource); } return this.connection; }重点是DataSourceUtils.getConnection(dataSource)。这个静态方法第一件事就是去TransactionSynchronizationManager找当前线程绑定的连接找到了说明DataSourceTransactionManager已经在管理这条连接MyBatis就用这条连接执行SQL没找到说明当前没有Spring事务才自己新建连接。这样MyBatis的读写就永远和Spring事务坐在同一条船上。事务提交或回滚时DataSourceTransactionManager对连接执行的动作就是MyBatis刚才那批SQL的最终结局。3.4 另一个伪装成“事务不生效”的高频原因自调用排查这类问题时还得把AOP自调用干扰排除掉。Transactional是靠Spring AOP代理实现的外部调用orderService.createOrder()时进入的是代理对象的方法事务拦截器才会生效。如果createOrder()内部再通过this.xxx()去调同类中的另一个Transactional方法这属于普通对象调用不是代理对象第二层事务不会生效。这种问题经常和手动openSession混在一个方法里给人“双重暴击”。你要是排查时先看到自调用反而会把真正的openSession问题忽略掉。4. 实锤过程日志、连接身份和回滚行为对照4.1 打开日志让两种写法自己说话排查这类问题最直接的办法是打开相关组件的调试日志观察会话和事务的行为差异。logger nameorg.springframework.jdbc.datasource.DataSourceTransactionManager levelDEBUG/ logger nameorg.mybatis.spring.SqlSessionUtils levelDEBUG/ logger nameorg.mybatis.spring.SqlSessionTemplate levelDEBUG/ logger nameorg.apache.ibatis.transaction.jdbc.JdbcTransaction levelDEBUG/以logback为例把上面几个logger配上。然后在同一个方法里分别用“手动openSession”和“注入Mapper”的方式跑一遍对照日志输出。正常使用Mapper或SqlSessionTemplate时你会看到类似这样的关键日志Creating new transaction with name [com.example.OrderService.createOrder] Registering transaction synchronization for SqlSession Committing JDBC transaction on connection [HikariProxyConnection123456] Releasing transactional SqlSession手动openSession()时日志里只会出现Creating a new SqlSession Closing SqlSession没有“Registering transaction synchronization”这一行也没有Spring的“Committing JDBC transaction”。翻译一下手动创建的会话从诞生到关闭始终游离在Spring事务体系之外。4.2 打印连接身份与 autoCommit 状态如果日志还不够直观可以在本地调试环境里加一段临时代码直接打印两种方式拿到的连接对象和自动提交状态Transactional public void debugSession(boolean manual) throws Exception { if (manual) { try (SqlSession session sqlSessionFactory.openSession()) { Connection conn session.getConnection(); System.out.println([manual] conn conn autoCommit conn.getAutoCommit()); OrderMapper mapper session.getMapper(OrderMapper.class); mapper.insertOrder(new OrderDTO()); throw new RuntimeException(manual rollback); } } else { OrderMapper mapper orderMapper; mapper.insertOrder(new OrderDTO()); throw new RuntimeException(spring rollback); } }两种方式打印出来的连接对象通常不是同一个autoCommit状态也可能不同。手动openSession那边MyBatis已经把自动提交关了它的事务完全在自己的JdbcTransaction内部处理Spring管理的那边则由DataSourceTransactionManager统一控制。这段代码别拿去生产跑但本地复现问题绝对够用。4.3 我自己用得比较顺手的“体检清单”遇到“事务不生效”的类似问题我习惯按这张表逐项排查检查项Spring受管会话正常手动openSession异常是否有“Creating new transaction”日志有无只有“Creating a new SqlSession”是否有“Registering transaction synchronization”有无连接来源DataSourceUtils.getConnection从当前事务绑定资源中获取dataSource.getConnection()直接新拿异常时回滚动作由DataSourceTransactionManager执行并输出日志仅依赖MyBatis自身close()或连接池归还时的行为不可控事务结束后数据残留不应残留是否残留取决于有没有手动commit和连接池策略这张表最大的价值是帮你快速判断问题到底在不在事务体系这一层。如果日志符合右侧特征那基本可以断定是SqlSession拿到了“野”的后面才轮到查数据源配置、查AOP代理。5. 改法让事务生效的三种形态以及选型理由5.1 方案一注入 Mapper 接口把事务交给代理这是最推荐、也最省心的方式。业务代码里根本不用接触到SqlSession这个概念。Autowired private OrderMapper orderMapper; Transactional(rollbackFor Exception.class) public void createOrder(OrderDTO dto) { orderMapper.insertOrder(dto); throw new IllegalArgumentException(库存不足); }理由是调用链非常干净OrderMapper代理 -SqlSessionTemplate-SqlSessionUtils- Spring受管SqlSession。你不需要关心会话的打开、提交、关闭Spring和mybatis-spring会帮你把一切收口。绝大多数Crud和业务操作都该写成这种形态。5.2 方案二注入 SqlSessionTemplate处理批量和动态SQL有些场景确实需要“直接操作SqlSession”的感觉比如动态SQL拼接、一段逻辑里反复执行同一条语句、批量insert比较方便的时候。这时候正确的注入对象是SqlSessionTemplate而不是SqlSessionFactory。Autowired private SqlSessionTemplate sqlSessionTemplate; Transactional public void batchOperate(ListOrderDTO list) { for (OrderDTO dto : list) { sqlSessionTemplate.insert(com.example.mapper.OrderMapper.insertOrder, dto); } }SqlSessionTemplate实现了SqlSession接口本身是线程安全的内部所有方法都会走SqlSessionUtils.getSqlSession()那套受管逻辑。它并不会像DefaultSqlSession那样让你自己管理提交回滚处于事务中时提交动作一律交给Spring处理。5.3 方案三非要用 openSession也请用 SqlSessionUtils 的受管方式有一种比较少见、但确实存在的需求你需要在Spring管理范围内拿到一个“可感知当前事务”的SqlSession实例。如果实在需要这样做请别直接sqlSessionFactory.openSession()改用SqlSessionUtilsSqlSession session SqlSessionUtils.getSqlSession(sqlSessionFactory); try { OrderMapper mapper session.getMapper(OrderMapper.class); mapper.insertOrder(dto); // 不要手动 session.commit() } finally { SqlSessionUtils.closeSqlSession(session, sqlSessionFactory); }这样拿到的会话会参与Spring事务同步异常时能跟着Spring一起回滚。这里有一个铁律不要在这个会话上手动调commit()或rollback()。一旦你手动调了就破坏了Spring对全局事务的统一调度。既然事务边界已经交给Transactional全局提交还是回滚就该由Spring决定。5.4 三种方式怎么选方式事务一致性代码简洁度适用场景主要坑注入Mapper接口完全受Spring管理高绝大多数业务读写注意不要在事务方法里自调用注入SqlSessionTemplate完全受Spring管理中批量操作、动态SQL、复杂SQL反复执行别把它当成裸的DefaultSqlSession乱提交手动openSession 自己维护不受Spring管理低仅限完全脱离容器事务的独立场景数据残留、连接泄漏、事务状态错乱从表格可以直观看出前面两种方案都站在事务体系内第三种基本是给自己挖坑。实际项目中如果看到有人注入了SqlSessionFactory然后到处openSession()我基本都会建议直接改成Mapper注入。6. 顺着同一条线索缓存失效、流式查询和多数据源里的连环坑6.1 一级缓存“时灵时不灵”的真相MyBatis的一级缓存默认开启作用范围是SqlSession生命周期。Spring集成环境中同一个事务方法里多次执行相同的查询因为所有Mapper调用都复用同一个受管SqlSession一级缓存能正常命中。但如果你在代码里手动openSession()每次拿到的都是全新的SqlSession一级缓存必然每次都miss。表现形式就是缓存“好像没配一样”其实不是配置问题而是会话都没有复用。6.2 流式查询Cursor与连接释放风险很多同学喜欢用openSession()做游标查询处理大量数据时时开一个会话循环遍历Cursor。这里的问题非常现实手动会话一旦忘记在finally里close池里的连接就会被白白占用流量一大直接连接耗尽。而SqlSessionTemplate因为受到Spring事务管理连接释放时机是确定的不会因为忘写finally就泄漏。流式场景建议把SqlSessionTemplate配合Transactional使用让框架统一收口连接释放。6.3 多数据源下的“连接错配”也是同一个病灶如果你的项目配置了多个数据源还会遇到一个同源变种Transactional没指定transactionManager或者指定的transactionManager绑定的数据源跟MyBatis的SqlSessionFactory用的数据源不是一个。此时哪怕你肉眼觉得“我正在写主库”实际MyBatis用的却是副库连接Spring事务盯着主库连接看两边还是各管各的。排查看法跟openSession问题一模一样先确认事务管理器和SqlSessionFactory指向的是同一个DataSource再往下查连接归属。6.4 遇到“事务不生效”时我个人习惯的排查顺序这类问题我排查过很多轮踩的坑多了慢慢沉淀出一套顺序先看代码里有没有sqlSessionFactory.openSession()、this.xxx()自调用、以及非public方法上加Transactional这类低级问题再开日志确认有没有“Registering transaction synchronization for SqlSession”这行关键记录然后确认事务管理器、SqlSessionFactory、DynamicDataSource之间指向的数据源是否一致最后才去怀疑连接池、数据库隔离级别、甚至嵌套事务传播行为。这套顺序大部分时候能在第1、2步干掉问题。如果你也在为“注解写了、异常也抛了数据就是回滚不掉”发愁先别急着换框架改配置回头看一眼项目里是不是又有人从SqlSessionFactory那儿手动开了个野会话。这个问题本质上不是MyBatis的bug也不是Spring的锅而是我们把两个框架的职责边界踩穿了。代码写顺手了之后我现在的习惯很固定业务层一律注入Mapper确实需要SqlSession能力时只碰SqlSessionTemplate绝不直接碰SqlSessionFactory.openSession()。
返回列表