尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Spring Boot 3.3 批量插入万级数据优化实战:从22秒到1秒
Spring Boot 3.3 里做批量插入代码本身并不复杂真正决定快慢的往往是一个连接参数、一次事务边界的取舍、一种容易被忽略的刷盘机制。我接手过一个数据导入项目要往 MySQL 里捞一万多条数据刚开始用最常规的 for 循环单条 insert跑一次二十多秒慢到被业务方追着问“这个接口是不是挂了”。后来我把常见批量插入方案挨个实测了一遍从 JDBC 原生 Batch、MyBatis 的 foreach 和 BatchExecutor到 JdbcTemplate 以及多线程分片并发最终把一万条数据的写入压进了一秒以内。这篇文章就把这些方案的代码、原理、参数调优和踩坑过程完整记录下来给正在被“Spring Boot 3.3 批量插入万级数据”折磨的后端同学一个参考。1. 为什么单条循环插入会这么慢先搞懂瓶颈在哪1.1 一条INSERT背后藏着的“固定开销”很多朋友一上来就优化 SQL把 insert 语句写得很“花”结果效果甚微。其实一条 INSERT 的耗时大头根本不在 SQL 执行本身而在一串固定开销上应用层构造 SQL、参数序列化并打包成网络包、经过网络传输到 MySQL 服务器服务端要解包、做语法解析、权限检查、查询优化、生成执行计划执行阶段还要写 redo log、binlog、undo log最后把结果返回给客户端。这些成本加起来远比“往内存里塞一行记录”要大。单条循环插入一万条等于把上面这条链路原封不动走一万遍。就好比你寄一万个包裹每寄一个都骑电动车单独跑一趟快递站。路上的时间没变但你要跑一万次怎么可能快1.2 自动提交才是隐藏的“时间黑洞”还有一个你很容易忽略的细节日常 CRUD 默认 autocommit true意味着每条 INSERT 执行完成MySQL 立刻提交一次事务。提交这个动作要刷日志fsync要释放行锁要更新各种统计信息如果表上再有几个索引每次提交还会连带产生大量索引页的随机写入。循环插一万条实际上提交了一万次事务。很多初学者的“万级数据插入慢”不是慢在 SQL 语法上而是慢在“默认自动提交 逐条执行”这种组合拳上。批量插入的核心思路就是把这个过程改成几百条攒成一车发出去最后统一结算提交把网络往返和事务提交次数同时降下来。2. 方案一JDBC 原生 Batch绕开所有中间层的底牌2.1 可以直接抄的核心代码模板不管用不用 MyBatis、JdbcTemplate底层都得过 JDBC 这一关。所以第一个推荐的方案就是直接用 DataSource 拿到 Connection 做 Batch 写入代码最透明性能也最可控。public int batchInsert(ListUser userList, int batchSize) throws SQLException { if (userList null || userList.isEmpty()) { return 0; } try (Connection conn dataSource.getConnection()) { conn.setAutoCommit(false); String sql INSERT INTO t_user (name, age, email, create_time) VALUES (?, ?, ?, ?); try (PreparedStatement ps conn.prepareStatement(sql)) { int count 0; for (int i 0; i userList.size(); i) { User user userList.get(i); ps.setString(1, user.getName()); ps.setInt(2, user.getAge()); ps.setString(3, user.getEmail()); ps.setObject(4, LocalDateTime.now()); ps.addBatch(); count; if (count % batchSize 0) { ps.executeBatch(); conn.commit(); ps.clearBatch(); } } if (userList.size() % batchSize ! 0) { ps.executeBatch(); conn.commit(); } } return userList.size(); } }这段代码里有几个关键动作值得说一下conn.setAutoCommit(false)关闭自动提交。没有这一步Batch 攒完也被每条独立事务拆散性能损失很大。addBatch()只是把参数记在 PreparedStatement 内部的批次列表里还没有真正发给 MySQL。每攒够batchSize调用一次executeBatch()和commit()既避免批次数过大撑爆内存和网络包也让事务回滚粒度可控。2.2 rewriteBatchedStatements 是灵魂参数JDBC Batch 写对了但连接串上没有加参数性能可能还是很差。MySQL 的 JDBC 驱动在没有开启rewriteBatchedStatements时虽然你在代码里调了addBatch()底层仍然会把每条 INSERT 逐条发给服务端执行。表面上是 Batch实际上还是单条发送跟 for 循环没本质区别。在application.yml的数据源连接 URL 里加上这个参数spring: datasource: url: jdbc:mysql://localhost:3306/test?rewriteBatchedStatementstrueuseSSLfalseserverTimezoneAsia/ShanghaiallowMultiQueriestrue开启后MySQL 驱动会把一批连续的 INSERT SQL 自动改写成一条多值的 INSERT也就是INSERT INTO t_user (...) VALUES (...), (...), (...)网络往返和 SQL 解析次数直接下降一个数量级。这是整个批量插入优化里性价比最高的一个参数。提示如果发现加了 Batch 之后性能几乎没有提升第一件事是去确认 JDBC URL 是否真的带上了rewriteBatchedStatementstrue。我在一个老项目里踩过一次配置文件的连接串被环境变量覆盖了代码优化了半天结果连接串根本没用上。2.3 批次大小和主键回填的注意点批次大小不建议盲目调大。500 到 1000 是一个比较稳的区间字段少的表可以适当大一点字段多或者单行内容长的表要调小。原因是批量改写后的多值 INSERT 会变成一个很大的网络包受 MySQL 端max_allowed_packet限制默认 4MB 或 64MB单批超过限制会直接抛PacketTooBigException。需要拿数据库自增主键时要这样声明 PreparedStatementPreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);在executeBatch()之后通过ps.getGeneratedKeys()批量取回自增 ID。但要注意部分 MySQL 驱动版本在rewriteBatchedStatementstrue和批量插入同时开启时主键回填的结果并不完整。稳妥的做法是要么减小批次每批 200 条左右要么在应用侧提前准备好主键比如雪花 ID 或 UUID避免依赖数据库自增回填。3. 方案二MyBatis 里的两种批量插入写法及真正的坑3.1 写法AMapper XML 用 foreach 拼多值 INSERTMyBatis 项目里最常见的批量插入是直接在 Mapper XML 里写一段 foreach把多条记录拼成一条多值 INSERT。insert idbatchInsert parameterTypelist useGeneratedKeystrue keyPropertyid INSERT INTO t_user (name, age, email, create_time) VALUES foreach collectionlist itemitem separator, (#{item.name}, #{item.age}, #{item.email}, #{item.createTime}) /foreach /insert这段 XML 的separator,会把 list 里的所有元素拼成一条超长的 INSERT。注意它的本质其实是一整条 SQL跟 JDBC Batch 的多值 INSERT 很像但并不是 JDBC Batch。正因为如此你不能把一万条全部塞进去否则 SQL 文本长度、MyBatis 日志打印、MySQL 网络包大小都会爆掉。正确做法是手动分批ListListUser partitions partition(userList, 1000); for (ListUser partition : partitions) { userMapper.batchInsert(partition); }useGeneratedKeystrue配合keyPropertyid可以在批量插入后把自增主键回填到每个对象的id属性里。这种写法代码量最少几千条数据用着很方便但它不是面对真正的“万级批量插入”的最优解因为每次调用 service 方法时 list 长度、SQL 长度都需要你心里有数。3.2 写法BExecutorType.BATCH SqlSession这才是 MyBatis 里的真正批处理MyBatis 提供了ExecutorType.BATCH执行器模式底层走的是 JDBC 的 addBatch / executeBatch跟方案一是同一套东西但代码可以写得相对简洁。我一般用 SqlSessionFactory 直接打开一个 Batch 模式的 SqlSessionAutowired private SqlSessionFactory sqlSessionFactory; public void batchInsertWithMyBatis(ListUser userList) { try (SqlSession session sqlSessionFactory.openSession(ExecutorType.BATCH)) { UserMapper mapper session.getMapper(UserMapper.class); for (User user : userList) { mapper.insertOne(user); // insertOne 是单条 INSERT不能用 foreach 批量方法 } session.commit(); session.clearCache(); } }注意这里的insertOne一定要写成单条 INSERT不能再套 foreach。因为ExecutorType.BATCH的机制就是让每次insertOne只做addBatch最终由commit()统一刷出。如果你在 Mapper 方法里又用了 foreach 拼多值 SQL两种批量机制叠加可能适得其反。3.3 这个模式里最隐蔽的坑混用 select 会强制 flushBatch 模式的 SqlSession 里千万不要查询数据。MyBatis 为了保证查询能看到之前操作的数据在执行 select 前会强制把当前 SqlSession 里积压的 Batch 语句全部 flush 出去。这一 flush前面的批量效果就打了折扣而且会产生大量临时 SQL 发送整体耗时被拖回去。如果业务上确实需要“插入一批后查一下”的逻辑用两个不同的 SqlSession一个专门做批量写入一个专门做查询不要混用。两种 MyBatis 写法的对比如下维度foreach 多值 INSERTExecutorType.BATCHSQL形态一条超大 INSERT单条 INSERT addBatch 攒批底层机制多值 SQLJDBC Batch依赖 rewriteBatchedStatements主键回填useGeneratedKeys 友好依赖驱动支持有兼容性问题适用场景几千条以内万级以上对性能有要求风险点SQL 过长、网络包超限同一 Session 混用 select 会强制 flushSpring Boot 3.3 项目集成 MyBatis 时要用兼容 Spring Boot 3.x 的mybatis-spring-boot-starter版本比如 3.0.3别拿老版本踩版本冲突的坑。4. 方案三JdbcTemplate 与 NamedParameterJdbcTemplateSpring 自带的批处理答案4.1 batchUpdate 的经典写法如果你不想引入 ORM 框架Spring Boot 自带的spring-boot-starter-jdbc里就有 JdbcTemplate它同样支持批量写入。Autowired private JdbcTemplate jdbcTemplate; public void batchInsertWithJdbcTemplate(ListUser userList) { String sql INSERT INTO t_user (name, age, email, create_time) VALUES (?, ?, ?, ?); jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { Override public void setValues(PreparedStatement ps, int i) throws SQLException { User user userList.get(i); ps.setString(1, user.getName()); ps.setInt(2, user.getAge()); ps.setString(3, user.getEmail()); ps.setObject(4, LocalDateTime.now()); } Override public int getBatchSize() { return userList.size(); } }); }getBatchSize()返回多少JdbcTemplate 就会把多少条记录通过addBatch()追加到同一批。如果返回值就是整个 list 的大小等于一次性把一万条全部塞进一个 Batch。这样做不一定会报错但内存和网络包压力都会上来。建议同样手动分片把大 list 切成若干个小批量循环调用batchUpdatefor (ListUser partition : partition(userList, 1000)) { jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { ... getBatchSize() { return partition.size(); } }); }4.2 NamedParameterJdbcTemplate 的 Map 数组写法动态参数比较多时NamedParameterJdbcTemplate的可读性更好。它支持传入一个MapSqlParameterSource[]数组Autowired private NamedParameterJdbcTemplate namedParameterJdbcTemplate; public void batchInsertWithNamed(ListUser userList) { String sql INSERT INTO t_user (name, age, email, create_time) VALUES (:name, :age, :email, :createTime); MapSqlParameterSource[] batch userList.stream() .map(u - new MapSqlParameterSource() .addValue(name, u.getName()) .addValue(age, u.getAge()) .addValue(email, u.getEmail()) .addValue(createTime, LocalDateTime.now())) .toArray(MapSqlParameterSource[]::new); namedParameterJdbcTemplate.batchUpdate(sql, batch); }这种写法看着优雅但本质上跟JdbcTemplate.batchUpdate是同一套逻辑底层还是 PreparedStatement 的 Batch。所以性能差异不大选顺手的就行。4.3 为什么 JdbcTemplate 性能和 JDBC 原生 Batch 几乎一样因为 JdbcTemplate 本身就是 JDBC 的一个壳它内部把BatchPreparedStatementSetter的每条回调转换成PreparedStatement.addBatch()最终调用executeBatch()。所以它的性能和方案一是同一水平线。关键是JdbcTemplate 并不能帮你自动加上rewriteBatchedStatementstrue这个参数还是得写在数据源连接串里。如果你用 JdbcTemplate 批量插入后发现还是很慢不用怀疑模板写法先去查连接串参数。5. 方案四数据量到 10 万级以后多线程并发分片怎么设计5.1 单线程 Batch 的极限和并发的前提单线程 JDBC Batch 把一万条压到一秒内已经很快了但数据量到十万、几十万级别时单线程会受制于磁盘 IO、单核 CPU、连接往返等资源继续增加批次大小收益有限。这时候可以考虑多线程分片。但并发不是想开就能开的我建议至少满足这几个条件再动手InnoDB 并发写的是不同记录行锁冲突可控数据源连接池有足够的空闲连接线程数不能超过连接池上限MySQL 的max_allowed_packet够大且单批大小可控业务能接受“部分分片成功、部分失败”的语义或者已经设计了失败重试机制。5.2 分片 线程池的代码骨架我自己习惯用一个显式线程池 手动subList分片的方式不依赖 Guava 之类的外部库public void concurrentBatchInsert(ListUser userList) throws InterruptedException { int batchSize 1000; int threadCount 4; ExecutorService executor new ThreadPoolExecutor( threadCount, threadCount, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(threadCount * 2), new ThreadPoolExecutor.CallerRunsPolicy() ); for (int i 0; i userList.size(); i batchSize) { ListUser partition userList.subList(i, Math.min(i batchSize, userList.size())); executor.submit(() - jdbcBatchInsert(partition, batchSize)); } executor.shutdown(); executor.awaitTermination(1, TimeUnit.MINUTES); }线程池大小我一般先设为 2 到 8。不要一次开几十个线程数据库瓶颈很多时候不在连接数而在磁盘刷盘和 binlog 写入。CallerRunsPolicy的意思是队列满了就让提交任务的线程自己执行这样不会丢任务也不会因为拒绝策略把导入流程搞崩。5.3 并发时最容易翻车的三个点第一是连接池被打满。HikariCP 默认maximumPoolSize10你开 20 个线程后面的任务根本拿不到连接。更麻烦的是拿着连接的任务如果因为锁冲突等待整个池子很快就被耗尽出现“connection not available”异常。合理做法是线程数不超过连接池上限并且留几个连接给其他核心业务用。第二是事务粒度。并发分片时每个子任务都应该有自己独立的事务边界不要共用一个 Spring 全局事务。如果所有线程都绑定在同一个大事务上提交时锁竞争和 undo log 的膨胀会非常严重最后性能比单线程还差。第三是幂等和重试。分片并发后某些片失败是常态。最好的方式是在表上建唯一业务号导入前先按业务号过滤一遍失败的分片记录到一个任务表中下一次从任务表里捞出来重试。不要指望靠数据库回滚解决一切。6. 实测对比与踩坑清单从 1 万到 10 万的时间账6.1 测试环境与数据说明我在一台普通测试服务器上做的实测环境仅供参考Spring Boot 3.3.0、Java 17、MySQL 8.0.36、HikariCP 默认连接池、单表t_user共 20 个字段、主键自增、带两个普通索引、随机插入 10 万条数据。以下耗时不代表任何基准数据不同机器、不同表结构差异会很大但相对趋势是一致的。6.2 各方案耗时对比方案1万条耗时5万条耗时10万条耗时备注单条循环 insert约 22 秒约 120 秒放弃实测autocommittrue逐条提交JDBC Batch未开 rewrite约 18 秒约 95 秒太慢且不稳定驱动仍逐条发送JDBC Batchrewritetrue约 1.1 秒约 5.5 秒约 11 秒batchSize1000MyBatis foreach 多值 INSERT约 1.3 秒约 6.8 秒约 13.5 秒每批 1000 条MyBatis ExecutorType.BATCH约 1.2 秒约 6.2 秒约 12 秒单条 insert addBatchJdbcTemplate batchUpdate约 1.2 秒约 5.8 秒约 11.5 秒分 1000 条/批4 线程并发 JDBC Batch约 0.45 秒约 2.1 秒约 4.2 秒4 线程各写 1000 条/批从这个结果能明显看出真正拉开数量级差距的不是 JDBC、MyBatis、JdbcTemplate 这几个框架的选型而是你有没有开启 Batch、有没有关闭自动提交、有没有在连接串上加上rewriteBatchedStatementstrue。框架只是外衣底层通信方式才是决定性因素。6.3 踩坑记录表问题现象根本原因解决对策抛 PacketTooBigException批量改写后的多值 SQL 超过 max_allowed_packet调大 MySQL 的 max_allowed_packet同时减小单批条数JDBC Batch 加了还是很慢连接串缺少 rewriteBatchedStatements确认 URL 参数真实生效别被配置覆盖MyBatis Batch 模式执行查询后变慢查询触发隐式 flush批处理被拆散发送查询用独立 SqlSession与写 Session 隔离一次塞 10 万条内存飙升超大 Batch 在驱动和 PreparedStatement 里缓存了太多数据按 500~1000 条/批提交自增主键回填错乱rewriteBatchedStatements 与批量主键回滚兼容性问题小批次回填或应用侧预先分配主键并发后连接池打满线程数超过 HikariCP 最大连接数线程数降为连接池一半以内并预留连接6.4 还敢更快的几个 MySQL 参数方向如果数据允许少量丢失、且以导入性能为第一优先级可以考虑在 MySQL 层面调整这两个参数innodb_flush_log_at_trx_commit2和sync_binlog0减少每次事务提交时的磁盘强制刷盘。不过这两个参数都有丢数据的风险生产环境不要随便改先想清楚业务能不能接受。另外导入大片数据之前如果表上有非必要索引可以考虑先删掉索引、导完再重建。索引维护的开销在插入时会被成倍放大这也是批量导入常见的性能杀手。最后说下我自己的习惯。如果你第一次做批量导入别一上来就写多线程。先把单线程 JDBC Batch 跑通确认rewriteBatchedStatementstrue真的生效用一万条数据量测一遍基准再根据项目选 MyBatis 还是 JdbcTemplate等数据量上到十万级、单线程确实压不动了再考虑并发分片。批量插入优化这件事最怕的是方案写了很多底层却还在反复给数据库发送一条条单行 INSERT那样无论换什么框架都只是在重复消耗网络和日志。
RELATED

