ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot + MyBatisPlus千万级数据批量插入优化实战

Spring Boot + MyBatisPlus千万级数据批量插入优化实战

做后台开发的,早晚会遇到这样的活:在Spring Boot项目里,用MyBatisPlus做千万级数据的批量插入导入。我最近刚交付一个数据迁移任务,第三方提供的数据包解压后接近两千万行,单表二十多个字段,原本用saveBatch跑了一夜,进度条惨不忍睹。后来换了一套组合拳,包括JDBC连接参数、批处理执行器、分片策略和并发控制,最终把整库导入压缩到二十分钟以内。这篇文章把我验证过、真实可用的完整思路写出来,适合同样被大数据量导入折腾的后端开发者直接参考,Spring Boot 2.x和3.x都适用,核心逻辑完全一致。

1. 千万级导入的瓶颈,到底卡在哪

1.1 无脑用saveBatch为什么不行

很多项目的代码里,批量插入就是一句saveBatch(list)。它能跑,但跑不快,尤其在千万数据量级下,差距是数量级的。MyBatisPlus的saveBatch内部确实开启了ExecutorType.BATCH,会把多次insert攒起来统一提交。问题在于,MySQL的JDBC驱动默认不会把这批单条insert合并成一条多值的insert语句,而是逐条发送到服务端执行。也就是说,你表面上写了批量插入,实际数据库收到的仍然是几千条独立的insert请求,每条都有独立的SQL解析、权限检查、事务日志,性能自然上不去。

这个现象非常坑人。因为从代码层面看,它确实是"批量"的,日志里也看不到明显问题,只有看数据库端的请求数或者对比耗时,才看得出端倪。

1.2 真正拖慢速度的三个根因

千万级导入慢,不是单点问题,通常是三层叠加:

第一层,网络与驱动层。每次insert请求都要走一次网络往返,批量模式下如果没有让驱动合并SQL,网络开销直接把吞吐压死。第二层,数据库写入层。每次insert都要写undo日志、redo日志、binlog,逐条提交时刷盘次数爆炸,磁盘IO成为最大瓶颈。第三层,索引维护层。表上每建一个二级索引,插入一行都要更新索引树,索引越多写入越慢,导入数据时如果带着好几个索引硬插,速度可以慢十倍以上。

所以,优化必须从这三层同时下手:让驱动合并SQL、控制事务和刷盘频率、导入期间暂时拆掉非必要索引。

1.3 我最终采用的方案骨架

我测试了多种组合后,最终确定的主方案是:JDBC连接串开启rewriteBatchedStatements=true,配合手写XML的foreach批量insert,取500条一批,每5000条提交一次事务,并发8个线程按ID范围分片,导入前先删除全部二级索引,导入完成后统一重建。这套方案在4核16G的云主机上,纯插入速率稳定在每秒10万行以上,配合解析和数据清洗,两千万行整体耗时约十八分钟。

下面每个环节单独讲,附全部配置和代码。

2. 环境准备:先把参数调到最优再写代码

2.1 依赖与版本选型

先说我用的依赖组合,都是我实际验证过兼容性的版本:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>2.7.18</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency>

三个注意点。第一,MyBatisPlus 3.5.x和Spring Boot 2.7.x是稳定组合,如果你用的是Spring Boot 3.x,记得把starter换成mybatis-plus-spring-boot3-starter,包名和自动配置有差异。第二,MySQL驱动从8.0.31之后改名成了com.mysql:mysql-connector-j,老的mysql-connector-java坐标虽然还能用,但不再推荐,新版驱动的类名依然是com.mysql.cj.jdbc.Driver。第三,千万别用Druid的某些老版本搭配MySQL 8,参数传递有兼容问题,我自己就踩过坑,还是HikariCP最省心。

2.2 JDBC连接串:一个参数决定成败

这是全篇最重要的一段配置,直接贴出来:

spring: datasource: url: jdbc:mysql://127.0.0.1:3306/data_import?useUnicode=true&characterEncoding=utf8&rewriteBatchedStatements=true&useServerPrepStmts=false&useCompression=true&allowPublicKeyRetrieval=true&useSSL=false&maxAllowedPacket=104857600 username: root password: "你的密码" driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 32 minimum-idle: 8 connection-timeout: 30000

