Spring Boot 4.0 百万级 Excel 导入导出与异步处理
前言
小表格直接转成对象列表很方便,数据量上来后却会同时消耗堆内存、数据库连接和 HTTP 等待时间。百万行场景的第一步不是调大 -Xmx,而是避免把整份 Excel 和完整导入结果同时放在内存里。
这篇教程在订单项目中完成两条链路:导出时使用 SXSSFWorkbook 分批写临时文件,导入时使用 POI 的 SAX 事件模型按行读取,再把解析后的批次交给虚拟线程处理。
前端不会一直等待全部导入完成,而是先拿到任务 ID,再轮询状态。读者需要会运行前面的多模块项目、使用 Maven 和 curl,并了解基本的异步任务概念。
完整代码位于 dvsusan/spring-boot4-demo。本章代码用于讲清流式处理骨架,不是已经压测过一百万行业务数据的生产系统。
最终结果
完成后会得到三个可用操作:
GET /api/v1/chapter5/excel/export?rows=2000
POST /api/v1/chapter5/excel/import
GET /api/v1/chapter5/excel/jobs/{jobId}新增代码位于:
spring-boot4-demo/
├── demo-integration/
│ └── src/main/java/com/susan/training/integration/excel/
│ ├── ExcelBatchService.java
│ ├── ExcelIntegrationConfiguration.java
│ ├── ExcelJobInfo.java
│ ├── ExcelJobStatus.java
│ ├── ExcelOrderRow.java
│ └── ExcelSheetHandler.java
└── demo-web/
└── src/main/java/com/susan/training/web/integration/
└── Chapter5Controller.java导出请求在响应流中直接写文件,导入请求先保存临时文件并立即返回任务 ID。任务由虚拟线程读取,状态和已处理行数通过独立接口查询。

