ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MyBatis批量插入:foreach、BATCH、原生JDBC性能实测

MyBatis批量插入:foreach、BATCH、原生JDBC性能实测 在业务系统里最常见的性能话题之一就是批量插入。特别是当你第一次接到“把Excel里几十万行历史数据迁移进MySQL”这种任务时按照直觉用MyBatis写一个foreach循环咔咔一执行结果跑了十几分钟还没跑完数据库CPU直接拉满……我前前后后在好几个项目里都遇到过类似的事。今天就把MyBatis插入大量数据时最常用的三种方式——foreach拼接多值插入、SqlSession的BATCH模式、以及原生SQL批处理——放在同一台机器上做个真实对比说一下各自的原理、实测耗时和踩坑点。这篇文章不搞教科书理论就是我实际测试下来的一些数据和经验希望能帮你避开那些我踩过的坑。1. 到底在比什么三种插入方式的本质差异1.1 foreach一条SQL插入多行最简单但并非万金油foreach插入的样子大家应该很熟悉Mapper XML里通常是这么写的insert idbatchInsert parameterTypelist INSERT INTO user_info(name, age, email, create_time) VALUES foreach collectionlist itemitem 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类型的SqlSessionSqlSession 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.33InnoDB引擎驱动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方法传入listSqlSession BATCH模式多次insert单条最后commitSqlSession BATCH模式 MySQL连接参数rewriteBatchedStatementstrue原生JDBC addBatch rewriteBatchedStatementstrue这里额外把rewriteBatchedStatements单独拎出来是很多朋友容易忽略的点。这个参数的意思是让MySQL驱动尝试把连续的INSERT batch重写成一条多行VALUES的INSERT比如把10条单行INSERT自动拢成一条10行的INSERT发送给服务端。它和MyBatis的foreach是殊途同归但时机不同foreach是在业务代码里拼好大SQLrewrite是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 BATCHrewrite原生JDBCrewrite1000条410ms180ms95ms80ms5000条2100ms650ms330ms290ms10000条5800ms接近包上限1250ms620ms560ms50000条失败Packet too large5800ms2800ms2600ms100000条失败11900ms5200ms4900ms需要说明这不是一个标准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连接串后面加上rewriteBatchedStatementstrue1万条降到了620ms5万条从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条紧接着又去查这些数据你会发现查不到——因为它们还蹲在客户端缓冲区里。代码上要特别注意手动flushStatementssqlSession.flushStatements();这个操作会把当前执行器里缓存的statement全部通过executeBatch提交但事务还没有提交仍然可以回滚。commit本身也会触发flush所以如果你不需要中途查询直接commit即可。另一个大坑是Spring环境下SqlSessionTemplate的执行器选择。默认情况下Spring事务管理器会绑定SqlSession到当前线程如果你在Service方法加Transactional后再getMapper然后循环insert即使你声明要用ExecutorType.BATCH实际上用的也可能是简单执行器。因为这个执行器是在事务开启时就已经确定下来的。正确的做法是单独获取批量类型的SqlSession并且尽量不让Spring事务管理器直接管理它或者把批量操作单独拆到专用方法里。4.3 原生JDBC批处理中容易被忽视的连接设置原生JDBC看起来自由但自由往往意味着“坑得自己填”。我见过很多人写原生批量插入时忘记关闭自动提交导致每addBatch一条就COMMIT一次性能比逐条插入还差。还有的人明明设置了rewriteBatchedStatementstrue但因为连接被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?useSSLfalserewriteBatchedStatementstrueuseServerPrepStmtstrue一个经常被提到但我不太建议随意搭配的参数是useServerPrepStmts。你可能会看到网上说“开启服务端预编译配合批量更高效”但在MySQL 8的驱动下服务端预编译拿不到准确的自增ID还需要额外开useLocalSessionState等参数配合链路过长很容易出问题。我的经验是单纯做批量INSERTrewriteBatchedStatementstrue带来的收益最直接其他参数按需加不要无脑堆。这个参数对批量UPDATE的优化效果不稳定尤其是带CASE WHEN的批量更新不同版本驱动重写行为不一致。所以我建议只在明确做INSERT批量的数据源连接上开启它避免把全局数据源参数改得面目全非。另外如果你的SQL里包含ON DUPLICATE KEY UPDATEMySQL驱动也可能不会重写成多行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)建议同样给数据源开启rewriteBatchedStatementstrue并且注意传入的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_packetSET 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如果你发现自己的批量方式没有生效请先检查三处连接串是否真的加了rewriteBatchedStatementstrue且没有写错参数名。代码里是否真的在循环调用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写得多花哨而是想清楚哪些开销能省、哪些交互能合并、哪些坑必须绕开。做到这三条性能基本就赢了一大半。
返回列表