拆开讲每个参数的作用。rewriteBatchedStatements=true是核心中的核心,它让MySQL驱动把一批单行insert自动改写成一条多值insert,比如100条insert变成insert into t (...) values (...),(...),...,网络往返次数直接除以100。useServerPrepStmts=false是配合rewriteBatchedStatements使用的,如果开启服务端预编译,某些驱动版本下批量重写会失效或退化,关掉更稳。useCompression=true在数据量大的场景下能减少网络传输量,本地库效果不明显,跨机房的库收益很明显。maxAllowedPacket=104857600是客户端允许的最大包,默认4M,批量insert的SQL很容易超,必须调大,且要和数据库服务端保持一致。

2.3 服务端也要改的MySQL参数

只有客户端配置不够,MySQL服务端同样要动。用DBA权限执行:

SET GLOBAL max_allowed_packet = 104857600; SET GLOBAL innodb_buffer_pool_size = 4294967296; SET GLOBAL innodb_flush_log_at_trx_commit = 2; SET GLOBAL sync_binlog = 0;

逐个说明。max_allowed_packet是服务端最大接收包,默认只有64M或者更小,批量SQL容易触发限制。innodb_buffer_pool_size建议设为物理内存的50%-70%,导入时InnoDB的缓存命中率直接影响写入性能,我跑两千万行时4G的buffer pool基本是满的。innodb_flush_log_at_trx_commit=2和sync_binlog=0这两个参数,非严格生产环境下能大幅减少磁盘刷盘频率,导入速度至少提升30%,但这是以牺牲一小部分崩溃恢复完整性为代价的,导入完成后记得改回来。

索引策略在导入场景下的重要性被我低估过。目标表创建时先只保留主键,全部二级索引等数据导入完成后再用一条ALTER TABLE批量添加,这个操作让我的总耗时缩短了近一半。

3. 三种批量插入实现与实测对比

3.1 方案A:restart connect后直接用saveBatch

这一版最省事,什么都不用改,只要连接串里加了前面那些参数:

