干后端的人,迟早都会碰上这么一档子事:一张几十万行的表要批量写入,你按老规矩在for循环里一条一条insert,跑了半小时还没完,DBA已经在群里喊话了。换成Mybatis做批量插入,网上方案五花八门,最常听到的就是foreach插入、SqlSession批量插入、原生sql拼接这三种。说实话,我第一次对比完这三者的时候被结果吓到了——在特定连接参数下,SqlSession批量插入居然比foreach还慢,而手拼SQL却能快上一倍。这个“Mybatis插入大量数据效率对比”说白了不是比谁的代码写得炫,而是比谁真正懂得JDBC批处理、SQL文本长度、事务刷盘这些底层机制。这篇文章把三种方案全部拆开讲,附上实测数据和踩坑记录,适合正在优化批量写入速度、准备mybatis面试题、或者做电商后台这类高写入场景的人参考。
1. 三种批量插入方案的代码形态与直观差异
1.1 foreach插入:最直观,但SQL文本会无限膨胀
foreach插入应该是我见过最普遍的写法。mapper.xml里写一个insert标签,里面用foreach把list拼成多条values:
<insert id="batchInsertForeach" parameterType="list"> INSERT INTO t_order (order_no, user_id, amount, create_time) VALUES <foreach collection="list" item="item" separator=","> (#{item.orderNo}, #{item.userId}, #{item.amount}, #{item.createTime}) </foreach> </insert>从码农视角看,这段代码清晰好懂,不需要额外的执行器设置,直接用mapper接口调一次就完成全部插入。它的工作方式是一次请求发一条巨大的INSERT语句给MySQL,把所有记录织进一条SQL的多个VALUE元组里。
这种做法的好处是网络往返只有一次,对比一条一条insert动不动几百次请求,已经算得上是质的飞跃。但代价同样明显:SQL文本长度和条目数成正比。list里100条的时候,SQL文本几KB,问题不大;到了几千条,SQL文本十几MB,MySQL服务端的max_allowed_packet直接给你脸色看。而且MySQL解析一条超长SQL,还需要消耗更多的CPU做词法分析、语法树构建和执行计划生成,条目越多,单条SQL的平均解析成本越高,性能边际递减非常明显。
所以foreach不是不能用,而是必须限制批量大小。我自己用foreach的底线是单批不超过500条,超过就拆分多次调用。如果你单批2000条还能跑得动,那多半是表结构简单、字段少、平均每条values只有几十字节,千万别觉得全世界都这样。
1.2 SqlSession批量插入:依赖ExecutorType.BATCH
第二种方案是直接用SqlSession打开批量模式。做法是先拿到一个指定了ExecutorType.BATCH的SqlSession,然后反复调用mapper的insert方法,最后由自己控制flushStatements和commit:
SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH, false); try { OrderMapper mapper = session.getMapper(OrderMapper.class); for (int i = 0; i < orders.size(); i++) { mapper.insert(orders.get(i)); if (i % 1000 == 0) { session.flushStatements(); } } session.commit(); } finally { session.close(); }Mybatis在BATCH模式下会使用BatchExecutor。它的设计思路是:同一条SQL语句,如果反复执行,就不再重复创建PreparedStatement,而是把每次传入的参数攒下来,通过JDBC的addBatch丢进同一个批量集合里。只有当你调用flushStatements或者触发commit的时候,这些参数才会真正作为一个batch发送出去。
这个方案从表面看没有改变SQL文本,仍然是单条insert语句循环执行,但它把“每条SQL一次网络往返”变成了“一批参数一次网络往返”,网络消耗大幅下降。很多人的误解是:只要打开BATCH就自动快。实际上不是。JDBC的addBatch只是把参数放进了客户端内存,真正发送到服务端是在executeBatch阶段,而且发送方式还取决于MySQL驱动的rewriteBatchedStatements参数。这个细节我放在第二章详细说,因为它是整个性能测试里最大的变量。
另外,BATCH模式对事务的要求更高。它基本必须配合手动commit,而且flushStatements本身不会提交事务,只是把本地缓存的批量语句推给数据库执行,事务边界仍然由commit和rollback控制。如果忘记commit,数据不会落库;如果flushStatements执行中一半失败,你要么自己处理部分提交的问题,要么把整个事务回滚。
注意:Spring环境下,如果用SqlSessionTemplate,它对ExecutorType.BATCH的支持是有特殊处理的。很多时候你在业务代码里手动openSession,绕过Spring事务管理,容易造成连接泄漏或事务不生效,建议直接用SqlSessionTemplate配置batch模式,或者单独在特定方法里切换,别图省事。
1.3 原生SQL拼接:绕过Mybatis,自己拼values
第三种方案更暴力:彻底绕过Mybatis动态SQL,直接在service层用StringBuilder把SQL拼出来,再用JdbcTemplate或原生JDBC执行。典型写法:
StringBuilder sql = new StringBuilder("INSERT INTO t_order (order_no, user_id, amount, create_time) VALUES "); for (int i = 0; i < orders.size(); i++) { if (i > 0) { sql.append(","); } sql.append("('").append(orders.get(i).getOrderNo()).append("',") .append(orders.get(i).getUserId()).append(",") .append(orders.get(i).getAmount()).append(",") .append("NOW()").append(")"); } jdbcTemplate.execute(sql.toString());这段代码我会先放出来,然后必须强调:它只适合内部数据导入工具,不适合任何暴露给外部输入的接口。因为字符串拼接天然会让SQL注入有可乘之机——如果orderNo是用户传入的,单引号转义处理不好,一个精心构造的payload就能把你的SQL改得面目全非。另外,Date类型、BigDecimal的格式都要你自己处理序列化,Mybatis的类型转换、null处理、特殊字符转义全部失效,开发量并不小。
但它确实能带来最极端的插入速度。因为MySQL收到一条巨长的多values INSERT时,从解析到执行只需要一次SQL状态机处理,比重复多轮的batch更快。如果控制好长度不触发max_allowed_packet,对5k条记录而言,实测往往比加了rewriteBatchedStatements的BATCH还要快一点。这种差距主要来自于SQL文本本身比batch执行中反复的消息编解码更“直接”。
然而,为了那一点速度牺牲安全性和可维护性,在业务代码里很不划算。我见过有人在订单表的主流程里手拼SQL,后来一条脏数据让整张表出现重复订单,排查了一下午。批量导入工具里用一用可以,业务代码里我还是强烈建议用前两种方案。
2. 底层原理深挖:为什么同一份代码性能差好几倍
2.1 JDBC的batch机制和rewriteBatchedStatements参数
先说JDBC原生batch。我们执行“一条insert语句,循环绑定参数,再executeBatch”的时候,JDBC驱动做的事情并不是真的把一批数据打包成一个网络包发出去。在MySQL Connector/J里,默认情况下,executeBatch的实现是:在客户端循环调用executeStatement,也就是网络层面还是一条一条发,只是应用层看起来像批量。真正能提速的开关是连接串里的rewriteBatchedStatements=true。
当这个参数打开后,Connector/J在executeBatch收到多条相同结构的INSERT语句时,会先把它们按VALUES结构合并成一条多values的INSERT,再发送给服务端。这一步等于在客户端自动地帮你做了“foreach拼接”,而且由于参数仍然是以PreparedStatement方式传入的,类型转换、转义都由驱动处理,安全性和可靠性比手拼高得多。
所以说,SqlSession批量插入的性能好,本质是“JDBC batch + rewriteBatchedStatements”这两个条件同时满足的结果。如果连接串没加这个参数,BATCH模式的收益主要只有PreparedStatement复用,网络往返并没有实质减少,性能自然上不去。这就是为什么很多人在网上发帖说“批量插入10w条用了20秒”,另一个人同配置却只要3秒,这一行连接参数就是分水岭。
提示:MySQL Connector/J从5.1.x到8.x都支持rewriteBatchedStatements参数,但要注意,这个参数对INSERT ... ON DUPLICATE KEY UPDATE这类语句的支持并不完整,某些版本会退化成逐条执行。使用前最好在驱动源码或文档里确认识别条件。
2.2 Mybatis三种Executor的差异与BatchExecutor源码关键点
回到Mybatis框架本身,它有三个执行器:
- SimpleExecutor:每执行一次SQL都创建新的StatementHandler,用完即关闭;
- ReuseExecutor:把StatementHandler缓存起来,碰到相同SQL的重复执行就复用PreparedStatement;
- BatchExecutor:在Reuse的基础上,把同一条SQL的多次执行打包成addBatch,延迟到flushStatements才真正提交。
很多人容易混淆Reuse和Batch。Reuse只复用PreparedStatement,不批量submit;Batch才真正调用addBatch。而BatchExecutor的判断逻辑藏在一段关键的if里,大致是:
if (ms != currentMs || sql.equals(currentSql)) { // 不是同一条SQL,关闭原来的Statement,新建一个 }也就是说,BATCH模式下如果两次插入的SQL文本不同,批量会立刻失效,退化成逐条执行。最常见的触发原因就是在mapper.xml里用了动态SQL:foreach里如果拼了if判断,list中某些对象缺失字段,生成的SQL文本就会不一样,例如有的条目包含字段remark,有的不包含,等于一会儿是INSERT(...) VALUES(...),一会儿是INSERT(...,remark) VALUES(...,?),结构不一致,batch彻底失效。
另外,BATCH模式下Mybatis对parameterObject的处理是延迟到flush时才真正绑定参数,所以自定义TypeHandler可能出现行为异常。常规SIMPLE模式下,setParameter是立即执行的;BATCH模式下,参数是在addBatch时记录到集合里,最后由JDBC驱动统一绑定。如果你写了一个自定义TypeHandler做字段加密,批量插入时可能发现加密逻辑没生效,最后库里存的是原文。排查时要先想到这个坑,不要一上来就怀疑TypeHandler本身写错了。
2.3 InnoDB提交刷盘与事务取舍
抛开Mybatis,批量插入的最终写入方是InnoDB。InnoDB在提交事务时,默认innodb_flush_log_at_trx_commit=1,意思是每次事务提交都要把redo log刷到磁盘,保证持久性。如果业务代码里每插入一条就commit一次,那就是1万次fsync;而把1万条放进一个事务提交一次,fsync次数直接降到1次。这块性能差距,比Mybatis内部任何优化都大。
很多批量插入慢,根因根本不在SQL层,而是事务粒度太细。把多条插入包进一个事务后,磁盘IO从“每秒几百次同步刷盘”变成“每秒几万条数据的顺序写”,差距非常直观。如果把innodb_flush_log_at_trx_commit从1调成2,性能还能再上一个台阶,代价是操作系统宕机时可能丢失最近一秒的事务日志,是否接受取决于业务属性。对订单这种核心数据,我一般维持1;对日志表、流水表这类允许轻微丢失的数据,我见过不少团队调到0或2来换吞吐。
此外,批量插入还要注意记录锁和间隙锁。如果表上有二级索引,InnoDB的插入会维护索引结构,二级索引越多,插入越慢。批量数据用事务打包后,锁的范围可能变大,如果其他事务还要插入相近主键的数据,可能出现锁等待。INSERT和INSERT之间本身不会互相阻塞,只要没有唯一键冲突和间隙锁干扰,大批量插入在同一个事务里反而是最优的。
3. 实测:10万条数据三种方案跑分对比
3.1 测试环境、表结构与数据准备
先交代测试环境,方便你对比自己的机器。
- JDK:8
- Spring Boot:2.7.x
- mybatis-spring-boot-starter:2.3.1
- MySQL:8.0.33
- 连接方式:JDBC mysql-connector-java 8.0.33
- 操作系统:macOS 笔记本,SSD
- 数据量:10万条订单记录
表结构:
CREATE TABLE `t_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL, `user_id` bigint NOT NULL, `amount` decimal(10,2) NOT NULL DEFAULT '0.00', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;准备数据时,我用一个循环生成10万个OrderBean,其中order_no用UUID前8位加自增序号拼接,user_id随机,amount在1到1000之间。生成完成后分别跑三种方案,每组重复三次取中间值,避免JIT和缓存抖动带来的误判。
3.2 三种方案的完整代码
foreach方案:
<insert id="batchInsertForeach" parameterType="list"> INSERT INTO t_order (order_no, user_id, amount, create_time) VALUES <foreach collection="list" item="item" separator=","> (#{item.orderNo}, #{item.userId}, #{item.amount}, #{item.createTime}) </foreach> </insert>service里按500条一批切分后调用:
public int batchInsertForeach(List<OrderBean> orders) { int batchSize = 500; int total = 0; for (int i = 0; i < orders.size(); i += batchSize) { List<OrderBean> subList = orders.subList(i, Math.min(i + batchSize, orders.size())); total += orderMapper.batchInsertForeach(subList); } return total; }SqlSession批量方案:
public void batchInsertBySqlSession(List<OrderBean> orders) { SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH, false); try { OrderMapper mapper = session.getMapper(OrderMapper.class); for (int i = 0; i < orders.size(); i++) { mapper.insertBySingle(orders.get(i)); if (i % 1000 == 0) { session.flushStatements(); } } session.commit(); } finally { session.close(); } }对应的mapper.xml只需要一个普通单条insert:
<insert id="insertBySingle" parameterType="com.demo.OrderBean"> INSERT INTO t_order (order_no, user_id, amount, create_time) VALUES (#{orderNo}, #{userId}, #{amount}, #{createTime}) </insert>原生SQL方案:
public void batchInsertByRawSql(List<OrderBean> orders, int batchSize) { JdbcTemplate jdbcTemplate = ...; for (int i = 0; i < orders.size(); i += batchSize) { List<OrderBean> subList = orders.subList(i, Math.min(i + batchSize, orders.size())); StringBuilder sb = new StringBuilder("INSERT INTO t_order (order_no, user_id, amount, create_time) VALUES "); for (int j = 0; j < subList.size(); j++) { if (j > 0) sb.append(","); OrderBean ob = subList.get(j); sb.append("('").append(ob.getOrderNo()).append("',") .append(ob.getUserId()).append(",") .append(ob.getAmount()).append(",") .append("NOW()").append(")"); } jdbcTemplate.execute(sb.toString()); } }这段代码我特意没有在字符串里对单引号做过滤,就是为了提醒你真实工程里千万不要这么裸接外部数据。
3.3 跑分结果与逐项分析
我跑完了完整的10万条数据,下表是纯插入耗时(数据生成和事务提交之外的执行段)。
| 方案 | 关键配置 | 单批条数 | 总耗时(秒) | 备注 |
|---|---|---|---|---|
| foreach | 默认连接串 | 500 | 2.3 | SQL约几十KB,正常 |
| foreach | 默认连接串 | 2000 | 8.7 | SQL明显变长,解析成本上升 |
| foreach | 默认连接串 | 5000 | 报错 | Packet too large |
| SqlSession BATCH | 默认连接串 | 1000 | 18.6 | 没有rewrite时很慢 |
| SqlSession BATCH | rewriteBatchedStatements=true | 1000 | 3.9 | 有明显提升 |
| SqlSession BATCH | rewriteBatchedStatements=true | 5000 | 2.8 | 本次最快 |
| 原生SQL | 默认连接串 | 5000 | 1.9 | 最快但有隐患 |
| 原生SQL | 默认连接串 | 20000 | 报错 | 超过packet限制 |
有几个结果值得细说。
第一,foreach 500条一批只要2.3秒,因为它的网络往返只有200次(10w/500),SQL长度可控,MySQL解析起来压力不大。但是到了2000条一批,总往返虽然只有50次,SQL单条文本却长了四倍,解析开销开始盖过网络收益,耗时飙到8.7秒。这就是“解析成本随SQL长度非线性增长”的直观体现。
第二,SqlSession BATCH在默认连接串下慢得离谱,18.6秒。原因就是我前面说的,JDBC batch没有rewriteBatchedStatements时,只是在客户端逐个execute,网络往返并没有减少,反而因为多了一层Executor包装多一些开销。一旦把rewriteBatchedStatements=true加进连接串,同样代码直接掉到3.9秒,配置5000条一批时甚至到2.8秒,已经接近手拼SQL的水平。
第三,手拼SQL 5000条一批只要1.9秒,是全场最快,但你要记得这里面是没有任何PreparedStatement处理的,安全性完全裸奔。我用它做参照,更多是为了展示“上界在哪里”:如果有一天MySQL本身成了瓶颈,手拼SQL也不是银弹,因为它的提升主要来自于绕过驱动层消息编解码,也就是优化网络和解析,并没有改变InnoDB的写入本质。
3.4 决定性能的几个关键参数计算
很多人问我,批量大小到底怎么定?我会先估算SQL长度,再反向推算批大小。
一条values元组文本长度,可以通过字段加权估算。以上面的t_order为例,一条元组大约65字节(含括号和逗号)。如果单批N条,整体SQL长度大约是:
SQL总长度 ≈ INSERT固定部分(约60字节) + N × 65字节
max_allowed_packet在MySQL 8.0默认是64MB,线上很多环境调到128MB。如果单批5000条,SQL大约325KB,远低于64MB,为什么我们5000条还报Packet too large?原因是用foreach时,Mybatis实际上是先把所有值拼成一个字符串再传给JDBC,同时PreparedStatement的参数在客户端要缓冲,有些版本的驱动会把参数展开一定倍数,导致内存中实际占用的判定空间比文本长度大很多。为了安全,我的经验值是:单批条数×单条values字节数,不要超过max_allowed_packet的十分之一。比如64MB的packet,单批SQL文本控制在6MB以内,换算到上面的表就是大约9万条,但实际工程里单批9万条显然太大了,真正受限制的是事务日志、连接读写超时、应用内存,所以我会把单批控制在1000到5000条之间。
连接串参数影响也不小,建议参考:
jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8&rewriteBatchedStatements=true&useServerPrepStmts=false其中useServerPrepStmts=false会关闭服务端预编译,对批量写场景通常能减少一次额外的预处理往返,同时避免一些版本下的缓存问题。不过这个参数在MySQL 8.0 Connector/J里的默认行为有变化,具体表现因版本而异,跑分时我建议分两组对照。
4. 批量插入避坑指南:生产环境问题复盘
4.1 Packet for query is too large为什么这么常见
异常信息长这样:
java.sql.SQLException: Packet for query is too large (4,358,708 > 4,194,304). You can change this value on the server by setting the 'max_allowed_packet' variable.大部分新人看到这个第一反应是调大max_allowed_packet,但我要说明两点:第一,这是服务端参数,同时客户端也有一个maxAllowedPacket参数,两端只要有一边小,就会报错;第二,调大之后只是让“更大的SQL”能进来,不代表性能好,MySQL要为一个几十MB的SQL预分配内存,连接一多就可能拖垮实例。
注意:连接串里用maxAllowedPacket单独设置时,单位是字节,而且只对当前连接生效。生产环境如果用的是连接池,要确认连接池的参数配置方式能够正确传递这个值,否则你改了JDBC URL也没用。
针对这个问题,治本的办法永远是控制单批条数,其次才是调参数。我见过的严重事故是:业务侧把1万条订单一次性foreach出去,packet超限后重试又把内存打满,直接把MySQL实例搞到OOM边缘。先拆批,再谈优化。
4.2 事务回滚失效、自动提交陷阱
还是那句话,批量插入必须开事务。Mybatis的SqlSession默认不自动提交,但不少人用Spring却忘了加@Transactional,于是底层连接每次execute之后都会自动commit,数据写一条提交一条。一旦中途报错,前面成功的那几千条就留在库里了,你以为是“全部回滚”,其实是“只滚了最后一条”。
正确做法是,在批量插入入口方法上标记@Transactional,并保证事务传播行为符合预期。如果手动openSession,记得显式session.commit()和session.rollback()。另外,flushStatements并不代表事务提交,它只是把批量语句发到数据库;如果flush之后、commit之前抛异常,事务还是可以整体回滚的。
我遇到过最隐蔽的问题:在Spring事务方法里手动openSession,拿到的是同一个底层连接,但事务状态由Spring管理,你手动调session.commit()后,事务直接提前提交,后面的代码再执行时发现连接已经脱离了事务上下文。所以,能用Spring事务管住的场景,就别自己手动openSession。
4.3 动态SQL导致批量失效、TypeHandler失效
这个坑是我这半年才真正意识到的。BATCH模式下,如果mapper.xml里的INSERT用到了 动态标签,批量是否生效完全取决于每次拼接出的SQL文本是否一致。比如:
<insert id="dynamicInsert"> INSERT INTO t_order (order_no, user_id, amount, create_time <if test="remark != null">,remark</if> ) VALUES (#{orderNo}, #{userId}, #{amount}, #{createTime} <if test="remark != null">,#{remark}</if> ) </insert>list里如果有几条remark是null、几条不是null,那么“前面一条SQL的列集合”和“后面一条的列集合”不同,BatchExecutor就会发现currentSql变化,立刻换一批,批量退化成逐条执行。你以为是批量,实际是一条条发的。这类问题最大的麻烦是:线上数据一旦混合,性能波动很难解释。
解决办法很简单:批量插入使用的insert语句不要用动态标签,要么所有字段都传,要么把常见字段统一处理。如果你确实要支持部分字段,可以按字段组合先分桶,保证同一个桶里的insert结构完全一致,再分别批量执行。
TypeHandler失效的问题我刚才提过:BATCH模式下,自定义TypeHandler的setParameter不一定按SIMPLE模式那样立即执行。如果你要做字段加密、枚举转换,先确保在批量模式下也拿去验证一遍,不要等上了线才发现库里全是明文。我当时给订单号加解密逻辑,单条插入一切正常,改成批量后加密字段全变成了空,排查了半天最后定位到BatchExecutor参数绑定时机上。
4.4 慢SQL与IO瓶颈的排查顺序
上线后发现批量插入慢,不要一头扎进Mybatis配置。我的排查顺序是:
- 看慢查询日志,确认慢的是哪条SQL、执行时间和锁等待时间;
- 用SHOW ENGINE INNODB STATUS看是否有锁等待、undo log膨胀;
- 看磁盘IO的await和util,确认是不是刷盘导致瓶颈;
- 看binlog同步、主从复制是否拖后腿;
- 最后再回头检查应用侧连接串、批大小、事务提交频率。
常见的原因是:主库IO占用高,因为批量写入把binlog和redo log的量放大,如果每个batch都commit,group commit的作用会被削弱。把多个batch合进一个事务提交,是立竿见影的优化。还有一个容易被忽略的点:批量插入时如果表上有频繁的二级索引变更,InnoDB的change buffer压力会变大,可以在一次性导入场景里暂时删除部分非必要索引,导完再重建。业务的实时写入别乱用这招。
5. 选型建议与实践经验
5.1 按数据量分级:100条、1000条、1万条、10万条的推荐
根据我自己的实际使用经验,批量插入方案的选型不能脱离数据量。我建议按这个表来:
| 数据量 | 推荐方案 | 必要条件 | 备注 |
|---|---|---|---|
| ≤100 | foreach | 无 | 代码简洁,性能足够 |
| 100~1000 | foreach或BATCH | rewriteBatchedStatements | 两者差距不大 |
| 1000~10000 | SqlSession BATCH | 控制单批1000~5000 | 性能最稳 |
| 10000以上 | SqlSession BATCH + 多线程 | 连接池足够 | 注意事务和幂等 |
| 百万级导入 | LOAD DATA INFILE | 文件可访问、权限允许 | 和业务代码解耦 |
单批条数不是越大越好。BATCH模式下,单批太大意味着JDBC驱动要预先缓冲大量参数,内存占用高,flush时也可能触发更长的单次网络传输,一旦失败,重试代价很大。我更看重“可控的批量”加上“合理的重试机制”。
5.2 常见mybatis面试题:批量插入高频考点
最近面试题里经常出现Mybatis批量,高频问题大概是这几个:
- Mybatis能不能用一条SQL完成批量插入?能,多values的INSERT,foreach拼出一串values。
- 为什么用了foreach批量插入反而性能不好?SQL文本过长、解析成本提高、有max_allowed_packet上限,还有MySQL对超长SQL的内存占用问题。
- ExecutorType.BATCH的原理是什么?BatchExecutor缓存PreparedStatement,参数通过addBatch累积,flushStatements统一提交,网络往返减少。
- rewriteBatchedStatements=true的作用是什么?驱动层把一批相同结构的INSERT合并成多values SQL,减少SQL文本解析基数和服务端处理次数。
- 批量插入时一级缓存为什么直接失效?因为insert/update/delete操作会触发SqlSession一级缓存的清空。
- BATCH模式下TypeHandler为什么会失效?因为参数延迟到flush时才绑定,某些自定义处理逻辑在批量模式下的执行时机不同。
能把这几个问题讲清楚,基本可以把“熟悉Mybatis”写得名正言顺。如果还能画出BatchExecutor的执行流程,印象分会高很多。
5.3 从批量插入到SQL优化的延伸:去重、忽略插入、唯一索引
既然聊到了SQL优化,这里也分享一个批量插入里的去重场景。业务上经常要往一张表里批量写入订单或日志,重复数据如果不处理,后面统计全脏。比起“先查一遍再插”,我建议建好唯一索引后用INSERT IGNORE或ON DUPLICATE KEY UPDATE:
INSERT IGNORE INTO t_order (order_no, user_id, amount, create_time) VALUES (...), (...); INSERT INTO t_order (order_no, user_id, amount, create_time) VALUES (...), (...) ON DUPLICATE KEY UPDATE amount = VALUES(amount);这样MySQL会在索引冲突时自动跳过或更新,应用侧不需要额外查询,性能比“先查再插”高出一大截,而且唯一索引冲突本身就是幂等保护。不过要注意,ON DUPLICATE KEY UPDATE在多行VALUES批量下会让每个冲突行的更新触发额外索引写入,量大的时候也要评估。如果写入频率不高、但单次量很大,我觉得INSERT IGNORE更稳。
“sql语句去重”这个需求其实还有另一层含义:批量插入时先做内存去重。JVM里用HashSet对id或唯一键过滤一遍,再走批量插入。这个方案的优点是数据库压力小,缺点是对超大列表内存有要求。我通常是双层:应用层先用HashSet粗过滤,库里再用唯一索引兜底,双保险。
6. 写在最后的个人体会
文章写到这儿,其实没必要强行总结了。就说一点我踩透了的感悟:批量插入性能这件事,永远不要只盯着Mybatis层。SQL文本长度、JDBC驱动连接参数、事务提交频率、InnoDB刷盘策略,这四个变量没理清,你在Mybatis里怎么折腾都是隔靴搔痒。
我自己现在写批量插入前会习惯性问三个问题:数据量级是多少?允许丢数据吗?连接串参数确认过没有?如果都答得上来,再选方案。如果你正打算把项目里的批量插入改掉,哪怕只做一件事,也建议先给JDBC连接串加上rewriteBatchedStatements=true,然后跑一次压测,你会看到惊喜。
最后再分享一个小技巧:真遇到百万级以上的数据导入,别死磕Mybatis,直接用LOAD DATA INFILE配合临时表,和业务代码解耦,性能差不多是线性级别的提升。工具用对了,比堆优化参数有效得多。