资讯动态

MyBatis批量插入List:三条路线、参数调优与踩坑排查

发布时间:2026/10/1 12:52:13 来源:尧图企业网站定制
1. 先想明白为什么要把List攒起来一次写而不是循环单条insert1.1 一次三万条数据同步把两种写法跑一遍去年做设备台账同步从上游接口一次性拉回三万两千条记录当时第一版代码写得很朴素解析完 JSON 得到一个 Listfor 循环里挨个调deviceMapper.insert(entity)本地跑了一千条测试数据感觉挺顺上线当晚就崩了——接口耗时统计显示 47 秒上游网关 30 秒超时任务被判失败并重试结果同一个批次被重复写入还得人工写脚本去清理重复数据。那次之后我把写法换成了 Mybatis 的批量插入同样三万条耗时掉到 3.8 秒左右同样的机器、同样的库代码量反而更少。这个差距不是我调了什么神仙参数换来的纯粹是把三万次数据库交互压缩成六十次带来的自然结果。很多刚接触 Mybatis 的朋友会有一个直觉误区insert 语句就那么短执行一次能有多慢单看一次 insertMySQL 本地环境可能 0.3 毫秒到 1 毫秒确实不慢。但业务代码里的 insert 不走裸的 JDBC 直连中间还压着连接池、网络往返、事务提交、SQL 解析、日志落盘这些东西。单条执行时这些开销被完整付了一遍三万条就是三万遍。批量插入的本质不是让数据库写得更快而是把那些恒定开销摊到几百条记录上一起付单位成本被摊薄了。1.2 那些被忽略的固定开销究竟花在哪几个环节把一次单条 insert 拆开看大致有这么几段成本。第一段是应用侧到数据库的网络往返即使同机房内网一次往返也在 0.1 到 0.5 毫秒之间跨机房就更夸张第二段是数据库侧的 SQL 文本解析与语义分析MySQL 要把 SQL 字符串解析成语法树再生成执行计划短 SQL 这一步不贵但也不能忽略第三段是执行与索引维护插入一行要更新聚簇索引、写 redo log 和 binlog第四段是事务提交如果代码里每条 insert 都独立提交那每条都要触发一次刷盘动作这是整个链路里最贵的部分。批量插入为什么能省因为它把第一、第二段成本直接除掉了分母。一条 SQL 里拼进 500 条数据网络往返从 500 次变成 1 次SQL 解析从 500 次变成 1 次。第三段索引维护省不掉那是数据库必须做的工作但因为数据页在内存里连续操作实际也比分批多次写入更省 IO。第四段则取决于你怎么用事务——把整个批次包在一个事务里提交次数从 500 次降到 1 次这一块的收益往往比前面几项加起来还大。注意批量插入的收益主要来自减少往返次数和减少提交次数不是来自某种隐藏的写优化。理解这一点后面所有的参数取舍才有依据。1.3 什么情况下我劝你别用批量批量不是无脑开就对了。有这么几种场景我会主动退回单条写法。一是数据量本身很小几十条以内批量拼 SQL 的字符串拼接开销和可读性下降反而不划算。二是每条记录之间需要强隔离比如某条失败必须立刻中断且不能影响前面已成功的记录这时独立的短事务更清晰。三是单行数据特别大比如带长文本或者二进制字段一行就好几 KB拼 500 行直接撞上数据库的报文大小限制这种要单独降批次甚至逐条写。四是需要插一条拿一个自增主键再去做关联插入的强依赖场景不是说批量拿不到主键而是逻辑会变得绕容易出错。另外还有一个常被忽视的点批量插入对数据库的锁行为影响更大。一条大 SQL 插入 500 行可能在索引上加锁的时间更长遇到高并发写入时更容易和别的会话产生等待。所以如果你的场景是单个应用在灌数据批量基本没副作用如果是多个应用同时高频写同一张表批次大小就要保守一点别一口气干到两千。2. Mybatis 把 List 写进库的三条路选哪条2.1 路线一foreach 拼一条大 SQL最常用也最直观这是绝大多数人接触到的第一种写法XML 里一个foreach循环用逗号把(#{item.a}, #{item.b})这样的片段连起来最终交给数据库的是一条insert into t (a, b) values (?, ?), (?, ?), (?, ?)这样的多值插入语句。它的优点是直观、跨数据库兼容性较好、能一次拿到影响行数、能在同一条 SQL 里复用动态条件比如choose判断某些字段要不要拼进去。缺点也很明确SQL 长度随集合规模线性增长集合一大就会撞上max_allowed_packet另外拼接出的 SQL 文本很长日志里刷出来一大片排查问题时眼睛会很累。我在生产里用得最多的就是这条路线但一定会配一个分批切片工具把 List 按 500 到 1000 条一组切开循环调用 batchInsert。这样既拿到了批量的性能又不会让单条 SQL 无限膨胀。2.2 路线二ExecutorType.BATCH 的 SqlSessionMybatis 提供了SqlSessionFactory.openSession(ExecutorType.BATCH)这个入口用这种会话执行 insert 时Mybatis 不会立刻发 SQL 给数据库而是把语句攒在本地等到flushStatements()或者事务提交时再一次性发给数据库驱动。配合 JDBC 驱动自身的批处理能力这种方式在写法上更接近原来的单条 insert 代码改动成本低。但它有两个坑必须知道。第一个是主键回填。BATCH 模式下只有在flushStatements()之后才能真正拿到自增主键如果你在循环里边插边读entity.getId()拿到的多半是 null 或者过期值。第二个是错误定位。批量攒着发某一条失败时抛出的异常信息往往不能精确指向是第几条数据有问题需要额外记录下标。所以这条路适合数据已经在入库前做过完整校验、出错概率极低的批量导入场景。2.3 路线三MyBatis-Plus 的 saveBatch 和它的真实行为如果你的项目用 MyBatis-PlusIService.saveBatch(list)看起来是最省事的。但要知道它内部做了什么默认实现就是走 BATCH 模式的 SqlSession按batchSize默认 1000切片循环sqlSession.insert最后统一 flush。所以它的行为特征和路线二完全一致主键回填、异常定位那两条坑一样存在。很多人以为 saveBatch 是专有的高效实现其实并没有魔法理解了内部机制你在出现问题时就更容易判断该往哪个方向查。另外MyBatis-Plus 的版本差异要留意不同版本对saveBatch的 flush 时机和事务处理有细微调整升级框架的时候建议把批量相关的单测跑一遍再上。2.4 三条路线的对照表维度foreach 多值 SQLExecutorType.BATCHMyBatis-Plus saveBatch代码改动量中要写 XML中要管 SqlSession 生命周期小一行调用单次 SQL 长度随批次线性增长需控制单条仍是一行长度可控同 BATCH自增主键回填支持写好 useGeneratedKeys 即可需 flush 后才能取需 flush 后才能取失败定位精度高SQL 里能看到具体值低需额外记录下标低同 BATCH跨库兼容性MySQL 好Oracle 要用 other 语法好好适合的数据量中等批量几千到几万大批量几万以上大批量事务可控性自己控制最灵活需配合 Spring 事务稍绕需配合 Spring 事务选型建议很直接日常接口里的批量写几千条以内选 foreach 多值 SQL代码清晰、好排查几万条以上的数据导入任务选 BATCH 模式或者 saveBatch配合更小的批次切分如果库里是 Oracle 或者达梦先确认方言写法再定路线。3. 从建表到跑通把批量插入完整写一遍3.1 表结构和实体定义用一个设备台账表做例子字段设计得简单点方便看清重点CREATE TABLE device_record ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, device_code VARCHAR(64) NOT NULL COMMENT 设备编号, device_name VARCHAR(128) DEFAULT NULL COMMENT 设备名称, status TINYINT DEFAULT 0 COMMENT 状态, collect_time DATETIME DEFAULT NULL COMMENT 采集时间, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), UNIQUE KEY uk_device_code (device_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT设备台账;实体类保持和表字段的驼峰映射一致注意collectTime这种字段名要确保mapUnderscoreToCamelCase打开否则插入时参数会绑不上报Parameter collectTime not found。这个报错我在新手代码里见过太多次根因就是下划线和驼峰没对上。public class DeviceRecord { private Long id; private String deviceCode; private String deviceName; private Integer status; private LocalDateTime collectTime; private LocalDateTime createTime; // getter setter 省略 }3.2 Mapper 接口的定义方式接口上有个细节值得说批量插入的参数是 ListMybatis 默认会把 List 放进一个 Mapkey 是list或者collection。我习惯用Param显式命名这样 XML 里的collection属性写起来不会含糊也避免以后有人给方法加第二个参数时把名字搞混。public interface DeviceRecordMapper { int batchInsert(Param(list) ListDeviceRecord list); }3.3 XML 里 foreach 的每个属性逐个拆开说insert idbatchInsert useGeneratedKeystrue keyPropertyid insert into device_record (device_code, device_name, status, collect_time, create_time) values foreach collectionlist itemitem separator, (#{item.deviceCode}, #{item.deviceName}, #{item.status}, #{item.collectTime}, now()) /foreach /insertcollectionlist因为接口上用了Param(list)名字要对得上。item是循环变量的名字随便取但要在#{}里一致。separator,是最关键的一个属性它插在每个循环体之间所以循环体本身不要自己写逗号写了就会变成(),(),这种语法错误——这是最高频的低级错误报错信息通常是You have an error in your SQL syntax看着毫无头绪其实回头看 XML 一眼就能发现。useGeneratedKeystrue加上keyPropertyid是主键回填执行完成后 List 里每个对象的 id 都会被填上。MySQL 驱动支持多值插入的主键回填但要注意批次不要太大我实测批次超过 2000 之后回填偶尔会出现顺序错乱官方文档也建议控制规模我一般控制在 500 到 1000。另外如果插入的某些字段是可选的可以用choose或者trim动态拼列但列名和值必须同步判断否则会出现列数和值数不匹配。我的经验是批量插入的动态列尽量别做真要做就老老实实分两条 SQL一条全字段插入、一条精简插入代码反而更好维护。3.4 MySQL、Oracle、达梦的方言差异MySQL 支持values (...),(...),(...)这种多值插入foreach 用separator,就够了。Oracle 不支持这种语法要用insert all ... into ... select 1 from dual的形式foreach 的写法完全不同separator通常留空靠每个循环体自带的into语句拼接insert idbatchInsertForOracle insert all foreach collectionlist itemitem into device_record (device_code, device_name, status) values (#{item.deviceCode}, #{item.deviceName}, #{item.status}) /foreach select 1 from dual /insert达梦数据库在语法上对 Oracle 兼容度较高上面这种insert all的写法通常可以直接用但要注意版本差异有些版本对select 1 from dual的处理略有不同建议先在测试库跑通再改生产。用国产数据库时我一般都会单独准备一套 XML靠 Mybatis 的databaseId机制做多方言切换不要在一条 SQL 里用if去兼容那样只会让 SQL 变成一锅粥。4. 参数调优那些数字不是拍脑袋定的4.1 先算清楚单条 SQL 的字节数再定批次批次大小不能凭感觉定要先估算单行数据在 SQL 文本里占多少字节。假设一行五个字段两个字符串平均 32 字节两个时间字段各 19 字节加上占位符、括号、逗号、空格大约 20 字节一行文本粗算 120 字节。再乘以批次大小、乘以 UTF-8 下中文可能占 3 字节的系数500 行大约 60 到 90 KB。MySQL 默认max_allowed_packet是 4 MB部分版本 64 MB看起来完全够用但要注意这个包是整条语句的大小如果你的表里有长文本字段几行就能顶上去。我的做法是先查show variables like max_allowed_packet把值记下来再按单行估算字节 × 批次 × 1.5 安全系数 max_allowed_packet来定批次上限最后在测试环境实跑一遍确认。4.2 分批大小的计算过程演示假设表里有device_name这种可能存中文名称的字段最长见过 60 个字符那 UTF-8 下最坏情况占 180 字节。全字段估算6412841919字符宽度最坏情况按 3 字节算约 700 字节一行。目标单条 SQL 控制在 1 MB 以内则批次上限约 1430 行取整保守到 1000。上生产前再用真实数据的最大值跑一遍如果发现接近上限就把批次降到 500。提示不同表、不同字段长度算出来的批次完全不同别把别人博客里的 500 或者 1000 直接抄到自己项目里那只是个经验起点不是结论。4.3 rewriteBatchedStatements 这个开关用对了收益巨大如果你走的是 BATCH 模式MySQL 连接串上有个参数值得关注rewriteBatchedStatementstrue。默认情况下即使你用的是 BATCH 模式MySQL 驱动也可能把语句一条一条发给服务端批量体感不明显。打开这个开关后驱动会把多条结构相同的 insert 合并成一条多值 SQL 发出去效果和 foreach 多值插入接近而代码不用改。它会自动处理批次切分和迁移使用前建议在测试环境对比开启前后的 QPS 和耗时。注意这个参数在不同驱动版本上的行为有差异且它对 select 语句无效只对批量写有意义。另外如果批次太大驱动内部也会再切片不必担心一次发几万条。4.4 日志和监控怎么开判断批量到底有没有生效最直接的办法是打开 Mybatis 的 SQL 日志把 mapper 包路径的日志级别调到 DEBUG你能看到打印出来的完整 SQL。如果看到一条 SQL 里带着一大串 values说明生效了如果看到的是一堆相同的单条 insert说明批量没起作用要回去检查 foreach 或者 rewriteBatchedStatements。生产环境我一般不全量开 DEBUG只在排查时段临时开或者用 Mybatis 的慢 SQL 拦截器记录超过阈值的语句。另外把批次耗时、批次大小、失败条数打到监控里出问题时一眼就能定位是哪个环节变慢了。5. 踩过的坑和排查速查表5.1 常见报错现象和根因对照现象大概率根因处理方式SQL syntax报错位置在逗号附近foreach 的 separator 和循环体里多写了逗号检查循环体末尾是否自带逗号Parameter xxx not found驼峰映射没开或者参数名没加 Param开启 mapUnderscoreToCamelCase接口加 Param插入后主键为 nulluseGeneratedKeys 没配或 BATCH 模式没 flush检查 keyPropertyBATCH 下先 flushStatementsPacket for query is too large批次太大超出 max_allowed_packet降低批次或调大数据库参数部分数据没进去循环里 catch 了异常但没抛事务没回滚检查事务注解和异常吞噬唯一索引冲突报 Duplicate entry批内有重复数据或上游重试导致重复入库前用 Set 去重或改成 insert ignore/on duplicate key耗时没下降每条都在独立事务里提交把整批包在一个事务中5.2 事务和缓存造成的假象要分清有一种很迷惑的现象批量插入代码执行完代码里紧接着查一下发现数据在换个会话去查发现没有。这通常是事务还没提交导致的。如果这个方法上被 Spring 的事务代理包着方法内部的查询用的是同一个连接能看到未提交数据方法外部的查询看不到直到事务提交。这不是批量插入的 bug是你对事务边界的理解需要对齐。另一个现象是插入后查询拿到的还是旧值这跟 Mybatis 的一级缓存有关。一级缓存的作用域是同一个 SqlSession默认开启。批量插入和查询如果在同一个 SqlSession 里执行且插入没有触发缓存失效查询可能直接命中缓存里的旧结果。解决办法是让插入和查询不要在同一个会话里做或者显式配置 flushCache。二级缓存的问题更多涉及跨会话的数据一致性批量写入场景下我建议直接关掉二级缓存收益远小于它带来的排查成本。5.3 OGNL 表达式里字符比较的坑别在这里栽跟头批量插入前通常要做一轮数据过滤比如只处理状态为1的记录XML 或注解里可能写if teststatus 1。这里有个经典的 OGNL 陷阱单引号包裹的单个字符会被识别为 char 类型而你的 status 字段是 StringString char恒为 false条件永远不成立过滤逻辑静默失效数据少了一批你还找不到原因。正确写法是status 1用双引号或者直接1.equals(status)。这个问题在批量插入场景里特别隐蔽因为过滤失效不会报错只会让你发现怎么少插了几条。6. 几个绕不开的延伸问题6.1 foreach 的 collection 到底能写什么不显式加Param时传单个 List 参数Mybatis 会把 List 包成mapkey 是list和collection两个名字都能用。传数组时 key 是array。传 Map 时就直接写 Map 里的 key 名。加了Param之后名字变成你指定的那个。我的习惯是永远显式加Param别依赖默认名因为一旦方法签名变了比如又加了一个参数默认名就可能失效而显式命名的代码一眼能看出对错。6.2 一级缓存、flushCache 和批量操作的关系一级缓存默认对同一 SqlSession 生效批量插入属于写操作正常会清空当前 SqlSession 的缓存。但在一些复合场景里同一个 SqlSession 先查后写再查缓存行为就容易让人误判。如果发现数据表现和预期不一致最省事的做法是把这段逻辑拆成独立的事务方法让会话边界清晰而不是去和缓存兜圈子。配置层面把二级缓存关掉也是减少不确定性的常规做法。6.3 数据量再往上走还该不该用 Mybatis 插如果一次要灌几百万条Mybatis 的批量插入就不太合适了链路上的查询、参数绑定、事务日志都会成为瓶颈。这种规模通常走数据库原生的导入工具或者把数据先落成文件再整体导入速度会高一个数量级。Mybatis 批量插入的舒适区间我个人经验是单批几千到几万条配合合理的批次切分和事务控制这个范围内的表现足够支撑绝大多数业务场景。最后分享一个我自己一直在用的小技巧把批量插入的批次大小、事务边界、异常处理封装成一个通用的BatchInsertTemplate工具业务代码只传 List 和分批大小剩下的切片、循环、累计影响行数、异常时打印失败下标都交给工具。这个类写过一次后面所有项目直接复用也避免每个开发各写一套、各踩一遍同样的坑。真正省时间的从来不是某个参数而是把重复的坑固化成一套可靠的模板。

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

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

免费获取报价 →
↑