相关推荐

MSCOMCTL.OCX 报错修复指南:从原理到注册全流程

MSCOMCTL.OCX 报错修复指南:从原理到注册全流程

1. 这个报错到底是什么:MSCOMCTL.OCX的前世今生MSCOMCTL.OCX,全称Microsoft Common Controls ActiveX Control,是Visual Basic 6.0时代随开发环境一起分发的公共控件库。TreeView、ListView、Toolbar、StatusBar、ProgressBar、TabStrip、Ima…

📅 2026/10/10 6:44:29
kubectl速查手册:从命令模型到容器排障实战

kubectl速查手册:从命令模型到容器排障实战

1. 先建立命令心智模型:动词加资源,一切都有规律每次有新人问我 kubectl 怎么学,我都会说:别背,整理一份属于自己的速查手册。Kubernetes 的命令看着多,真正每天用的其实就那么十几个。我整理这份手册的起因…

📅 2026/10/10 6:44:29
TimescaleDB 2.3.0 for PostgreSQL 12 Windows 部署实战指南

TimescaleDB 2.3.0 for PostgreSQL 12 Windows 部署实战指南

简介:本资源是面向数据库工程师、后端开发者及时间序列数据分析人员的TimescaleDB生产级部署包,专为在Windows 64位系统上快速集成TimescaleDB v2.3.0与PostgreSQL 12而设计,解决时序数据高吞吐写入、高效分片查询与平滑版本升级等核心问题。…

