MyBatis List批量插入:三种方案、参数调优与踩坑实战
批量把 List 里的数据落进数据库几乎是每个做业务系统的人都绕不开的一道坎。刚入行那会儿我以为这就是个for循环加insert的事直到线上跑了一次二十万条的标签同步接口硬生生超时到三分钟我才意识到这里面水深得很。这篇文章不讲空泛概念只聊 MyBatis 场景下把 List 批量写库这件事该怎么干、有哪些坑、参数怎么算、方案怎么选。适合已经用过 MyBatis 但没认真研究过批处理的同学也适合正在做数据库课程设计、第一次面对一次性插入几千条数据这个需求的朋友。读完之后你应该能根据自己手里的数据量、数据库类型和事务要求直接挑出一套能落地的写法而不是照着网上抄一段 XML 就上线。1. 先搞清楚List 入库的三种写法到底差在哪很多人对批量插入的理解是模糊的——反正就是把一堆数据塞进去快就行。但实际动手前你得先知道 MyBatis 这个层面到底有哪几条路可走每条路的天花板在哪不然后面调优的时候根本不知道该调什么。1.1 循环单条 insert能跑但代价你未必算过最直觉的写法就是在 Service 里遍历 List一条一条调mapper.insert()。这种代码我见过太多尤其是在需要拿到每条记录自增主键的场景里大家图省事就这么写了。它的代价藏在两个地方。第一是网络往返每调一次 Mapper就要走一次应用发 SQL → 数据库执行 → 返回结果的完整流程。假如一次往返耗 0.5 毫秒插入 1 万条就是 5 秒而这 5 秒里大部分时间都花在了等网络上数据库本身可能只忙了 200 毫秒。第二是事务开销如果整个循环包在一个大事务里数据库要为每一行维护 undo、redo写压力其实比你想的大。不过它也不是一无是处。当你的 List 只有几十条而且必须逐条拿到主键做后续关联插入时循环单条反而是最省心的选择。我个人的判断标准是数据量在 100 条以内、且需要逐条回填主键就用循环超过这个规模就该考虑换写法了。1.2 foreach 拼 SQL快但有天花板这是网上教程里出现频率最高的写法靠 MyBatis 的foreach标签把一批参数拼成一条多值INSERT。它最大的优势是一条 SQL 搞定一批数据网络往返从 N 次降到 1 次速度提升非常明显。但它有一个物理上限单条 SQL 的长度。MySQL 有max_allowed_packet这个参数卡着Oracle 对 SQL 文本长度、绑定变量个数也有约束达梦这类国产库同样有自己的限制。你把 10 万条记录拼成一条 SQL大概率不是被数据库拒绝就是把数据库的内存顶上去。还有一个容易被忽略的点拼接出来的 SQL 是硬解析的。参数不一样SQL 文本就不一样数据库没法复用执行计划每条 SQL 都要重新解析一遍。数据量小的时候看不出来量大了这块开销会很明显。我的经验是foreach 方案适合单批 200 到 1000 条并且一定要配合切片使用也就是把大 List 拆成若干小 List 分批调用。1.3 ExecutorType.BATCH真正的批处理通道前两种方案的本质都是在 SQL 语句层面做文章而ExecutorType.BATCH走的是另一条路它借用 JDBC 的addBatch()/executeBatch()机制把多条独立的 insert 攒在客户端最后一次性发给数据库。这个模式下MyBatis 内部用的是BatchExecutor。它会把相同 SQL 的多次调用合并起来减少与数据库的交互次数。配合 MySQL 驱动里的rewriteBatchedStatementstrue参数驱动还会在发送前把多条INSERT ... VALUES (?)重写成一条多值 insert效果和 foreach 拼 SQL 类似但 SQL 文本是统一的执行计划可以复用。BATCH 模式最舒服的地方在于你写代码的时候完全是单条插入的写法但执行的时候是批量的。不用去动 XML不用关心数据库方言差异代码可读性也好。代价是事务和主键回填的处理稍微绕一点后面我会详细讲。1.4 三种方案怎么选一张表说清方案适用数据量主键回填数据库兼容性主要风险循环单条 insert100 条以内完美支持全兼容网络往返多慢foreach 多值 INSERT200 ~ 1000 条/批部分支持回填不全MySQL 好Oracle 需换语法单条 SQL 过长被拒ExecutorType.BATCH1000 条以上支持但需手动 flush全兼容事务边界、缓存处理要小心选型的时候我一般这么判断先看数据量再看数据库类型最后看要不要回填主键。三者里数据量的权重最高——几百条以内随便挑几千条往上就必须上批处理。2. 动手前的环境与配置准备方案定了接下来是环境。这一段很多人会跳过直接开始写代码结果后面调半天发现是连接串里少了一个参数。我吃过这个亏所以现在习惯先把配置理清楚再动手。2.1 依赖版本怎么搭现在主流是 Spring Boot 集成 MyBatis依赖上一般有两种组合原生的mybatis-spring-boot-starter或者mybatis-plus-boot-starter。前者轻后者功能多。我个人在纯 CRUD 项目里更倾向 MyBatis-Plus因为它自带的saveBatch已经帮你把分批逻辑写好了省事但在需要精细控制 SQL 的项目里还是用原生 starter 更清爽不会莫名其妙多出一堆自动填充的逻辑。版本上有个小提醒Spring Boot 3.x 之后MyBatis 的 starter 必须用支持 Jakarta EE 的版本一般是 3.0.x 及以上否则启动的时候会报javax.servlet找不到。这个坑我在升级项目时踩过一次排查了快一个小时才发现是版本不匹配。依赖大致长这样dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency2.2 连接串里那几个决定性能的参数这是我最想强调的一段。很多人批量插入慢问题不在代码而在 JDBC 连接串。以 MySQL 为例批量场景下必须关注的参数有这么几个rewriteBatchedStatementstrue这是最关键的一个。默认情况下MySQL 驱动收到addBatch后还是一条一条发给服务器批量等于没批。开启这个参数后驱动会把多条 insert 重写成一条多值语句性能差距能有几倍到十几倍。useServerPrepStmts是否使用服务端预处理。批量场景下一般建议关掉false让驱动在客户端拼 SQL配合上面的重写参数效果更好。allowMultiQueries只有当你真的要一次发多条用分号隔开的 SQL 时才需要开平时别开有注入风险。一个典型的生产连接串大概是这样jdbc:mysql://127.0.0.1:3306/demo?rewriteBatchedStatementstrueuseServerPrepStmtsfalsecharacterEncodingutf8useSSLfalse提示rewriteBatchedStatementstrue这个参数在 Oracle、达梦等数据库上不存在它们是原生支持 JDBC 批处理的。别把 MySQL 的连接参数无脑复制到别的库上会直接连接失败。2.3 表结构、实体类与 Mapper 骨架为了后面讲得具体我拿一个很常见的场景举例用户标签关联表user_tag字段有自增主键id、user_id、tag_id、create_time。CREATE TABLE user_tag ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, tag_id BIGINT NOT NULL, create_time DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;实体类用 Lombok 精简一下Data public class UserTag { private Long id; private Long userId; private Long tagId; private LocalDateTime createTime; }Mapper 接口给两个方法一个单条插入一个批量插入public interface UserTagMapper { int insertOne(UserTag userTag); int batchInsert(Param(list) ListUserTag list); }这里有个细节批量方法一定要用Param(list)明确指定参数名否则在 XML 里引用集合的时候容易出问题特别是当方法只有一个 List 参数时MyBatis 对参数名的推断有时候不按你预期走。2.4 别忽略的日志配置批量插入出问题的时候你第一件想做的事肯定是看看实际发出去的 SQL 长什么样。但 MyBatis 默认打印的日志里参数全是?根本对不上。这里有两个办法。一是把日志级别调到DEBUG能看到 SQL 和参数分两行打印二是装个 IDEA 的 MyBatis 日志插件它会把日志里的?自动替换成真实参数拼成一条完整可执行的 SQL复制出来直接能在客户端跑。这个插件在排查批量插入问题时特别有用——尤其是当你怀疑某条数据格式不对导致整批失败时把完整 SQL 拿到客户端单独执行一下问题立刻现形。配置日志级别logging: level: com.example.demo.mapper: debug注意生产环境千万别把这个日志级别开着。批量插入时一条 SQL 可能几千个参数日志文件能瞬间涨到几百兆磁盘告警就是这么来的。3. 三种方案的完整落地代码环境齐了下面逐个方案把代码写完整。每一段我都会说明为什么这么写以及边界在哪。3.1 foreach 多值 INSERT 的 XML 写法与边界MySQL 下的标准写法insert idbatchInsert useGeneratedKeystrue keyPropertyid INSERT INTO user_tag (user_id, tag_id, create_time) VALUES foreach collectionlist itemitem separator, (#{item.userId}, #{item.tagId}, #{item.createTime}) /foreach /insertseparator,负责在每行之间加逗号item是循环变量名取值时用item.字段名。这里有个必须提前知道的边界useGeneratedKeys在多值 insert 下主键回填是不完整的。MySQL 在某些版本下只会把第一个自增 id 填进去后面的都是空。如果你的业务依赖每条记录的主键这个方案就不合适得换 BATCH 或者逐条插。另外如果 List 是空的foreach会生成VALUES后面什么都没有SQL 直接语法报错。所以每个批量方法入口都要做非空判断if (list null || list.isEmpty()) { return 0; }3.2 Oracle、达梦下 foreach 要换个写法Oracle 不支持INSERT INTO t VALUES (...), (...)这种多值语法你得换成INSERT ALLinsert idbatchInsert INSERT ALL foreach collectionlist itemitem INTO user_tag (user_id, tag_id, create_time) VALUES (#{item.userId}, #{item.tagId}, #{item.createTime}) /foreach SELECT 1 FROM DUAL /insert达梦数据库比较特殊它同时兼容 Oracle 语法和部分 MySQL 语法具体能不能用多值VALUES取决于建库时选择的兼容模式。我建议在达梦上先用INSERT ALL这个写法在两种模式下基本都能跑通。还有个 Oracle 特有的坑IN条件里元素个数超过 1000 会报错。虽然这跟插入没直接关系但在先查再插的逻辑里很容易撞上需要提前把集合切片。顺带提一句向量数据库现在做向量检索的同学把 embedding 批量写进去的思路和这里是相通的——同样是拼批次、控包大小只不过目标端换了。3.3 BATCH 会话模式的标准模板这是我最推荐的方案代码稍微多几行但省心。核心是手动开一个ExecutorType.BATCH的 SqlSessionService public class UserTagBatchService { private static final int BATCH_SIZE 500; Autowired private SqlSessionFactory sqlSessionFactory; public void saveBatch(ListUserTag list) { if (list null || list.isEmpty()) { return; } SqlSession session sqlSessionFactory.openSession(ExecutorType.BATCH, false); try { UserTagMapper mapper session.getMapper(UserTagMapper.class); for (int i 0; i list.size(); i) { mapper.insertOne(list.get(i)); if ((i 1) % BATCH_SIZE 0) { session.flushStatements(); } } session.flushStatements(); session.commit(); } catch (Exception e) { session.rollback(); throw e; } finally { session.close(); } } }几个关键点解释一下。第一openSession(ExecutorType.BATCH, false)第二个参数是autoCommit这里给 false让我们手动控制提交。第二flushStatements()是真正把攒着的 SQL 发给数据库的动作每 500 条刷一次既控制了单次发送的数据量又及时释放了客户端内存。第三忘记commit()是新手最常见的错误——代码跑完没报错但数据库里一条没有查半天以为是缓存问题。这里要注意事务的问题。上面这段代码是自成一个事务的跟外层 Spring 的事务不在一个上下文里。如果你需要在同一个大事务里做插完标签再更新用户状态就得换一种集成方式把 SqlSessionTemplate 配成 BATCH 类型交给 Spring 管理或者干脆把批量插入单独作为一个事务方法通过Transactional(propagation REQUIRES_NEW)隔开。3.4 MyBatis-Plus saveBatch 的真实行为如果你用的是 MyBatis-Plus可以直接调saveBatchService public class UserTagServiceImpl extends ServiceImplUserTagMapper, UserTag implements UserTagService { Override public void save(ListUserTag list) { saveBatch(list, 1000); } }注意第二个参数是批大小默认是 1000。它的底层实现其实就是把ExecutorType.BATCH包了一层所以前面说的连接串参数同样生效。不过有个细节我要提一下saveBatch默认走的是flushStatementscommit的流程如果外层已经有事务它会复用外层事务不会自己提交。这挺好但如果你指望它自己提交而外层又用了Transactional就得自己留个心。3.5 分批切片的工具方法与事务边界不管用哪种方案切片工具都是刚需。写一个通用的public static T ListListT partition(ListT source, int size) { if (source null || source.isEmpty() || size 0) { return Collections.emptyList(); } ListListT result new ArrayList(); for (int i 0; i source.size(); i size) { result.add(new ArrayList(source.subList(i, Math.min(source.size(), i size)))); } return result; }这里用new ArrayList(subList)包一层很重要。subList返回的是原 List 的视图不是独立对象如果原 List 在后续被修改视图会抛ConcurrentModificationException。我之前在一个异步任务里就因为这个报错过。事务边界上我的建议是切片在外层事务在每片里。也就是说不要用一个覆盖全部数据的大事务而是每一片单独提交。好处是失败了只回滚那一片重试成本低数据库的压力也小。代价是失去了整体原子性——但这在批量导入场景下通常是可以接受的毕竟几十万条数据要保证全成功本来就不现实。4. 参数怎么算批大小、包大小与超时参数拍脑袋定是最容易出事的地方。我见过同事把批大小设成 5000本地测试没问题一上生产就报 Packet for query is too large。这一段就把怎么算讲清楚。4.1 max_allowed_packet 与单条 SQL 长度的估算MySQL 的max_allowed_packet默认在 4MB 左右8.0 之后服务端默认值是 64MB但客户端驱动的默认值可能还是 4MB这条 SQL 的长度必须小于它。粗算一下每条记录三个字段两个 bigint8 字节 一个 datetime8 字节加上 SQL 文本里的括号、逗号、占位符展开成实际值后一行大约 60 到 100 字节。取 100 字节做保守估算那么批大小 1000约 100KB批大小 5000约 500KB批大小 10000约 1MB看起来离 4MB 还有距离但别忘了字符串字段可能很长。如果你插的是文章内容、JSON 配置这类大字段单行可能就有几 KB那批大小就得往下压。我的估算公式是批大小 ≈ (max_allowed_packet × 0.5) ÷ 单行平均字节数乘 0.5 是留一半余量防止个别行特别大把整批撑爆。4.2 批大小和事务大小的区别这两个概念经常被混在一起但它们是两回事。批大小决定的是一次性发给数据库多少条影响的是网络往返次数和单条 SQL 长度。事务大小决定的是多少条数据共用一个事务影响的是锁持有时间、undo 日志大小和回滚成本。在ExecutorType.BATCH里flushStatements()控制的是批大小commit()控制的是事务大小。我一般把批大小设成 500事务大小设成 2000也就是每刷 4 次提交一次。这样既不会让网络闲下来也不会让事务开太久把锁攥死。过大的事务还有个隐蔽的坏处主从延迟。事务必须等主库提交完才发给从库一个跑了 30 秒的大事务从库要等这 30 秒之后才能开始重放读从库的业务就会看到旧数据。这个问题在写多读少的系统里特别明显。4.3 一个可复用的参数推算实例举个具体的例子。假设我要批量导入 5 万条用户标签字段就是前面那三个平均每行 80 字节。第一步定批大小。max_allowed_packet按 4MB 算乘 0.5 得 2MB除以 80 字节约等于 26000 行。这个数字太激进实际还得考虑 JVM 内存和网络稳定性所以我往上压到 500。第二步定事务大小。5 万条 ÷ 500 100 批。如果每批一个事务那就是 100 个事务数据库要处理 100 次提交有点碎。我一般四批一个事务也就是事务大小 2000总共 25 个事务。第三步估算耗时。按我在本地 MySQL 8 上的实测BATCH 模式下 500 条一批大约 15 到 25 毫秒5 万条就是 100 批约 2 秒出头。这个量级对大多数接口是可接受的如果超时限制是 3 秒得考虑拆成异步任务。5. 踩坑实录这些错我基本都犯过代码能跑通只是第一步真正花时间的是排查各种莫名其妙的现象。下面这些是我这几年实际遇到过的按出现频率排序。5.1 报错速查表报错信息大概率原因解决方向Packet for query is too large单条 SQL 超包大小减小批大小或调大 max_allowed_packetORA-00933: SQL 命令未正确结束Oracle 不支持多值 VALUES换成 INSERT ALL 写法执行成功但表里没数据忘记 commit 或事务回滚了检查 SqlSession 提交和 TransactionalParameter list not found参数名没指定Mapper 方法加 Param(list)主键 id 只有第一条有值useGeneratedKeys 不完整回填改用 BATCH 模式或逐条插入大量 GC、接口响应变慢List 太大内存里堆了太多对象上游就分批别一次性加载全量5.2 自增主键回填为什么只回了一个这个坑我在做订单批量导入时踩得最惨。当时用 foreach 多值插入useGeneratedKeystrue测出来第一单的 id 是对的后面全是 null导致关联表插入失败。原因在于 JDBC 驱动的getGeneratedKeys()在多值插入时不同驱动的实现不一致。MySQL 驱动在开启rewriteBatchedStatements时能正确返回所有自增主键但在普通多值 insert 下行为就不一定了。我的处理方式是需要回填主键的场景一律用 BATCH 模式。它走的是 JDBC 标准批处理驱动会为每条记录返回主键回填是完整的。如果非得用 foreach那就只能插入之后再用业务唯一键反查一次多一次查询成本。5.3 空集合导致的 SQL 语法错误前面提过空 List 会让foreach生成一个残缺的 SQL。但还有一种更隐蔽的情况List 不为空但里面的元素是 null。这时候#{item.userId}会直接报空指针或者生成(null, null, null)这样的值被非空约束拦下。我的习惯是在切片的入口统一过滤ListUserTag valid list.stream() .filter(Objects::nonNull) .collect(Collectors.toList());一行代码省掉后面无数排查时间。这个思路和做数据库课程设计时处理导入数据是一样的——脏数据在入口拦住比在数据库层报错再回头找要省事得多。5.4 动态 SQL 里那个要命的字符比较这是 MyBatis 里最经典也最容易被忽略的坑之一面试题里也经常考。假设你在批量插入前有个判断if teststatus 1 ... /if如果status是 String 类型这段判断永远为 false。原因是 OGNL 会把单引号包起来的1解析成 char 类型而 String 和 char 比较时走的是不同分支结果就是不等。正确的写法有两种!-- 写法一用双引号明确是字符串 -- if teststatus 1 ... /if !-- 写法二显式调 equals -- if teststatus ! null and status.equals(1) ... /if我一般用写法二语义清楚也不依赖引号嵌套的解析规则。这个坑在批量场景里特别致命因为条件不生效不会报错只会静默地少写数据事后对账才发现。顺便说一下如果你需要根据状态值走不同分支别写一堆if用choose/when/otherwise更清晰它相当于编程语言里的 switchchoose when testtype 1.../when when testtype 2.../when otherwise.../otherwise /choose5.5 一级缓存、flushStatements 与数据没进去MyBatis 的一级缓存是 SqlSession 级别的默认开启。在 BATCH 模式下你调了insert数据其实还在客户端攒着没发给数据库。这时候如果你在同一个 session 里查询是查不到刚插入的数据的。有人遇到这种情况会以为插入失败然后又插一遍最后数据重复。正确做法是要么调flushStatements()把攒的 SQL 发出去要么直接commit()提交之后缓存自然就清了。关于 MyBatis 二级缓存我的建议是批量插入的场景下别开。二级缓存是 namespace 级别的一旦开了批量插入后如果不显式清理其他查询可能读到旧数据。清理逻辑写起来麻烦收益又有限不划算。5.6 事务没生效的几种典型姿势Transactional不生效有几个非常经典的原因。一是方法不是 public 的Spring 的代理拦不住二是同类内部方法直接调用没走代理三是异常被 catch 了没抛出去事务管理器根本不知道出错了四是异常类型不在 rollbackFor 范围内默认只回滚 RuntimeException。批量插入场景里第四条最容易中招。比如你在批量方法里捕获了SQLException受检异常然后打了个日志事务不会回滚前面插进去的半批数据就留在库里了。我的习惯是显式写Transactional(rollbackFor Exception.class)别依赖默认行为。6. 性能实测与调优经验前面讲了这么多写法实际差距到底有多大我用一组实测数据说话。测试环境是本地 MySQL 8.0一共 5 万条记录字段和前面一致。6.1 实测数据对比方案批大小耗时内存峰值备注循环单条 insert1约 42 秒低网络往返是瓶颈foreach 多值 insert500约 2.8 秒中未开主键回填BATCH rewrite500约 2.1 秒中主键回填完整BATCH未开 rewrite500约 11 秒中参数影响巨大第四行和第三行的对比特别值得看。同样的代码只是连接串里少了rewriteBatchedStatementstrue耗时差了五倍。这也是我一直强调参数配置重要性的原因——很多时候不是代码写得不对是配置没配对。6.2 几个不写在文档里的技巧第一个技巧批量插入前先关自动提交。有些框架或者数据源默认 autoCommit 是 true每条 SQL 都自己提交一次批量等于白做。用 HikariCP 的时候确认一下auto-commit配置。第二个技巧索引别建太多。批量插入的性能很大一部分消耗在维护索引上。如果这是一次性的数据导入可以先禁用非唯一索引插完再重建速度能快不少。当然生产环境的常规写入不能这么干。第三个技巧用 MyBatis 拦截器做统一处理。如果你的项目里有很多地方都要批量插入可以写一个拦截器在 Executor 层面统一控制批大小和 flush 时机业务代码里就只需要调一个方法。这个思路在开源的批处理框架里很常见自己实现一版也就一百来行。不过拦截器要慎用改的是全局行为出问题影响面大。第四个技巧监控慢 SQL。批量插入的 SQL 通常很长日志里看起来很吓人但真正要关注的是执行时间。我一般会在数据库侧开慢查询日志阈值设成 500 毫秒批量 SQL 超过这个值就得看是不是批太大了。6.3 什么时候该放弃 MyBatis 直接上 LOAD DATA说到底MyBatis 再快也是逐行走 SQL 协议的。如果你的数据量到了百万、千万级而且是离线导入那就别在应用层折腾了直接用数据库原生的批量导入工具。MySQL 的LOAD DATA INFILE能直接把 CSV 文件灌进表里速度比任何 JDBC 批处理都快一个数量级。Oracle 有SQL*Loader达梦也有对应的dmfldr工具。这些工具的定位就是大批量数据装载专门优化过的。代价是它们绕过了应用的业务逻辑字段校验、默认值填充、关联计算都得提前在文件生成阶段做好。所以这条路适合数据来源干净、一次性导入的场景比如每月的数据归档、初始化导入。日常的业务写入还是老实用 MyBatis 批处理。另外提一句数据同步的场景。如果你做的是两个库之间的表同步像一些数据库同步软件那样思路和批量插入是一致的读一批、转一批、写一批控制好批大小别把内存打满。核心从来不是用哪个工具而是批次怎么切、事务怎么控、失败怎么重试。我个人的体会是批量插入这件事最容易被低估。它不像分布式事务那样有理论深度也不像分库分表那样有架构美感就是个体力活。但恰恰是这种体力活参数差一点、事务边界差一点线上表现就完全不同。我现在的习惯是任何批量写入的代码上手之前先把三个问题问清楚一次最多写多少条、要不要回填主键、失败之后能不能重试。答案有了方案自然就定了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →