☰
Excel多线程导入数据库实战:线程池、批量写入与事务控制
2026/10/21 22:01:26 网站建设 项目流程

1. 为什么“多线程导入”这件事值得认真对待

做过数据迁移或者后台管理系统的人,大概率都遇到过这样一个场景:业务方甩过来一个几十万行甚至上百万行的 Excel 文件,说“今天下班前导进系统”。你打开文件一看,几十列字段,还夹杂着合并单元格、日期格式混乱、手机号被识别成科学计数法。如果老老实实写个单线程循环,一行一行insert,那基本就是看着进度条发呆,中途还可能因为一个脏数据整批回滚,前功尽弃。

“Excel 表数据多线程导入数据库”这个主题,核心要解决的就是三个字:快、稳、准。快,是指面对大文件时不能傻等;稳,是指中途出错不能全盘崩溃,要能续传、能回滚、能定位问题;准,是指字段映射、类型转换、空值处理这些细节不能出错,否则导进去的数据比不导还麻烦。

这篇文章适合谁看?如果你正在做后台管理系统的批量导入功能,或者在做数据中台的数据接入模块,又或者你只是单纯被一个超大 Excel 折磨过,那这篇内容应该能帮到你。我会从整体设计思路讲起,把线程池怎么配、批次怎么切、事务怎么控、异常怎么兜底这些关键点全部拆开,配上可以直接参考的代码结构和参数计算过程。不堆砌概念,只讲我实际踩过坑之后总结出来的做法。

需要先说明一点:多线程导入不是万能药。它的收益取决于数据库写入能力、磁盘 IO、网络延迟和 Excel 解析速度这几个环节的短板在哪里。如果数据库本身就是瓶颈,开再多线程也只是把压力更集中地砸过去。所以下面我会先讲清楚“什么时候该用多线程”,再讲“怎么用才不出事”。

2. 整体设计思路与方案选型拆解

2.1 单线程导入到底慢在哪里

要理解多线程的价值,先得知道单线程的时间花在哪。一个典型的 Excel 导入流程可以拆成四段:读取文件、解析行、转换字段、写入数据库。假设一个 50 万行的文件,用常见的 POI 或 EasyExcel 解析,读取加解析大概占总时间的 30% 到 40%,字段转换占 10% 左右,剩下 50% 到 60% 全耗在数据库写入上——尤其是逐行insert的时候,每次都要经历网络往返、SQL 解析、事务日志写入。

单线程的致命问题在于,这四段是串行的。CPU 在解析的时候,数据库连接闲着;数据库在写入的时候,CPU 又闲着。多线程的核心思路就是把这几个阶段重叠起来:解析线程持续产出数据,写入线程池并行消费,让 CPU 和数据库都不空转。

但这里有个容易被忽略的点:Excel 解析本身通常是单线程的。POI 的XSSFWorkbook把整个文件加载进内存,解析过程很难并行化。所以真正能并行的是“写入”这一段。如果你的瓶颈在解析,那多线程写入帮助有限,得换流式解析方案(比如 EasyExcel 的 SAX 模式)先把解析这关过了。

2.2 三种主流方案的取舍

实际项目里,多线程导入大概有三种落地方式,各有适用场景。

第一种是生产者-消费者模型。一个线程负责读 Excel 并往阻塞队列里放数据,多个线程从队列里取数据批量写库。这是最经典也最可控的方案,队列起到缓冲作用,能削峰填谷。缺点是内存占用取决于队列容量,队列太大容易 OOM。

第二种是分片并行。把 Excel 按行号切成若干段,每个线程独立读自己那一段、独立写库。这种方式实现简单,但要求 Excel 能被多次打开或者提前拆分成多个文件,而且各线程进度不好统一管理。

第三种是异步批量提交。解析和写入用CompletableFuture编排,解析出一批就提交一批异步写库,主线程不阻塞。这种方式代码优雅,但异常处理和事务边界比较难控,适合对一致性要求没那么极端的场景。

我个人的选择是第一种,也就是生产者-消费者加批量写入。原因很直接:队列能控制内存,批量能提升数据库吞吐,线程池能限制并发上限保护数据库。下面所有的参数计算和代码结构都基于这个方案展开。

2.3 批量写入为什么比逐行快这么多

