MyBatis批量插入事务失效?BATCH执行器不生效的排查与解决
发布时间:2026/10/1 1:10:03来源:尧图网络
你在项目里是不是也遇到过这样的事Service上明明加了Transactional批量插入失败后数据库里却留下半截数据或者听说MyBatis的ExecutorType.BATCH能把批量插入性能拉高好几倍照着配了之后速度纹丝不动再或者想在Spring工程里手动openSession(ExecutorType.BATCH)做批处理结果和Spring事务纠缠在一起不是连接被提前关了就是提交时机彻底失控。我前几年排查这类问题把MyBatis和mybatis-spring的源码翻了不止一遍最后想明白一件事SqlSession事务和批量执行Spring和MyBatis各有一套“默认值”这些默认值拼在一起恰好就意味着你想要的“批量事务”不生效。这篇文章不打算从头讲SqlSession是什么而是带着这些真实踩坑经历把SqlSession事务边界、ExecutorType.BATCH真正生效的条件以及批量执行时容易被忽略的缓存、拦截器、内存问题一次讲透。如果你只是想拿一套方案去改代码可以直接跳到第5章想把原理也理顺建议从头往下看。1. 一个真实故障现场批量插入失败数据却留在了库里1.1 我把BATCH配置好了日志却还是逐条INSERT先说当时的现象。我负责的模块要做一个订单导入接口一次最多5000条按业务要求必须整体成功或整体失败。最初版本很朴素Service里for循环调用orderMapper.insert(order)方法上加了Transactional一次跑下来要二十多秒太慢。然后我查资料大家都说MyBatis可以开ExecutorType.BATCH把多条insert放到一个JDBC批次里执行。于是我在配置类里加了一个SqlSessionTemplate执行器类型指定为BATCHService里注入这个批量模板循环里继续调用Mapper的insert方法。跑起来那一刻我人傻了。日志里依然是 Preparing: insert into t_order (order_no, user_id, amount, status, create_time) values (?, ?, ?, ?, ?) Parameters: NO20250101001(String), 1001(Long), 199.90(Double), 0(Integer), 2025-01-01 12:00:00(Timestamp) Preparing: insert into t_order (order_no, user_id, amount, status, create_time) values (?, ?, ?, ?, ?) Parameters: NO20250101002(String), 1001(Long), ...一条一条的Preparing完全没有出现预期的executeBatch()。数据库端看到的也是一次次单独的INSERT请求。也就是说BATCH这个配置等于没生效。1.2 第一步排查排除Transactional自身失效遇到这种问题我习惯先冷静列可能性再去验证。第一个怀疑对象肯定是Spring事务没生效。为了快速确认我在循环里故意让第1000条抛一个RuntimeException结果诡异的事情来了控制台打印了异常但数据库里前999条数据还在。这说明事务确实没回滚。把Transactional排查一遍最常见的就是三类问题方法不是publicSpring AOP默认不代理非public方法同类内部自调用this.batchInsert()直接走目标对象代理器绕过去了异常类型不是RuntimeException或Error而Transactional默认rollbackFor只包含这两类。我的方法恰好是public也确实是外部Bean调用抛的也是RuntimeException不是这三类问题。那为什么数据没回滚呢继续往下查才发现问题出在SqlSessionTemplate的自动提交机制上这比Spring AOP的坑隐蔽得多。1.3 第二步排查SqlSessionTemplate的自动提交才是主谋我注入并使用BATCH型的SqlSessionTemplate但如果没有一个Spring事务把这个SqlSession包住SqlSessionTemplate在每次Mapper方法调用结束后会自主提交并关闭SqlSession。一个方法调用一次就commit一次。第1000条异常时前999次Mapper调用早就各自提交完了自然不可能再跟着回滚。Transactional失效不是Spring没接管而是MyBatis这层自己先“偷偷”把事务提交了。这个发现让我彻底明白SqlSession事务和Spring事务并不是一开始就绑在一起的它们之间靠mybatis-spring搭桥。桥没搭对两个默认值凑一块就是“默认不生效”。2. SqlSession到底谁说了算Spring事务与MyBatis会话的协作机制2.1 没有Spring时SqlSession的生死自己管先回忆原生MyBatis的用法。你要操作数据库必须拿到一个SqlSessionSqlSession session sqlSessionFactory.openSession(); try { OrderMapper mapper session.getMapper(OrderMapper.class); mapper.insert(order1); mapper.insert(order2); session.commit(); } catch (Exception e) { session.rollback(); throw e; } finally { session.close(); }这段代码里SqlSession自己的生命周期、事务边界、最终提交或回滚都要程序员手动控制。openSession()默认不由Spring管事务也默认不是自动提交你不调commit()数据不会落库。注意这里还有一个隐藏点openSession()默认使用ExecutorType.SIMPLE这意味着每条SQL执行后会立即关闭PreparedStatement不会做任何攒批优化。所以我们先记住第一条规律原生SqlSession是“独立王国”提交、回滚、关闭都由调用方负责。你手动commit多少次它就提交多少次和Spring没关系。2.2 Spring介入后SqlSessionTemplate的代理逻辑Spring工程里我们几乎不会手写openSession()而是通过Autowired注入Mapper接口或者注入SqlSessionTemplate。真正干的活都藏在SqlSessionTemplate内部。它实现了SqlSession接口但所有方法都经过一个SqlSessionInterceptor动态代理来处理代理逻辑大致是这样的每个方法执行前从Spring事务同步器里找当前线程有没有绑定连接方法执行后判断当前是否存在Spring事务如果没有Spring事务直接sqlSession.commit()并close()也就是“方法一结束立即提交”如果有Spring事务则不做commit把提交动作交给Spring事务管理器等到事务边界统一提交或回滚。这套设计在单体增删改查里很省心你调一个Mapper方法数据立刻落库不需要写事务代码。但放在批量场景它就成了最大的“猪队友”——你在Service里循环调用5000次mapper.insert()如果没有Spring事务包住整个循环那么每次Mapper调用后都会自动commit一次5000条数据被提交5000次BATCH模式想攒批也攒不起来。2.3 事务管理器管理的其实是Connection不是SqlSession再往深处看一层。Spring的DataSourceTransactionManager管理的是数据库连接更准确地说是把连接绑定到当前线程上。它会在事务开始时从DataSource里拿一个连接用TransactionSynchronizationManager把这个连接存在当前线程的ThreadLocal里之后所有数据库操作都复用这个连接直到事务结束关闭连接。而SqlSessionFactory.openSession()创建的SqlSession内部使用的连接来自它自己配置的DataSource。mybatis-spring在创建SqlSessionTemplate时默认配置了一个SpringManagedTransactionFactory这个工厂生成的SpringManagedTransaction不会自己从DataSource拿连接而是去Spring事务同步器里找若当前线程已有Spring事务绑定的连接就直接复用若没有才自己从DataSource拿。这就是为什么Transactional在大多数场景下能生效SqlSession实际上复用了Spring事务管理器绑定的那个连接Spring说提交就提交说回滚就回滚。但这里有个前提——SqlSessionFactory的DataSource必须和Spring事务管理器用的是同一个DataSource。一旦配置了多个数据源事务管理器绑定的连接是A数据源SqlSessionFactory却去B数据源取连接那你加多少个Transactional都没用数据该提交照样提交该回滚也回滚不了。2.4 多数据源场景为什么更容易“不生效”多数据源是最容易踩“默认不生效”的地方。有人会在配置里写两个DataSource一个主库一个从库或者一个业务库一个报表库。事务管理器只管理其中一个DataSource但写代码时某个Mapper用的却是另一个SqlSessionFactory。结果就是事务管理器说“我开始事务了”但它管理的连接没人用真正执行SQL的SqlSession连了另一个数据源完全没有事务保护任何异常都不会回滚批量插入失败后照样留下一堆脏数据。排查这类问题第一件事就是确认DataSourceTransactionManager和SqlSessionFactory持有的是否是同一个DataSource实例。在很多配置类里如果Bean方法参数写错或者直接new了一个新的DataSource都会出现这种“看起来配置了事务实际形同虚设”的情况。在Spring Boot的自动配置下通常没事但一旦你自定义SqlSessionFactory或自定义PlatformTransactionManager就要格外小心引用关系。3. ExecutorType.BATCH为什么配了也不提速3.1 SIMPLE、REUSE、BATCH三者的取舍讲清楚BATCH为什么不生效先要认识MyBatis的三种Executor实现。执行器执行策略适用场景性能表现注意点SIMPLE每执行一条SQL创建PreparedStatement执行完立即关闭常规增删改查事务内每条SQL独立网络往返批量场景慢在频繁建/关StatementREUSE同一个SqlSession内复用同名SQL的PreparedStatement同一条SQL反复执行减少Statement创建开销不攒批单条发送BATCH多条SQL攒到批次里只调用JDBC的executeBatch()统一发送批量insert、update网络往返大幅减少性能最优延迟到flush/commit才真正执行返回值和处理时机需注意SIMPLE是默认值相当于每次去数据库跑一趟REUSE只是把PreparedStatement留着复用但SQL还是一次一次发只有BATCH能做到真正攒批等批次攒够了一次性发送。这个攒批的过程很像公交车SIMPLE是一辆空车一个人也马上发车BATCH是等一车人差不多齐了再一起出发代价就是“谁也不知道车什么时候发”。3.2 BATCH失效的三个直接原因我把BATCH配置好却没效果的经历拆开发现原因可以归纳成三类你遇到的情况基本能对号入座。第一个原因Spring创建SqlSessionTemplate时没有把BATCH传进去。只改mybatis-config.xml里的defaultExecutorType不一定管用因为Spring Boot里SqlSessionFactoryBean和SqlSessionTemplate在创建时都有自己的ExecutorType默认值。SqlSessionTemplate的构造方法如果没有传ExecutorType默认是ExecutorType.SIMPLEMapper接口执行时用的就是这个模板里固定的执行器。你配置文件里写的默认值在Spring管理下可能根本不会被用到。第二个原因没有Spring事务包裹每次Mapper方法调用后自动提交打断攒批。上一章说过SqlSessionTemplate在没有事务时会在方法结束后自动commit。BatchExecutor攒批要等到commit或flushStatements才发送自动commit等于每攒一条就发一趟车BATCH毫无意义。第三个原因在Service里循环调用单条Mapper方法而不是在一条Mapper方法内完成多行操作。很多人以为只要把ExecutorType改成BATCHService里的循环就会自动变成批量这是误解。BATCH攒批的单位是“同一个SqlSession内、在flush之前连续添加的Statement”。如果你在Service里写for循环每轮都调用orderMapper.insert(order)每个方法调用其实都经过SqlSessionTemplate的代理配合上面第二点的自动提交BATCH永远攒不起来。3.3 让BATCH真正生效的三种姿势姿势一Spring事务 BATCH型SqlSessionTemplate。给SqlSessionTemplate显式传入ExecutorType.BATCH同时保证批量操作在Transactional事务内执行。有了事务包裹代理拦截器不会在每次Mapper调用后立刻commitBatchExecutor才有机会把多条insert攒在一起最后在事务提交时统一flush。姿势二编程式SqlSession手动控制。通过sqlSessionFactory.openSession(ExecutorType.BATCH, false)打开一个关闭自动提交的SqlSession自己调flushStatements()、commit()、rollback()、close()。这种方式完全绕开Spring的自动提交行为会让你对批次生成时机有绝对掌控权。姿势三放弃BATCH使用foreach大SQL。在一个Mapper方法里用foreach把一条条insert拼成insert into ... values (...), (...), (...)。这种方式不需要BATCH执行器用SIMPLE或者默认配置就能跑出不错的效果缺点是SQL字符串会很长对数据库的max_allowed_packet等参数有要求。三种姿势没有绝对的优劣关键看场景。要求整体原子性、数据量中等用姿势一数据量大、希望分批提交降低锁和日志压力用姿势二数据量小、想简单可控直接姿势三。3.4 全局开BATCH的代价有人可能图省事把所有Mapper都切到BATCH执行器。我试过当时是内部管理系统开发环境跑得挺好生产环境一到高峰期就出问题。后来我才意识到全局BATCH有这些代价查询操作也走BatchExecutor虽然没有攒批需求但BatchExecutor内部逻辑比SimpleExecutor重多一层状态判断BATCH模式会缓存未flush的SQL和参数长时间不flush内存占用越来越高一些依赖更新行数的业务逻辑在BATCH下拿到的返回值不是真实行数容易误判多个SqlSessionTemplate如果执行器类型不同在不恰当的事务边界下会创建额外SqlSession增加连接管理复杂度。所以我的建议是写操作单独用BATCH查询继续走SIMPLE不要图省事全局一把梭。Spring Boot里可以用Qualifier区分不同SqlSessionTemplate按需注入。4. 批量执行时看起来无辜的隐藏问题缓存、行数、拦截器与内存4.1 一级缓存同一SqlSession里读写顺序颠倒读到的还是旧值MyBatis默认开启一级缓存缓存范围是SqlSession级别的。批量更新或插入之后如果在同一个SqlSession里再执行查询查询结果可能直接命中缓存返回的是更新前的旧数据。我遇到过一个具体案例批量把订单状态从0改成1代码里update完立刻查列表做后续逻辑结果查出来的订单状态还是0。排查了很久最后发现就是一级缓存惹的祸。SIMPLE执行器下同一个SqlSession内先写后查缓存没有失效查询就把旧结果返回了。解决方案很简单批量写操作之后调用sqlSession.clearCache()清掉一级缓存或者把查询放到事务外层去执行。如果你用的是BATCH模式还要注意时机因为BATCH模式下SQL的真正执行发生在flush之后如果flush之前查数据缓存里根本没有最新状态结果更不可控。4.2 二级缓存跨SqlSession的脏读风险二级缓存默认是关闭的但有些项目会为了性能开启尤其是那种读多写少的表。开启后它按Mapper的namespace隔离多个SqlSession共享同一个缓存区域。批量写操作最大的问题在于二级缓存的失效是基于flushCachetrue或查询语句自身配置来的。如果你的批量更新Mapper方法没有正确配置刷新缓存数据库已经变了下一个查询却可能命中二级缓存返回旧值。更麻烦的是BATCH模式下SQL真正执行被延后缓存却没有跟着延迟失效一旦flush时机不对脏读几乎是必然的。我的经验是批处理场景尽量别开二级缓存或者至少对经常批量变动的表明确关闭二级缓存。为了那点查询性能引入一批脏数据风险对数据一致性的伤害远大于收益。4.3 BATCH模式下拿不到准确的update rows这是一个很多人没意识到的坑。在BatchExecutor里每次mapper.update()执行返回的并不是实际影响行数而是一个固定的Integer.MIN_VALUEMyBatis中的BATCH_UPDATE_RETURN_CODE。只有等到flushStatements()之后JDBC的Statement.executeBatch()返回一个int数组才能拿到每个语句的真实影响行数。也就是说如果你在批量更新时写这样的代码int rows userMapper.updateStatus(1, userId); if (rows 0) { // 永远进不来 }基本不会如你所愿。在BATCH模式下rows很可能等于Integer.MIN_VALUE判断逻辑直接短路。这也是为什么很多人说BATCH模式“感觉不对劲”——单条执行时好好的切到BATCH后返回值全乱了。在批量场景里如果必须依赖影响行数来做业务判断建议不要用BATCH执行器改用foreach拼一条大SQL或者切回SIMPLE执行器逐条执行再汇总行数。4.4 乐观锁、逻辑删除等拦截器的连带反应现在的项目里基本离不开MyBatis插件比如MyBatis-Plus的乐观锁插件、逻辑删除插件、自定义审计字段填充插件。这些插件本质是拦截器在Executor执行SQL前后插手。在BATCH模式下拦截器会碰到几个问题批量更新时乐观锁插件需要在update语句后面拼接and version ?并且在执行后判断影响行数。BATCH模式下返回值是Integer.MIN_VALUE插件如果用返回值判断是否更新成功结果会完全错乱逻辑删除插件在批量删除时会改写SQL但如果多个SQL攒在BatchExecutor里还没flush拦截器基于当前参数做的某些处理可能与最终执行的SQL对不上自定义拦截器如果通过invocation.getArgs()[0]取参数在批量操作里拿到的可能是MapperMethod.ParamMap或者List需要特殊处理很多拦截器根本没做这种兼容。我遇到过最典型的一次是分页插件把批量更新里的参数当分页参数解析了导致批量SQL被追加了LIMIT语句数据更新不全。排查好久才发现是插件优先级和BATCH模式冲突。遇到这类问题我的建议是先让批处理链路“无插件”跑通再逐个加回插件定位是哪一个出了问题。4.5 BatchExecutor攒批带来的内存水位BATCH模式并不是零成本的“性能银弹”。所有攒在BatchExecutor里的SQL和参数都要占内存。以批量insert为例每攒一条insert参数对象、预编译的BoundSql、PreparedStatement都需要驻留内存。如果一次攒100万条GC压力会明显上去搞不好OOM先找上门。更隐蔽的问题是BatchExecutor攒批的Statement对象和数据库游标、PreparedStatement句柄强相关一直不flush数据库端和服务端的内存同时升高。我见过一个线上案例批量任务跑到一半老年代内存曲线陡增最后Full GC直接卡死服务。罪魁祸首就是一次事务里攒了几十万条数据不flush。实战里建议每500到1000条做一次flushStatements()也就是把攒批的SQL先发出去但不commit。这样既保留了事务原子性又不会让内存无限膨胀。flush之后再继续下一批。5. 一套可以直接落地的批量事务方案5.1 方案ASpring事务 独立的Batch SqlSessionTemplate先配置一个BATCH型的SqlSessionTemplate不干扰默认的写路径。例如Configuration public class MyBatisBatchConfig { Bean public SqlSessionTemplate batchSqlSessionTemplate( SqlSessionFactory sqlSessionFactory) { return new SqlSessionTemplate(sqlSessionFactory, ExecutorType.BATCH); } }Service里通过Qualifier注入这个批量模板注意业务方法要加Transactional保证整个循环处于一个Spring事务中Service public class OrderService { Autowired Qualifier(batchSqlSessionTemplate) private SqlSessionTemplate batchSqlSessionTemplate; Autowired private OrderMapper orderMapper; Transactional public void batchInsert(ListOrder orderList) { OrderMapper batchMapper batchSqlSessionTemplate.getMapper(OrderMapper.class); for (int i 0; i orderList.size(); i) { batchMapper.insert(orderList.get(i)); if ((i 1) % 500 0) { batchSqlSessionTemplate.flushStatements(); } } // 剩余批次在事务提交时统一flush } }这段代码里flushStatements()每500条调用一次及时把SQL发出去但还没提交。最后一批数据会在Spring事务提交时统一flush。整个方法被Transactional保护中途抛异常前面的批次也会全部回滚。有一个经验之谈这里用的batchSqlSessionTemplate和默认的orderMapper其实来自不同SqlSessionTemplate但因为它们底层的SpringManagedTransaction都从Spring的事务同步器里取连接在同一个事务内连接是同一个所以事务边界不会乱。5.2 方案B编程式SqlSession手动flush/commit当你想完全控制批次大小甚至允许分段提交时可以直接操作原生SqlSession不依赖Spring事务。比如允许前500条成功、后500条失败不影响前者public void batchInsertInSegments(SqlSessionFactory sqlSessionFactory, ListOrder orderList) { SqlSession sqlSession sqlSessionFactory.openSession(ExecutorType.BATCH, false); try { OrderMapper mapper sqlSession.getMapper(OrderMapper.class); for (int i 0; i orderList.size(); i) { mapper.insert(orderList.get(i)); if ((i 1) % 500 0) { sqlSession.flushStatements(); sqlSession.commit(); sqlSession.clearCache(); } } sqlSession.commit(); } catch (Exception e) { sqlSession.rollback(); throw e; } finally { sqlSession.close(); } }这个方法没有被Transactional包裹事务边界完全由自己控制。每500条commit一次批次之间互不影响。要注意的是如果业务要求整体原子性就不能在中间commit而是只调flushStatements()最后统一commit。分段提交是业务上的“允许部分成功”选择不是所有场景都适合。5.3 方案Cforeach大SQL什么时候用如果数据量不大比如几十条到几百条用foreach拼一条SQL可能是最省事的方式。Mapper里这样写insert idbatchInsert insert into t_order (order_no, user_id, amount, status, create_time) values foreach collectionlist itemitem separator, (#{item.orderNo}, #{item.userId}, #{item.amount}, #{item.status}, #{item.createTime}) /foreach /insert这种方式的优点是简单直观不需要改执行器不需要关心BATCH的flush时机一条SQL带多少value都非常清楚。缺点是SQL字符串长度随数据量线性增长数据库的max_allowed_packet、SQL解析器对超大SQL的承受能力都是瓶颈。我的习惯是单批不超过500条如果1000条以上的数据还是老老实实走方案A或方案B。另外foreach拼接大批量SQL时如果字段很多单条SQL会非常长数据库锁的范围和undo日志也会跟着膨胀。不要以为批量就一定快关键要看对数据库的压力是否在合理范围内。5.4 如何验证批量真的生效配置完不能只看“好像变快了”要确认BATCH确实在攒批执行。最直接的方法是打开MyBatis的日志logging: level: com.example.mapper: debug如果你是MyBatis-Spring Boot把Mapper包日志开到debug观察日志输出。SIMPLE模式会看到大量Preparing和Parameters交替出现BATCH模式如果真正生效日志里会出现类似executeBatch()的JDBC批量执行输出或者至少日志节奏明显不同。更严谨一点给JDBC连接加上profileSQLtrue在MySQL通用日志里能看到客户端发来的请求是executeBatch而不是一串单条INSERT。早期我排查Batch为什么不生效就靠这一步定位到SqlSessionTemplate自动提交的问题。验证通过以后再去看接口的耗时有没有真正降下来这才是性能优化的完整闭环。实际项目里做批量导入我现在的惯性选择是这样的几百条的数据直接foreach拼SQL几千条且要求整体成功方案A几千条但允许分段提交方案B任何涉及先批量写后立刻查的场景要么清缓存要么把查询放到事务外面。BATCH模式尽量不要全局开单独写一个SqlSessionTemplate给批量链路用查询保持默认SIMPLE。少踩一个坑线上就少背一个锅。
网站建设高端定制企业官网