我刚开始用MyBatis做数据订正的时候栽过一个很实在的跟头直接用SqlSession循环insert几万条数据跑完了去看库数据确实在心里挺美。结果过了一会儿发现其中一段逻辑在中途抛了异常那批数据只该留着半截的结果全在库里前面已经写进去的根本没有回滚。再后来我想用ExecutorType.BATCH批量提速十万条数据又是差点一条没进去因为循环中间夹了一条select整个批被提前刷掉了更难受的是你还看不出来哪里断了。这两个坑其实是同一类问题在 MyBatis 里用SqlSession管事务、做批量很多“默认”行为和直觉是完全相反的。事务不会因为你调了接口就自动包一层批量也不会因为你写了个for循环就自动攒批。标题那句话说得直白一点就是SqlSession 下的事务和批量不按正确姿势写就默认不生效。这篇文章我就围绕这两个问题展开把SqlSession和事务、执行器之间的关系讲透再给出可直接抄作业的批处理事务代码最后把那些特别容易踩的“看起来生效了、实际没生效”的坑挨个说一下。适合用原生 MyBatis API 做数据同步、批量入库或者想搞懂 Spring 事务底下到底是怎么回事的人。1. 先搞清楚两个“默认不生效”是机制设计不是配置遗漏1.1 SqlSession、Executor、JDBC 事务到底谁在干活很多人把SqlSession当作一个“数据库连接工具”其实它是个门面。真正干活的是内部的Executor而数据库层面的提交、回滚又落在 JDBC 的Connection上。这三层职责拎清楚后面所有问题都顺了。SqlSession对外暴露insert、update、delete、selectOne、selectList、commit、rollback等方法负责把调用转发给 Executor。ExecutorMyBatis 执行器的抽象有SIMPLE、REUSE、BATCH三种实现分别对应普通执行、复用 Statement、批量执行。TransactionMyBatis 自己封装的 JDBC 事务控制持有Connectioncommit和rollback最终都是调connection.commit()和connection.rollback()。SqlSessionFactory.openSession()时你可以指定是否自动提交、事务隔离级别以及使用哪种ExecutorType。这三个参数缺一个没注意后面的行为就歪了。1.2 事务“默认不生效”在原生 API 场景下是什么意思用SqlSessionFactory手工拿SqlSession的时候默认openSession()其实就是openSession(false)也就是autoCommit false。在原生 JDBC 里autoCommit false意味着必须手动commit才会真正提交事务。放到 MyBatis 里也一样。但很多人写代码的时候没有 commit也没有 rollback跑完发现数据居然写进库里了于是以为“MyBatis 默认自动提交”。这通常发生在两种情况下你用的是openSession(true)明确开了自动提交你用的连接池或者某些框架在SqlSession.close()时帮你把连接状态重置了比如连接池在归还连接时把autoCommit改回 true或者某些拦截器顺手提交了。更常见的是你在 Spring 工程里直接用Mapper接口没加TransactionalMyBatis-Spring 的SqlSessionTemplate会在每次 Mapper 的方法调用后自动提交。这个行为造成了一个巨大的错觉MyBatis 好像默认帮你做了事务管理。实际上它提交的是“单个 SQL”这个级别的事务一旦一个循环里前面几条写进去了、后面某一条报错前面那些已经提交的数据是无论如何都回不掉的。原生场景下的“默认不生效”说白了就是事务边界没有被你显式定义MyBatis 不会替你决定从哪一行开始、到哪一行结束。1.3 批量“默认不生效”的真正含义还是原生 API 场景。默认情况下openSession()得到的 Executor 是SIMPLE它每条 SQL 都会创建一个新的Statement、执行一次、关闭。也就是说你在一个for循环里调用mapper.insert(...)一万次底层就是一万次独立的 JDBC 执行和批量没有任何关系。真正的 JDBC 批量应该是Connection conn ...; PreparedStatement ps conn.prepareStatement(insert into user(name) values(?)); for (User user : users) { ps.setString(1, user.getName()); ps.addBatch(); } ps.executeBatch(); conn.commit();addBatch()把参数攒在内存里最后executeBatch()一次性发送给数据库。MyBatis 的ExecutorType.BATCH就是把这个能力封装起来了。问题在于如果你不显式指定它默认根本不开。所以“批量默认不生效”这句话有两层一层是 Executor 默认不是 BATCH另一层是即使你开了 BATCH只要用法不对批也起不来反而可能变成“慢速逐条执行”。2. 正确的打开方式一个真实可用的 SqlSession 批量事务示例2.1 设定目标十万条数据不过量且可回滚我拿一个常见的“导入用户列表”场景举例。假设要从 Excel 或者某个文件里读出 10 万条用户数据插入t_user表要求是不能用SIMPLE执行器逐条 insert否则太慢不能把所有 10 万条攒成一个超级大批次否则内存和数据库报文都可能吃不住控制在“同一批内失败整体回滚已提交的批次不回滚”的粒度。前 10 万条数据无论怎么插失败后都不可能做到整体“全有或全无”因为每批 commit 之后该批事务就已经结束了。所以做这类批量任务要明确区分“批量分组”和“事务边界”一批就是一个独立事务这也是绝大多数批量导入任务的常规设计。2.2 完整代码示例public void batchInsertUsers(ListUser userList) { // 1. 手动打开 SqlSession指定 BATCH 执行器并且关闭自动提交 SqlSession session sqlSessionFactory.openSession(ExecutorType.BATCH, false); int batchSize 500; try { UserMapper mapper session.getMapper(UserMapper.class); for (int i 0; i userList.size(); i) { mapper.insert(userList.get(i)); // 2. 每 500 条手动刷一次批 if (i 0 (i 1) % batchSize 0) { session.flushStatements(); // 3. 刷批之后手动提交事务 session.commit(); } } // 4. 不足一批的剩余部分最后统一刷出并提交 session.flushStatements(); session.commit(); } catch (Exception e) { session.rollback(); throw new RuntimeException(批量插入失败本批次已回滚, e); } finally { session.close(); } }这段代码里的顺序是有讲究的先flushStatements()把 BATCH 执行器里攒的 Statement 真正executeBatch再commit()提交事务。如果你只 commit 不 flushBatchExecutor 的 commit 内部也会触发 flush所以你只写 commit 其实也能刷批。但显式 flush 有两个好处一是代码读起来意图明确二是可以精确控制在某个节点刷新比如为了保证每批 500 条的内存释放时机。2.3 为什么 500 条一个批次内存与事务粒度的平衡批大小这个值并没有放之四海皆准的标准。500 是我在 MySQL 下用得比较多的一个折中值下面讲讲它的依据。数据库方面单条 INSERT 语句的报文大小受max_allowed_packet限制。默认值一般是 4M 或 64M如果一条批量 SQL 拼出来的包太大会直接报PacketTooBigException或者把数据库连接干崩。500 条在普通行宽下基本不会触碰这个限制。客户端方面addBatch攒的是 PreparedStatement 的参数数据量越大JVM 内存和 GC 压力越大。对 10 万条数据来说500 一批会分成 200 个事务既不会让单次事务太长导致锁和 undo 日志膨胀也不会频繁 commit 导致性能明显下降。业务方面如果某批失败最大损失就是 500 条数据的回滚这个量级对业务影响可控。如果你插入的是超宽表几十个字段含大文本或者单条数据量特别大批大小建议下调到 100~200。如果每行数据非常小也可以适当上调。核心思路是批大小不是拍脑袋定的而是由数据库报文限制、内存、事务粒度共同决定的。3. 源码角度什么操作会打断刚搭好的 BATCH3.1 BatchExecutor 的累积与刷新机制BATCH执行器之所以能做到批量是因为它的doUpdate方法里有一套“复用 Statement 累加参数”的逻辑。大意如下如果当前执行的 SQL 和 MappedStatement 和上一次完全一致就把参数继续addBatch到同一个语句上如果换了 SQL 或者换了 MappedStatement就先把旧的 Statement 刷出去再为新的语句创建 Statement 继续攒。这里面最核心的触发点是只要语句发生变化前面攒的批就会被刷掉。所以 BatchExecutor 不是无脑把所有 SQL 攒到最后而是一旦遇到“不一样的语句”立刻会让前面的批生效。这也就是为什么你在批量插入里混入别的 Mapper 的 insert或者混入一条 update批处理的性能会立刻崩掉的原因——它每切换一次语句就执行一次executeBatch实际退化成近乎逐条执行。3.2 批处理会中断的常见“暗雷”第一个暗雷是 select。BatchExecutor 的doQuery里有一个非常容易忽略的细节在真正执行查询之前它会先调用flushStatements()把当前攒着的批全部刷出去。原因是 JDBC 的同一个 Statement 上不能既挂着一堆 addBatch 又去执行查询。所以如果你的批量循环里夹了一条 select那么每次执行到 select 之前批都会被强制 flush。你以为是批量插入实际变成“插入若干条、查一次、插入若干条、查一次”性能甚至比 SIMPLE 执行器还差。第二个暗雷是切换 Mapper 或者切换 SQL。比如你循环里先userMapper.insert再orderMapper.insert那么每处理一段就会触发一次批次切换。不要小看这个行为我在一次真实案例里看到过两套不同的 insert 交替循环 1 万次耗时比正常的SIMPLE还慢原因就在这里。第三个暗雷是连接和事务的运行时长。BatchExecutor 持有的是同一个 PreparedStatement如果一批攒得过大或者数据库端因为有事务长时间不提交导致 undo 膨胀、行锁堆积拖垮的不只是当前任务还可能把同库的其他业务查询拖死。这也是为什么我不建议“攒到结束再一次 flush”的原因而是每 500 条 flush commit。3.3 JDBC 层面还有一个隐藏变量rewriteBatchedStatements这个配置很多 MyBatis 使用者完全没听过但它对 MySQL 批量插入影响极大。MySQL JDBC 驱动默认情况下执行executeBatch时会把每条记录当成一条独立的 INSERT 语句发送只是减少了客户端到服务端的往返次数。真正想要把多条 INSERT 合并成一条多 VALUES 的语句需要在 JDBC URL 上加上rewriteBatchedStatementstrue举个例子插入 500 条用户数据不加这个参数MySQL 会收到 500 条INSERT INTO user(name) VALUES (?)只是协议上打包在一起发送服务端仍然逐条解析执行加了之后驱动会主动把 SQL 改写成INSERT INTO user(name) VALUES (?),(?),(?)...一条大语句带 500 组参数服务端一次解析效率差距非常明显。所以我的建议是在批量场景下JDBC 连接串后面都加上这个参数。不过要注意它也不是万能的。某些特殊 SQL比如带ON DUPLICATE KEY UPDATE或者INSERT ... SELECT的语句驱动可能不会做改写甚至可能失效实际生效与否可以用 MySQL 的通用日志去验证。4. 结合 Spring 使用为什么你会发现连事务都不生效了4.1 MyBatis-Spring 的“每方法一提交”机制如果项目里用了mybatis-spring默认情况下你从 Spring 容器拿到的 Mapper 是被SqlSessionTemplate代理过的。这个类的核心逻辑是每次调用 Mapper 方法时检查当前线程是否已经有 Spring 管理的事务。有就复用同一个 SqlSession没有就新建一个 SqlSession执行完方法后立刻 commit 并关闭。这个机制带来一个非常迷惑人的现象你在 Mapper 的一个方法里比如循环调用 100 次insert执行完后数据全在里你误以为事务自动提交了、自动管理了。实际上这 100 次 insert 是在同一 SqlSession 上执行的但只要没有Transactional每次insert本身不会提交而是在这个 Mapper 方法结束后SqlSessionTemplate 的代理统一 commit 一次。等一下这里说得更准确一点这个“统一”是针对当前任何一次从SqlSessionUtils.getSqlSession获取到 SqlSession 的方法调用周期。如果 100 次 insert 都是同一个循环里的同一个方法内部调用同一个 mapper 方法那确实只会在外层方法结束后提交一次。但你如果是在 Service 层写一个方法里面循环调用userMapper.insert每次调用都会走一次代理每次代理调用结束时没有事务的话就会提交一次。绕开了关键结论是没有 Spring 事务时每次 Mapper 方法调用都是独立提交的之前已提交的数据在后续异常时无法回滚。4.2 Transactional 失效的几类经典场景加了Transactional就一定能回滚吗当然不是下面这几个场景我至少都见过实际案例。同一类内部调用。ClassA的methodA调用了同类里的methodBmethodB上标了Transactional这个事务不会生效。因为 Spring 事务靠代理实现同类内部this.xxx()走的是原始对象不是代理对象。rollbackFor没配。默认情况下Spring 只对RuntimeException和Error回滚。如果你在事务方法里抛了一个自定义的受检异常数据照样提交事务不会回滚。数据库引擎不支持事务。比如 MySQL 的 MyISAM 引擎commit和rollback都是空操作只有改成 InnoDB 才会真正参与事务。多线程调用。事务是绑定在当前线程上的你在事务方法里 new Thread 去执行写操作子线程里没有继承原事务异常了也不会让主线程的事务回滚。事务方法被try-catch吞掉了异常。你捕获了异常并且没往外抛Spring 根本感知不到异常自然也不会回滚。解决思路不复杂确保事务注解加在 public 方法上、通过代理调用、明确rollbackFor、数据库用支持事务的引擎、不要在事务方法里自己消化异常。另外如果排查时拿不准可以把 Spring 的事务日志开起来看TransactionInterceptor是否真的给方法开启了事务。4.3 Spring 项目里真正想用 BATCH 怎么办Spring 项目里的 Mapper 默认是走SqlSessionTemplate如果你希望整个应用都以 BATCH 模式运行可以在配置SqlSessionFactoryBean时把executorType设置成BATCHbean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property nameconfigLocation valueclasspath:mybatis-config.xml/ property nametypeAliasesPackage valuecom.example.entity/ /bean bean idsqlSessionTemplate classorg.mybatis.spring.SqlSessionTemplate constructor-arg index0 refsqlSessionFactory/ constructor-arg index1 valueBATCH/ /bean但需要注意全局BATCH会带来副作用。比如查询操作在 BATCH 模式下也遵循 BatchExecutor 的刷新规则也就是说只要你执行过写操作没 flush再执行查询会先把写操作刷新出去。这在实际业务里会制造很多隐性问题。所以我更推荐的做法是只在真正需要批量的场景里单独拿一个 BATCH 型的 SqlSession 用不要全局开。5. 高频问题与排查速查表我把实际开发里经常被问到的问题整理成了一张表方便你直接对照排查。现象根因解决方案数据插进去了但异常时无法回滚没有显式事务边界MyBatis-Spring 默认每次 Mapper 调用后提交Service 方法加Transactional或用原生 SqlSession 手动 commit/rollback循环 insert 慢得离谱默认 SIMPLE 执行器每条 SQL 独立执行openSession(ExecutorType.BATCH, false)并配合rewriteBatchedStatementstrue开了 BATCH 还是慢循环里混了 select或切换了 Mapper/SQL 导致批次频繁刷新把查询语句移出循环确保同一批处理中只使用同一条 insert 语句批量插入报PacketTooBigException批大小设置过大超过 MySQL 的max_allowed_packet减小批大小或调大max_allowed_packet推荐前者事务注解加了但没效果同类内部调用、受检异常未设置rollbackFor、数据库引擎不支持事务等按 4.2 中的场景逐项排查查询后发现批量数据少了一部分执行 select 时 BatchExecutor 会先 flush但前面可能已经部分提交或有异常检查事务边界和 flush 时机确认各批次提交点批量插入入库后实际没有合并成多 VALUES 语句MySQL 驱动默认不开启 SQL 改写JDBC URL 加rewriteBatchedStatementstrue并验证还有一个很实用的排查技巧怎么确认你的“批量”真的批出去了。最简单的是看日志或加监控。MySQL 可以开general_log执行完批量任务后去日志里看实际下发的 SQL 是 N 条单行 INSERT还是一条多 VALUES 的 INSERT。如果是 N 条单行 INSERT说明rewriteBatchedStatements没生效或者你的语句不支持改写。如果是 MyBatis 的 SQL 日志你也能看到Preparing: insert into ...出现的次数如果每次循环都重新打印 Preparing那基本可以断言批没攒起来。6. 关于批量事务我在实际项目里的一些体感到这里技术点基本都覆盖了。最后想说的是批量事务“默认不生效”并不完全是一件坏事。MyBatis 把事务和批量的开关都设计成“显式开启”反而是为了避免程序员误用一个不可控的隐式机制。如果你愿意把事务边界、批大小、Executor 类型这些变量都抓在自己手里批量任务是可以做得又快又稳的。我自己的习惯是但凡涉及超过几千条的写入一律不依赖 Spring 默认的 Mapper 代理行为而是单独开一个 BATCH 类型的 SqlSession手工控制批量大小和提交点。虽然代码看起来比直接注入UserMapper复杂一点但至少行为是确定的、可预期的。以后你如果再遇到“数据明明写进去了、异常却回滚不了”或者“批量没提速反而更慢”这类问题回头看一下自己是不是也踩了“默认不生效”的坑大概率就能当场定位了。