
Excel 数据导入数据库这件事看起来简单真上手做过的都知道坑有多深。几十万行数据用单线程一行行插进度条能跑到天荒地老换成多线程吧数据库连接池爆了、事务冲突了、主键重复了各种问题接踵而至。我最近刚完成一个数据迁移项目把一份 80 万行的 Excel 台账导入到关系型数据库中间踩了不少坑也总结出一套相对稳妥的多线程导入方案。这篇文章就把整个思路和实操过程完整拆开讲从 Excel 读取的选型、线程池的设计、批量插入的批次控制到事务边界和异常回滚的处理都会给出可直接复用的代码和参数建议。不管你是刚接触数据导入的新手还是想优化现有导入效率的老手应该都能从中找到有用的东西。1. 先搞清楚瓶颈在哪Excel 读取和数据库写入谁更慢很多人一上来就想着多线程觉得线程越多越快结果发现加了线程之后速度没提升多少甚至更慢了。问题出在没有先定位真正的瓶颈。Excel 数据导入数据库这个流程实际上分成两个阶段第一阶段是把 Excel 文件解析成内存中的数据结构第二阶段是把这些数据写入数据库。这两个阶段的性能特征完全不同优化手段也不一样。1.1 Excel 解析阶段IO 密集还是 CPU 密集Excel 文件本质上是一个压缩包里面存放着 XML 格式的表格数据。用 Apache POI 这类库读取时需要解压、解析 XML、构建对象模型这个过程既有磁盘 IO 也有 CPU 计算。对于 .xlsx 格式的大文件POI 的 XSSF 实现会把整个文件加载到内存中构建 DOM 树内存占用非常夸张。我实测过一个 50 万行、20 列的 xlsx 文件用 XSSFWorkbook 直接读JVM 堆内存直接飙到 2GB 以上稍微小一点的机器直接 OOM。这里就引出一个关键选型问题用 XSSF 还是 SXSSF或者干脆用 EasyExcel。XSSF 是全内存模式适合小文件SXSSF 是流式写入模式主要面向写出场景读取支持有限EasyExcel 基于 SAX 解析逐行读取内存占用极低是读取大文件的首选。我后来统一换成了 EasyExcel 的读模式同样 50 万行的文件内存占用稳定在 100MB 以内差距非常明显。// EasyExcel 流式读取示例 EasyExcel.read(filePath, new AnalysisEventListenerMapInteger, String() { private ListMapInteger, String batch new ArrayList(5000); Override public void invoke(MapInteger, String row, AnalysisContext context) { batch.add(row); if (batch.size() 5000) { // 提交给线程池处理 submitBatch(new ArrayList(batch)); batch.clear(); } } Override public void doAfterAllAnalysed(AnalysisContext context) { if (!batch.isEmpty()) { submitBatch(batch); } } }).sheet().doRead();这段代码的核心思路是读取线程只负责解析每积累 5000 行就打包成一个批次提交给后续处理。这样读取和写入可以并行读取线程不会被数据库操作阻塞。1.2 数据库写入阶段网络往返才是大头数据库写入慢绝大多数情况下不是数据库本身慢而是网络往返次数太多。单条 INSERT 语句每次都要经历发送 SQL → 数据库解析 → 执行 → 返回结果这个完整往返假设一次往返 1ms插入 10 万条就是 100 秒。而如果用批量插入一次发送 1000 条往返次数降到 100 次总耗时可能只要几秒。所以优化的第一优先级不是加线程而是改批量插入。JDBC 的addBatch()和executeBatch()就是干这个的。MySQL 还需要在连接 URL 上加上rewriteBatchedStatementstrue否则驱动会假装批量、实际还是一条条发这个坑我踩过加了参数之后同样的数据插入耗时从 45 秒降到 6 秒。写入方式10 万条耗时实测说明单条 INSERT约 95 秒每次一个网络往返addBatch 无 rewrite约 42 秒驱动逐条发送addBatch rewrite约 6 秒真正批量发送多线程 批量约 2 秒4 线程并行这张表很直观地说明了问题批量插入的收益远大于多线程。正确的优化顺序应该是先做批量再做多线程而不是反过来。1.3 什么时候多线程才真正有用多线程能提速的前提是任务本身可以被拆分且各线程之间没有强依赖。对于 Excel 导入这个场景如果数据库连接池够大、磁盘 IO 不是瓶颈、数据库能承受并发写入那么把不同批次分给不同线程处理确实能提升吞吐。但如果数据库是单机低配、连接池只有 5 个连接你开 20 个线程只会让它们排队等连接反而增加上下文切换开销。我的经验是先确认数据库能承受多少并发写入再决定线程数。一般建议线程数控制在数据库连接池最大连接数的 50% 到 70%留一部分连接给其他业务。比如连接池配 20导入线程用 10 到 14 个比较合适。2. 线程池设计不是线程越多越快确定了要用多线程之后下一个问题就是线程池怎么配。这里面的门道比想象中多配不好轻则效率低重则直接把数据库打挂。2.1 核心线程数怎么算线程数的计算公式网上有很多版本比较通用的是CPU 密集型任务线程数 CPU 核心数 1IO 密集型任务线程数 CPU 核心数 × (1 平均等待时间 / 平均计算时间)Excel 导入数据库属于典型的 IO 密集型任务因为大部分时间花在等数据库响应上。假设平均等待时间是计算时间的 10 倍8 核机器上线程数可以到 8 × 11 88。但这是理论值实际还要受数据库连接池限制。所以最终线程数取min(理论值, 连接池大小 × 0.7)。我实际项目里用的是 8 核机器、连接池 30最终线程数定在 16实测吞吐比较稳定数据库负载也在可接受范围。2.2 队列和拒绝策略的选择线程池的队列不能设太大。如果队列设成无界的LinkedBlockingQueue任务会一直堆积内存暴涨而且你无法感知消费速度是否跟得上。我建议用有界队列容量设为线程数的 2 到 3 倍配合CallerRunsPolicy拒绝策略。CallerRunsPolicy的好处是当队列满了提交任务的线程也就是 Excel 读取线程会自己去执行这个任务这样读取速度自然就被压下来了形成一种背压机制不会让内存无限增长。这比直接丢弃任务或者抛异常要优雅得多。ThreadPoolExecutor executor new ThreadPoolExecutor( 16, // 核心线程数 16, // 最大线程数固定大小 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue(32), // 有界队列 new ThreadFactoryBuilder().setNameFormat(excel-import-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() // 背压策略 );线程名一定要设置不然出了问题看线程 dump 全是 pool-1-thread-x根本分不清是哪个模块的线程。2.3 线程池的关闭与任务等待导入完成后线程池要正确关闭否则 JVM 可能无法退出。正确做法是先调用shutdown()然后awaitTermination()等待所有任务完成超时后再shutdownNow()强制中断。executor.shutdown(); if (!executor.awaitTermination(30, TimeUnit.MINUTES)) { executor.shutdownNow(); // 记录日志说明有任务未完成 }这里有个容易忽略的点awaitTermination的超时时间要设得足够长因为最后几批数据可能还在写入。我一般设 30 分钟同时配合日志监控如果超时了说明有问题需要排查。3. 批量插入的批次大小1000 还是 5000批次大小是影响性能的另一个关键参数。批次太小网络往返次数多批次太大单次 SQL 包体过大数据库解析压力大而且一旦失败回滚代价高。3.1 批次大小的实测对比我在 MySQL 8.0 上做过一组对比测试表结构是 15 个字段包含 varchar、int、datetime 类型无索引冲突批次大小10 万条耗时内存峰值失败重试代价10018 秒低低5009 秒低中10006 秒中中50005.5 秒高高100005.8 秒很高很高可以看到批次从 100 增加到 1000耗时几乎减半但从 1000 增加到 5000收益就很小了而内存和失败代价却明显上升。所以1000 是一个比较平衡的选择如果单行数据特别大比如有长文本字段可以降到 500。3.2 批次大小与事务的关系批次大小还和事务边界有关。如果每个批次一个事务那么批次越大事务越大锁持有时间越长并发写入时锁冲突概率越高。如果整个导入一个事务那更不行几百万行数据一个事务undo log 能撑爆。我的做法是每个批次独立事务批次大小 1000。这样单个事务最多 1000 行锁范围可控失败回滚也只影响这 1000 行。配合多线程不同线程处理不同批次事务之间互不干扰。// 每个批次独立事务 Transactional(propagation Propagation.REQUIRES_NEW) public void batchInsert(ListMapInteger, String batch) { String sql INSERT INTO target_table (col1, col2, ...) VALUES (?, ?, ...); try (PreparedStatement ps connection.prepareStatement(sql)) { for (MapInteger, String row : batch) { ps.setString(1, row.get(0)); // ... 设置其他参数 ps.addBatch(); } ps.executeBatch(); } }注意REQUIRES_NEW会挂起当前事务、开启新事务如果外层没有事务效果和REQUIRED一样。这里主要是为了确保每个批次的事务独立。3.3 主键冲突与去重策略批量插入最怕的就是主键冲突一条冲突可能导致整个批次失败。处理方式有两种一是用INSERT IGNORE或ON DUPLICATE KEY UPDATE让数据库自己处理冲突二是在应用层先去重再插入。INSERT IGNORE简单粗暴但会静默丢弃冲突行如果业务上需要知道哪些行被忽略了就不合适。ON DUPLICATE KEY UPDATE适合更新场景但会带来额外的写放大。我一般推荐在应用层用 Set 做去重因为 Excel 数据本身可能就有重复提前去重比让数据库处理更可控。SetString seenKeys ConcurrentHashMap.newKeySet(); ListMapInteger, String deduped batch.stream() .filter(row - seenKeys.add(row.get(0))) // 假设第一列是唯一键 .collect(Collectors.toList());用ConcurrentHashMap.newKeySet()保证多线程下的线程安全这个细节很容易被忽略。4. 异常处理与数据一致性别让一条脏数据毁掉整批多线程导入最头疼的就是异常处理。一个线程抛异常了其他线程还在跑最后数据到底导入了多少、哪些失败了很难说清楚。4.1 单批次失败的处理策略我的策略是单批次失败不影响其他批次失败批次记录到重试队列。每个批次执行时用 try-catch 包住失败后把批次数据和一个失败原因写入一个阻塞队列由专门的补偿线程处理。try { batchInsert(batch); } catch (Exception e) { failedQueue.offer(new FailedBatch(batch, e.getMessage())); log.error(批次导入失败已加入重试队列批次大小{}, batch.size(), e); }补偿线程可以做一些降级处理比如把批次拆小重试、跳过问题行、或者写入一个失败文件供人工处理。这样即使有个别脏数据也不会阻塞整个导入流程。4.2 数据校验前置把问题挡在数据库之外很多导入失败其实是数据本身的问题日期格式不对、数字字段有字母、必填字段为空、字符串超长。这些问题如果在插入时才暴露处理起来很麻烦。更好的做法是在读取阶段就做校验把不合格的行单独收集起来。// 读取时校验 if (!isValid(row)) { invalidRows.add(row); return; // 不加入批次 }校验规则包括字段类型匹配、长度限制、非空约束、枚举值范围等。这些规则最好配置化不同 Excel 模板对应不同规则避免硬编码。4.3 导入结果的统计与核对导入完成后一定要做数据核对。最基本的是比对源文件行数和数据库实际行数如果对不上就要查是哪些行丢了。我一般会在导入过程中维护几个计数器总读取行数、成功插入行数、校验失败行数、插入失败行数最后打印出来。log.info(导入完成读取 {} 行成功 {} 行校验失败 {} 行插入失败 {} 行, totalRead.get(), successCount.get(), invalidCount.get(), failedCount.get());如果成功行数 校验失败行数 插入失败行数 ≠ 总读取行数说明有行在某个环节被静默丢弃了需要排查。这个等式是我做数据导入时的黄金法则帮我发现过好几次隐藏的 bug。5. 完整代码骨架与参数调优清单把前面的内容串起来给出一套可以直接参考的代码骨架。这套代码在我最近的项目里跑过 80 万行数据整体耗时约 90 秒平均每秒约 9000 行数据库负载平稳。5.1 整体流程编排整个流程分三个阶段读取阶段、提交阶段、写入阶段。读取阶段用 EasyExcel 流式读取每 5000 行打包一个读取批次提交阶段把读取批次拆成 1000 行的写入批次提交给线程池写入阶段由线程池中的线程执行批量插入。public class ExcelImportService { private final ThreadPoolExecutor executor; private final BlockingQueueFailedBatch failedQueue; private final AtomicLong totalRead new AtomicLong(); private final AtomicLong successCount new AtomicLong(); public void importExcel(String filePath) { EasyExcel.read(filePath, new AnalysisEventListenerMapInteger, String() { private ListMapInteger, String buffer new ArrayList(5000); Override public void invoke(MapInteger, String row, AnalysisContext context) { totalRead.incrementAndGet(); if (!isValid(row)) { invalidCount.incrementAndGet(); return; } buffer.add(row); if (buffer.size() 5000) { submitInBatches(buffer); buffer new ArrayList(5000); } } Override public void doAfterAllAnalysed(AnalysisContext context) { if (!buffer.isEmpty()) { submitInBatches(buffer); } } }).sheet().doRead(); // 等待所有任务完成 executor.shutdown(); try { executor.awaitTermination(30, TimeUnit.MINUTES); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 处理失败队列 processFailedBatches(); } private void submitInBatches(ListMapInteger, String rows) { for (int i 0; i rows.size(); i 1000) { int end Math.min(i 1000, rows.size()); ListMapInteger, String subBatch rows.subList(i, end); executor.submit(() - { try { batchInsert(subBatch); successCount.addAndGet(subBatch.size()); } catch (Exception e) { failedQueue.offer(new FailedBatch(subBatch, e.getMessage())); } }); } } }这段代码的关键点读取批次 5000、写入批次 1000、线程池有界队列、失败入队。每个参数都有明确的理由不是拍脑袋定的。5.2 参数调优速查表不同环境下的参数需要微调下面这张表可以作为起点参数推荐值调整依据读取批次大小5000内存充足可加大但不超过 10000写入批次大小1000单行大字段多时降到 500线程数连接池 × 0.7不超过 CPU 核数 × 2队列容量线程数 × 2太小会频繁触发背压连接池大小20 到 50根据数据库配置调整事务超时30 秒单批次不应超过此时间5.3 几个容易忽略的细节第一数据库连接要记得关闭。用 try-with-resources 或者连接池管理别手动 new Connection 然后忘了关跑几次导入连接池就满了。第二日期和数字的格式化。Excel 里的日期可能是数字序列号也可能是字符串读取时要统一转换。我一般用DataFormatter或者自定义转换器处理。第三大字段的处理。如果表里有 TEXT 或 BLOB 字段批量插入时要注意包体大小MySQL 默认max_allowed_packet是 4MB批次太大可能超限需要调大这个参数或者减小批次。第四日志不要打太多。每行都打日志会让 IO 成为瓶颈建议只在批次级别打日志比如每 10 个批次打一次进度。6. 实测中的意外情况与应对理论讲完了说说实际跑的时候遇到的一些意外。6.1 数据库连接池被打满第一次跑的时候线程数设了 32连接池只有 20结果大量线程在等连接日志里全是获取连接超时的警告。后来把线程数降到 14问题解决。这个教训是线程数永远不要超过连接池大小否则多出来的线程只是在排队没有任何收益。6.2 内存溢出一开始用 XSSFWorkbook 读文件50 万行直接 OOM。换成 EasyExcel 后内存稳定。另外读取批次如果设得太大比如 50000也会导致内存中堆积大量待处理数据。所以读取批次和写入批次都要控制。6.3 主键冲突导致批次失败有一次导入的数据里有重复主键一个批次 1000 行里有一行冲突整个批次回滚。后来加了应用层去重并且把批次拆小到 500这样即使冲突也只影响 500 行。再后来改用INSERT IGNORE冲突行直接跳过问题彻底解决。6.4 导入速度前快后慢跑了一段时间发现前 10 万行很快后面越来越慢。排查发现是数据库的索引维护和 undo log 增长导致的。解决办法是导入前先禁用非唯一索引导入完成后重建。这个操作要谨慎因为禁用索引期间查询会变慢最好在业务低峰期做。-- 导入前 ALTER TABLE target_table DISABLE KEYS; -- 导入后 ALTER TABLE target_table ENABLE KEYS;注意这个语法主要适用于 MyISAMInnoDB 不支持 DISABLE KEYS需要用其他方式比如先删除索引再重建。6.5 事务隔离级别的影响如果数据库隔离级别是 REPEATABLE READ长事务会导致大量快照影响性能。导入场景下可以考虑把隔离级别降到 READ COMMITTED减少间隙锁和快照开销。这个改动需要评估对业务的影响不是所有场景都适用。7. 写在最后的一些个人体会做数据导入这些年我最大的体会是不要迷信多线程先把单线程的批量插入做到极致。很多项目其实数据量不大单线程批量插入几秒就完事了硬上多线程反而增加了复杂度和出问题的概率。只有当数据量确实很大比如百万级以上、单线程批量插入耗时无法接受时才考虑多线程。另外导入这种一次性任务稳定性和可观测性比极致性能更重要。宁可慢一点也要保证数据不丢、不重、可追溯。我现在的习惯是每次导入都生成一份导入报告包含总行数、成功数、失败数、失败原因分布这样出了问题能快速定位。最后分享一个小技巧如果 Excel 文件特别大可以先把它拆成多个小文件用多个进程并行导入这样比单进程多线程更简单也更容易控制资源。拆分可以用 Python 的 pandas 或者 Java 的 EasyExcel 写出功能实现拆完之后每个文件独立导入互不影响。这个方案在超大数据量场景下比单机多线程更靠谱。