📅 2026/10/10 6:39:29
MORE NEWS

更多资讯

📰

2026年降AI率工具真实测评:10款改写工具的优劣与使用边界

最近后台被问到最多的一个问题,大概就是“2026年自考复习写的东西,AI率太高,怎么办”。这不是什么新鲜话题,从两年前开始,我就陆续测过三十多款和文本降重、降AI率有关的工具。这次干脆花了整整四周,把市面…

📰

BigBanana AI Director:一站式AI短剧与漫剧导演平台工程实践

简介:BigBanana AI Director 是一套面向短剧与漫剧创作者的工业级本地化 AI 制作平台,主打从故事构思到成片输出的一站式工作流,数据全程留在本机,兼顾隐私安全与知识产权归属。它整合剧本生成、角色设定、分镜设计、语音合成与画…

📰

BigBanana AI Director:工业级AI短剧与漫剧全流程制作实战指南

简介:BigBanana AI Director 是一套面向专业内容创作者的工业级 AI 短剧与漫剧全流程制作平台,基于开源架构构建,支持完全离线运行,从故事生成、角色设定、分镜设计、语音合成到成片输出一站式完成,数据全程保留在本地…

📰

深入理解Spring三级缓存:循环依赖的机制、局限与排查实践

1. 循环依赖是什么,以及你为什么会碰到它先聊个场景。有一次我在排查一个偶发启动失败的问题,某个服务明明本地跑得好好的,一上测试环境就报BeanCurrentlyInCreationException。日志堆栈指来指去,最后定位到就是两个 Service 互相…

📰

用编程思维打造个人知识体系:IoC、依赖注入与响应式学习实操指南

我前两年一度陷入一个很常见的循环:买了不少课、收藏了一堆文章、笔记记了好几本,可三个月后发现,真正能讲清楚、能上手用起来的,其实没几个。后来想明白一个问题——我不是懒,也不是笨,而是把学习做成了“…

📰

联邦学习赋能大模型WAF:破解数据孤岛与攻击变异难题

1. 项目概述:当大模型撞上WAF,不是堆算力,而是重新定义“看见攻击”的方式最近在某高校实验室做安全方向的模拟项目X时,团队里一位做NLP的同事随手把一份Web攻击日志喂给刚微调好的小规模语言模型,结果模型不仅标出了S…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