这里得解释一个关键原理。数据库每次执行一条insert,都要经历:建立(或复用)连接、发送 SQL、解析 SQL、执行、写事务日志、返回结果。逐行插入时,这些开销每条都要付一遍。而批量插入(insert into ... values (...),(...),(...)或者 JDBC 的addBatch/executeBatch)把多条合并成一次网络往返和一次日志刷盘,开销被摊薄了。

实测数据供参考:在普通机械盘加中等配置数据库上,逐行插入每秒大概能写 500 到 2000 条;改成 500 条一批的批量插入,每秒能到 1 万到 3 万条;再叠加多线程,能到 5 万到 10 万条。当然这个数字受硬件和数据库类型影响极大,但量级差异是真实的。所以批量是基础,多线程是放大器,两者缺一不可。

3. 核心细节解析与实操要点

3.1 线程池参数怎么算才不拍脑袋

线程池不是越大越好。线程太多,数据库连接池会被打满,反而互相等待;线程太少,又压不满数据库。一个实用的估算方法是:

线程数 ≈ 数据库可用连接数 × 0.8

比如连接池配了 20 个连接,那写入线程数控制在 16 个左右比较稳妥,留几个给其他业务。如果这是独立的数据导入服务,连接池可以专门为导入开大一点,比如 30 个连接配 24 个写入线程。

队列容量也要算。假设每批 500 条,每条数据平均占 1KB 内存,那 1000 批就是 500MB。队列容量设成 200 到 500 批比较合理,既能缓冲,又不会把内存吃光。核心线程数、最大线程数、队列容量这三个参数要一起调,不能只看一个。

// 线程池配置参考 int corePoolSize = 16; int maxPoolSize = 16; // 导入场景一般用固定线程数,避免频繁创建销毁 int queueCapacity = 300; // 约 15 万条数据的缓冲 ThreadPoolExecutor executor = new ThreadPoolExecutor( corePoolSize, maxPoolSize, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(queueCapacity), new ThreadFactoryBuilder().setNameFormat("excel-import-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy() // 队列满时由生产者自己执行,形成背压 );

注意最后那个拒绝策略用的是CallerRunsPolicy。这不是随便选的。当队列满了,说明消费速度跟不上生产速度,这时候让生产者线程自己去执行写入任务,等于自动降速,形成背压,避免数据丢失或者内存爆掉。用AbortPolicy会直接抛异常,用DiscardPolicy会静默丢数据,都不合适。

3.2 批次大小到底设多少合适

批次大小是个需要权衡的参数。批次太小,批量插入的优势发挥不出来,网络往返次数多;批次太大,单次 SQL 过长,数据库解析压力大,而且一旦失败回滚的代价高。

经验值是这样的:MySQL 单条 SQL 建议不超过 1MB,按每行 200 字节算,大概 5000 行;但考虑到事务日志和锁的粒度,实际用 500 到 1000 条一批比较稳。PostgreSQL 对大批次容忍度更高,可以到 2000 到 5000。Oracle 用INSERT ALL或者批量绑定,1000 到 2000 比较合适。

还有一个细节:批次大小最好和 JDBC 的rewriteBatchedStatements=true配合。MySQL 开启这个参数后,addBatch的多条语句会被重写成一条多值insert,性能提升非常明显。不开的话,executeBatch还是一条条发,只是省了网络往返。

3.3 字段映射和类型转换的坑

Excel 导入最容易翻车的地方不是性能,是数据本身。我列几个高频问题:

  • 手机号、身份证号变成科学计数法:Excel 默认把长数字当数值处理,读出来是1.38E+10。解决办法是读取时统一按字符串处理,或者用DataFormatter格式化。
  • 日期格式五花八门:有的是2024/1/1,有的是2024-01-01,还有的是 Excel 内部数字序列号。建议统一转成LocalDateTime再入库,转换失败就记录到错误行。
  • 空值和空字符串:Excel 里空单元格读出来可能是null,也可能是空字符串,还可能是空格。入库前要统一 trim 并判断。
  • 合并单元格:只有左上角有值,其他是空。如果业务需要填充,得自己处理。

提示:字段映射建议用配置化的方式,把“Excel 列名 → 数据库字段 → 转换器”做成一张映射表,而不是硬编码在代码里。这样业务方改列名时不用改代码。

3.4 事务边界怎么划

这是多线程导入里最需要想清楚的问题。如果整个导入放在一个大事务里,那多线程根本没意义,因为数据库锁会串行化所有写入。正确做法是每个批次一个独立事务。

但这样带来一个新问题:如果第 500 批失败了,前面 499 批已经提交,怎么办?两种策略:

