MyBatis-Plus saveBatch批量插入原理、性能优化与实战避坑指南

发布时间:2026/7/30 5:04:04
MyBatis-Plus saveBatch批量插入原理、性能优化与实战避坑指南 1. 项目概述为什么saveBatch会成为高频话题最近在几个技术群里发现不少朋友都在讨论MyBatis-Plus的saveBatch方法。有人抱怨它在TiDB上性能不佳有人疑惑为什么updateBatchById没数据修改也返回成功还有新手在新建Spring Boot项目时找不到MyBatis-Plus的依赖。这些看似零散的问题其实都指向了同一个核心我们真的会用MyBatis-Plus的批量操作吗saveBatch顾名思义是MyBatis-Plus提供的一个用于批量插入数据的方法。它封装了JDBC的批量处理能力旨在减少数据库连接的开销和网络传输次数从而提升数据插入的效率。在业务开发中无论是导入Excel数据、同步外部信息还是处理消息队列积压的任务批量插入都是一个高频且关键的操作。然而很多开发者仅仅停留在“知道有这个API”的层面对其背后的实现机制、性能陷阱以及最佳实践知之甚少这就导致了在实际生产环境中一个简单的批量插入操作可能成为性能瓶颈甚至引发数据一致性问题。我自己在多个项目中从早期的“无脑调用”到后来踩过各种坑逐渐摸索出了一套使用saveBatch的心得。今天我就以一个过来人的身份把这套经验掰开揉碎了讲给你听。无论你是刚接触MyBatis-Plus还是在批量操作上遇到了棘手问题相信这篇内容都能给你带来直接的帮助。我们会从最基础的依赖引入讲起深入到saveBatch的源码和原理再结合TiDB等分布式数据库的特性分析性能调优的策略最后把那些官方文档里不会写的“坑”和“技巧”一次性说清楚。2. saveBatch的核心原理与实现机制拆解2.1 从JDBC批处理到MyBatis-Plus的封装要理解saveBatch我们必须先回到它的基石——JDBC批处理。传统的JDBC执行一条SQL需要经历“建立连接 - 创建Statement - 执行 - 关闭”这样一个相对“重”的过程。当需要插入成千上万条数据时这种逐条执行的方式会带来巨大的网络I/O和数据库连接开销。JDBC的批处理addBatch,executeBatch机制就是为了解决这个问题。它允许我们将多条SQL语句“打包”一次性发送给数据库执行。数据库服务器在接收到这个“包”之后可以在一个事务上下文内连续执行大大减少了网络往返次数和SQL解析的开销。MyBatis-Plus的saveBatch方法本质上就是对JDBC批处理的一层优雅封装。它并不是生成一条巨大的INSERT INTO ... VALUES (...), (...), ...语句虽然某些场景下这可能更高效而是默认采用了JDBC的批处理模式。我们来看一下它的典型调用方式// 假设有一个User实体类 ListUser userList new ArrayList(); // ... 向userList中添加大量User对象 // 调用saveBatch进行批量插入 boolean isSuccess userService.saveBatch(userList);这行简洁的代码背后MyBatis-Plus为我们做了以下几件事参数校验与准备检查集合是否为空获取数据库连接。SQL生成根据实体类的映射信息动态生成INSERT语句。注意这里生成的是一条固定的INSERT INTO user (id, name, age ...) VALUES (?, ?, ? ...)其中的?是占位符。批处理执行遍历实体集合为每个实体对象的属性值填充到上述SQL的占位符中并通过PreparedStatement.addBatch()方法将其加入批处理队列。批量提交当累积到一定数量默认可能与JDBC驱动或MyBatis配置有关但MP有自己的逻辑我们后面会细说或遍历结束后调用PreparedStatement.executeBatch()一次性提交给数据库。事务与连接管理确保操作在事务内执行并在完成后正确释放资源。注意这里有一个非常重要的点saveBatch方法默认是依赖于事务的。如果你在没有声明式事务如Transactional的方法中调用它并且你的数据库连接默认是自动提交auto-committrue那么MyBatis-Plus可能会在内部执行过程中每处理一批数据就提交一次或者在某些配置下退化成逐条提交这将完全丧失批处理的性能优势。确保批量操作在一个事务内是使用saveBatch的第一要义。2.2 深入源码batchSize的关键作用很多开发者疑惑我传了一个10000条的List给saveBatch它是一次性发送给数据库的吗答案是否定的。这里就引出了saveBatch的一个核心参数batchSize。我们查看com.baomidou.mybatisplus.extension.service.IService接口中saveBatch的重载方法boolean saveBatch(CollectionT entityList); boolean saveBatch(CollectionT entityList, int batchSize);第一个方法默认使用了第二个方法而第二个方法的batchSize参数至关重要。在默认实现例如ServiceImpl类中它会按照batchSize的大小将大的实体列表分割成多个子批次batch进行提交。为什么需要分批避免内存溢出OOMJDBC的PreparedStatement在调用addBatch时会将参数保存在内存中。如果一次性添加数万甚至数十万条记录可能导致客户端JVM内存特别是堆内存急剧增长甚至溢出。数据库承受能力数据库服务器一次性处理超大量的批处理语句可能会长时间占用锁资源导致其他查询阻塞或者使数据库的undo日志、redo日志暴增影响整体稳定性。网络传输过大的数据包在网络传输中可能效率更低且容易出错。事务粒度将超大的批量操作放在一个事务中一旦失败需要整体回滚代价巨大且可能产生大事务引发数据库性能问题。MyBatis-Plus默认的batchSize是1000。这意味着如果你传入一个5000条记录的ListsaveBatch会在内部将其分成5个批次每个批次1000条分5次调用executeBatch()。这就在批处理的效率和系统稳定性之间取得了一个平衡。实操心得这个默认值1000对于大多数场景是个不错的起点但绝非金科玉律。你需要根据你的实际数据量、实体字段的多少决定单条记录参数字节数、数据库类型以及应用服务器和数据库服务器之间的网络状况来调整。对于字段很少的表可以适当调大如5000对于字段非常多或包含大字段如TEXT的表可能需要调小如500甚至100。调整的原则是监控应用服务器的内存使用和数据库的响应时间找到一个在内存增长可控的前提下吞吐量最高的值。2.3 与insertBatchSomeColumn插件的联动在MyBatis-Plus 3.4.0及以上版本官方推荐使用insertBatchSomeColumn插件来进行真正的批量插入。这与默认的saveBatch有本质区别。默认的saveBatch走的是JDBC批处理生成的是INSERT INTO ... VALUES (?)然后多次addBatch。而insertBatchSomeColumn插件会动态生成一条单条的多值插入SQL形如INSERT INTO user (id, name) VALUES (1, a), (2, b), (3, c) ...。这两种方式有何优劣JDBC批处理默认saveBatch优点通用性强对所有数据库兼容性好可以享受PreparedStatement的预编译优势同一SQL只需解析一次易于实现分批batchSize。缺点网络交互次数虽少于逐条插入但仍多于单条多值SQL。在某些数据库如MySQL上多值插入的绝对性能可能更高。单条多值插入insertBatchSomeColumn插件优点一条SQL搞定网络交互次数最少理论上极限吞吐量更高。对于MySQL等数据库这种语法是原生高效的。缺点生成的SQL语句可能非常长触及数据库或驱动对SQL语句长度的限制如max_allowed_packet。数据量极大时必须手动分片。并非所有数据库都支持该语法但主流数据库如MySQL, PostgreSQL, H2等都支持。如何选择对于万级以下的批量插入两者性能差异可能不明显默认的saveBatch更省心。对于明确的、需要极高吞吐量的MySQL/PostgreSQL等数据库的批量插入场景可以考虑启用insertBatchSomeColumn插件。启用方式是在配置类中添加该插件Configuration public class MybatisPlusConfig { Bean public InsertBatchSomeColumn insertBatchSomeColumn() { return new InsertBatchSomeColumn(); } }启用后你需要使用com.baomidou.mybatisplus.extension.toolkit.SqlHelper的executeBatch方法或者自己注入InsertBatchSomeColumn插件生成的Mapper方法来实现批量插入原有的service.saveBatch默认行为不变。踩坑记录曾经有一次我们使用多值插入插件导入一个约5万条记录、每个记录有几十个字段的数据。没有自己分片直接生成了一条巨大的SQL。结果导致数据库连接报错提示“Packet for query is too large”。这就是没有处理SQL长度限制的典型问题。后来我们改成了在应用层按batchSize比如1000手动分片每次用插件插入一个分片的数据问题才解决。所以再好的工具也要理解其边界和限制。3. 针对不同数据库的调优实战3.1 MySQL/PostgreSQL等传统关系型数据库对于MySQL和PostgreSQL使用saveBatch的默认JDBC批处理模式通常能获得不错的性能。但仍有优化空间连接参数优化在JDBC连接字符串中添加批处理优化参数。MySQLrewriteBatchedStatementstrue。这是一个至关重要的参数。默认情况下即使你使用了addBatch()MySQL的JDBC驱动Connector/J也会将批处理语句重写为多条单句执行只有开启这个参数它才会真正将语句重写为多值插入或高效的批处理协议。开启此参数后saveBatch的性能可能会有数量级的提升。PostgreSQLreWriteBatchedInsertstrue。作用与MySQL的类似用于优化批处理插入。# MySQL示例 spring.datasource.urljdbc:mysql://localhost:3306/test?useUnicodetruecharacterEncodingutf8useSSLfalserewriteBatchedStatementstrueserverTimezoneAsia/Shanghai # PostgreSQL示例 spring.datasource.urljdbc:postgresql://localhost:5432/test?reWriteBatchedInsertstrue事务控制务必确保批量插入操作在一个事务内。使用Spring的Transactional注解即可。大批量数据可以考虑在业务逻辑层进行手动分片每个分片用一个独立的事务避免产生运行时间过长的“大事务”。batchSize调优结合rewriteBatchedStatements参数batchSize的意义有所变化。因为驱动会重写SQL所以batchSize更多地是控制每次executeBatch时重写后的SQL包含多少条数据。建议的测试范围在500-3000之间。可以通过简单的单元测试对不同batchSize下的插入耗时进行压测找到性能拐点。3.2 TiDB分布式数据库的特殊考量“mybatis-plus tidb”成为热词正说明很多开发者在TiDB上使用MyBatis-Plus时遇到了挑战。TiDB高度兼容MySQL协议因此上述MySQL的优化如rewriteBatchedStatementstrue同样适用且重要。但由于其分布式架构还需要额外注意自增主键AUTO_INCREMENT的陷阱在TiDB中自增主键默认是全局唯一的但为了性能每个TiDB实例会缓存一段自增ID。在高速批量插入时如果使用默认的saveBatch即实体对象不设id由数据库自增可能会因为不同实例的ID缓存区间不连续导致插入的数据在主键上不是完全连续的但这不影响正确性。如果业务强依赖连续且单调递增的ID可能需要使用其他分布式ID方案如Snowflake或者在实体中预先设置好ID。批量写入的性能瓶颈可能转移在单机MySQL上批量写入的瓶颈可能在磁盘IO或CPU。在TiDB中写入压力会分散到多个TiKV节点存储节点。此时瓶颈可能出现在事务提交的环节特别是如果表没有合理分片导致写入热点集中在某个Region或者网络延迟上。监控TiDB的Grafana面板关注Batch Client、KV Request、GRPC等相关的指标。大事务限制TiDB对单个事务的大小有限制默认不超过100MB可通过txn-total-size-limit调整但不宜过大。使用saveBatch插入大量数据时很容易触及此限制。因此在TiDB上使用saveBatch必须更加严格地控制batchSize和事务范围。建议将batchSize设置得比MySQL更小例如200-500并且确保每次saveBatch调用都在一个独立的事务中避免在事务内进行其他大量数据操作。使用Load Data或TiDB Lightning对于超大规模的数据初始化或迁移saveBatch可能不是最优选择。TiDB提供了LOAD DATA命令和TiDB Lightning工具它们通过直接导入数据文件的方式速度远超任何基于SQL的插入方式。对于一次性导入数千万甚至上亿条数据的场景应优先考虑这些专用工具。3.3 关于“updateBatchById没数据修改也返回成功”这是一个很有趣的问题也反映了对MyBatis-Plus批量操作返回值理解的误区。updateBatchById的方法签名通常是返回一个boolean。这个boolean返回值代表的并不是“有多少条数据被成功更新”而是**“本次批量更新操作作为一个整体是否被成功执行execute到数据库”**。具体来说如果SQL语句成功发送并执行即使WHERE id ?的条件没有匹配到任何行即“没数据修改”数据库也会返回执行成功。JDBC驱动接收到这个成功信号MyBatis-Plus就会返回true。只有当网络异常、SQL语法错误、违反约束等导致操作失败时才会返回false或抛出异常。所以updateBatchById返回true只意味着“更新操作执行了”不意味着“数据被更改了”。如果需要知道实际影响了多少行需要调用updateBatchById后通过其他方式查询或者使用MyBatis原生的update方法并获取返回值int类型表示受影响行数。但MyBatis-Plus的Service封装为了保持通用性通常不返回具体行数。实操建议如果你的业务逻辑强依赖“是否实际更新了数据”那么可能需要分两步走先根据ID集合查询出已存在的记录再对存在的记录进行更新操作。或者直接使用SqlHelper执行自定义的批量更新SQL并获取int[]类型的返回值这个返回值包含了每条SQL影响的行数。4. 从零开始Spring Boot项目集成与依赖管理对于“新建Spring Boot项目时没有MyBatis-Plus依赖”这个问题主要是创建项目时选型或依赖配置有误。下面提供两种最常用的集成方式。4.1 使用Spring Initializr创建项目这是最推荐的方式直观且不易出错。访问 start.spring.io 。填写项目元数据Group, Artifact等。在Dependencies搜索框中输入MyBatis你会看到两个选项MyBatis Framework这是原生的MyBatis。MyBatis Plus这才是我们需要的MyBatis-Plus。选中MyBatis Plus然后生成项目。下载的zip包中pom.xml会自动包含正确的依赖。4.2 在现有项目中手动添加依赖如果你已经创建了一个空的Spring Boot项目需要在pom.xml中手动添加依赖。重要提示务必注意版本兼容性MyBatis-Plus的版本需要与你的Spring Boot版本匹配。一个常见的兼容配置如下以Spring Boot 2.7.x 和 MyBatis-Plus 3.5.x 为例dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version !-- 请查看官网使用最新稳定版 -- /dependencymybatis-plus-boot-starter这个依赖会自动引入MyBatis-Plus的核心库、与Spring Boot的整合包以及MyBatis本身无需再单独引入MyBatis。检查依赖是否引入成功 在IDE中你可以查看项目的依赖树确认mybatis-plus-boot-starter存在。或者启动项目时观察控制台日志如果看到类似Mapped {[/xxx]} onto public ...或者MyBatis Plus的Banner信息通常表示集成成功。常见问题排查依赖冲突如果项目中已有老版本的MyBatis可能会产生冲突。确保使用mybatis-plus-boot-starter并排除掉可能存在的旧版MyBatis依赖。配置缺失虽然starter会自动化配置很多内容但你仍然需要在application.yml中配置数据源DataSource以及使用MapperScan注解来扫描你的Mapper接口位置。spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/your_db?useUnicodetruecharacterEncodingUTF-8useSSLfalserewriteBatchedStatementstrueserverTimezoneAsia/Shanghai username: root password: your_passwordSpringBootApplication MapperScan(com.yourpackage.mapper) // 指定你的Mapper接口所在的包 public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }5. 高级技巧与避坑指南5.1 性能监控与批量操作优化盲目使用saveBatch不一定能带来性能提升必须结合监控。开启SQL日志在开发或测试环境开启MyBatis-Plus的SQL日志观察saveBatch实际执行的SQL语句。确认它是否在以批处理方式执行你应该看到 Preparing: INSERT INTO ...只打印一次而 Parameters:后面跟着多组参数。mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl使用性能分析工具Arthas使用trace命令跟踪saveBatch方法的调用链和耗时定位是网络延迟、数据库执行慢还是应用层处理慢。JMeter / Gatling对批量插入接口进行压测观察不同batchSize下的TPS每秒事务数和响应时间找到最优值。数据库慢查询日志分析批处理语句在数据库端的执行时间。异步化与削峰填谷如果批量插入操作来自前端请求或消息队列且数据量巨大直接同步处理可能导致请求超时或线程池耗尽。可以考虑将数据先存入一个高速的临时存储如Redis List、本地内存队列然后由后台线程池定时或定量地取出数据进行saveBatch插入。这样可以将突发的流量高峰平滑掉保护数据库。5.2 事务与异常处理的最佳实践批量操作必须妥善处理事务和异常否则极易导致数据不一致。事务边界要清晰如前所述给包含saveBatch的方法加上Transactional注解。但要注意Spring事务的默认传播行为是REQUIRED如果从另一个事务方法中调用它会共用同一个事务。确保这个事务的粒度是你期望的。异常处理要周全saveBatch可能会抛出多种异常如DataIntegrityViolationException数据违反约束、DuplicateKeyException主键冲突、CannotGetJdbcConnectionException数据库连接异常等。Transactional(rollbackFor Exception.class) // 默认只回滚RuntimeException和Error这里设置为所有异常都回滚 public void batchInsertUsers(ListUser users) { try { userService.saveBatch(users, 1000); } catch (DuplicateKeyException e) { // 处理主键冲突可能是重复提交可以记录日志并跳过或更新 log.error(批量插入发生主键冲突部分数据可能已存在, e); // 这里可以选择将冲突的数据筛选出来进行更新操作或者直接忽略 // 注意捕获异常后事务默认不会回滚除非你再次抛出异常 throw new BusinessException(数据重复请检查, e); // 重新抛出业务异常触发回滚 } catch (DataIntegrityViolationException e) { // 处理数据不合法如字段超长、非空约束等 log.error(批量插入数据违反完整性约束, e); throw new BusinessException(数据不合法插入失败, e); } catch (Exception e) { // 捕获其他所有异常 log.error(批量插入发生未知错误, e); throw e; // 抛出异常触发事务回滚 } }关键点在catch块中如果你不希望事务回滚例如只是记录日志并跳过重复数据那么就不要重新抛出异常或者抛出一个被标记为Transactional(noRollbackFor...)的异常。但通常批量插入失败我们都希望整体回滚保持数据一致性所以最常见的做法是捕获异常进行日志记录和转换后再抛出一个运行时异常。部分失败的处理JDBC批处理执行时如果其中一条数据失败如唯一键冲突默认情况下整个批处理都会失败并回滚。这符合“原子性”要求。如果你希望实现“部分成功”即跳过失败的行继续插入其他行JDBC的标准批处理无法直接实现。你需要要么在应用层进行更精细的控制比如先对数据进行校验和去重。要么使用数据库特有的功能如MySQL的INSERT IGNORE或ON DUPLICATE KEY UPDATE但这需要你编写自定义的SQL而不是使用通用的saveBatch。5.3 自定义批量插入以应对极端场景当默认的saveBatch无法满足需求时例如需要用到数据库特定语法或者需要更极致的性能就需要自定义批量插入。使用MyBatis的foreach标签在Mapper XML文件中编写SQL利用MyBatis的foreach标签动态拼接多值插入语句。这种方式最灵活可以完全控制SQL。insert idbatchInsert parameterTypejava.util.List INSERT INTO user (name, age, email) VALUES foreach collectionlist itemitem separator, (#{item.name}, #{item.age}, #{item.email}) /foreach /insert注意这种方式需要你手动在业务代码中控制每次插入的数据量避免SQL过长。使用SqlHelper.executeBatch这是MyBatis-Plus提供的底层批量执行工具它结合了insertBatchSomeColumn插件时效率很高。你需要自己管理连接和事务。Autowired private SqlSessionFactory sqlSessionFactory; Transactional public void customBatchInsert(ListUser userList) { try (SqlSession sqlSession sqlSessionFactory.openSession(ExecutorType.BATCH)) { UserMapper mapper sqlSession.getMapper(UserMapper.class); for (int i 0; i userList.size(); i) { mapper.insert(userList.get(i)); // 每积累1000条提交一次 if (i % 1000 0 i 0) { sqlSession.flushStatements(); } } sqlSession.flushStatements(); // 提交剩余的数据 sqlSession.commit(); // 提交事务 } // try-with-resources会自动关闭sqlSession }这种方式给了你最大的控制权但代码也更复杂需要处理好资源关闭和异常。最后也是最重要的心得批量操作是性能优化的利器但也是一把双刃剑。在追求性能的同时务必把数据一致性和系统稳定性放在首位。每次优化前后都要进行充分的测试和性能对比。监控数据库的负载避免一个批量操作拖垮整个数据库。理解你使用的每一个API背后的原理才是写出健壮、高效代码的关键。