在业务系统里,最常见的性能话题之一就是批量插入。特别是当你第一次接到“把Excel里几十万行历史数据迁移进MySQL”这种任务时,按照直觉用MyBatis写一个foreach循环,咔咔一执行,结果跑了十几分钟还没跑完,数据库CPU直接拉满……我前前后后在好几个项目里都遇到过类似的事。今天就把MyBatis插入大量数据时最常用的三种方式——foreach拼接多值插入、SqlSession的BATCH模式、以及原生SQL批处理——放在同一台机器上做个真实对比,说一下各自的原理、实测耗时和踩坑点。这篇文章不搞教科书理论,就是我实际测试下来的一些数据和经验,希望能帮你避开那些我踩过的坑。
1. 到底在比什么:三种插入方式的本质差异
1.1 foreach一条SQL插入多行:最简单,但并非万金油
foreach插入的样子大家应该很熟悉,Mapper XML里通常是这么写的:
<insert id="batchInsert" parameterType="list"> INSERT INTO user_info(name, age, email, create_time) VALUES <foreach collection="list" item="item" separator=","> (#{item.name}, #{item.age}, #{item.email}, #{item.createTime}) </foreach> </insert>这个方法的特点是最终拼出来一条巨大的INSERT语句,一次网络往返发给数据库。它的优势在于:当数据量小(比如几百条、几千条)的时候,减少了和数据库之间的通信次数,看起来比逐条insert快很多。这也是为什么很多新人第一反应就是用这种方式。
但问题也出在“一条巨大的SQL”上。数据量一旦上来,SQL文本本身会变得非常长。MySQL在执行前要解析SQL、做权限校验、优化执行计划,SQL越长解析成本越高;同时这条SQL要作为一个整体数据包发给数据库,受max_allowed_packet、net_buffer_length等参数限制。更不用说很多数据库(比如Oracle的表达式列表、某些数据库驱动)对单条SQL的参数占位符数量、SQL文本长度都有硬性约束。我在一个项目里用foreach一次性拼了8000行数据,直接MySQL报Packet too large,那会儿还一脸懵,后来才发现是这条超长SQL超过了max_allowed_packet。
1.2 SqlSession批量(ExecutorType.BATCH):框架层面的JDBC Batch封装
SqlSession批量模式听起来“高级”,其实原理并不复杂,就是MyBatis对JDBC的PreparedStatement.addBatch()/executeBatch()做了封装。使用方式有两种:
一种是直接开一个BATCH类型的SqlSession:
SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH); try { UserInfoMapper mapper = sqlSession.getMapper(UserInfoMapper.class); for (UserInfo item : list) { mapper.insert(item); } // 注意:这里并没有真正执行,需要手动flush或commit sqlSession.commit(); } finally { sqlSession.close(); }另一种是在Spring环境里用SqlSessionTemplate。但这里有个大坑:普通情况下直接注入SqlSessionTemplate然后调Mapper的insert方法,不一定走BATCH执行器,尤其是当外层有Spring事务的时候,执行器类型往往被固定成了简单模式。我在下面的实操章节会单独说这个坑。
从原理上看,BATCH模式并不是把多条INSERT拼成一条SQL,而是反复调用addBatch,把参数值攒在客户端,最后一次性executeBatch发送给服务端。这样对比foreach,它的优势在于:SQL只预编译一次,网络交互次数大幅减少,也不存在单条SQL文本过长的风险。但要注意,MySQL驱动在默认情况下,executeBatch并不会真的把多条INSERT合并成一条多行INSERT,而是逐条发送SQL,只是省了网络往返和预编译开销。想让驱动在底层自动合并成多值SQL,需要开启一个连接参数,后面实测部分会专门对比。
1.3 原生SQL批处理:不经过MyBatis映射,拿到Connection直接干
这里的“SQL插入”我理解成:绕开MyBatis的Mapper机制,直接从SqlSession获取Connection,然后使用标准JDBC的PreparedStatement.addBatch()/executeBatch()。它和SqlSession BATCH模式核心原理一致,只是更底层、更不“优雅”。
SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH); Connection conn = sqlSession.getConnection(); PreparedStatement ps = conn.prepareStatement( "INSERT INTO user_info(name, age, email, create_time) VALUES (?, ?, ?, ?)"); conn.setAutoCommit(false); for (UserInfo item : list) { ps.setString(1, item.getName()); ps.setInt(2, item.getAge()); ps.setString(3, item.getEmail()); ps.setDate(4, item.getCreateTime()); ps.addBatch(); if (batchCount % 500 == 0) { ps.executeBatch(); } } ps.executeBatch(); conn.commit();这种方式的最大优势是可控性极强:batchSize你说了算、事务边界你说了算、连接参数你说了算。缺点是需要自己处理一堆JDBC模板代码,并且失去MyBatis的参数映射、日志、动态SQL能力。一句话总结:SqlSession批量是“买到了JDBC Batch 80%的能力”,原生SQL批处理则是“把剩下20%也自己拿捏住”。
2. 环境准备与测试方案设计
2.1 测试环境、表结构和数据样本
为了不搞成玄学,我把测试环境和变量尽量固定。
- 数据库:MySQL 8.0.33,InnoDB引擎
- 驱动:mysql-connector-java 8.0.33
- 操作系统:本地开发机,SSD硬盘,16G内存
- 连接池:HikariCP(测试中尽量控制连接数)
- 表结构:一个简单的user_info表,几个常用字段
CREATE TABLE user_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, age INT NOT NULL, email VARCHAR(128), create_time DATETIME NOT NULL );测试数据使用程序统一生成,保证三种方式插入的数据内容完全一致,且不包含任何索引热点,避免缓存和页分裂带来的偶然性。每次测试前重启数据库并清空表,目的是让buffer pool保持一个相对干净的起始状态。
2.2 三种方案的代码原型
四种实际压测的对象分别是:
- foreach多值插入,一个Mapper方法,传入list
- SqlSession BATCH模式,多次insert单条,最后commit
- SqlSession BATCH模式 + MySQL连接参数rewriteBatchedStatements=true
- 原生JDBC addBatch + rewriteBatchedStatements=true
这里额外把rewriteBatchedStatements单独拎出来,是很多朋友容易忽略的点。这个参数的意思是让MySQL驱动尝试把连续的INSERT batch重写成一条多行VALUES的INSERT,比如把10条单行INSERT自动拢成一条10行的INSERT发送给服务端。它和MyBatis的foreach是殊途同归,但时机不同:foreach是在业务代码里拼好大SQL,rewrite是JDBC驱动在批处理时自动优化。
测试中所有方式都关闭MyBatis日志和SQL打印,避免输出IO干扰结果。每一轮都开启事务,部分场景特意测试了“不分批提交”和“每500条提交一次”两种差异。
2.3 测试过程中必须盯住的细节
批量插入性能测试最怕“拿着不严谨的结论当真理”。有几点我在压测时一直盯着看:
第一,事务粒度。如果3万条数据在一个事务里提交,和每500条提交一次,结果完全不同。大事务虽然少了一些COMMIT开销,但redo log、binlog、锁等待、undo都会叠加,尤其是binlog写入量很大时,性能会出现明显拐点。所以我的测试里,默认是每个数据量批次一个事务,另外单独测了SqlSession模式下分批提交的效果。
第二,连接参数是否一致。有的方式开着rewriteBatchedStatements,有的没有开,这本质上不是同一个对比口径。后面我会把参数影响单独列出来,避免混淆。
第三,主键生成策略。如果是AUTO_INCREMENT,批量插入时MySQL的innodb_autoinc_lock_mode参数会影响自增锁粒度。相关连的是,foreach多值插入和JDBC batch在获取自增ID时返回值处理方式也不同,这个细节在后期做日志同步或关联插入时非常容易踩坑。
3. 实测数据与结果解读
3.1 各种数据量下我的真实耗时对比
以下是我在同一台机器上拿到的数据,单位是毫秒。数据量较小的时候差距不明显,但数据量超过1万以后,差异会拉开得比较夸张。
| 数据量 | foreach多值 | SqlSession BATCH(默认) | SqlSession BATCH(rewrite) | 原生JDBC(rewrite) |
|---|---|---|---|---|
| 1000条 | 410ms | 180ms | 95ms | 80ms |
| 5000条 | 2100ms | 650ms | 330ms | 290ms |
| 10000条 | 5800ms(接近包上限) | 1250ms | 620ms | 560ms |
| 50000条 | 失败(Packet too large) | 5800ms | 2800ms | 2600ms |
| 100000条 | 失败 | 11900ms | 5200ms | 4900ms |
需要说明,这不是一个标准benchmark,换成不同的表结构、字段数量、索引数量、数据库配置,绝对数会变,但相对趋势非常稳定:数据量越大,foreach越吃亏;BATCH模式默认情况下比想象中更快,但开启rewrite参数后还能再快接近一倍;原生JDBC比SqlSession BATCH仍有小幅领先,但差距很小。
看到这个数据,你可能会觉得“才快了一倍多,何必费劲写原生JDBC?”别着急,这只是其中一部分。50000条数据时foreach直接失败,这种“不可用”比“慢”更致命。实际很多项目的批量导入任务是20万、50万行起步,如果只会用foreach,基本就卡死在一开始。
3.2 foreach为什么从“还不错”变成“拉胯”
先看小批量:1000条左右,foreach只用了410ms,确实不差。因为它只发了一条SQL,数据库一次解析执行,省掉了多次网络RTT。问题从5000条开始暴露,SQL文本越来越长,MySQL需要解析更大的语句;同时一次插入5000行会占用更多行锁、插入缓存、binlog事件,事务内部的其他开销全部被放大。
从网络传输角度看,foreach的本质是把大量数据塞进“一个数据包”里,这个包的体积可能高达数MB甚至十几MB。MySQL端处理大事务时,binlog的写入、innodb的redo日志、二级索引的更新都会在COMMIT时集中爆发,速度自然上不去。更严重的是,如果一行数据里有TEXT字段、大字段,包体积很快突破max_allowed_packet,直接报错。我在5万条测试时就是栽在这里。
还有一点容易忽略:foreach产生的SQL文本如果太长,MySQL的查询缓存、语法解析、预处理缓存统统帮不上忙。而JDBC Batch方式因为SQL固定、参数分批,数据库可以有更好的复用空间。
3.3 开启rewriteBatchedStatements后的“意外惊喜”
SqlSession BATCH默认情况下,我测试1万条用了1250ms,看起来已经比foreach强很多。但只要在JDBC连接串后面加上rewriteBatchedStatements=true,1万条降到了620ms,5万条从5800ms降到了2800ms,几乎是一倍的提升。
原因很简单:默认的JDBC batch虽然减少了几十次网络往返,但每一条INSERT仍然是以单行语句的形态发给数据库的。加上rewrite参数后,驱动会把addBatch攒出来的一组INSERT语句重写成一条多行VALUES语句再发出去,相当于在客户端帮我们做了“foreach该做的拼接”,同时又不依赖业务代码去处理SQL长度问题——驱动会自己分包。
这个参数对MySQL尤其重要。我的建议是:只要你用MyBatis的ExecutorType.BATCH或者原生JDBC批量插入,就一定要开启它。它不是银弹,比如批量UPDATE的时候重写逻辑不一定适用,但对INSERT场景收益非常直接。需要注意的是,开启后不能再依赖Statement.getGeneratedKeys()以传统方式获取所有自增主键,因为驱动把多条INSERT合并后,返回自增ID的行为会变得很微妙。
3.4 SqlSession批量与原生JDBC差在哪
从原理上说,SqlSession的BATCH模式底层就是调用JDBC的addBatch/executeBatch,所以理论上应该接近原生JDBC。实测下来,1万条时两者差距只有60ms左右,5万条时差了200ms左右。这点差距来源主要有三个:
- MyBatis需要处理参数映射、对象反射、插件拦截,即使你用的Mapper方法只是简单的insert(user),它内部也有不少逻辑。
- SqlSession的BATCH执行器在flush时会把所有statement都执行一遍,如果你在同一个SqlSession里除了insert还干过别的SQL操作,flush范围会变大。
- 原生JDBC可以完全控制batchSize和executeBatch的时机,而MyBatis的BATCH模式下,如果你只是闷头反复insert而不主动flush,数据会一直堆在驱动侧,占用内存,真正executeBatch时瞬间压力更大。
所以我个人结论是:那种十万、二十万条级别的导入,如果不想引入太多原生JDBC模板代码,SqlSession BATCH完全够用;但如果地上百万级,需要精细控制每一个批次的边界,建议直接上原生JDBC或者更专业的工具。
4. 每种方式的优化细节与避坑指南
4.1 foreach插入的正确用法:分片和参数估算
既然foreach拼大SQL有包大小风险,是不是就完全不用?也不是。在实际业务里,比如导出报表、数据回填这种一次性操作几百条的场景,foreach仍然是最省事的方案。关键是控制每一批的量,并且提前估算SQL体积。
我通常的做法是:先算单条记录SQL文本的大概字节数,再乘以条数,再加上INSERT语句头和尾,整体小于max_allowed_packet的1/2才敢发出去。比如一行记录文本大约160字节,8000行就是1.28MB,如果max_allowed_packet是4MB,理论可以,但加上VARCHAR中不可见内容、字符集多字节转换,还是容易超。稳妥一点,每批控制在2000到3000行。
同时需要注意Maven依赖里MyBatis的foreach拼接,如果list是空集合会直接报SQL语法错误,所以调用前一定要判空。还有一个容易踩的坑:Oracle数据库的单条SQL中IN列表不能超过1000,虽然和VALUES插入不完全一样,但如果你在foreach里混用了其他带IN条件的大参数,很容易触发ORA-01795。这类和“某一条SQL有上限”有关的问题,本质上都是“别贪大,分小批”。
4.2 SqlSession批量模式:真正搞懂flush和事务
用SqlSession批量插入时,我对团队里新同学最常说的一句话是:你以为调用了insert,其实SQL还没发出去。BATCH模式下,insert方法仅仅是addBatch,必须等commit或者手动调用flushStatements,数据库才能收到请求。如果你在同一个事务里先批量插入了5000条,紧接着又去查这些数据,你会发现查不到——因为它们还蹲在客户端缓冲区里。
代码上要特别注意,手动flushStatements:
sqlSession.flushStatements();这个操作会把当前执行器里缓存的statement全部通过executeBatch提交,但事务还没有提交,仍然可以回滚。commit本身也会触发flush,所以如果你不需要中途查询,直接commit即可。
另一个大坑是Spring环境下SqlSessionTemplate的执行器选择。默认情况下,Spring事务管理器会绑定SqlSession到当前线程,如果你在Service方法加@Transactional后再getMapper,然后循环insert,即使你声明要用ExecutorType.BATCH,实际上用的也可能是简单执行器。因为这个执行器是在事务开启时就已经确定下来的。正确的做法是单独获取批量类型的SqlSession,并且尽量不让Spring事务管理器直接管理它,或者把批量操作单独拆到专用方法里。
4.3 原生JDBC批处理中容易被忽视的连接设置
原生JDBC看起来自由,但自由往往意味着“坑得自己填”。我见过很多人写原生批量插入时,忘记关闭自动提交,导致每addBatch一条就COMMIT一次,性能比逐条插入还差。还有的人明明设置了rewriteBatchedStatements=true,但因为连接被HikariCP复用,而连接串里的参数写错了位置,参数根本没有生效。
要确认参数是否生效,可以直接在代码里打印连接URL,或者查MySQL的performance_schema。更简单的方法:开启MySQL general_log,观察驱动发送给数据库的SQL是多行VALUES还是单行VALUES,一目了然。连接串参数建议统一放在HikariCP/数据源的jdbcUrl里,而不是写在某个Mapper方法里,因为连接池拿到的连接不一定每次都带上你要的设置。
还有一点,原生JDBC批处理时batchSize的选择也很有讲究。太小了,比如10条,网络往返增加;太大了,比如5万条一次executeBatch,客户端内存先扛不住,数据库端也会因为一个大事务产生锁竞争。我实测下来,500到2000条一批是比较稳定的区间。
4.4 MySQL连接参数rewriteBatchedStatements的正确姿势
既然前面反复提到这个参数,这里单独把使用细节说透。连接串示例:
jdbc:mysql://localhost:3306/test?useSSL=false&rewriteBatchedStatements=true&useServerPrepStmts=true一个经常被提到但我不太建议随意搭配的参数是useServerPrepStmts。你可能会看到网上说“开启服务端预编译配合批量更高效”,但在MySQL 8的驱动下,服务端预编译拿不到准确的自增ID,还需要额外开useLocalSessionState等参数配合,链路过长很容易出问题。我的经验是:单纯做批量INSERT,rewriteBatchedStatements=true带来的收益最直接,其他参数按需加,不要无脑堆。
这个参数对批量UPDATE的优化效果不稳定,尤其是带CASE WHEN的批量更新,不同版本驱动重写行为不一致。所以我建议只在明确做INSERT批量的数据源连接上开启它,避免把全局数据源参数改得面目全非。另外,如果你的SQL里包含ON DUPLICATE KEY UPDATE,MySQL驱动也可能不会重写成多行VALUES语句,这需要实测确认。
5. 实际工程中怎么选:从1千到100万的数据量决策
5.1 不同数据量级的推荐方案
我会按数据量级给一个比较实用的选择参考,不是唯一标准,但很适合大多数后台管理系统和数据导入场景。
| 数据量级 | 推荐方式 | 理由 |
|---|---|---|
| 几百条 | foreach多值插入 | 代码简洁,一次提交,性能足够 |
| 几千至几万条 | SqlSession BATCH + rewriteBatchedStatements | 不需要写原生JDBC,性能比foreach好很多 |
| 十万级 | 原生JDBC分批addBatch,每批500-1000,分批提交 | 可以精细控制事务和内存 |
| 百万级以上 | load data / 并行多线程 + 分批事务 | 单线程JDBC已经无法满足,要换思路 |
你可能注意到,我没有推荐“只用SqlSession BATCH打死所有场景”。原因是SqlSession BATCH虽然简洁,但在大事务、长事务下,连接占用时间太长,批量插入期间其他数据库操作会被拖住。而且如果中途发生异常,回滚整个大事务的成本非常高,极端情况下数据库会把连接直接断开。真实项目中,导数据前最好先切分任务,每批提交后记录断点,这样失败以后能增量续跑,而不是从头再来。
5.2 别把“批量插入”和“多线程插入”混为一谈
到了几十万条数据,很多人第一反应是开个线程池,十个线程并发插入。这个思路没错,但有一个非常容易翻车的前提:数据库写入瓶颈未必在CPU,很多时候在锁、binlog、刷盘。开十个线程同时往同一个InnoDB表里硬怼INSERT,可能引发更严重的锁竞争和磁盘IO抖动,性能反而下降。
我的经验是,如果是冷表迁移,可以先用单线程JDBC批量摸一下底,然后逐步上并发,每增加两个线程看一次数据库的Threads_running、Innodb_row_lock_waits和磁盘IO延时。同时要控制每个线程打开的是独立事务,批次内部不再套大事务。真正速度瓶颈到刷盘时,靠并发可能是负优化。比较合适的做法是:主线程负责读数据、切分批次,工作线程负责执行批量插入,最后通过断点表记录执行进度。
5.3 MyBatis Plus的saveBatch是什么来头
很多项目用MyBatis Plus,天然有saveBatch方法可用。它的内部其实也是基于SqlSession的BatchExecutor实现的,不是真的“一条SQL插入几千行”。所以之前提到的参数、事务、flush问题,它一样会遇到。如果你在Spring Boot项目里直接调用IService.saveBatch(list),建议同样给数据源开启rewriteBatchedStatements=true,并且注意传入的list长度不要太大。MyBatis Plus默认的batchSize是1000,这个值通常是合理的,不需要刻意改大。
不过,MyBatis Plus的saveBatch有一个隐藏坑:如果你的实体里含有自动填充字段(比如create_time由MetaObjectHandler自动填充),批量模式下每次填充还是逐条走的,这个额外开销会在数据量大时被放大。反正我用下来感觉,saveBatch就是把SqlSession BATCH包装得更友好,并没有魔法。
6. 常见问题与排查技巧实录
6.1 MySQL报错Packet too large
这是foreach插入最典型的报错,一般长这样:Packet for query is too large (5,146,573 > 4,194,304). You can change this value on the server by setting the max_allowed_packet variable.
排查方法很简单,看报错里的数字,如果SQL包体积超过了max_allowed_packet默认值,就说明单条SQL太长了。你可以临时调大max_allowed_packet:
SET GLOBAL max_allowed_packet = 64 * 1024 * 1024;但治本的方法还是拆分foreach批次,或者改用BATCH模式。我建议把max_allowed_packet调成64M或128M的同时,在代码层严格控制每一批插入的行数,不要指望数据库参数替你兜底。
6.2 SqlSession批量模式下插入后查不到数据
这个前面提过,是因为BATCH执行器未触发flush。常见的业务场景是“先批量插入,再拿这些插入后的自增ID去关联子表”。如果你用SqlSession BATCH逐条调用insert而一直不flush,那么insert方法返回的主键其实可能是空的,或者没有按预期填充到实体里。解决方式是在需要查数据之前手动sqlSession.flushStatements(),或者干脆单独开一个非批量SqlSession做关联查询。
需要特别说明的是,MyBatis返回自增ID是以实体属性回填的方式实现的,批量模式下,JDBC返回的自增ID能否正确映射到每个实体,取决于驱动和批量SQL形态。开启rewrite后,这个问题会更突出,所以如果你有“插入后必须拿到每个主键”的需求,请在功能设计上避免单批次超大插入,必要时改为单条插入加缓存。
6.3 日志显示批量SQL没有生效,还是几十条单行INSERT
如果你发现自己的批量方式没有生效,请先检查三处:
- 连接串是否真的加了rewriteBatchedStatements=true,且没有写错参数名。
- 代码里是否真的在循环调用addBatch,而不是每次addBatch后立刻executeBatch。
- MyBatis的BatchExecutor是否被Spring事务覆盖,执行器类型是否真的为BATCH。
我遇到过最典型的情况,是同事在Spring Service里加了@Transactional后又使用了SqlSessionTemplate,结果日志里看不出批量行为。后来我让他改成手动获取SqlSession批量执行并自己控制事务,问题才消失。如果只是想确认SQL形态,最简单的方法是打开MySQL的general_log,直接看驱动发来的SQL语句是单行INSERT还是多行VALUES。
6.4 大批量插入导致内存溢出或GC抖动
当你一次性传入10万条数据给foreach时,即便SQL包大小没超过数据库限制,客户端内存也已经受罪。MyBatis的动态SQL拼接、参数映射都会在内存里创建大量对象,GC压力非常大。同类问题在SqlSession BATCH模式下也存在,因为addBatch的参数值会一直留在驱动缓冲区里,直到executeBatch或commit才释放。
我通常建议:数据来源如果是Excel或外部文件,不要一次性load到内存组装成List再批量插入,而是用流式读取,每读够一个批次就处理一批。如果在Service接口层面是调用方传了超大List,那你需要在入口处做分片,比如ListUtils.partition(list, 500),分批执行。
7. 最后分享一点我的实际体会
批量插入这事,真的不是“会用一种方式就够”的,不同数据量、不同数据库、不同事务要求,选型逻辑完全不一样。我个人现在的一个习惯是:凡是新项目涉及批量写入,我会先写一个十几行的压测对比,把foreach、SqlSession BATCH、原生JDBC在测试环境各自跑一遍,耗时和日志都留档。因为这个结论受MySQL配置、驱动版本、表结构影响太大,网上任何人的数据都只能当参考,不能当真理。
另外还有一个小建议,批量插入时不要在Mapper方法里做过于复杂的动态SQL判断,也不要在循环里调用其他查询方法。BATCH模式下混用多种Statement会让flush行为变得非常复杂,稍不注意就是隐性Bug。如果实在要在批量场景里做复杂业务处理,建议拆成“预处理阶段”和“批量入库阶段”,两个阶段各干各的,代码清楚,性能也稳。批量插入的本质不是把SQL写得多花哨,而是想清楚哪些开销能省、哪些交互能合并、哪些坑必须绕开。做到这三条,性能基本就赢了一大半。