做后台开发的早晚会遇到这样的活在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连接串开启rewriteBatchedStatementstrue配合手写XML的foreach批量insert取500条一批每5000条提交一次事务并发8个线程按ID范围分片导入前先删除全部二级索引导入完成后统一重建。这套方案在4核16G的云主机上纯插入速率稳定在每秒10万行以上配合解析和数据清洗两千万行整体耗时约十八分钟。下面每个环节单独讲附全部配置和代码。2. 环境准备先把参数调到最优再写代码2.1 依赖与版本选型先说我用的依赖组合都是我实际验证过兼容性的版本dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId version2.7.18/version /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.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?useUnicodetruecharacterEncodingutf8rewriteBatchedStatementstrueuseServerPrepStmtsfalseuseCompressiontrueallowPublicKeyRetrievaltrueuseSSLfalsemaxAllowedPacket104857600 username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 32 minimum-idle: 8 connection-timeout: 30000拆开讲每个参数的作用。rewriteBatchedStatementstrue是核心中的核心它让MySQL驱动把一批单行insert自动改写成一条多值insert比如100条insert变成insert into t (...) values (...),(...),...网络往返次数直接除以100。useServerPrepStmtsfalse是配合rewriteBatchedStatements使用的如果开启服务端预编译某些驱动版本下批量重写会失效或退化关掉更稳。useCompressiontrue在数据量大的场景下能减少网络传输量本地库效果不明显跨机房的库收益很明显。maxAllowedPacket104857600是客户端允许的最大包默认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_commit2和sync_binlog0这两个参数非严格生产环境下能大幅减少磁盘刷盘频率导入速度至少提升30%但这是以牺牲一小部分崩溃恢复完整性为代价的导入完成后记得改回来。索引策略在导入场景下的重要性被我低估过。目标表创建时先只保留主键全部二级索引等数据导入完成后再用一条ALTER TABLE批量添加这个操作让我的总耗时缩短了近一半。3. 三种批量插入实现与实测对比3.1 方案Arestart connect后直接用saveBatch这一版最省事什么都不用改只要连接串里加了前面那些参数Resource private IUserService userService; public void importBySaveBatch(ListUser 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)); } }实测效果开启rewriteBatchedStatementstrue后saveBatch的速度比默认状态下提升了一个数量级。原因就是驱动把每个分片内的多条insert合并成了多值SQL单批500条就是一条500行的insert语句。这个方案我评价为性价比之王改动为零收益巨大。缺点是MyBatisPlus底层的BATCH执行器在部分场景下不会返回主键如果你的业务后续依赖自增ID得自己处理。另外saveBatch默认的事务粒度是一个分片一提交你要是设置了更大的batchSize事务也会相应变大OOM风险也同步上升。3.2 方案B手写XML的foreach批量插入要追求极致性能绕不开手写SQL。在Mapper接口加一个方法public interface UserMapper extends BaseMapperUser { void insertBatch(Param(list) ListUser list); }XML里这样写insert idinsertBatch INSERT INTO t_user (id, name, age, address, create_time) VALUES foreach collectionlist itemitem 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 方案CExecutorType.BATCH配合手动flush如果不想写XML又想榨干性能可以绕过MyBatisPlus直接用底层的SqlSession操作Resource private SqlSessionFactory sqlSessionFactory; private static final int BATCH_SIZE 500; public void importWithBatchExecutor(ListUser 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)第二个参数是autoCommitfalse手动控制事务。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秒性能最优需写SQLBatch Executor手动flush39000约51秒中等性能控制灵活结论很清楚无论选哪条路rewriteBatchedStatementstrue必须开这是所有方案的前提。在这个基础上追求省事选saveBatch追求极致选XML foreach。4. 千万级导入的完整落地流程4.1 流式读取与解析不能把两千万行装进内存很多导入代码第一步就写崩了Files.readAllLines()把整个文件读进内存一千万行轻松吃掉几个G直接OOM。正确做法是流式读取逐行处理用一个固定大小的列表做缓冲public void importFromFile(String filePath) throws IOException { ListString 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) { ListUser userList parseAndValidate(buffer); doBatchInsert(userList); buffer.clear(); } } if (!buffer.isEmpty()) { ListUser 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(ListUser 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侧走完整链路更稳妥。关于这套方案的最后几句话我交付出这个导入任务之后回头总结最重要的收获其实是千万级导入没有银弹每层都有对应的坑真正拉开差距的是细节。连接串少一个参数性能差十倍索引没拆又差好几倍事务粒度不对再差两三倍并发控不住直接死锁。这些因素叠加起来一个看着能跑的代码和一套真正能抗千万行的方案之间性能差着百倍。最后分享一个我习惯性的兜底做法正式导入前永远找一小份样本数据比如十万行先按完整链路跑一遍记录每个阶段的耗时、内存、数据库状态确认没问题再放全量。这一步多花十分钟能帮你避免全量跑一个小时后才发现配置有问题返工的成本远远不止十分钟。