@Resource private IUserService userService; public void importBySaveBatch(List<User> list) { // 分批,每批500条 for (int i = 0; i < list.size(); i += 500) { int end = Math.min(i + 500, list.size()); userService.saveBatch(list.subList(i, end)); } }

实测效果:开启rewriteBatchedStatements=true后,saveBatch的速度比默认状态下提升了一个数量级。原因就是驱动把每个分片内的多条insert合并成了多值SQL,单批500条就是一条500行的insert语句。这个方案我评价为"性价比之王",改动为零,收益巨大。缺点是MyBatisPlus底层的BATCH执行器在部分场景下不会返回主键,如果你的业务后续依赖自增ID,得自己处理。另外saveBatch默认的事务粒度是一个分片一提交,你要是设置了更大的batchSize,事务也会相应变大,OOM风险也同步上升。

3.2 方案B:手写XML的foreach批量插入

要追求极致性能,绕不开手写SQL。在Mapper接口加一个方法:

public interface UserMapper extends BaseMapper<User> { void insertBatch(@Param("list") List<User> list); }

XML里这样写:

<insert id="insertBatch"> INSERT INTO t_user (id, name, age, address, create_time) VALUES <foreach collection="list" item="item" separator=","> (#{item.id}, #{item.name}, #{item.age}, #{item.address}, #{item.createTime}) </foreach> </insert>

用法和saveBatch一样,也是分批调用。这个方案的优势在于完全可控,SQL就是一条多值insert,没有执行器和代理层的开销,实测比saveBatch再快30%-50%。批量大小建议500到1000条,太小合并效果不明显,太大SQL文本过长,解析和参数绑定反而变慢,而且容易撞上上一节说的max_allowed_packet限制。字段数量多的时候优先选择更小的批次,比如20个字段、1000条就是两万个占位符,MySQL对预编译语句的参数个数有上限,这个后面问题排查部分再细说。

3.3 方案C:ExecutorType.BATCH配合手动flush

如果不想写XML又想榨干性能,可以绕过MyBatisPlus,直接用底层的SqlSession操作:

@Resource private SqlSessionFactory sqlSessionFactory; private static final int BATCH_SIZE = 500; public void importWithBatchExecutor(List<User> totalList) { try (SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH, false)) { UserMapper mapper = sqlSession.getMapper(UserMapper.class); int index = 0; for (User user : totalList) { mapper.insert(user); index++; if (index % BATCH_SIZE == 0) { sqlSession.flushStatements(); } } sqlSession.flushStatements(); sqlSession.commit(); } }

关键在openSession(ExecutorType.BATCH, false),第二个参数是autoCommit=false,手动控制事务。BATCH执行器会把多个单条insert缓存到JDBC Batch中,配合驱动层的rewriteBatchedStatements,同样能获得多值insert的效果。flushStatements的时机就是Batch的刷新点,你可以理解为手动决定"攒多少条真发一次"。实测性能介于方案A和方案B之间。这个方案适合不想额外维护XML文件、但又想拿到批量性能的团队,代价是控制逻辑稍微复杂,事务边界和flush点必须自己管理好,忘调flushStatements的话数据会一直积在内存里,直到commit前才强制刷新。

3.4 实测数据对比

我在同一台4核16G云主机、MySQL 8.0.33、单表10个字段无二级索引的条件下,分别用三种方案导入了200万行测试数据:

方案速率(行/秒)200万行耗时优缺点
单条insert循环420约80分钟只能用于玩具项目
saveBatch(未开重写)3600约9分钟配置简单但性能平庸
saveBatch(开启重写)29000约70秒零改动,收益高
XML foreach46000约43秒性能最优,需写SQL
Batch Executor+手动flush39000约51秒中等性能,控制灵活

结论很清楚:无论选哪条路,rewriteBatchedStatements=true必须开,这是所有方案的前提。在这个基础上,追求省事选saveBatch,追求极致选XML foreach。

4. 千万级导入的完整落地流程

4.1 流式读取与解析:不能把两千万行装进内存

很多导入代码第一步就写崩了:Files.readAllLines()把整个文件读进内存,一千万行轻松吃掉几个G,直接OOM。正确做法是流式读取,逐行处理,用一个固定大小的列表做缓冲:

public void importFromFile(String filePath) throws IOException { List<String> buffer = new ArrayList<>(500); try (BufferedReader reader = new BufferedReader( new InputStreamReader(new FileInputStream(filePath), StandardCharsets.UTF_8), 8192)) { String line; while ((line = reader.readLine()) != null) { buffer.add(line); if (buffer.size() >= 10000) { List<User> userList = parseAndValidate(buffer); doBatchInsert(userList); buffer.clear(); } } if (!buffer.isEmpty()) { List<User> userList = parseAndValidate(buffer); doBatchInsert(userList); } } }

这个读法有两点值得说。第一,BufferedReader的缓冲区设为8192,也就是8K,是按磁盘块大小对齐的,读大文件时减少系统调用次数。第二,每次读到1万行原始字符串,解析成User对象后立刻插入,内存里最多同时存在这批数据,JVM堆占用始终平稳。我实测两千万行的文件,整个导入过程中JVM内存曲线是一条直线,峰值不过300M。

4.2 数据清洗与去重

数据源是第三方给的,永远不要信任它的质量。我在parseAndValidate里做了三层处理:空行跳过、字段数不符的记录单独记到错误日志文件、主键或唯一键重复的抛到去重集合。这里有一个实践细节:去重用HashSet虽然简单,但两千万行全量去重会占用大量内存,我改用BloomFilter来预筛,误判率设为千分之一,内存占用只有HashSet的几十分之一,拦截大部分重复,少量漏网之鱼在数据库的主键冲突报错中兜底处理。

清洗阶段的耗时占比不能忽视。两千万行的字符串切分、类型转换、日期格式化,加起来差不多要占总耗时的三分之一。如果JVM并发下GC频繁,优先用StringBuilder复用临时对象,不要每行都new一堆中间体。

4.3 事务粒度:既不能一行一提交,也不能两千万一提交

事务边界是批量导入最容易踩坑的地方。行数太多不提交,undo log膨胀、锁持有时间过长、回滚段压力大,MySQL很容易把磁盘写满,而且一旦某个脏数据触发约束错误,整个大事务回滚的代价是不可接受的。一行一提交则把性能全废了。

我的习惯是:解析后每500条执行一次insertBatch,攒够10次也就是5000条做一次commit。这个粒度下,单事务的SQL数量适中,失败回滚只回滚最近5000行,重试成本低,性能也基本无损。如果你用方案A的saveBatch,它默认就是一批一事务,可以直接把batchSize设成5000,内部按500拆分执行,但注意单事务过大时内存和undo的压力,建议自己控制分片加显式提交:

@Resource private DataSource dataSource; public void importWithTxn(List<User> totalList, int batchSize, int commitEvery) { int count = 0; for (int i = 0; i < totalList.size(); i += batchSize) { int end = Math.min(i + batchSize, totalList.size()); userMapper.insertBatch(totalList.subList(i, end)); count++; if (count % commitEvery == 0) { // 由外部事务切分,或者用TransactionTemplate包住每段 } } }

配合Spring的TransactionTemplate最好使,每个提交段包一个编程式事务,简洁且不依赖代理的自调用坑。

4.4 索引:导入前先全拆掉

这步收益最直接。插入一行数据,InnoDB要维护主键聚簇索引和每一个二级索引。我的测试表在带3个二级索引的情况下,插入速率只有拆掉索引后的1/4。

具体做法:建表时只保留主键。数据全部导入完成后,再用一条静态SQL把所有索引补回来:

ALTER TABLE t_user ADD INDEX idx_name (name), ADD INDEX idx_age (age), ADD INDEX idx_create_time (create_time);

要注意的是,重建索引也会消耗时间,但它是一次性的,排序批量构建比边插边维护快得多,而且索引构建期间没有并发写的问题。如果业务不允许停表,至少选择一个深夜窗口做这件事。另外,如果有外键约束,导入期间也建议先禁用或删除,外键检查的开销在单行插入时微乎其微,但在千万行场景下会被放大成明显的性能损失。

5. 多线程并发导入,把多核用起来

5.1 分片策略:避免每个线程互相踩

导入单线程跑满CPU的一个核很简单,但服务器通常有4核、8核甚至16核,剩下的核闲着也是闲着,自然要考虑并发。最容易出问题的并发方式就是无脑把一个List丢给线程池,让多个线程同时往同一张表里插,锁竞争和死锁马上就来。

我的分片思路是:如果数据源带自增主键,按主键ID范围切段,每个线程处理一个不重叠的段。如果是文件导入,按行号范围切段,用RandomAccessFile的seek跳到指定位置开始读。这样每个线程写入的行row完全独立,互不干扰,从根上避免了锁竞争。

5.2 线程池与连接池的匹配

连接池、线程池、事务三者要联动配置。线程数设为CPU核心数,我用的是4核机器,开8个线程,因为解析清洗是CPU密集型,但数据库IO会等待,稍微超订一点能压满IO。连接池的maximum-pool-size必须大于等于线程数,否则线程会互相等待连接。我在HikariCP里设了32,线程8个,连接充裕。

线程池用固定的ThreadPoolExecutor,显式指定队列容量和拒绝策略,千万不要用无界队列:

ExecutorService executor = new ThreadPoolExecutor( 8, 8, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(16), new ThreadPoolExecutor.CallerRunsPolicy());

CallerRunsPolicy是个容易被忽略的好东西:任务队列满了之后,提交任务的线程自己接管执行,天然实现背压,不会因为任务堆积把内存打爆。我实测过,16的队列加CallerRunsPolicy,整个导入过程任务积压量非常健康。

5.3 导入进度与结果追踪

并发场景下必须有一个进度追踪机制,我建了一张导入进度表,字段包括任务ID、线程ID、起始行号、结束行号、成功数、失败数、状态。每个线程每处理完一批就更新一行,主线程周期性汇总。这样有两个好处:一是能实时看到整体进度,知道还要跑多久;二是某一线程失败时,能准确定位到失败区间,方便断点续传。导入完成后,对一下总行数和目标表count,保证没有丢数据,再把错误文件里的脏数据单独处理一遍。

6. 常见问题与排查技巧实录

6.1 "Packet for query is too large"报错

这是批量插入最经典的报错,原因就是单条SQL超过了max_allowed_packet。排查三步走:先查服务端SHOW VARIABLES LIKE 'max_allowed_packet',再把JDBC URL里的maxAllowedPacket调成和服务端一致,最后检查批量大小,字段多的时候适当减小批次。我遇到过一次诡异的场景:服务端已经调到100M还报错,后来发现是JDBC URL里的maxAllowedPacket没改,客户端默认值只有4M,驱动在客户端就拦截了。两边必须同时改。

6.2 内存溢出与GC频繁

八成是因为一次性把所有数据读进了内存,或者线程池队列无界导致任务堆积。解决办法在第四章已经说了:流式读取加固定队列加拒绝策略。还有一个隐蔽原因:MyBatis的执行器在BATCH模式下会持有待执行的SQL和参数对象,如果你忘记定期flushStatements,积压的批数据会一直堆在内存里,这个也容易忽略。

6.3 并发导入死锁与锁等待超时

表现是Deadlock found when trying to get lock或者Lock wait timeout exceeded。排查思路就一条:不同线程是否在竞争同一批行的锁。我的经验是,即使按ID范围分片,如果使用INSERT ... ON DUPLICATE KEY UPDATE这类语句,在撞唯一键时依然会触发锁竞争,并发越高死锁概率越大。解法有两个:一是严格保证分片不重叠,二是对唯一键冲突的行先在业务层去重,尽量让数据库不做更新路径。另外调大innodb_lock_wait_timeout可以缓解一部分偶发报错,但治标不治本。

6.4 主键冲突与字段截断

第三方数据里的主键重复是常态。我在导入前用BloomFilter预筛,但最终防线还是SQL层面的容错。如果你对数据质量有信心,坚持用普通insert,冲突直接抛错并记录批次号;如果希望尽量多导入数据,可以用INSERT IGNORE或者ON DUPLICATE KEY UPDATE,前者忽略冲突行,后者做更新。但要注意,这两个语句在批量场景下性能都比普通insert差一些,尤其是ON DUPLICATE KEY UPDATE对每行都要做判断,实测会慢20%以上。我通常是预筛加普通insert,冲突行记录到错误表,后续人工处理。

字段截断报Data too long,常见于字符串字段长度不够,排查时可以临时把目标表对应字段改成更大的类型,或者导入前用脚本统计源数据的最大字段长度,据此调整表结构。

6.5 断点续传:导入挂了一半怎么办

导入两千万行,网络抖动一下或者磁盘满一次,整个任务中断并不罕见。如果没有续传机制,从零开始等于白干。我的做法是在进度表里记录每个线程的已提交位置,线程启动时先查进度表,如果存在未完成任务就跳到最后提交的行号继续。配合主键或唯一键,即使重复执行了某些分片,插入也不会产生重复数据。这套机制让我处理异常从容很多,中断后恢复通常只要几十秒。

6.6 追求极限:LOAD DATA INFILE也是备选

如果你要导入的是规整的CSV文件,流程允许绕开Java层,MySQL自带LOAD DATA INFILE是终极武器,导入速率能到几十万行每秒,比Java侧最快方案还要快一个量级。基本语法:

LOAD DATA INFILE '/data/t_user.csv' INTO TABLE t_user FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY '\n' IGNORE 1 LINES (id, name, age, address, create_time);

但它的限制也很明显:MySQL服务端必须能访问到那个文件路径,或者开启LOCAL参数从客户端上传,而且它跳过了业务层的清洗和转换逻辑,适合数据已经规整好的场景。我一般把它定位为"数据搬运的最后一招",实际项目中因为数据要经过清洗、映射、去重,大多数时候还是在Java侧走完整链路更稳妥。

关于这套方案的最后几句话

我交付出这个导入任务之后,回头总结最重要的收获其实是:千万级导入没有银弹,每层都有对应的坑,真正拉开差距的是细节。连接串少一个参数,性能差十倍;索引没拆,又差好几倍;事务粒度不对,再差两三倍;并发控不住,直接死锁。这些因素叠加起来,一个"看着能跑"的代码和一套"真正能抗千万行"的方案之间,性能差着百倍。

最后分享一个我习惯性的兜底做法:正式导入前永远找一小份样本数据,比如十万行,先按完整链路跑一遍,记录每个阶段的耗时、内存、数据库状态,确认没问题再放全量。这一步多花十分钟,能帮你避免全量跑一个小时后才发现配置有问题,返工的成本远远不止十分钟。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进