拿一个我经手过的真实需求来说:一张Excel表格,50万行业务数据,要导进MySQL里。最早我用for循环一条一条插入,跑了足足四十多分钟,业务方当场坐不住了。后来改成多线程批量插入,再把事务处理从@Transactional换成手动事务方案,整体时间直接压缩到几十秒,数据也能按批次稳定落库。这个过程中踩的坑,尤其是@Transactional在多线程下失效的问题,几乎每个做Java后端的同学都会遇到。
这篇文章就把这套多线程批量插入数据库的完整思路拆开讲清楚:先说为什么需要它,再彻底解释@Transactional为什么在子线程里“不听话”,然后给出手动事务的完整落地代码,最后附上连接池、线程池、MySQL批量写入的调优参数和常见问题排查清单。适合刚接触并发编程的初级开发,也适合写过批量导入但没深究过事务原理的中高级工程师。
1. 多线程批量插入:从业务痛点倒推技术方案
1.1 先说清楚什么场景需要多线程插入
很多人一开始对“多线程批量插入”有误解,以为只是为了炫技。实际上它解决的是一类非常朴素的业务问题:单条插入太慢,慢到无法接受。
单条INSERT在MySQL里慢在哪,拆解开其实就三块:网络往返、事务提交开销、SQL解析执行。每一条都要走一次客户端到服务端的完整往返,哪怕连接池复用连接,这个RTT也省不掉。而且如果每条都单独提交,MySQL要刷一次binlog和redo log,事务提交的成本全部打在单条数据上。数据量到几万条以后,这个性能损失会放大得非常明显。
批量插入的优化思路也很直接:把多条数据的INSERT拼接成一条多值SQL,或者用JDBC的addBatch一次性提交,让原本需要往返N次的请求变成发送一次请求,SQL执行引擎在内部循环写入。单靠这一步,插入性能就能提升10倍以上。
当批量插入还不够快时,第二步才是拆线程并行。但要注意,多线程不是银弹,它是在“单条插入已优化为批量插入”的基线上再做的加速。如果连批量都没做就上多线程,那等于一边加并发一边放大单条插入的开销,效果会非常差。
1.2 串行改并行的两个关键动作
把一段串行插入逻辑改成并行,看起来就是把for循环换成线程池,但实际操作中核心是两步:先分批,再分片提交。
分批指的是每次提交的数据量要控制在合理范围。我常用的经验值是500到2000条一批,具体取决于单条记录字段多少和大小。字段多、单条记录文本长,就往下调;字段少、数据紧凑,可以往上走。这一步是为了避免SQL语句过长触碰max_allowed_packet限制,也让单批事务的执行时间可控。
分片指的是把总数据集划分成若干互不重叠的子集,交给不同线程处理。最简单的方式是直接用Guava的Lists.partition按固定大小切分,或者按主键取模、按ID区间分段。分片设计直接决定了并行任务之间会不会出现重复写入或漏写,划分原则就一条:每个数据块有且只有一个线程负责,线程之间没有交集。
只要这两个动作做对,插入性能基本就能形成“单线程批量”乘以“线程数”的量级提升。实测一个5万行的导入任务,单线程批量插入大约需要几分钟,改成4个线程并行后,三分半的任务能压到四十秒左右,再配合后续提到的MySQL参数,可以进一步压缩到十几秒。
1.3 事务粒度的权衡:大事务还是批量小事务
串行插入的时候,很多人习惯在最外层方法上直接加@Transactional,想着“所有数据要么都成功,要么都不成功”,这种思路确实符合直觉。但一旦上了多线程,这个“大事务”思路就要立刻抛弃。
问题出在事务的粒度上。一个事务如果覆盖了全部50万条数据,意味着要持有一堆行锁或者间隙锁很长时间,在此期间任何其他事务对相关表的DML都会被阻塞。更糟的是,多线程并行时如果也套用这个大事务思路,每个线程拿到的连接都归入同一个事务里,那这个事务本质上已经把并发拖回了串行——因为多个线程必须在同一个事务下协同工作,协调成本极高,而且连接之间的事务隔离让这件事几乎无法实现。
所以工程上最终几乎都选择“批量小事务”:每个分片任务自己开一个事务,每批500条数据一个事务,执行完就提交,出错就只回滚这一批。这样做有几大好处:锁持有时间短,冲突概率小;失败影响面可控,最多回滚一批;配合失败重试,整个任务最终可以把数据全部写入成功。
后面讲到手动事务,本质就是把“大事务”的幻想打破,改成若干个短小精悍的分片事务。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. @Transactional在多线程环境为什么失效
2.1 一段看似正确但注定出问题的代码
先看一段我见过很多次的写法,它的意图是“用多线程加速插入,用@Transactional保证整体原子性”:
java复制@Transactional
public void batchInsert(List<UserInfo> list) {
List<List<UserInfo>> partition = Lists.partition(list, 1000);
ExecutorService executor = Executors.newFixedThreadPool(8);
for (List<UserInfo> batch : partition) {
executor.execute(() -> userMapper.batchInsert(batch));
}
executor.shutdown();
}
这段代码实际跑起来会出现几种情况:要么报连接超时异常,要么某些线程插入的数据部分成功部分丢失,要么无论如何回滚都无法把所有数据回滚掉。原因非常简单:外层方法上的@Transactional只在主线程中生效,子线程里执行的插入操作根本不在这个事务范围内。
这里更迷惑人的是,看起来代码确实是“同一个方法里调用了一个被Spring管理的Mapper”,直觉上以为事务应该全局笼罩着所有数据。但Spring的事务设计和我们直觉理解的“全局事务”完全不同,它的事务边界从来都是跟着线程走的。
2.2 底层原理:Spring事务是怎么绑定线程的
Spring的@Transactional底层依赖AOP拦截器+事务同步管理器。拦截器在方法执行前获取数据库连接,调用connection.setAutoCommit(false),然后通过DataSourceUtils把当前连接绑定到一个ThreadLocal上。后续同一个线程里执行的SQL操作,都会从这个ThreadLocal里取出同一个Connection,从而处于同一个事务内。
问题就出在ThreadLocal上。线程是一个个独立的执行上下文,主线程里绑定的Connection,子线程根本读不到。子线程自己去操作数据库时,只能从连接池里拿一条全新的连接,这条新连接默认处于自动提交模式,所以每一条插入都会立即提交,压根不受外层事务控制的约束。
用生活里的例子类比:你可以把Spring事务想象成一张“工地出入证”,Spring把这个证塞进了主线程的裤子口袋里。主线程干活时能掏出证来进出工地,一证一人一操作,管理得清清楚楚。但子线程是另一个人
