☰
EasyExcel分批读取实战:海量Excel导入数据库的内存优化与性能调优
2026/10/17 14:12:51 网站建设 项目流程

1. 从一次内存溢出说起:为什么单次全量读取Excel会翻车

很多人第一次处理Excel导入数据库的需求时,脑子里蹦出来的方案特别直接:用EasyExcel的read方法一把梭,把整个文件读成一个List,然后循环insert进数据库。数据量小的时候这套逻辑跑得飞快,几百行、几千行毫无压力。可一旦文件膨胀到几十万行甚至上百万行,问题就来了——JVM堆内存被瞬间撑爆,OutOfMemoryError直接糊脸,服务进程当场挂掉。

我印象特别深的一次,是帮一个做电商后台的朋友排查导入功能。他们的运营导出订单明细,一个xlsx文件大概四十多万行,用原来的写法跑,本地开发机16G内存都扛不住,线上容器只给了2G堆,直接崩。后来改成EasyExcel的分批读取模式,内存占用稳定在几十兆,跑完整个文件也就几分钟。这个对比太强烈了,所以我觉得有必要把分批读取这件事从头到尾讲透。

EasyExcel之所以能扛住海量数据,核心在于它没有把整个文件一次性加载进内存。它底层用的是SAX解析模式,逐行读取、逐行回调,读一行处理一行,处理完就释放。这跟传统的Apache POI把整个工作簿对象树建在内存里完全是两码事。POI的XSSFWorkbook会把每个单元格、每行、每个样式都实例化成对象,一个几十万行的文件轻松吃掉几个G。而EasyExcel的模型是"流式"的,内存里同时存在的行对象非常有限。

那分批读取具体怎么落地?关键就是自定义监听器配合批量阈值。监听器在invoke方法里逐行接收数据,我们自己在监听器内部维护一个List缓冲区,攒够比如500条或1000条,就触发一次批量入库,然后把缓冲区清空。这样内存里最多只有阈值数量的对象,数据库那边也是批量提交,效率比单条insert高出一个数量级。

这里有个容易被忽略的点:分批的"批"到底该设多大。设太小,比如50条,数据库交互次数太多,网络往返和事务开销把性能拖垮;设太大,比如5000条,内存虽然还能扛,但单次SQL的包体可能超过数据库的max_allowed_packet限制,直接报错。我实测下来,MySQL场景下500到1000是比较舒服的区间,具体还要看单行字段的多少和字段长度。

提示:分批读取解决的是"读"的内存问题,批量入库解决的是"写"的效率问题,这两件事要分开设计,不要混为一谈。

还有一个认知误区得提前打破:不是用了EasyExcel就万事大吉。如果你在监听器里把每行数据都塞进一个成员变量的大List里,那跟一次性读取没区别,照样OOM。分批的精髓在于缓冲区要定期清空,这个清空动作必须由我们自己控制,框架不会帮你做。

2. 监听器里的缓冲区设计:攒批、清空与事务边界

理解了分批读取的宏观思路,接下来要啃的是最核心的实现细节——监听器内部到底怎么攒批、什么时候清空、事务怎么控制。这块设计得好不好,直接决定了导入功能是稳如老狗还是时不时抽风。

2.1 用成员变量维护一个可控的缓冲区

监听器类会被EasyExcel实例化,invoke方法每读一行就被回调一次。我们要在这个类里放一个List<T>作为缓冲区,再加一个int记录当前批次已攒的数量。每次invoke进来,先把解析出来的实体加进List,然后判断list.size() >= BATCH_SIZE,满足就执行入库、清空List。