一是快速失败:任何一批失败就停止整个导入,记录已成功的批次,支持从失败批次续传。适合数据准确性要求高、允许人工介入的场景。

二是跳过错误行:把失败的行单独收集起来,其他行继续导入,最后输出错误报告。适合数据质量参差、以“尽量多导入”为目标的场景。

我一般推荐第一种为主、第二种为辅:批次级失败就停,但批次内单行失败可以跳过并记录。这样既保证了整体可控,又不会因为一行脏数据卡死整个任务。

4. 实操过程与核心环节实现

4.1 整体流程编排

先把整个流程用文字串一遍,方便你对照自己的项目。

  1. 上传 Excel 文件,保存到临时目录,记录任务 ID。
  2. 启动一个解析线程,用流式方式逐行读取,每读满一个批次(比如 500 行)就封装成一个List<Map>或实体列表,放入阻塞队列。
  3. 写入线程池里的线程从队列取批次,执行批量插入,每个批次独立事务。
  4. 解析线程读完后,往队列放入“结束标记”,写入线程收到标记后退出。
  5. 主线程等待所有写入线程结束,汇总成功数、失败数、错误详情。
  6. 清理临时文件,更新任务状态。

这个流程里,解析线程只有一个,写入线程有多个,队列是它们之间的桥梁。下面拆几个关键环节。

4.2 流式解析避免 OOM

如果用XSSFWorkbook一次性加载,50 万行的文件轻松吃掉几个 G 内存。正确做法是用 EasyExcel 的监听器模式,或者 POI 的SXSSFWorkbook(读的时候用XSSFReader加 SAX)。

// EasyExcel 监听器模式的核心结构 public class ImportListener extends AnalysisEventListener<Map<Integer, String>> { private final BlockingQueue<BatchData> queue; private final List<Map<Integer, String>> batch = new ArrayList<>(BATCH_SIZE); private final Map<Integer, String> headMap = new HashMap<>(); @Override public void invokeHeadMap(Map<Integer, String> head, AnalysisContext context) { headMap.putAll(head); // 保存表头,用于字段映射 } @Override public void invoke(Map<Integer, String> row, AnalysisContext context) { batch.add(row); if (batch.size() >= BATCH_SIZE) { flush(); } } private void flush() { if (batch.isEmpty()) return; try { // 阻塞放入,队列满时自动等待,形成背压 queue.put(new BatchData(new ArrayList<>(batch))); batch.clear(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("导入被中断", e); } } @Override public void doAfterAllAnalysed(AnalysisContext context) { flush(); // 处理最后不足一批的数据 queue.add(BatchData.END); // 结束标记 } }

这里用queue.put而不是offer,是因为put在队列满时会阻塞,正好实现背压。如果用offer且不处理返回值,队列满了数据就丢了,这是很多人踩过的坑。

4.3 批量写入与事务控制

写入线程的核心逻辑是:从队列取批次,开启事务,批量插入,提交或回滚。

// 写入线程的核心逻辑 while (true) { BatchData batch = queue.take(); if (batch.isEnd()) break; try { transactionTemplate.execute(status -> { jdbcTemplate.batchUpdate(INSERT_SQL, batch.getRows()); return null; }); successCount.addAndGet(batch.size()); } catch (Exception e) { // 批次失败,记录并决定是否终止 failCount.addAndGet(batch.size()); errorLog.record(batch, e); if (failFast) { // 通知其他线程停止 stopFlag.set(true); break; } } }

jdbcTemplate.batchUpdate底层用的是 JDBC 的addBatch/executeBatch,配合rewriteBatchedStatements=true效果最好。注意每个批次一个事务,不要跨批次共享事务。

4.4 进度反馈与任务状态

用户最关心的是“导到哪了”。进度可以用已处理行数除以总行数来算。总行数在解析前可以先快速扫一遍文件拿到,或者用 EasyExcel 的AnalysisContext里的信息估算。

进度更新不要每行都写数据库,那样反而成瓶颈。建议每处理完一个批次更新一次内存中的计数器,再每隔几秒异步刷一次到数据库或缓存。前端轮询这个状态即可。

// 进度更新示例 AtomicLong processedRows = new AtomicLong(0); // 每批完成后 processedRows.addAndGet(batch.size()); // 定时任务每 3 秒把 processedRows 写入任务状态表

4.5 参数计算实例

