
1. 从单条插入到批量入库为什么saveBatch值得深究在任何一个涉及数据库操作的后端项目里数据插入都是最基础也最高频的动作。刚开始做项目时我们可能习惯性地用save方法一条一条地把数据往数据库里塞。这在小数据量、低频次的场景下没什么问题但当你要处理一个包含上千条记录的Excel导入或者一个实时流中不断涌来的数据点时这种“单打独斗”的方式就会立刻成为性能瓶颈。我见过不少项目在数据量稍微起来之后导入功能就慢得让人无法忍受甚至直接把数据库连接池打满导致服务不可用。问题的根源往往就出在最简单的“插入”操作上。MyBatis-Plus简称MP作为MyBatis的增强工具包其saveBatch方法就是为解决这个问题而生的。但如果你认为它只是简单地把多个save方法调用包在一个循环里那就大错特错了。真正“玩转”saveBatch意味着你需要理解它背后的批量提交Batch机制、事务边界、以及在不同场景下的性能表现和潜在陷阱。这不仅仅是调用一个API那么简单而是涉及到JDBC驱动行为、数据库连接配置、以及MP自身封装逻辑的一整套知识。处理得当它能将插入性能提升几个数量级使用不当它可能比单条插入还要慢甚至引发数据一致性问题。这篇文章我就结合自己多次在数据同步、日志归档、批量初始化等场景中踩过的坑和积累的经验带你彻底拆解saveBatch方法。我们会从它的默认行为聊起深入到源码层面看它如何工作然后探讨如何通过配置最大化其性能最后分享几个实战中高频出现的“坑”及其解决方案。目标很明确让你不仅能“用”saveBatch更能“用好”它在面对海量数据操作时心里有底手上有招。2. saveBatch的默认行为与底层机制拆解很多开发者第一次使用saveBatch时会下意识地认为它对应着SQL中的INSERT INTO table VALUES (…), (…), (…)这种多值插入语句。实际上在默认配置下MP的saveBatch并非如此。理解这一点是避免后续很多误区的关键。2.1 默认模式模拟批量与JDBC批处理MP的saveBatch方法在默认情况下其内部逻辑可以概括为“模拟批量”。当你传入一个实体列表例如ListUser时MP会开启一个事务如果当前没有事务然后遍历这个列表。对于列表中的每一个实体它会动态生成一条完整的INSERT语句例如INSERT INTO user (id, name) VALUES (?, ?)并通过MyBatis的SqlSession去执行。这里的关键在于MP默认会利用JDBC的PreparedStatement.addBatch()和executeBatch()机制。也就是说对于每一条INSERT语句MP会将其添加到同一个PreparedStatement的批处理队列中而不是立即执行。当累积的语句数量达到一个阈值这个阈值我们后面会详细讲或者遍历完所有实体后MP会一次性调用executeBatch()将这批INSERT语句发送给数据库执行然后清空批处理队列。所以数据库接收到的并不是一条包含多组值的SQL而是多条独立的INSERT语句只不过它们被JDBC驱动打包在一个网络请求里发送并在数据库端作为一个批次执行。这相比循环调用save每条语句独立请求、独立执行已经带来了巨大的性能提升主要体现在减少网络往返RTT多次单条插入需要多次完整的“请求-响应”网络交互而批处理将多个操作合并极大减少了网络延迟开销。数据库优化大多数数据库如MySQL、PostgreSQL对批处理执行有内部优化虽然是一条条执行但上下文切换开销更小。你可以通过开启MyBatis的SQL日志来验证这一点。默认情况下你会看到多条INSERT语句被打印出来但它们通常是在一个JDBC Batch的上下文中执行的。2.2 一个容易被忽略的核心参数batchSizesaveBatch方法有一个重载版本saveBatch(CollectionT entityList, int batchSize)。这里的batchSize参数至关重要它直接决定了上面提到的“阈值”。这个batchSize是什么意思它并不是指一次向数据库插入多少条数据记录而是指一次executeBatch()调用执行多少条SQL语句。在默认的“模拟批量”模式下一条数据记录对应一条INSERT语句所以batchSize在这里等价于“每批插入的记录数”。MP的默认batchSize值是1000在DbConfig中可配置。这意味着如果你传入一个3000条记录的列表MP会将其分成3批每批1000条依次执行executeBatch()。为什么需要分批防止内存溢出OOMPreparedStatement的批处理队列是在JVM内存中维护的。如果一次性将数万条语句加入批处理会占用大量内存可能引发OOM。避免超时与锁竞争一个包含数万条INSERT的巨型批处理执行时间会非常长。这可能导致数据库连接占用过久增加锁等待时间甚至触发事务超时。数据库限制某些数据库的驱动或服务端对单次批处理的语句数量有限制。因此设置一个合理的batchSize是性能调优的第一步。对于大多数OLTP场景1000是一个比较安全的默认值。但对于数据迁移、归档等场景你可能需要根据实际情况调整。注意batchSize调得过大比如10000可能适得其反导致单批执行时间过长内存压力增大。通常建议在500-2000之间进行测试找到最适合当前数据库和网络环境的甜蜜点。2.3 与真正的批量插入Bulk Insert的对比我们常说的“批量插入”在SQL层面有两种形式多值插入Multi-Value InsertINSERT INTO table (col1, col2) VALUES (v1, v2), (v3, v4), (v5, v6)...JDBC批处理Batch Insert多次INSERT INTO table (col1, col2) VALUES (?, ?)然后通过addBatch()和executeBatch()执行。MP默认的saveBatch采用的是第二种方式。第一种方式多值插入在插入大量数据时理论上性能更高因为SQL解析、执行计划生成的次数更少。MP本身不直接生成多值插入的SQL但我们可以通过其他方式实现这会在后面的高级用法中探讨。这里先明确一个结论在默认配置下saveBatch的性能提升主要来自于JDBC批处理对网络和数据库执行的优化而非生成更高效的SQL语句。它的优势在于通用性和无侵入性对数据库和SQL语法没有特殊要求。3. 深入配置让saveBatch性能飞起来理解了默认行为我们就可以通过配置来解锁saveBatch的更强性能。这些配置分散在MP的全局配置、数据源配置以及数据库驱动层面。3.1 关键配置参数解析要让JDBC批处理发挥最大效力以下几个参数必须关注1. MyBatis-Plus 全局配置 (MybatisPlusProperties或MybatisConfiguration)defaultBatchSize: 可以修改全局默认的批处理大小。在配置类中通过Bean注入SqlSessionFactory时设置。Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // ... 添加其他插件 return interceptor; } Bean public ConfigurationCustomizer configurationCustomizer() { return configuration - configuration.setDefaultBatchSize(500); // 修改默认批次大小 }2. 数据源连接池配置 (如 HikariCP)批处理性能与数据库连接密切相关。务必在连接池配置中开启对批处理的支持rewriteBatchedStatementstrue(MySQL驱动最关键参数): 这个参数是MySQL JDBC驱动 (mysql-connector-java) 的。当设置为true时驱动会尝试将executeBatch()中的多条INSERT语句重写为一条多值插入语句即上面提到的第一种方式。这是让MySQL下saveBatch性能产生质变的神奇参数URL示例jdbc:mysql://localhost:3306/test?rewriteBatchedStatementstrueuseUnicodetruecharacterEncodingutf8useSSLfalse效果原本INSERT INTO t (a) VALUES (1); INSERT INTO t (a) VALUES (2);会被重写为INSERT INTO t (a) VALUES (1),(2);性能提升极为显著。cachePrepStmtstrueprepStmtCacheSize250prepStmtCacheSqlLimit2048: 这些是MySQL驱动关于预编译语句缓存的参数。开启后数据库会缓存预编译语句避免同一批INSERT语句的重复编译开销对批处理性能也有积极影响。3. 数据库服务端配置确保数据库服务端允许接受大的数据包。对于MySQL需要检查max_allowed_packet参数。如果一次批处理的数据总量超过了这个值会导致错误。通常建议设置为256M或更高具体根据单条数据大小和batchSize来估算。-- 查看当前值 SHOW VARIABLES LIKE max_allowed_packet; -- 临时设置重启失效 SET GLOBAL max_allowed_packet 256*1024*1024; -- 需要在配置文件中修改以永久生效3.2 性能对比测试与参数调优建议纸上得来终觉浅我通过一个简单的测试来展示不同配置下的性能差异。测试场景向一个简单的user表id, name, age中插入10万条数据。测试方案关键配置耗时 (约)说明方案A循环单条save无批处理无优化120 秒性能灾难绝对要避免。方案BsaveBatch (默认)batchSize1000未加rewriteBatchedStatements35 秒利用JDBC批处理性能提升明显。方案CsaveBatch (优化)batchSize1000添加rewriteBatchedStatementstrue4 秒性能王者。驱动重写SQL为多值插入。方案DsaveBatch (超大batch)batchSize10000rewriteBatchedStatementstrue5-6 秒相比方案C提升不大甚至可能因内存和数据库压力略慢。方案EsaveBatch (过小batch)batchSize100rewriteBatchedStatementstrue15 秒批次太多网络和事务开销增加。调优建议总结首要任务无论如何一定要在MySQL连接字符串中加上rewriteBatchedStatementstrue。这是成本最低、收益最高的优化。调整batchSize在添加了上述参数后batchSize的影响会相对变小但依然重要。可以从默认的1000开始根据实际数据量和服务器性能在500-2000之间微调。建议进行压测找到最佳值。配合连接池优化同时设置cachePrepStmts等相关参数形成组合拳。关注数据库侧确保max_allowed_packet足够大。实操心得在一次历史数据迁移中我使用了未优化的saveBatch方案B迁移500万数据花了近半小时。加上rewriteBatchedStatementstrue后方案C同样的数据量只用了不到2分钟。这个参数的重要性怎么强调都不为过。4. 事务、ID生成与并发场景下的陷阱当你把saveBatch的性能调上去之后接下来就要面对它在复杂场景下的稳定性和正确性问题。这里有几个常见的“坑”。4.1 事务的边界与控制saveBatch方法本身不开启事务。它依赖于外部的事务上下文。如果外部没有事务MP在saveBatch方法内部会为每一批注意是每一批不是整个列表的执行开启并提交一个事务。这意味着如果插入3000条数据分3批每批1000条那么会存在3个独立的事务。如果第二批插入失败第一批已经提交的数据不会回滚这可能导致数据不一致。如果外部有事务例如方法上标注了Transactional那么整个saveBatch的所有批次操作都在同一个事务中。任何一批失败整个事务回滚所有数据都不会插入。这是通常我们期望的行为。因此最佳实践是在调用saveBatch的业务方法上显式添加Transactional注解确保数据操作的原子性。Service public class UserService { Transactional(rollbackFor Exception.class) // 显式声明事务 public void batchImportUsers(ListUser userList) { userService.saveBatch(userList, 1000); } }坑点警示我曾经遇到过在异步方法如Async中调用saveBatch但没有注意事务传播导致部分数据插入后异步线程异常使得数据只插入了一半。务必确保事务边界清晰。4.2 主键ID生成策略的冲突MP默认的主键生成策略是IdType.ASSIGN_ID雪花算法或IdType.AUTO数据库自增。这在saveBatch时需要注意ASSIGN_ID (雪花算法)MP会在Java代码层面为每一条记录的ID赋值然后再执行插入。这对于saveBatch是友好的因为所有ID在插入前就已确定不会引发数据库侧的主键冲突。性能也最好。AUTO (数据库自增)这依赖于数据库的自增字段如MySQL的AUTO_INCREMENT。在批处理插入时即使使用rewriteBatchedStatements重写为多值插入MySQL也只会为第一条记录生成一个自增ID然后依次递增。这看起来没问题。但是如果你在插入前业务逻辑中已经依赖了实体对象的ID例如用ID作为关联其他数据的键那么就会出问题因为ID在插入前是null。建议在需要批量插入且后续逻辑可能依赖ID的场景优先使用ASSIGN_ID雪花算法。如果必须用AUTO请确保在数据完全插入成功后再使用这些数据的ID。4.3 并发调用与连接池耗尽高并发场景下多个线程同时调用saveBatch每个批处理都会占用一个数据库连接直到该批执行完毕。如果batchSize很大或数据量很大单个批处理执行时间较长就可能导致连接池中的连接被迅速占满新的请求需要等待引发系统雪崩。解决方案控制并发度对于后台批量任务使用线程池并控制最大并发线程数。例如通过ThreadPoolExecutor限制同时执行批量导入的线程数。优化单次处理量不要一次性尝试用saveBatch处理百万级数据。应该将其拆分成多个小任务分批次、分时段处理。可以使用分页查询源数据然后循环调用saveBatch。监控与扩容监控数据库连接池的使用情况如HikariCP的activeConnections、idleConnections。在压力大时适当增加连接池最大连接数maximumPoolSize但这只是缓解根本还是要优化单次批处理的效率和控制并发。5. 超越saveBatch更高阶的批量操作策略当saveBatch加上rewriteBatchedStatements优化后性能已经非常可观。但对于一些极端场景如单次初始化亿级数据我们还可以考虑更激进的方案。5.1 使用MP的executeBatch与自定义SqlInjectorMP的saveBatch最终会调用SqlSession的flushStatements()来执行批处理。我们也可以直接获取SqlSession手动控制批处理过程实现更精细的控制比如混合INSERT和UPDATE操作。更高级的用法是自定义SqlInjector为你的Mapper注入一个全新的批量插入方法。这个方法可以直接编写使用foreach标签的MyBatis XML映射文件生成真正的多值插入SQL。这种方式绕过了JDBC批处理重写直接生成最优SQL理论上性能极限更高。但实现复杂度也更高需要深入理解MP的扩展机制。5.2 终极武器数据库原生批量导入工具如果数据量真的巨大例如TB级别任何在应用层进行的批量插入都可能不是最优解。此时应该考虑数据库的原生工具MySQL:LOAD DATA INFILE命令。它可以从一个文本文件如CSV中高速加载数据到表中速度远超任何SQL插入方式。应用层的职责变为生成格式正确的数据文件。PostgreSQL:COPY命令。其他数据库都有类似的批量加载工具。操作流程通常是应用将待插入数据生成一个标准格式如CSV的文件。应用或运维人员通过命令行、脚本或JDBC执行LOAD DATA INFILE ...语句。数据库直接从文件系统读取并加载数据。这种方式的速度比应用层插入快一个数量级以上是数据迁移、初始化等场景的终极解决方案。当然它牺牲了部分灵活性和事务控制需要额外的文件生成和清理步骤。5.3 实战中的选择策略如何选择这些方案我的经验是日常业务批量操作千到十万级saveBatchrewriteBatchedStatementstrue 合理batchSize 事务控制。这是性价比最高、最通用的方案能满足99%的场景。定期数据同步或归档百万级在上述方案基础上进行应用内分页分批。例如从源分页查询每页1000条循环调用saveBatch。同时可以考虑在业务低峰期执行并监控数据库负载。历史数据迁移或初始化千万级以上优先评估使用数据库原生工具如LOAD DATA INFILE。如果条件不允许如无文件服务器权限再考虑使用自定义SqlInjector生成多值插入SQL并严格控制批次大小和并发。最后无论用哪种方式一定要在预发布环境进行充分的性能测试和数据一致性验证。批量操作无小事一次线上事故的代价远高于开发时多花的时间。我自己的习惯是任何新的批量逻辑上线前都会用生产数据的子集做一次全流程的压测和回归确认性能和结果都符合预期。