图 1:HTTP 请求只负责接收文件,实际解析与分批处理在后台完成。
为什么不用普通 XSSFWorkbook 处理百万行?
普通 XSSFWorkbook 会在内存里维护单元格、样式和共享字符串。行数变多后,对象数量迅速膨胀,最终可能触发频繁 GC,甚至 OutOfMemoryError。
本教程使用两种不同机制:
| 场景 | 实现 | 内存策略 |
|---|---|---|
| 导出 | SXSSFWorkbook | 只保留指定数量行在内存,其他行刷到临时文件 |
| 导入 | XSSFReader + SAX | 不构建完整工作簿树,按事件逐行读取 |
| 异步执行 | Java 21 虚拟线程 | 等待 I/O 时释放平台线程,但不会减少 CPU 或数据库压力 |
虚拟线程只是执行模型,不能替代分页、限流和批处理。百万行数据最终写入数据库时,单条 SQL、无界事务和一次性消息仍会把系统压垮。
开工前准备
| 工具 | 建议版本 | 检查命令 | 预期结果 |
|---|---|---|---|
| JDK | 21 | java -version | 输出 Java 21 |
| Maven | 3.9+ | mvn -version | Maven 使用 JDK 21 |
| curl | 任意常用版本 | curl --version | 可执行 HTTP 请求 |
| unzip | 任意常用版本 | unzip -v | 可检查 .xlsx 压缩结构 |
xlsx 本质上是 ZIP 容器。检查导出的文件是否真的是工作簿,比只看 HTTP 200 更可靠。
第 1 步:添加 Apache POI
在根 pom.xml 中固定版本:
<properties>
<!-- 示例统一以 Java 21 编译,避免不同电脑的默认 JDK 造成结果差异。 -->
<java.version>21</java.version>
<!-- 导出和导入共用同一套 POI 组件,避免 SDK 版本不一致。 -->
<poi.version>5.4.1</poi.version>
</properties>在 demo-integration/pom.xml 中增加:
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>${poi.version}</version>
</dependency>poi-ooxml 同时包含普通工作簿、流式工作簿和 OOXML 事件读取相关类。
macOS 上运行 POI 时,测试进程还应在初始化 AWT 前开启 headless 模式。仓库在 demo-integration/pom.xml 中固定了该配置:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables>
<!-- POI 访问字体时会触发 AWT;测试进程固定无界面模式。 -->
<java.awt.headless>true</java.awt.headless>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>Spring Boot 应用启动时通常已经开启 headless;单独运行 JUnit/Surefire 时则不一定,所以要在构建配置中明确声明。
第 2 步:按章节启用 Excel 服务
ExcelIntegrationConfiguration.java 只在 demo.chapter5.excel.enabled=true 时创建服务:
package com.susan.training.integration.excel;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 显式开启文件批处理章节时才创建 Excel 服务。
*/
@Configuration
@ConditionalOnProperty(
prefix = "demo.chapter5.excel",
name = "enabled",
havingValue = "true"
)
public class ExcelIntegrationConfiguration {
@Bean(destroyMethod = "close")
public ExcelBatchService excelBatchService() {
return new ExcelBatchService();
}
}destroyMethod = "close" 会在应用停止时关闭虚拟线程执行器。否则虽然不会阻止应用退出,但测试和长期运行环境会留下不必要的线程资源。
第 3 步:定义行模型和任务状态
ExcelOrderRow.java 保存四列业务数据:
package com.susan.training.integration.excel;
import java.math.BigDecimal;
/**
* Excel 订单导入导出行。
*/
public class ExcelOrderRow {
private String orderNo;
private String customerId;
private BigDecimal amount;
private String status;
public ExcelOrderRow() {
}
public ExcelOrderRow(
String orderNo,
String customerId,
BigDecimal amount,
String status
) {
this.orderNo = orderNo;
this.customerId = customerId;
this.amount = amount;
this.status = status;
}
public String getOrderNo() {
return orderNo;
}
public void setOrderNo(String orderNo) {
this.orderNo = orderNo;
}
public String getCustomerId() {
return customerId;
}
public void setCustomerId(String customerId) {
this.customerId = customerId;
}
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}任务状态使用枚举,避免不同接口返回随意字符串:
package com.susan.training.integration.excel;
/**
* 异步 Excel 任务状态。
*/
public enum ExcelJobStatus {
PENDING,
RUNNING,
SUCCEEDED,
FAILED
}任务快照是一个不可变 record:
package com.susan.training.integration.excel;
/**
* 可查询的异步任务快照。
*/
public record ExcelJobInfo(
String jobId,
ExcelJobStatus status,
long processedRows,
String message
) {
}record 只保证引用不可变。加入更多字段后,如果包含 List 等可变集合,仍需在构造和读取时做防御性复制。
第 4 步:用 SAX 按行读取并形成批次
创建 ExcelSheetHandler.java:
package com.susan.training.integration.excel;
import org.apache.poi.xssf.eventusermodel.XSSFSheetXMLHandler;
import org.apache.poi.xssf.usermodel.XSSFComment;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
/**
* SAX 事件处理器按行读取 Excel,并按批次交给业务处理。
*/
public class ExcelSheetHandler implements XSSFSheetXMLHandler.SheetContentsHandler {
private final int batchSize;
private final Consumer<List<ExcelOrderRow>> batchConsumer;
private final List<ExcelOrderRow> currentBatch = new ArrayList<>();
private String orderNo;
private String customerId;
private String amount;
private String status;
private long rowCount;
public ExcelSheetHandler(
int batchSize,
Consumer<List<ExcelOrderRow>> batchConsumer
) {
if (batchSize <= 0) {
throw new IllegalArgumentException("batchSize 必须大于 0");
}
this.batchSize = batchSize;
this.batchConsumer = Objects.requireNonNull(
batchConsumer,
"batchConsumer 不能为空"
);
}
@Override
public void startRow(int rowNum) {
// 每次开始新行时清空上一行状态,避免单元格缺失导致串行。
orderNo = null;
customerId = null;
amount = null;
status = null;
}
@Override
public void endRow(int rowNum) {
// 第 0 行是表头,从第 1 行开始计为数据行。
if (rowNum > 0) {
rowCount++;
currentBatch.add(new ExcelOrderRow(
orderNo,
customerId,
parseAmount(amount),
status
));
if (currentBatch.size() >= batchSize) {
flush();
}
}
}
@Override
public void cell(String cellReference, String formattedValue, XSSFComment comment) {
String column = cellReference.replaceAll("\\d", "");
switch (column) {
case "A" -> orderNo = formattedValue;
case "B" -> customerId = formattedValue;
case "C" -> amount = formattedValue;
case "D" -> status = formattedValue;
default -> {
// 教学模板只读取前四列,额外列留给练习题扩展。
}
}
}
@Override
public void headerFooter(String text, boolean isHeader, String tagName) {
}
public long getRowCount() {
return rowCount;
}
/**
* 最后一个不足 batchSize 的批次也要提交。
*/
public void finish() {
flush();
}
private void flush() {
if (currentBatch.isEmpty()) {
return;
}
// 传入不可变快照,避免业务处理器在异步消费时看到后续修改。
batchConsumer.accept(List.copyOf(currentBatch));
currentBatch.clear();
}
private BigDecimal parseAmount(String value) {
if (value == null || value.isBlank()) {
return null;
}
return new BigDecimal(value.replace(",", ""));
}
}每个 cell 回调只带少量字符串,不保留整行 XML 或整个工作簿。finish() 必须提交最后不足 1000 行的尾批,这是分批程序最常见的漏数位置之一。
DataFormatter 会把 Excel 中显示出来的值交给处理器。金额仍然要做业务校验,不能直接信任单元格格式化结果。
第 5 步:实现流式导出和异步导入
创建 ExcelBatchService.java:
package com.susan.training.integration.excel;
import org.apache.poi.openxml4j.opc.OPCPackage;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.eventusermodel.XSSFReader;
import org.apache.poi.xssf.eventusermodel.XSSFSheetXMLHandler;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.xml.sax.InputSource;
import org.xml.sax.XMLReader;
import org.xml.sax.helpers.XMLReaderFactory;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Excel 分批导出和异步导入服务。
*/
public class ExcelBatchService implements AutoCloseable {
private static final int BATCH_SIZE = 1_000;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
private final Map<String, ExcelJobInfo> jobs = new ConcurrentHashMap<>();
/**
* 分批次写入百万行,避免构造完整 List。
*/
public void exportOrders(OutputStream outputStream, int totalRows) {
if (totalRows < 0) {
throw new IllegalArgumentException("totalRows 不能为负数");
}
// SXSSF 只保留 100 行在内存,其余行分批写入临时文件。
try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) {
Sheet sheet = workbook.createSheet("orders");
Row header = sheet.createRow(0);
header.createCell(0).setCellValue("订单号");
header.createCell(1).setCellValue("客户编号");
header.createCell(2).setCellValue("金额");
header.createCell(3).setCellValue("状态");
for (int index = 0; index < totalRows; index++) {
Row row = sheet.createRow(index + 1);
row.createCell(0).setCellValue("SO" + index);
row.createCell(1).setCellValue("C" + (index % 10_000));
row.createCell(2).setCellValue(index + 1D);
row.createCell(3).setCellValue("CREATED");
}
workbook.write(outputStream);
workbook.dispose();
} catch (IOException exception) {
throw new IllegalStateException("Excel 导出失败", exception);
}
}
/**
* 先落到临时文件,再把文件处理交给虚拟线程。
*/
public String importAsync(InputStream inputStream) throws IOException {
Path tempFile = Files.createTempFile("orders-", ".xlsx");
Files.copy(inputStream, tempFile, StandardCopyOption.REPLACE_EXISTING);
String jobId = UUID.randomUUID().toString();
jobs.put(jobId, new ExcelJobInfo(
jobId,
ExcelJobStatus.PENDING,
0,
"等待处理"
));
executor.submit(() -> {
jobs.put(jobId, new ExcelJobInfo(
jobId,
ExcelJobStatus.RUNNING,
0,
"正在读取"
));
try {
ExcelSheetHandler handler = readFirstSheet(tempFile);
jobs.put(jobId, new ExcelJobInfo(
jobId,
ExcelJobStatus.SUCCEEDED,
handler.getRowCount(),
"处理完成"
));
} catch (Exception exception) {
jobs.put(jobId, new ExcelJobInfo(
jobId,
ExcelJobStatus.FAILED,
readerCount(jobId),
exception.getMessage()
));
} finally {
try {
Files.deleteIfExists(tempFile);
} catch (IOException ignored) {
// 临时文件清理失败不应覆盖原始任务状态。
}
}
});
return jobId;
}
public ExcelJobInfo status(String jobId) {
ExcelJobInfo info = jobs.get(jobId);
if (info == null) {
throw new IllegalArgumentException("Excel 任务不存在: " + jobId);
}
return info;
}
private ExcelSheetHandler readFirstSheet(Path path) throws Exception {
try (OPCPackage packageFile = OPCPackage.open(path.toFile())) {
XSSFReader reader = new XSSFReader(packageFile);
ExcelSheetHandler handler = new ExcelSheetHandler(
BATCH_SIZE,
this::processBatch
);
XMLReader parser = XMLReaderFactory.createXMLReader();
parser.setContentHandler(new XSSFSheetXMLHandler(
reader.getStylesTable(),
reader.getSharedStringsTable(),
handler,
new DataFormatter(),
false
));
XSSFReader.SheetIterator sheets = reader.getSheetIterator();
if (!sheets.hasNext()) {
throw new IllegalArgumentException("Excel 中没有工作表");
}
try (InputStream sheet = sheets.next()) {
parser.parse(new InputSource(sheet));
}
handler.finish();
return handler;
}
}
private void processBatch(List<ExcelOrderRow> batch) {
// 教学版在批次边界停止;生产项目在这里执行数据库批量写入或投递 MQ。
// 业务消费者必须自行保证幂等、事务和失败重试。
}
private long readerCount(String jobId) {
ExcelJobInfo info = jobs.get(jobId);
return info == null ? 0 : info.processedRows();
}
@Override
public void close() {
executor.shutdown();
}
}SXSSFWorkbook(100) 的 100 是内存行窗口,不是最终导出行数。超过窗口的行会写入 JVM 临时目录,所以调用 dispose() 清理临时文件很重要。
processBatch 目前停在一个清楚的扩展边界,只完成解析和分批。接入数据库时,应把这一处替换成分批 Mapper 调用或消息投递,不要在 SAX 回调里单行写库。
第 6 步:暴露导出、上传和任务查询接口
Chapter5Controller 中的 Excel 相关方法如下:
/**
* 流式导出指定行数,默认只生成 1000 行。
*/
@GetMapping("/excel/export")
public void export(
@RequestParam(defaultValue = "1000") int rows,
HttpServletResponse response
) throws Exception {
ExcelBatchService service = excelServiceProvider.getIfAvailable();
if (service == null) {
response.setStatus(503);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"code\":\"FEATURE_DISABLED\",\"message\":\"Excel 服务未开启\"}"
);
return;
}
if (rows < 0 || rows > 1_000_000) {
throw new IllegalArgumentException("rows 必须在 0 到 1000000 之间");
}
response.setContentType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
);
response.setHeader(
"Content-Disposition",
"attachment; filename=orders.xlsx"
);
service.exportOrders(response.getOutputStream(), rows);
}
/**
* 接收 Excel 后立即返回任务 ID,实际读取在虚拟线程中完成。
*/
@PostMapping("/excel/import")
public ApiResponse<String> importExcel(
@RequestParam("file") MultipartFile file
) throws Exception {
ExcelBatchService service = excelServiceProvider.getIfAvailable();
if (service == null) {
return ApiResponse.failure("FEATURE_DISABLED", "Excel 服务未开启");
}
try (InputStream inputStream = file.getInputStream()) {
return ApiResponse.success(service.importAsync(inputStream));
}
}
/**
* 查询异步导入状态和已处理行数。
*/
@GetMapping("/excel/jobs/{jobId}")
public ApiResponse<ExcelJobInfo> excelJob(@PathVariable String jobId) {
ExcelBatchService service = excelServiceProvider.getIfAvailable();
if (service == null) {
return ApiResponse.failure("FEATURE_DISABLED", "Excel 服务未开启");
}
return ApiResponse.success(service.status(jobId));
}导出接口直接写 HttpServletResponse 输出流,避免先把百万行文件完整拼接进 byte[]。导入接口只复制到临时文件,不把上传内容交给平台线程慢慢解析。
第 7 步:启动并完成一次 2000 行闭环
在项目根目录启动:
mvn clean package -DskipTests
java -jar demo-web/target/demo-web-1.0.0-SNAPSHOT.jar \
--spring.profiles.active=chapter5 \
--server.port=18100导出 2000 行:
curl -sS -D /private/tmp/chapter5-headers.txt \
-o /private/tmp/orders.xlsx \
'http://127.0.0.1:18100/api/v1/chapter5/excel/export?rows=2000'
cat /private/tmp/chapter5-headers.txt
unzip -l /private/tmp/orders.xlsx响应头应包含:
HTTP/1.1 200
Content-Disposition: attachment; filename=orders.xlsx
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheetunzip -l 能看到 [Content_Types].xml、xl/workbook.xml 和 xl/worksheets/sheet1.xml,说明响应确实是 OOXML 工作簿,不是伪装成 Excel 的错误 JSON。
上传同一文件:
curl -sS \
-F file=@/private/tmp/orders.xlsx \
'http://127.0.0.1:18100/api/v1/chapter5/excel/import'接口会立即返回类似任务 ID:
{
"code": "0",
"message": "success",
"data": "<jobId>",
"timestamp": "2026-11-06T10:45:06.952106Z"
}把 <jobId> 替换成实际值后查询:
curl -sS \
'http://127.0.0.1:18100/api/v1/chapter5/excel/jobs/<jobId>'本次 2000 行闭环的实际结果结构为:
{
"code": "0",
"message": "success",
"data": {
"jobId": "<jobId>",
"status": "SUCCEEDED",
"processedRows": 2000,
"message": "处理完成"
},
"timestamp": "2026-11-06T10:45:11.169142Z"
}时间和任务 ID 每次都会变化。processedRows=2000 说明表头之外的 2000 条数据都被 SAX 解析器计数,并经历了最后尾批提交。
第 8 步:验证失败任务
准备一个不是真正工作簿的文件:
printf 'not an xlsx' > /private/tmp/broken.xlsx
curl -sS \
-F file=@/private/tmp/broken.xlsx \
'http://127.0.0.1:18100/api/v1/chapter5/excel/import'上传仍会成功受理,因为解析在后台线程执行。随后用返回的 ID 查询,状态应变成 FAILED,message 会说明文件格式错误。
这是异步接口必须保留的反馈路径:HTTP 200 只代表“任务已接收”,并不代表“业务处理已完成”。
第 9 步:运行自动化测试
只运行本章集成模块测试:
mvn -pl demo-integration -am \
-Dtest=ExcelBatchServiceTest \
test测试会先导出 2000 行,跨过 1000 行批次边界,再把字节流交给异步导入,最终断言:
status = SUCCEEDED
processedRows = 2000本仓库的该测试已经按上述配置实际通过。若在其他操作系统仍出现测试 JVM 异常,先用上面的真实接口闭环定位业务问题,再分别核对 JDK、Maven 和 Surefire 配置;不能在测试进程崩溃时写成“测试通过”。
百万行真正落地时还要补什么?
这份代码解决的是 Excel 工作簿层面的内存问题,离生产可用还有几道边界:
jobs当前保存在ConcurrentHashMap,应用重启会丢失。生产环境应写入数据库,记录状态、进度、错误摘要、创建人和时间。- 临时文件需要设置目录、最长保留时间、磁盘配额和启动时清理策略,避免异常退出后不断堆积。
- 多实例部署时,上传实例和任务执行实例可能不同。任务状态要共享,文件进入对象存储或消息队列,不能只依赖本机内存。
- 每 1000 行写库只是一个起点。批大小要结合 SQL 长度、事务日志、唯一索引和锁等待做压测。
- 重试必须幂等。可按文件哈希、行号或业务唯一键建立去重约束,失败后从检查点继续。
- 要限制
.xlsx解压后的体积和压缩比,防止很小的 ZIP 在解析时膨胀成超大文件。 - 错误报告不能只返回整个任务失败。生产系统要给出错误行号、字段和原因,并限制错误明细体积。
- 导出要有权限校验、字段脱敏、下载审计和过期清理,不能只依赖 URL 难猜。
这些边界比“换一个更快工具”更影响最终可靠性。
常见问题排查
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 下载文件打不开 | 响应被异常处理成 JSON,或工作簿未完整写出 | 检查响应头和 unzip -l,查看应用日志 |
FEATURE_DISABLED | Excel 服务未启用 | 使用 --spring.profiles.active=chapter5 |
| 导入行数少 1 或少于预期 | 表头计数或最后一小批未提交 | 检查 rowNum > 0 和 finish() 是否执行 |
上传后一直 PENDING | 执行器已关闭或任务提交失败 | 检查 Bean 生命周期、线程状态和应用日志 |
| 大文件导出时磁盘占满 | SXSSF 临时文件没有及时清理 | 确认执行 dispose(),监控 java.io.tmpdir |
| 导入导致 OOM | formattedValue 很大、错误收集无上限,或另建完整对象树 | 配置单元格长度和解压阈值,限制错误明细 |
| 金额解析失败 | 文本含货币符号或异常格式 | 增加字段级解析错误与行号,不使用静默兜底 |
练习题
把 processBatch 改成真正的数据库批量写入,并增加一张 excel_import_error 表。要求:
- 每批最多 1000 行,事务只在批次内部开启。
- 订单号为空或金额小于等于 0 时,记录错误行号和原因,不中止整个文件。
- 同一
jobId + rowNum重复处理时不会插入两次。 - 最终状态能返回成功行数、失败行数和错误明细下载地址。
验收时分别上传 2000 行正常文件、尾批 1 行文件和包含 3 行错误数据的文件,核对数据库数量与任务状态。
总结
百万级 Excel 的核心策略是流式读写、固定批次、后台执行和可查询状态。SXSSFWorkbook 限制导出内存窗口,XSSFReader + SAX 避免导入时构建完整对象树,虚拟线程让等待 I/O 的任务不再长期占用平台线程。
完成这一章后,项目已经具备对象存储和批处理基础。下一篇进入企业安全,从 Spring Security 7 的认证、授权和过滤器链开始。
上一节:MinIO 文件存储
相关阅读:全局异常处理
相关阅读:Elasticsearch 商品搜索
返回课程目录
13 个企业级实战项目 · 源码 + 教程 + 答疑
简历包装指导AI Agent 实战高并发·微服务Offer 导向持续更新3000+ 同行交流1V1 答疑3 天包退
覆盖高并发、微服务、分布式与 AI Agent:100万QPS短链、企业智能知识库、商城微服务、SaaS点餐、秒杀、刷题小程序、代码生成器、AI Agent 等。券后 ¥129(原价 ¥159),不满意 3 天包退。