假设一个具体场景:MySQL 数据库,连接池 20 个连接,Excel 文件 80 万行,每行 15 个字段,平均 300 字节。

  • 批次大小:取 800 条,单批 SQL 约 240KB,安全。
  • 写入线程数:连接池 20 个,留 4 个给其他业务,用 16 个。
  • 队列容量:300 批,约 24 万条,内存占用约 72MB,可接受。
  • 预计耗时:单线程批量写入约 2 万条/秒,80 万行约 40 秒;16 线程理论上限受数据库限制,实测约 8 万条/秒,约 10 秒。加上解析时间 15 秒左右,总耗时约 25 秒。

这个计算不是精确预测,但能帮你判断参数是否合理。如果算出来内存占用超过堆的 50%,就要调小队列容量。

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

5.1 高频问题速查表

问题现象可能原因排查方向解决办法
导入中途 OOM队列容量过大或解析未流式看堆 dump,确认队列和批次对象调小队列,改用流式解析
数据库连接被打满写入线程数超过连接池看连接池监控线程数降到连接池的 80%
导入速度反而变慢批次太小或事务太大看单批耗时和锁等待调大批次,缩小事务范围
部分数据丢失队列满时用了 offer 未处理检查入队逻辑改用 put 或处理返回值
日期/数字格式错乱未做类型转换抽查原始单元格类型统一按字符串读再转换
失败后无法续传未记录批次位置检查是否有断点记录记录已成功批次号

5.2 几个我踩过的坑

坑一:线程池用了无界队列。Executors.newFixedThreadPool底层是无界队列,队列会一直涨到 OOM。必须手动new ThreadPoolExecutor并指定有界队列。

坑二:忘了处理最后一批。如果总行数不是批次大小的整数倍,最后会剩一批不足的,很多人只在invoke里判断批次满没满,忘了在doAfterAllAnalysed里 flush 剩余数据,导致尾部数据丢失。

坑三:异常被吞掉。写入线程里的异常如果不往外抛也不记录,主线程根本不知道失败了。一定要用Future收集每个线程的结果,或者用共享的原子变量记录失败数。

坑四:数据库自增主键和批量插入冲突。有些场景需要拿到插入后的主键,批量插入时getGeneratedKeys行为因数据库而异,MySQL 支持但要注意配置,Oracle 需要用RETURNING。如果不需要主键,就别去取,省事。

坑五:导入时锁表影响线上业务。大批量插入会持有行锁甚至间隙锁,如果和线上业务操作同一张表,可能造成阻塞。建议在低峰期导入,或者用临时表先导入再合并。

5.3 性能调优的几个额外技巧

除了线程和批次,还有几个地方能榨出性能:

  • 关闭自动提交:JDBC 默认自动提交,每条 SQL 一个事务。批量场景要手动setAutoCommit(false),由事务模板控制。
  • 调整 JDBC 参数:MySQL 的rewriteBatchedStatements=true、useServerPrepStmts=true对批量插入帮助很大。
  • 禁用索引和约束:导入前临时禁用非唯一索引,导入后重建,能大幅提升写入速度。但要注意这期间数据一致性风险。
  • 用 LOAD DATA 或 COPY:如果数据库支持,直接走文件导入命令比 JDBC 快一个数量级。但这种方式绕过了应用层校验,适合数据已经清洗干净的场景。

注意:禁用索引和约束属于高风险操作,生产环境务必在维护窗口进行,并做好回滚预案。

6. 一些收尾的经验之谈

多线程导入这件事,说到底是在“速度”和“可控性”之间找平衡。线程开多了快是快,但数据库压力大、异常难定位;批次开大了吞吐高,但单次失败代价大。我自己的习惯是先把单线程批量跑通,确认数据准确、字段映射无误,再逐步加线程、调批次,每加一档就压测一次,观察数据库的 CPU、连接数和锁等待。

还有一个容易被忽视的点:导入任务的幂等性。同一个文件重复提交,不能产生重复数据。常见做法是用文件 MD5 加业务唯一键做去重,或者在导入前先清空目标批次的数据。这个设计要在动手写代码之前就想好,事后补会很痛苦。

最后分享一个小技巧:把导入过程中的错误行单独写到一个“错误文件”里,格式和原 Excel 一致,只是多一列错误原因。用户拿到这个文件,改完直接重新导入,比让他们对着日志一行行找问题友好得多。这个功能实现起来不复杂,但用户体验提升非常明显,值得花时间做。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询