public class OrderImportListener extends AnalysisEventListener<OrderExcelDTO> { private static final int BATCH_SIZE = 800; private final List<OrderExcelDTO> buffer = new ArrayList<>(BATCH_SIZE); private final OrderService orderService; public OrderImportListener(OrderService orderService) { this.orderService = orderService; } @Override public void invoke(OrderExcelDTO data, AnalysisContext context) { buffer.add(data); if (buffer.size() >= BATCH_SIZE) { flush(); } } @Override public void doAfterAllAnalysed(AnalysisContext context) { // 处理最后不足一批的尾巴数据 flush(); } private void flush() { if (buffer.isEmpty()) { return; } orderService.batchInsert(new ArrayList<>(buffer)); buffer.clear(); } }

这段代码看着简单,但有几个坑点值得展开说。第一,flush里传给service的是new ArrayList<>(buffer)而不是buffer本身。为什么?因为如果直接把buffer引用传进去,service层如果异步处理或者延迟执行,等你buffer.clear()之后,service拿到的就是个空List。复制一份是最稳妥的做法,代价就是多一次浅拷贝,对于几百条数据来说完全可以忽略。

第二,doAfterAllAnalysed这个回调千万别漏。假设文件有1000行,阈值是800,那么前800行触发一次flush,剩下200行如果不在doAfterAllAnalysed里再flush一次,这200行就永远躺在缓冲区里进不了库。这个bug特别隐蔽,因为程序不报错,只是数据"少了一截",排查起来很费劲。

2.2 事务边界:别让一个批次拖垮整个导入

批量入库的事务怎么划,是个需要权衡的问题。最粗放的做法是整个导入过程包一个大事务,所有批次要么全成功要么全回滚。听起来很美好,但实际很危险:几十万行数据在一个事务里,数据库的undo log会膨胀得非常大,锁持有时间极长,一旦中途失败回滚,代价高得吓人,而且长事务会阻塞其他业务。

更合理的做法是每个批次一个独立事务。800条一个事务,提交快、锁释放快,即使某一批失败,也只影响这一批,前面成功的批次已经落库。当然这带来一个新问题:导入到一半失败了,数据库里是"半截"数据,怎么办?

我的处理经验是引入导入任务记录表。每次导入生成一个任务ID,记录总行数、已处理行数、状态(进行中/成功/失败)。每批入库成功后更新已处理行数。如果失败,任务状态标记为失败,并记录失败的行号范围。这样后续可以基于任务记录做断点续传或者人工核对,而不是两眼一抹黑。

事务策略优点缺点适用场景
单一大事务强一致,失败全回滚长事务、锁重、回滚代价大数据量小(万级以内)
每批独立事务提交快、锁轻、吞吐高失败时数据半截海量数据导入
每批独立+任务表可追溯、可续传实现稍复杂生产环境推荐

2.3 缓冲区大小与数据库参数的联动

前面提到批次大小建议500到1000,这个数字不是拍脑袋来的,它跟数据库的几个参数强相关。以MySQL为例,max_allowed_packet默认在4M到64M之间(不同版本不一样),如果你的单行数据平均2KB,800行就是1.6M,加上SQL语句本身的开销,还在安全范围内。但如果单行有大量文本字段,平均10KB,800行就是8M,就可能顶到上限。

所以批次大小要根据单行平均大小反推。我的做法是先估算单行字节数,然后用max_allowed_packet的70%除以单行字节数,得到一个理论上限,再结合内存和性能实测微调。另外,批量insert的SQL是insert into t values (...),(...),(...)这种形式,拼接的SQL字符串本身也占内存,批次太大时字符串拼接的开销也不容忽视。

注意:不同数据库对批量insert的语法和参数上限支持不同。MySQL靠max_allowed_packet,Oracle的IN列表有1000个元素限制(用批量绑定可以绕过),PostgreSQL对单条SQL的参数个数也有上限。跨库迁移时这块要重新验证。

3. 从Excel列到数据库字段:映射、校验与脏数据处理

数据能读进来、能批量写进去,只是完成了"搬运"。真正让导入功能好用的,是中间那层字段映射和校验。Excel是给人填的,格式千奇百怪;数据库是给程序用的,要求严格规整。这中间的鸿沟,就是导入功能最容易出问题的地方。

3.1 用注解做声明式映射,但别迷信它

EasyExcel提供了@ExcelProperty注解,可以把Excel表头跟Java字段对应起来。比如@ExcelProperty("订单编号")标在orderNo字段上,读取时框架自动按表头名匹配。这套机制在表头规范的情况下非常好用,代码干净。

public class OrderExcelDTO { @ExcelProperty("订单编号") private String orderNo; @ExcelProperty("下单时间") private String orderTime; @ExcelProperty("金额") private BigDecimal amount; }

但现实往往不按套路出牌。运营给的Excel表头可能叫"订单号"、"订单ID"、"订单编号 "(注意末尾有空格),甚至同一列在不同批次的文件里名字都不一样。这时候纯靠注解匹配就会读成null。我的经验是注解打底,兜底逻辑补位:对于关键字段,如果注解读出来是null,再尝试用列索引去取,或者用模糊匹配表头。

还有一种情况是表头行不在第一行。有些模板前面有标题、说明、合并单元格,真正的表头在第3行甚至第5行。EasyExcel的headRowNumber参数可以指定表头行号,这个一定要根据实际模板设置,否则整个映射全乱。

3.2 校验要分层,别一股脑塞进监听器

数据校验如果全写在监听器的invoke里,代码会变得又臭又长,而且校验失败的处理逻辑(跳过、记录、中断)混在一起,很难维护。我习惯把校验分成三层:

第一层是格式校验,在监听器里做轻量判断,比如必填字段是否为空、数字字段是否能解析。这一层只做最基础的过滤,发现明显非法的行直接记录到错误列表,不加入缓冲区。

第二层是业务校验,放到入库前的service层。比如订单编号是否重复、商品SKU是否存在、金额是否在合理区间。这一层可能需要查数据库,放在监听器里会拖慢读取速度,所以攒批之后统一校验更合适。

第三层是数据库约束,靠唯一索引、外键、非空约束兜底。这是最后一道防线,前面校验漏掉的,这里会报错。但要注意,批量insert时如果有一条违反约束,整个批次都会失败,所以要么用insert ignore,要么在应用层把脏数据提前剔掉。

校验层级执行位置校验内容失败处理
格式校验监听器invoke非空、类型转换记入错误列表,跳过
业务校验service批量前重复性、关联性记入错误列表,跳过
数据库约束DB层唯一、外键、非空批次回滚或ignore

3.3 错误数据的收集与反馈

导入功能最怕的就是"闷头跑完,用户不知道哪些行失败了"。所以错误数据的收集和反馈机制必须做好。我的做法是在监听器里维护一个List<ErrorRow>,每条错误记录包含行号、原始数据、失败原因。导入结束后,把这个列表返回给前端,或者写到一个"错误明细"的Excel里让用户下载。

这里有个细节:行号要准确。EasyExcel的AnalysisContext里可以拿到当前行索引,但要注意它是从0开始还是从1开始,以及是否包含了表头行。算错行号会让用户对着错误提示找不到对应的Excel行,体验极差。我一般会在监听器里自己维护一个计数器,从表头下一行开始算,确保跟用户在Excel里看到的行号一致。

另外,错误列表本身也可能很大。如果几万行都错了,全塞内存里也是隐患。可以设个上限,比如只保留前1000条错误明细,超出的只统计数量。或者边收集边写入一个临时文件,避免内存堆积。

4. 性能调优实战:批次大小、线程与数据库写入方式

功能跑通只是及格线,海量数据场景下性能才是硬指标。同样是一百万行数据,不同的参数配置和写入方式,耗时可能差好几倍。这一章我把实测过的调优手段摊开讲。

4.1 批次大小的实测对比

我拿一个50万行的订单Excel做过一组对比测试,单行平均1.5KB,MySQL数据库,硬件是4核8G的容器。批次大小分别设100、500、1000、2000、5000,记录总耗时和内存峰值。

批次大小总耗时内存峰值备注
1004分20秒80M数据库交互太频繁
5002分50秒110M比较均衡
10002分35秒150M推荐区间
20002分40秒230M内存开始上升
50003分10秒480MSQL包体大,GC压力明显

从数据看,1000左右是甜点区。批次太小,瓶颈在数据库往返;批次太大,瓶颈在内存和SQL解析。当然这个结论跟单行大小、数据库配置、网络延迟都有关,不能照搬,但趋势是通用的:存在一个最优区间,需要实测确定。

4.2 多线程读取:能提速,但别乱用

EasyExcel本身是单线程逐行读的,读取速度受限于磁盘IO和解析开销。如果文件特别大,读取本身成了瓶颈,可以考虑多线程分片读取。思路是把一个大文件按行数切成多个片段,每个线程读一段。但这里有个大坑:xlsx本质是个zip包,随机访问某一行并不容易,切分逻辑很复杂。

更实际的做法是读取单线程,入库多线程。监听器攒够一批后,把批次数据丢给一个线程池去异步入库,读取线程继续往下读。这样读取和写入并行,整体吞吐能提升不少。但要注意:异步入库后,事务边界和错误处理会变复杂,而且如果入库速度跟不上读取速度,队列会堆积,内存照样爆。所以线程池要有界队列,满了就阻塞读取线程,形成背压。

private final ExecutorService executor = new ThreadPoolExecutor( 4, 4, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(20), new ThreadPoolExecutor.CallerRunsPolicy() // 队列满时由调用线程执行,形成背压 );

CallerRunsPolicy这个拒绝策略在这里特别合适:队列满了就让读取线程自己去执行入库任务,这样读取自然就慢下来,不会无限往队列里塞数据。

4.3 数据库写入方式的取舍

批量入库有几种常见写法,性能差异明显。第一种是MyBatis的foreach拼接批量insert,一条SQL插入多条,简单直接。第二种是用JDBC的addBatch和executeBatch,走PreparedStatement的批量接口。第三种是用rewriteBatchedStatements=true参数配合JDBC批量,让MySQL驱动把多条insert重写成一条多值insert。

实测下来,开启rewriteBatchedStatements=true的JDBC批量性能最好,比MyBatis的foreach还要快一些,因为它在驱动层做了优化,而且避免了超长SQL字符串的拼接开销。配置方式是在JDBC URL上加参数:

jdbc:mysql://host:3306/db?rewriteBatchedStatements=true&useServerPrepStmts=true

提示:rewriteBatchedStatements只对insert和update的批量操作生效,而且要求SQL是参数化的PreparedStatement,用字符串拼接的SQL不享受这个优化。

另外,导入期间可以临时关闭数据库的自动提交,或者调整innodb_flush_log_at_trx_commit参数(生产环境慎改),减少磁盘刷写次数。但这些属于数据库层面的调优,要结合具体环境和DBA的规范来,不能擅自乱动。

5. 那些年踩过的坑:OOM、数据丢失与格式陷阱

前面讲的都是"应该怎么做",这一章专门讲"哪里容易翻车"。这些坑我基本都亲身踩过,有的排查了大半天才找到原因,写出来给大家省点时间。

5.1 监听器被复用导致的串数据

EasyExcel在读多个sheet或者多次读取时,如果监听器实例被复用,缓冲区里的数据可能串到一起。比如你读第一个sheet攒了300条还没到阈值,接着读第二个sheet,这300条就跟第二个sheet的数据混在一起入库了。解决办法是每个sheet或每次读取都new一个监听器实例,别图省事复用。

还有一种情况是监听器里用了静态变量存缓冲区。静态变量是类级别的,多个导入任务并发时会互相干扰,数据错乱得一塌糊涂。缓冲区必须是实例变量,而且监听器实例不能跨任务共享。

5.2 最后一批数据凭空消失

这个坑前面提过,但值得再强调一遍。doAfterAllAnalysed是处理尾巴数据的唯一时机,漏写这个方法,最后不足一批的数据就永远进不了库。更隐蔽的是,如果读取过程中抛异常,doAfterAllAnalysed可能不会执行,缓冲区里的数据也就丢了。所以异常处理里也要考虑是否要flush一次,或者把缓冲区数据记录到日志里。

5.3 日期和数字的格式陷阱

Excel里的日期本质是个数字,显示成日期只是格式问题。EasyExcel读日期字段时,如果单元格格式不标准,可能读出来是个数字串,或者直接抛转换异常。数字字段也类似,有的单元格是文本格式存的数字,有的是数值格式,读出来类型不一致。

我的处理办法是日期和数字字段统一用String接收,然后在业务层自己做解析和转换。这样虽然多了一步,但能兼容各种奇葩格式,而且解析失败时能给出明确的错误提示,而不是框架抛一个看不懂的异常。解析日期时用DateTimeFormatter配合多种模式尝试,比如先试yyyy-MM-dd HH:mm:ss,失败再试yyyy/MM/dd,再失败试Excel的序列号转换。

5.4 大文件读取超时与进度反馈

几十万行的文件,读取加入库可能要几分钟甚至十几分钟。如果这是个HTTP接口,前端早就超时了。所以海量导入不适合同步接口,应该改成异步任务:接口只负责接收文件、创建任务、返回任务ID,实际导入在后台线程跑,前端轮询任务进度。

进度反馈的实现,可以在监听器里每处理一批就更新一次任务表的已处理行数,前端定时查这个数字算百分比。这样用户能看到进度条在动,体验好很多,也不会因为等待而重复提交。

6. 一套可复用的海量导入骨架

讲了这么多原理和坑,最后给一套我平时用的骨架代码,把关键点都串起来。这套骨架的核心思想是:读取单线程流式、缓冲区攒批、批量入库、任务表跟踪、错误明细收集。

6.1 任务表设计

CREATE TABLE import_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, file_name VARCHAR(255), total_rows INT DEFAULT 0, processed_rows INT DEFAULT 0, status TINYINT COMMENT '0进行中 1成功 2失败', error_msg TEXT, create_time DATETIME, update_time DATETIME );

6.2 监听器骨架

public class BatchImportListener<T> extends AnalysisEventListener<T> { private static final int BATCH_SIZE = 1000; private final List<T> buffer = new ArrayList<>(BATCH_SIZE); private final Consumer<List<T>> batchConsumer; private final Long taskId; private final TaskService taskService; private int rowCount = 0; public BatchImportListener(Consumer<List<T>> batchConsumer, Long taskId, TaskService taskService) { this.batchConsumer = batchConsumer; this.taskId = taskId; this.taskService = taskService; } @Override public void invoke(T data, AnalysisContext context) { buffer.add(data); rowCount++; if (buffer.size() >= BATCH_SIZE) { flush(); } } @Override public void doAfterAllAnalysed(AnalysisContext context) { flush(); taskService.markSuccess(taskId, rowCount); } @Override public void onException(Exception exception, AnalysisContext context) { // 单行解析异常,记录后继续,不中断整个导入 taskService.recordError(taskId, context.readRowHolder().getRowIndex(), exception.getMessage()); } private void flush() { if (buffer.isEmpty()) { return; } List<T> batch = new ArrayList<>(buffer); batchConsumer.accept(batch); buffer.clear(); taskService.updateProgress(taskId, rowCount); } }

6.3 调用入口

public void importExcel(InputStream inputStream, Long taskId) { BatchImportListener<OrderExcelDTO> listener = new BatchImportListener<>(batch -> orderService.batchInsert(batch), taskId, taskService); EasyExcel.read(inputStream, OrderExcelDTO.class, listener) .headRowNumber(1) .sheet() .doRead(); }

这套骨架跑几十万行数据很稳,内存占用可控,进度可查,错误可追溯。实际使用时根据业务调整批次大小、校验逻辑和错误处理策略就行。

6.4 上线前的自检清单

最后列一个我每次上线海量导入功能前都会过一遍的清单,供参考:

  • 批次大小是否根据单行大小和数据库参数实测确定?
  • doAfterAllAnalysed是否处理了尾巴数据?
  • 监听器实例是否每次读取都新建,没有复用?
  • 缓冲区是否在flush后清空,没有内存泄漏?
  • 错误行号是否跟Excel实际行号对齐?
  • 导入是否异步化,有任务进度反馈?
  • 数据库批量参数(如rewriteBatchedStatements)是否开启?
  • 异常情况下缓冲区数据是否有兜底记录?

这套东西我在几个项目里反复用过,从几万行到上百万行都扛住了。核心其实就一句话:别让数据在内存里堆积,读一批、写一批、清一批。把这个原则贯彻到底,海量Excel导入就不再是让人头疼的事了。

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

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

立即咨询