ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MyBatis手动openSession导致事务失效?正确集成与排查指南

MyBatis手动openSession导致事务失效?正确集成与排查指南 几个星期前有朋友私信我一段代码说项目里加上了Transactional数据库数据却照样写进去了回滚根本不管用。我一看代码他在 Service 方法里没有用注入的 Mapper而是自己搞了个工具类里面通过SqlSessionFactory.openSession()拿了一个 SqlSession 出来执行更新操作。这种写法事务要是能生效那才叫见鬼了。今天就以这个问题为引子把 MyBatis 和 Spring 集成后sessionFactory.openSession()为什么会让事务形同虚设这事掰开揉碎讲清楚顺便把正确的集成姿势、自查思路和常见坑一并整理出来。这个问题不是什么高深理论但踩中的人绝对不少。尤其是用了 SpringBoot 之后很多人已经很久没手动 new 过SqlSessionFactory一旦回到 SSM 手动集成场景就容易凭老印象写出openSession()来操作数据库然后被事务问题折磨到怀疑人生。这篇东西适合正在经历这种折磨的同学也适合准备面试、想搞清楚 MyBatis-Spring 工作原理的人。1. 问题的现场还原与直观确认1.1 典型的错误代码长什么样先说最常见的出问题代码基本长这样Service public class OrderService { Autowired private SqlSessionFactory sqlSessionFactory; Autowired private OrderMapper orderMapper; Transactional(rollbackFor Exception.class) public void createOrder(OrderDO order) { orderMapper.insert(order); // 这里走的是原生 SqlSession try (SqlSession session sqlSessionFactory.openSession()) { OrderLogMapper logMapper session.getMapper(OrderLogMapper.class); logMapper.insert(orderLog(order)); } catch (Exception e) { // 日志记录 } if (order.getAmount() 100) { throw new RuntimeException(金额校验失败事务应该回滚); } } }这段代码表面上看逻辑没问题Mapper 插入订单SqlSession 写日志最后故意抛异常验证回滚。但实际运行后你会看到订单数据回滚了因为orderMapper走的是 Spring 管理的代理 Mapper而order_log表里却多出了一条记录。如果两个操作反着来或者主操作也走openSession()那就更惨所有数据全部写入异常完全无效。为什么同样的数据库、同一个SqlSessionFactory通过不同途径拿到的东西行为就完全不一样这才是核心问题。1.2 最直观的验证方法看日志不靠猜测直接开日志验证。把 MyBatis 的 SQL 日志打开同时打开 Spring 事务管理日志你会看到两种截然不同的行为logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManagerDEBUG logging.level.org.mybatis.spring.SqlSessionUtilsDEBUG logging.level.com.example.mapperDEBUG正常走 Mapper 代理的事务方法日志里会出现Creating new transaction with name [com.example.service.OrderService.createOrder] Acquired Connection [HikariProxyConnection1234] for JDBC transaction Registering transaction synchronization for SqlSession Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession5678]而你手动openSession()的那段日志只会出现Opening new SqlSession JDBC Connection [HikariProxyConnection9999] will not be managed by Spring Closing SqlSession注意最后一句will not be managed by Spring这句话就是问题本质Spring 事务管理器根本不认识你手动开的这个 SqlSession 拿到的数据库连接它不在同一个事务上下文里自然谈不上回滚。2. 根因剖析Spring 事务和 MyBatis 会话是两个层面的东西2.1 Spring 声明式事务的本质是什么先理清 Spring 的声明式事务到底干了什么。在 Spring 容器里标注了Transactional的 Bean 会被 AOP 代理。代理类在进入目标方法前会从容器中找到DataSourceTransactionManager让它从数据源里拿一个数据库连接开启事务并把连接绑定到当前线程的一个 ThreadLocal 资源上。记住这个关键词连接是绑定在线程上的。Spring 认为一次事务就是一次线程执行上下文事务管理器管的是那个数据库连接 Connection。之后无论谁要在这个线程里执行数据库操作都必须去这个 ThreadLocal 里拿同一个 Connection 才能参与当前事务。如果某个环节绕过了这个机制自己另开了一个连接那么它对 Spring 来说就成了外部连接事务管不到也没法回滚。这里可以打个比方Spring 事务像一个公司内部的严格报销流程所有支出必须走 OA 系统审批备案才有效。openSession()相当于部门主管私下掏钱先垫了事后走报销流程发现没录入系统财务根本不认账。2.2 MyBatis 的 SqlSession 到底封装了什么MyBatis 里SqlSession是对数据库会话的抽象。每一次openSession()通常会从数据源获取一个新的数据库连接也可能从连接池复用然后基于这个连接创建执行器Executor。连接的管理、事务提交、回滚本质上由 SqlSession 内部的 Connection 决定。默认情况下直接用sqlSessionFactory.openSession()创建出来的 SqlSession它的事务边界完全由自己控制默认 autoCommit 为 false也就是不会自动提交你必须在操作完成后手动调用session.commit()才能把数据写进库报错时如果不主动rollback()连接关闭时会回滚但注意这个手动提交/回滚和 Spring 的声明式事务没有任何关系。如果你手动commit()了数据就真落库就算外层 Spring 事务后面抛异常也没办法。实际上在实际业务代码里最容易出现的两种错误结局是每次 openSession 后没有及时 commit业务上看起来没生效其实数据不是丢了而是回滚了或者连接关闭时没提交这批数据悄悄不见了。某些框架封装里把 autoCommit 设置为 true或者代码里主动 commit导致数据提前落库外层 Spring 事务回滚失败。第一种是以为数据会写但没写第二种是以为会回滚但没回滚。很多人遇到的其实都是第二种所以排查方向被带偏。2.3 MyBatis-Spring 的桥接机制SqlSessionTemplateMyBatis 官方也知道这种混乱所以提供了一个集成包mybatis-spring里面最核心的组件就是SqlSessionTemplate。这个类不是单纯地包装SqlSession它的关键设计是实现了 MyBatis 的SqlSession接口你拿到它就能像原生 SqlSession 一样用它内部通过SqlSessionUtils.getSqlSession()获取会话这个方法会先检查当前线程有没有被 Spring 事务绑定过数据源和会话如果存在 Spring 管理的事务就直接复用事务内的 SqlSession也就是复用同一个 Connection如果没有事务它会自己开一个 SqlSession并注册到 Spring 的同步管理器里请求结束或方法结束自动关闭所以当你注入 Mapper 接口并使用它时事务管理器、SqlSession、Connection 三者天然是绑在一起的。这就是为什么注入 Mapper 能参与事务而手动 openSession 不能。在 SpringBoot 项目中mybatis-spring-boot-starter在自动配置时会把SqlSessionTemplate创建好然后所有 Mapper 代理内部持有的 SqlSession 实际都是它。所以正常的 SpringBoot MyBatis 项目基本不会遇到标题所说的问题。但一旦你手动集成 SSM或者自己写工具类封装 MyBatis就很容易掉进openSession()的坑。2.4 一个关键实验观察事务中的 SqlSession 复用为了彻底理解第三条我建议你做个实验。在同一个事务方法里分别通过注入的 Mapper 连续执行两次查询并打印 SqlSession 的 hashCodeTransactional(readOnly true) public void testSameSession() { User user1 userMapper.selectById(1); User user2 userMapper.selectById(1); SqlSessionTemplate sqlSessionTemplate (SqlSessionTemplate) sqlSessionFactory; SqlSession session sqlSessionTemplate.getSqlSession(); System.out.println(Session from Mapper: session.hashCode()); }但直接用注入的 SqlSessionTemplate 打印不出内部 SqlSession更好的方式是自定义一个拦截器或者直接用日志。不过这个实验重点在于多次调用 Mapper 方法时MyBatis-Spring 会保证在同一个事务内复用同一个 DefaultSqlSession。这一点对理解 MyBatis 一级缓存尤其重要。一级缓存是 SqlSession 级别的如果 Spring 事务内复用了同一个 SqlSession那么同一条 SQL 在事务内查两次第二次会直接命中缓存不走数据库。如果你手动 openSession 查了一次然后 Mapper 又查一次两次用的不是同一个 SqlSession一级缓存永远不可能命中还可能引发数据不一致的诡异现象。3. 解决方案与正确姿势从救火到根治3.1 标准方案用 SqlSessionTemplate 替代直接 openSession既然问题出在绕过了 Spring 的资源绑定机制那最直接的解法就是不自己 openSession而是用 Spring 管理的SqlSessionTemplate。手动集成 SSM 的场景把 Bean 的配置写成这样bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath*:mybatis/mapper/*.xml/ /bean bean idsqlSessionTemplate classorg.mybatis.spring.SqlSessionTemplate constructor-arg index0 refsqlSessionFactory/ /bean然后在你需要操作 SqlSession 的地方注入SqlSessionTemplateService public class OrderService { Autowired private SqlSessionTemplate sqlSessionTemplate; Transactional(rollbackFor Exception.class) public void createOrder(OrderDO order) { orderMapper.insert(order); OrderLogMapper logMapper sqlSessionTemplate.getMapper(OrderLogMapper.class); logMapper.insert(orderLog(order)); if (order.getAmount() 100) { throw new RuntimeException(事务回滚); } } }这种方式下logMapper的 insert 操作会走 Spring 当前事务的 SqlSession异常时统一回滚。注意SqlSessionTemplate本身是线程安全的可以被注入到任意地方不需要每次创建。在 SpringBoot 项目里直接用自动配置注入的SqlSessionTemplate也一样不需要自己 new。3.2 特殊场景实在要用 openSession 怎么办有些场景确实绕不开手动的 SqlSession比如在执行数据库 DDL、批量特殊操作、在拦截器里处理会话时可能非要一个独立的会话。这种情况下不是不能用openSession()但你必须知道自己正在脱离 Spring 事务并自行管理事务边界。最常见也最稳妥的做法既要用 openSession 操作又想和 Spring 事务保持一致那就不要用 openSession改用SqlSessionTemplate。如果非要用独立的会话执行一个不需要参与外层事务的操作那么至少要做到手动执行完后立即关闭并且通过编程式事务手动控制或者干脆让这个操作自己保证原子的语义。我记得在一个老项目的定时任务里因为需要在一次任务内批量处理好几千条数据有人图省事就写了try (SqlSession session sqlSessionFactory.openSession(ExecutorType.BATCH)) { for (SomeDO item : list) { mapper.insert(item); } session.commit(); }这种写法没问题只要它不在一个需要和 Spring 事务协同的方法里就行。问题只出在混用上外层方法标记了事务里面一部分操作走代理 Mapper一部分走原生 openSession两边连接不同哪边提交了都可能让外层失败。如果确实需要在事务方法里用 openSession我见过有人直接操作当前事务连接的写法但非常繁琐且容易出错完全不推荐。记住一句话Spring 容器里SqlSession 的获取、使用、释放都交给 mybatis-spring 管理别自己造轮子。3.3 批量写操作的正确打开方式顺便提一下很多人会问 MyBatis 批量写和事务的关系。如果你想在事务里批量插入一万条数据常见做法是Transactional public void batchInsert(ListOrderDO orders) { for (OrderDO order : orders) { orderMapper.insert(order); } }这种写法如果每条 insert 都走数据库性能很差。更优化的方式是配置 ExecutorType 为 BATCH或者用 MyBatis 的 SqlSessionTemplate 配合 batch 模式的 Mapper 执行。实际项目中我倾向于在 XML 里写 foreach 批量 insertinsert idbatchInsert INSERT INTO t_order (order_no, amount, user_id) VALUES foreach collectionlist itemitem separator, (#{item.orderNo}, #{item.amount}, #{item.userId}) /foreach /insert这样既能在事务内执行又减少了 SQL 往返次数比纠结 openSession 的 Batch Executor 方便得多。如果你真的要用openSession(ExecutorType.BATCH)建议确认它不在任何 Spring 事务里使用否则 Batch 执行器刷出 SQL 的时机和 Spring 事务的提交时机不匹配很容易出现数据没写进库或者写了一半的诡异现象。3.4 手工触发 SqlSession 管理的备用方案有些老项目我见过MyBatisUtil这样的工具类内部自己维护一个静态 SqlSessionFactory然后各种 Service 都直接调用它拿 session。这种设计在纯 MyBatis 项目里能用一旦引入 Spring 就会破坏事务一致性。所以如果代码里已经有这样的工具类我建议分两步改造第一步把数据源统一交给 Spring 管理SqlSessionFactory 使用org.mybatis.spring.SqlSessionFactoryBean创建Mapper 全部改成注入方式。 第二步手工获取 SqlSession 的地方全部改为注入SqlSessionTemplate删除MyBatisUtil。并不是所有项目都能一次性改完所以过渡期可以让工具类内部使用 Spring 的SqlSessionTemplate也就是把 openSession 替换为 getSqlSession同时不要手动 close交给 Spring 管理。这样改动最小但能立刻修复事务不生效的根因。4. 常见问题与排查技巧实录4.1 排查事务不生效的三板斧遇到 MyBatis Spring 事务不生效我建议按顺序排查以下三个方向基本能覆盖 90% 的情况第一确认入口方法是否被 AOP 代理。Transactional只对代理有效同类内部调用比如方法 A 调用同类方法 BB 有事务注解不会走代理事务完全不生效。这个和本次主题无关但最容易中招。第二确认所有数据库操作是否走同一个事务资源。看日志里有没有will not be managed by Spring这个关键提示如果出现了基本可以断定是手动 SqlSession 或某个第三方库自己开了连接。第三确认事务管理器是否配置正确。如果容器里存在多个数据源但没有指定PlatformTransactionManagerSpring 可能使用了一个和你 MyBatis 操作完全不同的数据源这时事务也会静默失效。排查工具方面mybatis log plugin这类日志插件只能帮你看到 SQL 执行看不到事务连接归属所以核心还是看 Spring 事务日志和 SqlSession 日志。你可以在项目里临时把org.mybatis.spring.SqlSessionUtils调到 DEBUG它会在每次获取、关闭 SqlSession 时打日志特别直观。4.2 一个更隐蔽的坑多个数据源下的 Session 串用多数据源场景下问题会进一步放大。假设你配置了两个数据源master和slave分别生成两个 SqlSessionFactory。你的Transactional指定了transactionManager masterTransactionManager那么 Spring 绑定的 Connection 来自 master 数据源。如果某个 Mapper 配置到了 slave 数据源或者代码里用 slave 的 SqlSessionFactory 开了 session那不管哪种方式它都不可能参与 master 的事务。这种情况下很容易出现的主副库数据不一致问题比单数据源更隐蔽因为两边连接都是独立的你甚至不会看到will not be managed by Spring这种日志因为两个都是 Spring 管理的 Bean但事务资源根本不在同一个上下文。解决办法是要么使用分布式事务比如 Seata要么把跨数据源操作拆分成独立事务并由业务兜底要么用AbstractRoutingDataSource实现动态路由但必须保证路由键在同一事务内一致。这一类问题已经超出单机事务的范围属于分布式事务一致性的范畴处理方式要复杂得多。4.3 缓存与事务的纠缠MyBatis 一级缓存是 SqlSession 级别的二级缓存是 namespace 级别的。在 Spring 集成后一级缓存的生命周期等同于 SqlSession 的持有周期。在 Spring 事务内SqlSession 复用一级缓存天然生效所以事务内先查再更新再查的同一条 SQL第二次经常拿的是缓存结果。这个特性有时候会坑到人。比如事务内某条数据被另一个连接并发修改你以为自己查的是最新值其实因为一级缓存返回了旧值。一级缓存在同一个事务里不会自动刷新除非触发更新操作或者你手动调用sqlSession.clearCache()。而如果你手动 openSession 去查又和事务内的 SqlSession 各自独立缓存完全不共享可能导致事务内读到旧数据外部读到新数据两个结果互相矛盾。理解这个机制后写复杂业务时就要明确事务内多次查询依赖缓存是无法控制的需要最新数据就自己写好查询条件或者关闭二级缓存并且接受一级缓存在一个事务内的作用范围。这个不是 bug而是 MyBatis 的设计语义。4.4 连接池耗尽与量级膨胀的隐患每次手动openSession()之后如果忘记关闭会造成连接泄漏。尤其是前面提到的那种代码用户在一次请求里 openSession 多次还有 if 分支提前 return结果 session 一直关不掉。连接池的大小是有限的比如 HikariCP 默认配置 10 个连接一旦被泄漏的 session 占满业务就会出现连接获取超时的假死现象。我一直强调这个关联手动 openSession 不光是事务失效的问题它同时还是连接泄漏的入口。Spring 管理的 SqlSession 有自动关闭机制请求完毕会强制释放而你自己的 session 只能靠 finally 块保证。如果一开始就依赖try-with-resources确实能减少泄漏风险但并不能解决事务不参与的问题。所以在代码评审时只要看到openSession出现在 Service 层或者 Controller 层我一般都会重点审查两件事一是有没有在同一事务里被混用二是有没有在异常路径里打开连接没关闭。这两类问题几乎都是同一段代码制造出来的。5. 一个完整的正确集成样例前面原理和解决方案都说了最后给一个可直接抄作业的完整集成配置。假设是纯 SSM 项目使用 XML 配置核心文件如下数据源配置用 Druid 或 HikariCP 都行bean iddataSource classcom.zaxxer.hikari.HikariDataSource destroy-methodclose property namejdbcUrl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ property namemaximumPoolSize value20/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath*:mapper/*.xml/ property nametypeAliasesPackage valuecom.example.entity/ /bean bean idsqlSessionTemplate classorg.mybatis.spring.SqlSessionTemplate constructor-arg index0 refsqlSessionFactory/ /bean bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/ mybatis:scan base-packagecom.example.mapper/在 Java 代码里Service 层只注入 Mapper不使用SqlSessionFactory。如果确实需要 SqlSession 级别的操作注入SqlSessionTemplateService public class OrderService { private final OrderMapper orderMapper; private final SqlSessionTemplate sqlSessionTemplate; public OrderService(OrderMapper orderMapper, SqlSessionTemplate sqlSessionTemplate) { this.orderMapper orderMapper; this.sqlSessionTemplate sqlSessionTemplate; } Transactional(rollbackFor Exception.class) public void createOrder(OrderDO order) { orderMapper.insert(order); // 这里使用 SqlSessionTemplate 而不是 factory.openSession() OrderLogMapper logMapper sqlSessionTemplate.getMapper(OrderLogMapper.class); logMapper.insert(orderLog(order)); // 故意抛异常验证回滚 if (order.getAmount() 100) { throw new RuntimeException(金额校验失败触发回滚); } } }这种写法下orderMapper和logMapper在整个事务里共用同一个 SqlSession 和同一个数据库连接任何一步抛出异常Spring 都会让整条链路回滚。手动 openSession 的方式彻底从代码里消失事务问题自然解决。如果你想知道当前事务内到底复用了哪个 SqlSession也可以在代码里加一段临时诊断SqlSession currentSqlSession sqlSessionTemplate.getSqlSession(); System.out.println(Current SqlSession: System.identityHashCode(currentSqlSession));记得用完不要手动关闭因为 Spring 会统一管理这个 SqlSession 的生命周期手动关闭反而会破坏后续操作。6. 关于面试和知识体系的一点延伸最后聊点题外话。标题这个问题其实是一个非常典型的面试考点它能串起 Spring AOP、事务传播机制、MyBatis Executor、连接管理、缓存机制等多个知识点。面试官之所以爱问是因为它考察的不是死记硬背而是对框架协作关系的整体理解。如果你正在准备相关面试建议顺着这条线把下面几个问题串起来梳理Spring 事务管理器的 Connection 是怎么和线程绑定的MyBatis-Spring 的 SqlSessionTemplate 是怎么检测当前线程是否已有 SqlSession 的一级缓存为什么在一个 Spring 事务里生效在无事务的 Service 方法里未必生效如果 Service 方法没有事务注解但里面调用了两个 Mapper 方法它们是不是同一个 SqlSession这些问题全部弄懂之后所谓MyBatis 和 Spring 集成就不再是两套框架的拼装而是一套完整的资源管理模型了。我自己在带团队做代码评审的时候也是按照这条线去考察候选人对 ORM 框架理解深度的。如果你还没到这个阶段那先把这篇文章里提到的现象和解决方案亲手复现一遍收获绝对比单纯看十篇总结要大得多。
返回列表