资讯动态

MyBatis批量插入性能优化:从5分钟到3秒的实战记录

发布时间:2026/10/15 23:52:27 来源:尧图企业网站定制
几万条数据用MyBatis批量插入跑一次要5分钟。我最早在某后台系统的导入功能里遇到这个问题。产品要求把Excel里的十万条订单快速写入MySQL刚开始的实现是典型的MyBatis foreach拼接大SQL数据一上万耗时肉眼可见地往上涨。后来我花了几天时间做了三个关键优化把同一批数据的插入耗时从5分钟左右压到3秒。整个过程牵扯到MyBatis的ExecutorType、MySQL JDBC驱动参数以及事务提交策略并不复杂但每一步都有讲究。这篇文章把这个问题的来龙去脉完整记录下来适合正在用MyBatis MySQL做批量写入、被导入功能卡得头疼的Java后端开发。1. 项目背景与性能瓶颈定位1.1 最初的代码长什么样当时导入模块的落库逻辑大概是这样的请求进来后从Excel解析出一批对象然后丢给Service方法去插入。最早用的方式是MyBatis XML里写一条insert内部用foreach把整个List拼接成一个大VALUES列表类似下面这样insert idbatchInsert parameterTypeList insert into biz_order (order_no, user_id, amount, create_time) values foreach collectionlist itemitem separator, (#{item.orderNo}, #{item.userId}, #{item.amount}, #{item.createTime}) /foreach /insert调用方把几千条甚至几万条数据一次性传进来MyBatis会把List里的每个元素展开成一个value元组最终拼成一条巨长的INSERT语句。当时跑1万条大概要20秒跑10万条直接5分钟起步偶尔还会因为SQL超长触发PacketTooBigException。为什么这种写法会慢我拆成三个原因。第一SQL文本本身在Java和MySQL之间反复传输数据量越大字符串越大网络IO和数据库端解析SQL的开销就越大。第二MyBatis做foreach时要遍历对象、拼接字符串这块本身也吃CPU。第三一次提交的事务变得非常大MySQL在提交时涉及内存、redo log、binlog的协同事务越大提交那一刻的阻塞和磁盘刷盘压力就越明显。这种写法最直观的问题是SQL文本可能达到几十MBMySQL解析一条几十MB的SQL语句优化器要处理的内容也很多整体性能自然上不去。而且异常处理也很尴尬任何一行出问题整条巨大的SQL全部失败。1.2 定位瓶颈的三个步骤要优化第一步不是乱改代码而是先看时间花在哪里。我在Service方法里简单打点解析Excel耗时、insert调用耗时、最后返回耗时分开统计。结果发现几乎95%的时间都集中在insert调用上。解析Excel其实只要一两秒。第二步看数据库侧。慢查询日志打开结果并没有出现什么单条慢SQL因为这条SQL其实不慢它卡住的真正原因是“太长太笨重”以及“一次性事务提交”。慢查询日志只能看到SQL执行时间看不到客户端等待、网络传输这些环节。第三步我做了个对照组写一个纯JDBC循环插入的Demo一条一条insert1万条大概3到4秒已经很接近目标了。这就很说明问题MyBatis的foreach大SQL反而比逐条JDBC插入还慢。真正要优化的不是单条SQL而是整批调用的模式。这个定位过程比结果重要。很多人一上来就调MySQL各种参数或者盲目加连接池方向错了越调越偏。先确认瓶颈在哪个环节再动手永远比瞎试高效。2. 三个关键优化逐个拆解2.1 第一个优化告别foreach拼接大SQL改用BatchExecutor先说结论用MyBatis自带的BatchExecutor取代foreach拼接大SQL。MyBatis默认的ExecutorType是SIMPLE它执行一条语句就立刻发给数据库。而BatchExecutor会把连续执行的同构SQL缓存在应用层攒到一定数量后通过JDBC的addBatch/executeBatch一起发出去。这个机制特别适合循环插入同一个Mapper方法。具体操作有两种方式。方式一全局配置。在Spring Boot中修改MyBatis配置mybatis: executor-type: batch但这种方式会影响整个应用的所有Mapper。如果项目里查询特别多不建议全局开因为BatchExecutor遇到查询操作会先触发一次flush反而增加无谓开销。方式二局部使用。在需要批量插入的地方单独开一个SqlSessionSqlSession sqlSession sqlSessionFactory.openSession(ExecutorType.BATCH); try { BizOrderMapper mapper sqlSession.getMapper(BizOrderMapper.class); for (OrderItem item : list) { mapper.insert(item); } sqlSession.commit(); } finally { sqlSession.close(); }注意这里Mapper的insert方法对应的是单条插入比如insert idinsert parameterTypeOrderItem insert into biz_order (order_no, user_id, amount, create_time) values (#{orderNo}, #{userId}, #{amount}, #{createTime}) /insertBatchExecutor会把循环中的insert收集起来在commit或者执行executeBatch时一次性发送。这样网络上没有了几十MB的大SQL字符串MySQL端也不用解析一整条巨长的SQL而是收到一批结构一致的参数化语句处理效率会上一个台阶。但从这步开始很多人会发现一个问题单纯用BatchExecutor效果其实没有想象中那么神。10万条可能从5分钟降到40多秒还是远远达不到3秒。原因在于MySQL JDBC驱动在默认情况下并没有真正把Batch的收益发挥出来。这就引出第二个优化。2.2 第二个优化JDBC驱动层面的rewriteBatchedStatements第二个优化给JDBC连接URL增加一个参数rewriteBatchedStatementstrue为什么加这个因为MySQL Connector/J的Batch执行默认并没有把多条预处理语句合并成一条多值INSERT。也就是说即使代码里用了executeBatch驱动仍然是“一条一条发给数据库”虽然网络往返确实减少了一些但优势不明显。打开rewriteBatchedStatements之后驱动会识别到一批结构相同的INSERT将其重写成一条多值语句insert into biz_order (order_no, user_id, amount, create_time) values (?, ?, ?, ?), (?, ?, ?, ?), (?, ?, ?, ?), ...等于说我们既保留了BatchExecutor的编程便利又拿到了foreach大SQL那种多值插入的性能。两者优点合在一起10万条的耗时一下子可以降到10秒以内。调整后的JDBC URL大致是jdbc:mysql://localhost:3306/yourdb?useSSLfalserewriteBatchedStatementstrueuseServerPrepStmtsfalse这里有个细节如果搭配useServerPrepStmtstrue驱动可能会启用服务端预编译但在某些场景下会和批量重写产生冲突容易让性能又退化到逐条执行。我自己实测在5.x和8.x驱动版本中保持useServerPrepStmtsfalse配合rewriteBatchedStatementstrue效果最稳定。还要提醒一点这个参数是MySQL驱动层面的对PostgreSQL的驱动没有作用。如果用PG可以把insert改成COPY或者用PG原生的批量处理那是另一套方案。我见过不少朋友查资料只看到“BatchExecutor”就照着改改完发现提升有限然后质疑方案。其实真正让批量插入产生质变的恰恰是这两个优化点叠加使用。一个是应用层的批处理一个是驱动层的SQL重写缺一不可。2.3 第三个优化事务分片与批量大小控制第三个优化是控制批量粒度。最开始优化完把10万条全部放入一个Batch然后一次commit确实很快但在生产环境会遇到两个问题。第一单次事务过大如果中途发生异常回滚代价非常高。第二一批SQL长度过长会触达max_allowed_packet虽然rewriteBatchedStatements会重写成大SQL但大SQL本身也有上限尤其当表中字段特别多、单行数据特别长时。更稳妥的策略是分片每500到2000条作为一个Batch每一片独立commit。比如int batchSize 1000; for (int i 0; i list.size(); i batchSize) { ListOrderItem subList list.subList(i, Math.min(i batchSize, list.size())); try (SqlSession sqlSession sqlSessionFactory.openSession(ExecutorType.BATCH)) { BizOrderMapper mapper sqlSession.getMapper(BizOrderMapper.class); for (OrderItem item : subList) { mapper.insert(item); } sqlSession.commit(); } }为什么要控制在1000条左右而不是越大越好我特地做过对照一次Batch塞1万条、2万条也都能跑但耗时并没有显著下降反而在写入高峰期更容易把数据库的redo日志、binlog会话缓存拖得很高锁等待和内存压力更容易冒出来。另外单条SQL重写后的values元组太多优化器和执行器都会花额外时间处理当前这个量级算是性能和稳定性的平衡点。当然这个数值不是死的。如果你的表字段少比如只有三四个字段batchSize可以适当放大到2000。如果字段很多或者有TEXT/BLOB大字段我会建议把batchSize降到500甚至更小。经验判断标准是单次重写后的SQL文本控制在1MB以内比较安全。从5分钟到3秒本质上是把原本一次巨大SQL加一次巨大事务的模式拆成若干个小批加驱动级合并加参数化复用每一步的浪费都被压到很低。3. 优化前后的实测对比与效果链路分析3.1 测试方案与数据口径我当时的测试环境MySQL 8.0Java 8MyBatis 3.5.xJDBC驱动用的5.1.x版本。数据集是模拟的订单数据10万条字段保持6个左右除了主键没有其他索引约束表结构固定。测试过程中固定机器、固定连接池大小只改代码方式和驱动参数确保数据口径统一。每次执行我统计的是“从调用批量插入方法开始到所有事务提交完成”的耗时不包含Excel解析。每个方案跑3次取中位数避免偶发网络抖动影响结论。3.2 实验数据表方案10万条耗时说明原始方案foreach拼接大SQL5分10秒SQL文本和事务都巨大慢的根源BatchExecutor 单条insert42秒有一定提升但没利用多值INSERTBatchExecutor rewriteBatchedStatementstrue6.8秒明显质变批量重写生效分片提交 batchSize1000 rewriteBatchedStatementstrue3.1秒最终采用稳定性和速度都比较理想为什么最后一步反而从6.8秒降到3.1秒因为分片之后每次事务的redo和binlog压力变小commit更轻量MySQL内部不需要为一个巨大事务预留那么多资源。整批一次提交时final commit阶段会等待binlog刷盘等一系列操作事务越大等待越明显拆成1000条一批之后每次提交都很快总体累计反而更优。3.3 从5分钟到3秒钱到底省在哪开个链路账。原来的链路中10万条数据的SQL文本可能有几十MBJava端拼字符串花了大量CPUMySQL端解析巨大的SQL也花时间一次大事务提交还要做大量日志落盘。优化后Java端只循环调用单条insertBatchExecutor在应用服务内部攒批驱动层再多值合并发送给数据库的是参数化请求或以批为单位的合并SQL。数据量没有变但三块大头全被优化了SQL解析开销几乎消失网络传输从几十MB变成了很小的批量请求事务刷盘从一次巨大刷盘变成多次轻量刷盘。其中事务刷盘的优化比想象中更大因为MySQL事务提交涉及redo log fsync一次大提交的刷盘压力和多次小提交的累计成本在合适的批次大小下反而是小的。这个链路账也解释了为什么单纯增加连接池线程数没有用。瓶颈不在数据库连接数而在单次请求的数据量和事务粒度不解决这两个线程再多也是空转。4. 常见问题与排查技巧实录4.1 高频问题速查表现象原因解决方案用了BATCH还是慢没开rewriteBatchedStatements确认URL参数重启连接池BatchExecutor里执行select出现异常Batch遇到查询会flush打乱了批处理批处理循环内不要穿插查询查询拆到另一个SqlSession插入数据没生效缺少commitBATCH模式下记得显式commit或调用flushStatements报PacketTooBigException单批values过多或字段太长调小batchSize或调大max_allowed_packet首选前者主键冲突时整批报错多值INSERT中一条失败导致整批回滚拆小批按主键或唯一键做幂等预处理批处理时其他接口卡住BATCH长时间占用同一个连接批量任务单独线程池或单独配置连接池4.2 几个值得注意的细节坑第一不要为了图方便把executor-type全局改成batch。一旦全局开启你很难保证所有查询都避开这个SqlSession稍不留神一次select就把前面攒的批flush掉结果可能比SIMPLE还慢。我只在导入接口专用的事务流程里打开BATCH其他查询继续走默认模式。如果你的Spring项目里使用的是SqlSessionTemplate可以通过构造方法单独为批量接口创建一个BATCH类型的模板而不是全局替换。第二建议在批处理循环的外面做数据预处理包括去重、校验。如果批处理过程中出现大量重复主键多值合并SQL会整批报错排查问题时很难精确知道是第几行。做一个前置Set去重会省很多事。我踩过一次坑导入文件里有两千行数据主键重复BatchExecutor跑了大半天最后整批回滚日志里只看到一个Duplicate entry提示根本定位不到具体哪一行。第三关注数据库连接池的连接数。BATCH会长时间占用同一个SqlSession如果连接池很小其他请求会被卡住。我一般会把批量任务挪到单独的线程池并使用独立的连接或合理放宽等待超时。另外不要开着autoCommit来用BatchExecutor否则每一条insert都会被立刻提交批处理就形同虚设了。第四如果表有过多的冗余索引插入前可以临时评估是否禁用插入后重建。这个优化不是必须的但像导入导出这种低峰期任务收益非常明显需要结合业务风险来判断。索引越多插入时维护成本越高把不必要的索引暂时去掉对批量插入提速效果很大。5. 实战后的个人体会与建议这套方案在项目里稳定跑了一年多后来每次看到有人还在用foreach拼接几万条数据我都想劝一句不要用那套方式设计批量写入。理解MyBatis的Executor机制、理解JDBC驱动的批处理参数比背一堆“性能优化秘籍”有用得多。如果你现在也遇到批量插入慢的问题我建议按这个顺序排查。先看是不是一次大SQL加一次大事务然后把代码切到BatchExecutor再把rewriteBatchedStatements打开最后用小批次提交收尾。这四步下来大部分场景都能有十倍以上的提升。最后分享一个实操小技巧不要只看平均耗时要关注耗时曲线的尾部。优化前10万条插入的耗时波动非常大最慢能到6分钟优化后耗时稳定在3秒左右偶尔数据量大也就4秒。稳定的性能表现对生产系统来说比极限速度更重要这也是我坚持用分片提交的原因之一。先把这三板斧吃透收益已经很可观了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