第一时间收到文章更新

Excel 导入导出是后端业务系统的家常便饭:数据批量入库、报表导出、台账下载、运营批量更新,几乎每个项目都绕不开。但绝大多数团队还在使用原生 Apache POI 写样板代码,不仅写法臃肿、重复代码多,更大的问题是内存爆炸:几千行数据就占用上百 MB 内存,几万行直接 OOM 宕机,大文件导出永远是线上稳定性隐患。
业界公认的最优解是阿里开源的 EasyExcel,它基于 SAX 事件驱动逐行读写,全程内存占用仅几 MB,百万行数据也不会 OOM;同时采用注解驱动设计,实体类加几个注解就能完成读写,代码量比原生 POI 减少 80%,优雅度和性能同时拉满。配合 Spring Boot 自动装配,3 分钟即可接入生产级导入导出能力。
Apache POI 是 Excel 操作的底层基础,但直接使用原生 API 存在无法规避的生产问题:
HSSF/XSSF 属于 UserModel 模式,会把整个 Excel 文件全部加载到内存中构建 DOM 树。一个 10 万行的 Excel 往往需要占用 1~2GB 堆内存,并发稍微上来就直接内存溢出。虽然 SXSSF 支持流式写入,但读取依然没有很好的解决,且 API 极其难用。EasyExcel 在 POI 基础上做了深度重构,专门解决大文件场景的性能与易用性问题,核心优势非常突出:
@ExcelProperty 注解即可映射列,无需手动处理单元格,代码简洁易维护;Spring Boot 项目直接引入 EasyExcel 官方依赖,无需额外配置 POI,版本跟随官方稳定版即可:
<dependency><groupId>com.alibaba</groupId><artifactId>easyexcel</artifactId><version>3.3.2</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>以用户数据导出为例,实体类字段直接通过注解绑定 Excel 列、格式、排序:
import com.alibaba.excel.annotation.ExcelProperty;import com.alibaba.excel.annotation.format.DateTimeFormat;import com.alibaba.excel.annotation.write.style.ColumnWidth;import lombok.Data;import java.time.LocalDateTime;@Data@ColumnWidth(20)// 全局默认列宽publicclassUserExcelVO {@ExcelProperty(value = "用户ID", index = 0)private Long userId;@ExcelProperty(value = "用户名", index = 1)private String username;@ExcelProperty(value = "手机号", index = 2)private String phone;@ExcelProperty(value = "所属部门", index = 3)private String deptName;@DateTimeFormat("yyyy-MM-dd HH:mm:ss")@ExcelProperty(value = "创建时间", index = 4)@ColumnWidth(25)private LocalDateTime createTime;@ExcelProperty(value = "状态", index = 5)private String status;}直接通过 EasyExcel 工具类写入响应输出流,前端直接触发下载,中文文件名乱码问题一并处理:
import com.alibaba.excel.EasyExcel;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import javax.servlet.http.HttpServletResponse;import java.io.IOException;import java.net.URLEncoder;import java.nio.charset.StandardCharsets;import java.util.List;@RestController@RequestMapping("/excel")publicclassExcelController {@Autowiredprivate UserService userService;/** * 用户数据导出 */@GetMapping("/user/export")publicvoidexportUser(HttpServletResponse response)throws IOException {// 1. 查询业务数据 List<UserExcelVO> list = userService.listAllUser();// 2. 设置响应头,处理中文文件名 response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8");StringfileName= URLEncoder.encode("用户数据导出", StandardCharsets.UTF_8).replaceAll("\\+", "%20"); response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");// 3. 一行代码写入Excel EasyExcel.write(response.getOutputStream(), UserExcelVO.class) .sheet("用户列表") .doWrite(list); }}读取 Excel 需要通过监听器逐行回调,避免一次性加载全部数据到内存。我们封装通用读取监听器,配合业务逻辑分批入库:
import com.alibaba.excel.context.AnalysisContext;import com.alibaba.excel.event.AnalysisEventListener;import lombok.Getter;import java.util.ArrayList;import java.util.List;import java.util.function.Consumer;/** * 通用Excel读取监听器,分批处理数据 */publicclassCommonExcelListener<T> extendsAnalysisEventListener<T> {// 每批处理数量privatestaticfinalintBATCH_SIZE=1000;// 缓存当前批次数据privatefinal List<T> batchList = newArrayList<>(BATCH_SIZE);// 业务处理逻辑,由调用方传入privatefinal Consumer<List<T>> batchConsumer;publicCommonExcelListener(Consumer<List<T>> batchConsumer) {this.batchConsumer = batchConsumer; }// 每读取一行回调一次@Overridepublicvoidinvoke(T data, AnalysisContext context) { batchList.add(data);// 达到批次大小,处理一次if (batchList.size() >= BATCH_SIZE) { batchConsumer.accept(batchList); batchList.clear(); } }// 全部读取完成后回调@OverridepublicvoiddoAfterAllAnalysed(AnalysisContext context) {// 处理剩余不足一批的数据if (!batchList.isEmpty()) { batchConsumer.accept(batchList); batchList.clear(); } }}Controller 层接收文件,调用监听器解析:
@PostMapping("/user/import")public Result<String> importUser(MultipartFile file)throws IOException { CommonExcelListener<UserExcelVO> listener = newCommonExcelListener<>(batch -> {// 分批入库业务逻辑 userService.batchSaveUser(batch); }); EasyExcel.read(file.getInputStream(), UserExcelVO.class, listener) .sheet() .doRead();return Result.success("导入成功");}状态、枚举、字典字段,数据库存的是数字编码,导出需要转成中文描述。自定义转换器一键搞定,无需业务代码手动转换:
import com.alibaba.excel.converters.Converter;import com.alibaba.excel.metadata.GlobalConfiguration;import com.alibaba.excel.metadata.data.WriteCellData;import com.alibaba.excel.metadata.property.ExcelContentProperty;/** * 用户状态转换器:0禁用 1启用 */publicclassStatusConverterimplementsConverter<Integer> {@Overridepublic Class<Integer> supportJavaTypeKey() {return Integer.class; }@Overridepublic WriteCellData<?> convertToExcelData(Integer value, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) {if (value == null) {returnnewWriteCellData<>(""); }returnnewWriteCellData<>(value == 1 ? "启用" : "禁用"); }}字段上指定转换器即可:
@ExcelProperty(value = "状态", index = 5, converter = StatusConverter.class)private Integer status;导入数据不能直接入库,必须做参数合法性校验。配合 JSR-380 校验注解,逐行校验并收集错误行号与信息,最终返回给用户修正:
@NotBlank(message = "用户名不能为空")@ExcelProperty(value = "用户名", index = 1)private String username;@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式错误")@ExcelProperty(value = "手机号", index = 2)private String phone;// 在invoke方法中加入校验Set<ConstraintViolation<T>> violations = validator.validate(data);if (!violations.isEmpty()) {introwIndex= context.readRowHolder().getRowIndex() + 1;Stringmsg= violations.iterator().next().getMessage(); errorList.add("第" + rowIndex + "行:" + msg);}校验完成后返回所有错误信息,用户可精准定位修改。
支持多级表头、跨行跨列合并,注解中配置多级表头名称即可:
@DatapublicclassOrderExcelVO {@ExcelProperty({"订单信息", "订单编号"})private String orderNo;@ExcelProperty({"订单信息", "下单时间"})private LocalDateTime orderTime;@ExcelProperty({"收货信息", "收货人"})private String receiver;@ExcelProperty({"收货信息", "收货地址"})private String address;}导出后自动生成二级合并表头,层级清晰。
全量查询数据库再导出,数据量大了会把数据库和内存同时打垮。正确做法是分页查询 + 分批写入,查一批写一批,内存里始终只有一批数据:
@GetMapping("/user/export/big")publicvoidexportBigUser(HttpServletResponse response)throws IOException {// 设置响应头同上StringfileName= URLEncoder.encode("全量用户数据", StandardCharsets.UTF_8).replaceAll("\\+", "%20"); response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");// 分页参数intpageSize=1000;intpageNum=1;// 先创建Excel对象,后续循环写入ExcelWriterexcelWriter= EasyExcel.write(response.getOutputStream(), UserExcelVO.class) .sheet("用户列表") .build();while (true) {// 分页查询 List<UserExcelVO> pageList = userService.listUserByPage(pageNum, pageSize);if (pageList.isEmpty()) {break; }// 写入当前页 excelWriter.write(pageList); pageList.clear(); // 手动释放,帮助GC pageNum++; }// 关闭流,完成写入 excelWriter.finish();}固定格式的复杂报表(如发票、合同、统计报表),用代码写样式非常麻烦。EasyExcel 支持模板填充:提前做好 Excel 模板,预留占位符,代码只负责填充数据,样式完全复用模板。
// 模板中用 {name}、{amount} 占位Map<String, Object> data = newHashMap<>();data.put("name", "张三");data.put("amount", 12800);EasyExcel.write(response.getOutputStream()) .withTemplate("template/invoice_template.xlsx") // 模板文件路径 .sheet() .doFill(data);根因:把所有数据一次性查出来放到 List 里,还没写 Excel 内存就已经爆了。解决:必须分页查询分批写入,内存只保留当前批次数据;超大文件走异步+对象存储。
根因:不同浏览器对文件名编码处理不一致,没有做 URL 编码和转义。解决:使用标准的 filename*=utf-8'' 编码方式,兼容主流浏览器。
根因:没有逐行校验和错误收集,一批数据一条出错整批回滚,用户不知道哪行有问题。解决:逐行校验收集错误行号与信息,全部校验通过再入库;或者成功的入库、失败的返回错误清单。
根因:合并单元格只有首行有值,其余行读取为 null。解决:使用 EasyExcel 自带的合并单元格处理器,或者自定义监听器缓存上一行的值,遇到空行自动填充。
根因:没有限制文件大小、上传频次,攻击者上传超大 Excel 耗尽服务器资源。解决:限制单文件最大 50MB,接口加限流,校验文件格式与魔数,防止伪装的恶意文件。
根因:分批处理时每批独立事务,某一批出错前面的批次已经提交,无法整体回滚。解决:先全量校验收集所有错误,校验通过后再统一事务入库;或者记录处理进度,支持失败回滚。
Excel 导入导出看似简单,实则很容易写出性能差、难维护的代码。EasyExcel 从底层重构了读写模型,用极低的内存占用解决了大文件 OOM 问题,同时注解驱动的设计让业务代码非常简洁优雅,配合 Spring Boot 可以快速落地生产级方案。
掌握基础注解、通用监听器、分批流式处理、参数校验这几套组合拳,就能覆盖 90% 以上的业务场景,既保证性能稳定,又让代码干净易维护,彻底告别原生 POI 的样板代码与内存噩梦。
Excel 导入导出是业务系统最高频的功能之一,很多团队长期受困于性能差、代码乱、线上 OOM 等问题。后续持续更新办公处理实战:Word 模板导出、PDF 生成、大文件分片上传、文件处理性能优化干货。喜欢后端工具类、业务实战、性能优化干货,欢迎点赞、收藏、关注,持续跟进 SpringBoot 业务开发进阶专栏!

往 期 推 荐
1、面试官皱眉:“你懂 Claude Code?” 我笑了:“何止懂?CLAUDE.md、Skills、Subagents、MCP、Hooks、Plugins样样都懂”
2、Spring Boot 4.0官宣:弃用 Undertow
4、DDR4 和 DDR5 的价格太离谱了,准备继续用DDR3了
5、面试官不屑:“用 Claude Code 迁移百万行代码?少吹牛 *!” 我镇定:“让我入职,教你怎么做!”
6、面试官坏笑:“GPT-5.6 刚发布,比 Claude 5 和 Grok 4.5 强吗?” 我怒骂:“现场给你测试是吧?”
点分享
点收藏
点点赞
点在看