多线程Excel批量导入数据库:生产者-消费者模型与批处理优化实战
1. 为什么单线程导入Excel会让人崩溃做过数据迁移的人都有一个共同体会Excel 文件一旦超过几万行用单线程一行一行往数据库里塞那种等待感堪比看进度条卡在 99%。我最早接手一个数据归档项目时手里有个 12 万行的 Excel 台账字段大概二十几个用最朴素的for循环加insert逐条提交跑完花了将近四十分钟。更难受的是中途一旦报错前面导入的数据还得手动清理否则主键冲突会让你重新来过。这个项目的核心目标很明确把 Excel 表里的数据用多线程的方式高效、稳定地导入数据库。它解决的是单线程导入慢、大批量数据吞吐低、失败后难以恢复的问题。适合有一定编程基础、正在做数据迁移、报表入库、批量建档这类工作的开发者参考也适合刚接触并发编程想找一个真实练手场景的朋友。但多线程导入这件事绝不是把单线程代码套个线程池就完事了。它牵扯到 Excel 解析方式的选择、线程安全的边界、数据库连接的管理、事务粒度的划分、批量提交的参数调优以及最容易被忽略的失败重试与幂等设计。下面我按自己实际踩过的路把整套思路和实现拆开讲。2. 整体方案设计与技术选型思路2.1 先想清楚瓶颈到底在哪很多人一上来就想着加线程但没搞清楚慢在哪。Excel 导入数据库这条链路大致分三段读取解析、数据处理、写入数据库。这三段的耗时占比完全不同优化重点也不一样。读取解析阶段如果用 POI 的XSSFWorkbook一次性把整个 xlsx 加载进内存大文件会直接 OOM而且解析本身是 CPU 密集型操作。数据处理阶段通常是格式转换、校验、字段映射耗时相对可控。写入数据库阶段如果是一条一条insert且每条都自动提交事务那网络往返和磁盘刷盘的开销会占大头这才是真正的瓶颈。所以正确的思路是先解决写入的批量问题再用多线程并行处理解析和写入。如果只加线程不做批量提交线程之间抢数据库连接反而会让性能更差。2.2 为什么选生产者-消费者模型多线程导入最忌讳的是让每个线程各自读整个 Excel。正确做法是一个线程负责读取解析把数据切成批次放进队列多个线程从队列取批次并发写库。这就是典型的生产者-消费者模型。这样设计有几个好处。第一Excel 解析本身对文件句柄和内存有要求单线程读能避免多个线程同时操作同一个工作簿导致的异常。第二写入线程数量可以独立调节根据数据库承载能力灵活配置。第三队列天然起到了缓冲和削峰的作用读取快于写入时数据暂存写入快于读取时线程不会空转抢资源。我用的是ArrayBlockingQueue做有界队列容量设成写入线程数的 4 到 8 倍。有界很关键无界队列在读取远快于写入时会把内存撑爆有界队列则会让生产者在队列满时自然阻塞形成背压。2.3 技术栈的取舍Excel 解析这块小文件用 Apache POI 的XSSFWorkbook没问题但大文件强烈建议换成EasyExcel或者 POI 的SAX 事件模式。EasyExcel 基于 SAX 解析逐行读取内存占用极低而且它的监听器模式天然适合做流式处理。我实测一个 50 万行的文件EasyExcel 内存峰值稳定在几百兆而XSSFWorkbook直接爆掉。数据库写入这块用原生 JDBC 的addBatch和executeBatch就够了不一定非要上 ORM 框架。MyBatis 的foreach批量插入也可以但拼接超长 SQL 在数据量大时有 SQL 长度限制的风险。JDBC 批处理更底层、更可控配合rewriteBatchedStatementstrue参数MySQL 场景性能提升非常明显。线程池方面不要用Executors.newFixedThreadPool它内部用的是无界队列任务堆积会 OOM。老老实实用ThreadPoolExecutor手动指定核心线程数、最大线程数、有界队列和拒绝策略把控制权握在自己手里。3. 核心细节解析与实操要点3.1 Excel 解析方式决定内存生死先讲一个我踩过的坑。早期我用XSSFWorkbook读一个 30 万行的文件JVM 堆设了 2G结果跑一半就OutOfMemoryError。原因是XSSFWorkbook会把整个文档的 DOM 树加载到内存行数越多内存占用线性增长。换成 EasyExcel 后它的ReadListener是逐行回调的每读一行就交给你处理处理完这行对象就可以被回收。内存占用基本和文件行数无关只和批次大小有关。下面是一个典型的监听器写法public class ExcelDataListener extends AnalysisEventListenerMapInteger, String { private final BlockingQueueListMapInteger, String queue; private final int batchSize; private ListMapInteger, String buffer new ArrayList(batchSize); public ExcelDataListener(BlockingQueueListMapInteger, String queue, int batchSize) { this.queue queue; this.batchSize batchSize; } Override public void invoke(MapInteger, String row, AnalysisContext context) { buffer.add(row); if (buffer.size() batchSize) { flush(); } } private void flush() { if (!buffer.isEmpty()) { try { queue.put(new ArrayList(buffer)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } buffer.clear(); } } Override public void doAfterAllAnalysed(AnalysisContext context) { flush(); } }这里有个细节queue.put在队列满时会阻塞这正是我们想要的背压效果。如果这里用offer且不处理返回值队列满了数据就被悄悄丢弃这种 bug 极难排查。3.2 批次大小的黄金区间批次大小直接决定性能。太小比如 100 条一批数据库往返次数多批处理优势发挥不出来。太大比如 10000 条一批单次 SQL 过长内存占用高失败重试的代价也大。我实测下来500 到 2000 条一批是比较舒服的区间。具体取值要看单行字段数量和字段总长度。字段少、单行短可以取 2000字段多、有长文本取 500 更稳。判断标准是单批生成的 SQL 或参数集不要超过数据库允许的最大包大小MySQL 默认max_allowed_packet是 4MB 或 64MB视版本而定。批次大小还和队列容量联动。如果批次是 1000队列容量设 8那内存里最多暂存 8000 行数据这个量级对内存完全无压力。3.3 数据库连接不能每个线程各玩各的多线程写库连接管理是重灾区。如果每个写入线程自己DriverManager.getConnection连接创建和销毁的开销会吃掉多线程带来的收益而且连接数不可控容易把数据库连接池打满。正确做法是用HikariCP这类连接池配置合理的最大连接数。写入线程数不要超过连接池最大连接数否则线程会阻塞在获取连接上。我的经验是写入线程数 连接池最大连接数 CPU 核数的 2 倍左右再根据数据库实际承载能力微调。数据库那边如果连接数有限制就以数据库的限制为准。还有一个关键点每个线程必须用独立的 Connection 和 PreparedStatement。Connection 不是线程安全的多个线程共用一个连接会出现数据错乱甚至死锁。连接池的getConnection每次返回独立连接用完close归还这个模式天然满足要求。3.4 事务粒度与失败处理事务粒度是个需要权衡的点。如果整个导入过程一个大事务中途失败全部回滚数据一致性最好但大事务会长时间占用锁和 undo 空间数据库压力大。如果每批一个事务失败只影响当前批恢复快但整体不是原子的。我的选择是每批一个事务配合幂等写入。所谓幂等就是同一批数据重复导入不会产生重复记录。实现方式有两种一是用insert ignore或on duplicate key update二是导入前先按业务主键删除再插入。前者依赖数据库唯一索引后者逻辑更清晰但多一次删除操作。失败处理上我做了批次级重试。某批写入失败时先回滚当前批事务然后重试最多 3 次每次间隔递增。如果 3 次都失败把这批数据连同异常信息写入一个失败记录文件继续处理后续批次。这样一次导入跑完成功的入库了失败的单独留档人工介入处理不用整个重来。4. 完整实操流程与关键环节实现4.1 环境准备与依赖引入先明确依赖。Excel 解析用 EasyExcel连接池用 HikariCP数据库驱动按实际数据库选。Maven 依赖大致如下dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.2/version /dependency dependency groupIdcom.zaxxer/groupId artifactIdHikariCP/artifactId version5.0.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency数据库连接 URL 里记得加上批处理重写参数这个参数能让 JDBC 把多条 insert 合并成一条多值 insert性能差距是数量级的jdbc:mysql://host:3306/dbname?rewriteBatchedStatementstrueuseServerPrepStmtstrue注意rewriteBatchedStatementstrue是 MySQL 场景的关键参数不加的话executeBatch实际上还是逐条发送批处理形同虚设。这个坑我见过太多人踩。4.2 线程池与队列的初始化线程池和队列的初始化要放在导入任务开始之前全局一份不要每次导入都新建否则线程反复创建销毁也是开销。int writeThreadCount Runtime.getRuntime().availableProcessors() * 2; int batchSize 1000; int queueCapacity writeThreadCount * 8; BlockingQueueListMapInteger, String queue new ArrayBlockingQueue(queueCapacity); ThreadPoolExecutor writePool new ThreadPoolExecutor( writeThreadCount, writeThreadCount, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue(writeThreadCount * 2), new ThreadFactoryBuilder().setNameFormat(excel-write-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() );这里线程池的队列和上面的数据队列是两回事。数据队列存的是待写入的数据批次线程池队列存的是待执行的任务。写入线程从数据队列取批次这个取的动作本身可以是一个长期运行的任务也可以每个批次提交一个任务。我倾向于每个写入线程跑一个循环任务不断从数据队列取批次直到收到结束信号这样线程复用更充分也避免了为每个批次创建任务的额外开销。4.3 生产者读取与消费者写入的协同生产者就是 EasyExcel 的读取过程它跑在主线程或者单独一个读取线程里。消费者是若干个写入线程。两者通过数据队列通信还需要一个结束信号来通知消费者何时停止。结束信号我用一个特殊的毒丸对象表示或者用一个volatile boolean标志位配合队列的poll超时。毒丸方案更干净生产者读完后往队列里放入与消费者数量相同的毒丸每个消费者取到毒丸就退出循环。// 消费者循环 while (true) { ListMapInteger, String batch queue.take(); if (batch POISON_PILL) { break; } writeBatchWithRetry(batch); }写入方法的核心是批处理加事务private void writeBatchWithRetry(ListMapInteger, String batch) { int retry 0; while (retry 3) { try (Connection conn dataSource.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement ps conn.prepareStatement(INSERT_SQL)) { for (MapInteger, String row : batch) { ps.setString(1, row.get(0)); ps.setString(2, row.get(1)); // ... 其余字段 ps.addBatch(); } ps.executeBatch(); conn.commit(); return; } catch (SQLException e) { conn.rollback(); throw e; } } catch (SQLException e) { retry; if (retry 3) { recordFailedBatch(batch, e); return; } sleepBackoff(retry); } } }4.4 参数计算与调优实录拿一个真实场景举例Excel 有 20 万行25 个字段平均每行数据约 300 字节。数据库是 MySQLmax_allowed_packet设为 64MB。批次大小计算单批 1000 行每行 300 字节一批约 300KB远小于 64MB安全。如果字段里有长文本单行可能到几 KB那批次就要降到 200 到 500。线程数计算假设机器 8 核写入线程设 16。但数据库连接池最大连接数如果只有 10那 16 个线程会有 6 个阻塞在获取连接上实际有效并发还是 10。所以线程数要和连接池上限对齐我最终设成 10 个写入线程连接池最大 10。实测结果20 万行数据单线程逐条插入约 25 分钟改成 10 线程加 1000 条批处理后约 40 秒完成提升接近 40 倍。这个提升里批处理贡献了大部分多线程在批处理基础上又叠加了一部分。4.5 导入进度与状态反馈大批量导入如果没有任何反馈用户会以为程序卡死。我在消费者里加了一个AtomicLong计数器每成功写入一批就累加再配合一个定时任务每隔几秒打印一次进度百分比。进度百分比用已处理行数除以总行数总行数可以在读取前先用 EasyExcel 的headRowNumber之外的方式快速估算或者干脆用已入队批次数来估算。提示进度反馈不要每批都打印日志日志本身有 IO 开销批次多的时候反而拖慢速度。定时打印或者每处理 N 批打印一次比较合适。5. 常见问题与排查技巧实录5.1 导入速度上不去的排查顺序速度慢是最常见的问题。排查要按顺序来别乱猜。先确认rewriteBatchedStatementstrue加了没有这是第一嫌疑。然后看批次大小是不是太小100 条一批和 1000 条一批差距明显。再看线程数是不是超过了连接池上限导致线程空等。最后看数据库本身有没有瓶颈比如目标表索引太多、有触发器、磁盘 IO 饱和。我遇到过一次速度死活上不去最后发现是目标表上有个触发器每插入一行都要更新另一张统计表这个触发器把批处理的优势全吃掉了。临时禁用触发器后速度立刻正常。5.2 内存溢出与队列积压OOM 通常来自三个地方Excel 解析方式不对、队列无界、批次太大。解析必须用流式队列必须有界批次大小要按数据宽度调整。如果发现队列持续处于满的状态说明写入速度跟不上读取速度这时候要么增加写入线程要么减小批次让写入更细粒度要么检查数据库是不是有锁等待。5.3 数据错乱与字段错位多线程本身不会导致字段错位但如果多个线程共享了同一个PreparedStatement或者同一个Map对象就会出现数据串行错乱。记住一个原则每个线程的资源必须独立。批次数据在入队前做一次深拷贝避免生产者后续修改影响已入队的数据。5.4 常见问题速查表问题现象可能原因排查方向导入速度慢未开启批处理重写检查 JDBC URL 参数内存溢出解析方式非流式换 EasyExcel 或 SAX队列持续满写入慢于读取增加写入线程或查数据库锁数据重复无幂等设计加唯一索引或先删后插部分批次失败单批数据超限减小批次大小线程阻塞不退出毒丸数量不对毒丸数等于消费者数连接耗尽线程数超连接池对齐线程数与连接池上限5.5 几个我踩过的坑第一个坑是毒丸数量。我一开始只放了一个毒丸结果只有一个消费者退出其他消费者一直阻塞在take上程序永远不结束。毒丸数量必须等于消费者线程数。第二个坑是异常吞没。消费者线程里的异常如果不捕获线程会静默死亡队列里的数据没人消费生产者最终阻塞。所以消费者循环必须用 try-catch 包住记录异常后继续处理下一批。第三个坑是连接未归还。用连接池时如果忘了close连接会泄漏跑一会儿连接池就空了。用 try-with-resources 能避免这个问题。第四个坑是批次数据被复用。我一开始为了省内存所有批次共用一个 List清空后继续用。结果入队的是同一个 List 的引用消费者拿到的时候内容已经被下一批覆盖了。必须每次new ArrayList拷贝一份。6. 进阶优化与扩展方向6.1 分片读取进一步提速如果 Excel 解析本身成为瓶颈可以考虑把一个大文件按行分片多个读取线程各读一段。但 Excel 格式不像 CSV 那样能随意按字节偏移切分xlsx 是压缩包结构直接切分行不通。可行的做法是先用流式解析把数据快速导出成多个临时 CSV 分片再多线程读 CSV 入库。这个方案适合超大文件代价是多一次磁盘写入。6.2 写入端的进一步压榨数据库写入还能再优化。一是用LOAD DATA这类批量加载命令比 insert 快得多但需要文件在数据库服务器可访问的路径上适用场景受限。二是调整数据库参数比如增大innodb_buffer_pool_size、关闭autocommit、导入期间临时关闭唯一性检查导入后重建索引。这些操作要谨慎最好在业务低峰期做。6.3 断点续传的设计对于动辄几十分钟的超大导入断点续传很有价值。思路是记录已成功导入的批次编号或行号范围失败重启时从断点继续。实现上可以在每批成功后往一张进度表写一条记录重启时先查进度表确定起始位置。这个设计让导入任务变得可恢复特别适合无人值守的定时任务。6.4 监控与告警生产环境里导入任务需要监控。关键指标包括队列积压深度、每秒处理行数、失败批次数、连接池活跃连接数。这些指标可以打到日志里也可以接入监控系统。失败批次数超过阈值时触发告警让人及时介入。我在实际使用中发现多线程导入这件事稳定性比极限速度更重要。一味追求快把线程数和批次调到极限往往换来的是偶发的连接超时和数据错乱。留一点余量把失败重试和幂等做扎实才是能长期跑在生产环境里的方案。最后再分享一个小技巧导入前先用几百行数据做一次全链路试跑确认字段映射、批次大小、线程数都合适再放开全量能省下大量返工时间。