Excel多线程导入数据库实战:线程池、批量写入与事务控制
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 的rewriteBatchedStatementstrue配合。MySQL 开启这个参数后addBatch的多条语句会被重写成一条多值insert性能提升非常明显。不开的话executeBatch还是一条条发只是省了网络往返。3.3 字段映射和类型转换的坑Excel 导入最容易翻车的地方不是性能是数据本身。我列几个高频问题手机号、身份证号变成科学计数法Excel 默认把长数字当数值处理读出来是1.38E10。解决办法是读取时统一按字符串处理或者用DataFormatter格式化。日期格式五花八门有的是2024/1/1有的是2024-01-01还有的是 Excel 内部数字序列号。建议统一转成LocalDateTime再入库转换失败就记录到错误行。空值和空字符串Excel 里空单元格读出来可能是null也可能是空字符串还可能是空格。入库前要统一 trim 并判断。合并单元格只有左上角有值其他是空。如果业务需要填充得自己处理。提示字段映射建议用配置化的方式把“Excel 列名 → 数据库字段 → 转换器”做成一张映射表而不是硬编码在代码里。这样业务方改列名时不用改代码。3.4 事务边界怎么划这是多线程导入里最需要想清楚的问题。如果整个导入放在一个大事务里那多线程根本没意义因为数据库锁会串行化所有写入。正确做法是每个批次一个独立事务。但这样带来一个新问题如果第 500 批失败了前面 499 批已经提交怎么办两种策略一是快速失败任何一批失败就停止整个导入记录已成功的批次支持从失败批次续传。适合数据准确性要求高、允许人工介入的场景。二是跳过错误行把失败的行单独收集起来其他行继续导入最后输出错误报告。适合数据质量参差、以“尽量多导入”为目标的场景。我一般推荐第一种为主、第二种为辅批次级失败就停但批次内单行失败可以跳过并记录。这样既保证了整体可控又不会因为一行脏数据卡死整个任务。4. 实操过程与核心环节实现4.1 整体流程编排先把整个流程用文字串一遍方便你对照自己的项目。上传 Excel 文件保存到临时目录记录任务 ID。启动一个解析线程用流式方式逐行读取每读满一个批次比如 500 行就封装成一个ListMap或实体列表放入阻塞队列。写入线程池里的线程从队列取批次执行批量插入每个批次独立事务。解析线程读完后往队列放入“结束标记”写入线程收到标记后退出。主线程等待所有写入线程结束汇总成功数、失败数、错误详情。清理临时文件更新任务状态。这个流程里解析线程只有一个写入线程有多个队列是它们之间的桥梁。下面拆几个关键环节。4.2 流式解析避免 OOM如果用XSSFWorkbook一次性加载50 万行的文件轻松吃掉几个 G 内存。正确做法是用 EasyExcel 的监听器模式或者 POI 的SXSSFWorkbook读的时候用XSSFReader加 SAX。// EasyExcel 监听器模式的核心结构 public class ImportListener extends AnalysisEventListenerMapInteger, String { private final BlockingQueueBatchData queue; private final ListMapInteger, String batch new ArrayList(BATCH_SIZE); private final MapInteger, String headMap new HashMap(); Override public void invokeHeadMap(MapInteger, String head, AnalysisContext context) { headMap.putAll(head); // 保存表头用于字段映射 } Override public void invoke(MapInteger, 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配合rewriteBatchedStatementstrue效果最好。注意每个批次一个事务不要跨批次共享事务。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 的rewriteBatchedStatementstrue、useServerPrepStmtstrue对批量插入帮助很大。禁用索引和约束导入前临时禁用非唯一索引导入后重建能大幅提升写入速度。但要注意这期间数据一致性风险。用 LOAD DATA 或 COPY如果数据库支持直接走文件导入命令比 JDBC 快一个数量级。但这种方式绕过了应用层校验适合数据已经清洗干净的场景。注意禁用索引和约束属于高风险操作生产环境务必在维护窗口进行并做好回滚预案。6. 一些收尾的经验之谈多线程导入这件事说到底是在“速度”和“可控性”之间找平衡。线程开多了快是快但数据库压力大、异常难定位批次开大了吞吐高但单次失败代价大。我自己的习惯是先把单线程批量跑通确认数据准确、字段映射无误再逐步加线程、调批次每加一档就压测一次观察数据库的 CPU、连接数和锁等待。还有一个容易被忽视的点导入任务的幂等性。同一个文件重复提交不能产生重复数据。常见做法是用文件 MD5 加业务唯一键做去重或者在导入前先清空目标批次的数据。这个设计要在动手写代码之前就想好事后补会很痛苦。最后分享一个小技巧把导入过程中的错误行单独写到一个“错误文件”里格式和原 Excel 一致只是多一列错误原因。用户拿到这个文件改完直接重新导入比让他们对着日志一行行找问题友好得多。这个功能实现起来不复杂但用户体验提升非常明显值得花时间做。