百万数据导出 Excel 实战:从 OOM 到丝滑落地的完整演进
本文面向有 Java 基础、做过业务开发的同学。我们从“为什么 100 万行导出会 OOM”讲起,逐层递进到 SXSSF、EasyExcel、CSV、游标分页、异步导出,最后给出可直接运行的完整工程代码和面试考点。
目录
- 先看问题:为什么百万导出会 OOM
- 方案演进全景图
- 环境准备:可运行工程
- Step 1:游标分页查询(地基)
- Step 2:EasyExcel 流式写
- Step 3:原生 POI SXSSF
- Step 4:CSV —— 最快的方案
- Step 5:异步导出 + 进度查询
- 完整可运行入口
- 生产落地 Checklist
- 常见面试考点与答案
一、先看问题:为什么百万导出会 OOM
新手最常写的代码长这样:
// ❌ 危险示范
List<User> list = userMapper.selectAll(); // 100 万条全捞进内存
XSSFWorkbook workbook = new XSSFWorkbook(); // 全内存工作簿
Sheet sheet = workbook.createSheet();
for (int i = 0; i < list.size(); i++) {
Row row = sheet.createRow(i);
row.createCell(0).setCellValue(list.get(i).getName());
// ...
}
workbook.write(response.getOutputStream());
这段代码有 两处内存炸弹:
| 炸弹 | 说明 | 大致内存 |
|---|---|---|
selectAll() |
100 万条实体一次性进堆 | 100 万 × 200B ≈ 200MB |
XSSFWorkbook |
POI 的 全内存 DOM 模型,每行 XSSFRow、每格 XSSFCell 都是对象 |
100 万行 × 5 列 ≈ 500 万个 Cell ≈ 500MB~1GB |
workbook.write() |
内部构建 OPCPackage,再复制一份 |
峰值再翻倍 |
结论:默认 4G 堆都扛不住,必 OOM。
根因是:XSSF 是"先全部构建到内存,最后一次序列化"的模型。要解决,就必须把"内存模型"换成"流式模型"。
二、方案演进全景图
| 方案 | 内存占用 | 单 Sheet 上限 | 速度 | 适用场景 |
|---|---|---|---|---|
| POI XSSF | 极高(全内存) | 1,048,576 | 慢 | 小数据量(< 1万) |
| POI SXSSF | 低(滑动窗口 + 磁盘临时文件) | 1,048,576 | 中 | 中大数据量 |
| EasyExcel | 低(基于 SXSSF 封装) | 1,048,576 | 中 | 推荐,开发效率高 |
| CSV | 极低(纯流式) | 无 | 最快 | 数据导出、对格式无强要求 |
| 异步 + 文件中心 | 低 | — | — | 生产标准方案 |
核心思想只有一句话:把"一次性全量"改成"分批 + 流式",把"同步阻塞"改成"异步任务"。
三、环境准备:可运行工程
3.1 Maven 依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>million-export</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<easyexcel.version>3.3.4</easyexcel.version>
<poi.version>5.2.5</poi.version>
<h2.version>2.2.224</h2.version>
</properties>
<dependencies>
<!-- EasyExcel:流式读写 Excel -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>${easyexcel.version}</version>
</dependency>
<!-- 原生 POI(SXSSF 演示用),EasyExcel 已传递依赖,显式声明便于锁版本 -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>${poi.version}</version>
</dependency>
<!-- H2 内存数据库:用于造 100 万条测试数据,无需装 MySQL -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>${h2.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>1.7.36</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.32</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>
运行参数建议:
-Xmx1g -Xms1g。100 万条 H2 内存表大概占 200~300MB,留足堆空间即可。
3.2 包结构
src/main/java/com/example/export/
├── DataSourceUtil.java # H2 建表 + 造 100 万数据
├── UserExcelRow.java # Excel 行模型(EasyExcel 注解)
├── ExportService.java # 三种同步导出实现
├── AsyncExportService.java # 异步导出 + 进度查询
└── ExportBenchmark.java # main 入口,一键跑通全部方案
四、Step 1:游标分页查询(地基)
为什么不能用 LIMIT offset, size?
-- 第 1 页很快,第 200 页开始变慢
SELECT * FROM t_user LIMIT 1000000, 5000; -- 要扫描并丢弃前 100 万行
深分页的时间复杂度是 O(offset),越翻越慢。
正确姿势:游标法(Seek Method)
-- 每次记住上一批的最大 id,下一页直接走主键索引定位
SELECT * FROM t_user WHERE id > ? ORDER BY id ASC LIMIT 5000;
每页都是 O(log n) 定位,百万数据翻 200 页耗时几乎恒定。
DataSourceUtil.java
package com.example.export;
import java.sql.*;
/**
* H2 内存数据库工具类:建表 + 灌数据。
* 生产环境请替换为你的 DataSource / MyBatis Mapper。
*/
public final class DataSourceUtil {
public static final String URL = "jdbc:h2:mem:exportdb;DB_CLOSE_DELAY=-1;MODE=MySQL";
public static final String USER = "sa";
public static final String PASSWORD = "";
private DataSourceUtil() {
}
public static Connection getConnection() throws SQLException {
return DriverManager.getConnection(URL, USER, PASSWORD);
}
/**
* 建表并灌入 total 条测试数据
*/
public static void initData(int total) throws SQLException {
try (Connection conn = getConnection()) {
conn.setAutoCommit(false);
try (Statement st = conn.createStatement()) {
st.execute("DROP TABLE IF EXISTS t_user");
st.execute("CREATE TABLE t_user (" +
" id BIGINT PRIMARY KEY," +
" name VARCHAR(64)," +
" age INT," +
" email VARCHAR(128)," +
" create_time TIMESTAMP)");
}
String sql = "INSERT INTO t_user(id,name,age,email,create_time) VALUES(?,?,?,?,?)";
long now = System.currentTimeMillis();
try (PreparedStatement ps = conn.prepareStatement(sql)) {
for (int i = 1; i <= total; i++) {
ps.setLong(1, i);
ps.setString(2, "用户_" + i);
ps.setInt(3, 18 + (i % 50));
ps.setString(4, "user" + i + "@example.com");
ps.setTimestamp(5, new Timestamp(now - i * 1000L));
ps.addBatch();
if (i % 10_000 == 0) {
ps.executeBatch();
conn.commit();
System.out.printf(" 已灌入 %,d 条%n", i);
}
}
ps.executeBatch();
conn.commit();
}
}
}
}
UserExcelRow.java
package com.example.export;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.format.DateTimeFormat;
import lombok.Getter;
import lombok.Setter;
import java.util.Date;
@Getter
@Setter
public class UserExcelRow {
@ExcelProperty(value = "ID", index = 0)
private Long id;
@ExcelProperty(value = "姓名", index = 1)
private String name;
@ExcelProperty(value = "年龄", index = 2)
private Integer age;
@ExcelProperty(value = "邮箱", index = 3)
private String email;
@ExcelProperty(value = "创建时间", index = 4)
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private Date createTime;
}
ExportService.java —— 游标分页查询
package com.example.export;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
public final class ExportService {
private ExportService() {
}
/**
* 游标分页:id > lastId,走主键索引,避免深分页
*
* @param lastId 上一批的最大 id(首批传 0)
* @param pageSize 每批条数,建议 5000 ~ 10000
*/
public static List<UserExcelRow> queryPage(long lastId, int pageSize) throws SQLException {
String sql = "SELECT id, name, age, email, create_time " +
"FROM t_user WHERE id > ? ORDER BY id ASC LIMIT ?";
List<UserExcelRow> list = new ArrayList<>(pageSize);
try (Connection conn = DataSourceUtil.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, lastId);
ps.setInt(2, pageSize);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
UserExcelRow row = new UserExcelRow();
row.setId(rs.getLong("id"));
row.setName(rs.getString("name"));
row.setAge(rs.getInt("age"));
row.setEmail(rs.getString("email"));
row.setCreateTime(rs.getTimestamp("create_time"));
list.add(row);
}
}
}
return list;
}
}
生产提示:真实项目里不要每批都新建 Connection,应使用连接池(HikariCP),或者直接把一个
Connection传进来复用。
五、Step 2:EasyExcel 流式写
EasyExcel 的写模式默认就是 SXSSF 流式,API 极其简洁:ExcelWriter + WriteSheet,可以反复 write() 多次。
/**
* 方案一:EasyExcel 分批流式写
*/
public static void exportByEasyExcel(File file, int pageSize) throws SQLException {
long t0 = System.currentTimeMillis();
long lastId = 0L;
long count = 0L;
// try-with-resources 会自动 finish(),把剩余数据刷盘并关闭流
try (ExcelWriter writer = EasyExcel.write(file, UserExcelRow.class).build()) {
WriteSheet sheet = EasyExcel.writerSheet("用户数据").build();
while (true) {
List<UserExcelRow> list = queryPage(lastId, pageSize);
if (list.isEmpty()) {
break;
}
writer.write(list, sheet); // 写一批,内存立刻可回收
lastId = list.get(list.size() - 1).getId();
count += list.size();
}
}
System.out.printf("[EasyExcel] 导出 %,d 条 -> %s,耗时 %,d ms%n",
count, file.getName(), System.currentTimeMillis() - t0);
}
内存曲线:每批 list 用完即弃,ExcelWriter 内部只保留 500 行窗口,堆内存稳定在几十 MB。
六、Step 3:原生 POI SXSSF
如果想彻底理解 EasyExcel 底层,必须手写一次 SXSSF。
SXSSF 原理:
内存窗口(默认 100 行)
┌──────────────────────────────┐
│ Row N-99 ... Row N │ ← 可读可写
└──────────────────────────────┘
│ 超出窗口,自动 flush
▼
/tmp/poi-sxssf-xxxx/row_*.xml ← 磁盘临时文件
│
▼ write()
最终 .xlsx 文件
/**
* 方案二:原生 POI SXSSF
*/
public static void exportBySxssf(File file, int pageSize) throws Exception {
long t0 = System.currentTimeMillis();
long lastId = 0L;
long count = 0L;
// 关键:内存中只保留 500 行
SXSSFWorkbook workbook = new SXSSFWorkbook(500);
workbook.setCompressTempFiles(true); // 临时文件 gzip 压缩,省磁盘、费 CPU
try {
CellStyle dateStyle = workbook.createCellStyle();
dateStyle.setDataFormat(workbook.createDataFormat().getFormat("yyyy-mm-dd hh:mm:ss"));
Sheet sheet = workbook.createSheet("用户数据_1");
writeHeader(sheet);
int rowIdx = 1;
int sheetIndex = 1;
while (true) {
List<UserExcelRow> list = queryPage(lastId, pageSize);
if (list.isEmpty()) {
break;
}
for (UserExcelRow u : list) {
// xlsx 单 Sheet 上限 1048576 行,超了自动开新 Sheet
if (rowIdx >= 1_048_576) {
sheetIndex++;
sheet = workbook.createSheet("用户数据_" + sheetIndex);
writeHeader(sheet);
rowIdx = 1;
}
Row row = sheet.createRow(rowIdx++);
row.createCell(0).setCellValue(u.getId());
row.createCell(1).setCellValue(u.getName());
row.createCell(2).setCellValue(u.getAge());
row.createCell(3).setCellValue(u.getEmail());
Cell dateCell = row.createCell(4);
dateCell.setCellValue(u.getCreateTime());
dateCell.setCellStyle(dateStyle);
}
lastId = list.get(list.size() - 1).getId();
count += list.size();
}
try (FileOutputStream fos = new FileOutputStream(file)) {
workbook.write(fos);
}
} finally {
// ⚠️ 必须 dispose(),否则 /tmp 会堆积大量 poi-sxssf-* 临时目录
workbook.dispose();
workbook.close();
}
System.out.printf("[SXSSF] 导出 %,d 条 -> %s,耗时 %,d ms%n",
count, file.getName(), System.currentTimeMillis() - t0);
}
private static void writeHeader(Sheet sheet) {
String[] headers = {"ID", "姓名", "年龄", "邮箱", "创建时间"};
Row header = sheet.createRow(0);
for (int i = 0; i < headers.length; i++) {
header.createCell(i).setCellValue(headers[i]);
}
}
SXSSF 的 4 个坑:
- 只能顺序写:
createRow(n)的行号必须递增,不能回头改已 flush 的行。 - 不能读:SXSSF 是只写模型,读文件要用 XSSF / SAX。
- 临时文件必须 dispose:否则磁盘爆满。
- 单 Sheet 有行数上限:1,048,576 行(xls 是 65,536 行)。
七、Step 4:CSV —— 最快的方案
如果业务对"必须是 .xlsx 格式"没有硬要求,CSV 是性能之王:没有 XML 序列化、没有临时文件、没有 Sheet 上限,纯流式,速度通常是 EasyExcel 的 3~10 倍。
/**
* 方案三:CSV 流式导出(性能最优)
*/
public static void exportByCsv(File file, int pageSize) throws Exception {
long t0 = System.currentTimeMillis();
long lastId = 0L;
long count = 0L;
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
try (BufferedWriter bw = new BufferedWriter(
new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8), 1 << 16)) {
// ⚠️ BOM 头:让 Excel 双击打开时正确识别 UTF-8,否则中文乱码
bw.write('\uFEFF');
bw.write("ID,姓名,年龄,邮箱,创建时间\n");
while (true) {
List<UserExcelRow> list = queryPage(lastId, pageSize);
if (list.isEmpty()) {
break;
}
for (UserExcelRow u : list) {
bw.write(u.getId() + ","
+ escapeCsv(u.getName()) + ","
+ u.getAge() + ","
+ escapeCsv(u.getEmail()) + ","
+ sdf.format(u.getCreateTime()));
bw.write('\n');
}
lastId = list.get(list.size() - 1).getId();
count += list.size();
}
}
System.out.printf("[CSV] 导出 %,d 条 -> %s,耗时 %,d ms%n",
count, file.getName(), System.currentTimeMillis() - t0);
}
/**
* CSV 字段转义:含逗号、引号、换行时用双引号包裹,内部引号翻倍
*/
private static String escapeCsv(String s) {
if (s == null) {
return "";
}
if (s.contains(",") || s.contains("\"") || s.contains("\n") || s.contains("\r")) {
return "\"" + s.replace("\"", "\"\"") + "\"";
}
return s;
}
CSV 的两个坑:
- UTF-8 BOM:不加 BOM,Excel 打开中文必乱码。
- CSV 注入:如果单元格以
=、+、-、@开头,Excel 会当作公式执行。生产上要做前缀转义(前面加')。
八、Step 5:异步导出 + 进度查询
为什么必须异步?
同步导出 100 万行通常要 30~120 秒,而 Nginx 默认 proxy_read_timeout=60s、网关 30s、Tomcat 连接超时……HTTP 请求早断了,用户看到 502,但后端还在傻跑。
生产标准方案:
用户点击导出
│
▼
① 提交任务 → 返回 taskId(毫秒级响应)
│
▼
② 线程池异步执行导出 → 写入本地磁盘 / OSS
│
▼
③ 前端轮询 /api/export/progress?taskId=xxx
│
▼
④ 状态为 SUCCESS → 返回下载 URL
AsyncExportService.java
package com.example.export;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import java.io.File;
import java.util.List;
import java.util.Map;
import java.util.concurrent.*;
public class AsyncExportService {
public enum Status {RUNNING, SUCCESS, FAILED}
/**
* 导出任务:状态、进度、结果文件
*/
public static class ExportTask {
private final String taskId;
private final long total;
private volatile Status status = Status.RUNNING;
private volatile long written;
private volatile String filePath;
private volatile String error;
public ExportTask(String taskId, long total) {
this.taskId = taskId;
this.total = total;
}
public int getPercent() {
if (total <= 0) {
return 0;
}
return (int) Math.min(100, written * 100 / total);
}
public String getTaskId() { return taskId; }
public long getTotal() { return total; }
public long getWritten() { return written; }
public Status getStatus() { return status; }
public String getFilePath() { return filePath; }
public String getError() { return error; }
void setWritten(long written) { this.written = written; }
void setStatus(Status status) { this.status = status; }
void setFilePath(String filePath) { this.filePath = filePath; }
void setError(String error) { this.error = error; }
@Override
public String toString() {
return "ExportTask{id='" + taskId + "', status=" + status
+ ", progress=" + getPercent() + "%}";
}
}
/** 任务注册表:生产环境请换成 Redis,否则多实例部署查不到任务 */
private static final Map<String, ExportTask> TASKS = new ConcurrentHashMap<>();
/** 导出专用线程池:隔离业务线程池,避免导出把主业务拖垮 */
private static final ExecutorService POOL = new ThreadPoolExecutor(
2, 2, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100),
r -> {
Thread t = new Thread(r, "excel-export-worker");
t.setDaemon(true);
return t;
},
new ThreadPoolExecutor.AbortPolicy());
private AsyncExportService() {
}
/**
* 提交异步导出任务,立即返回 taskId
*/
public static String submit(String taskId, File target, long total, int pageSize) {
ExportTask task = new ExportTask(taskId, total);
TASKS.put(taskId, task);
POOL.submit(() -> {
try {
doExport(task, target, pageSize);
task.setFilePath(target.getAbsolutePath());
task.setStatus(Status.SUCCESS);
} catch (Exception e) {
task.setError(e.getMessage());
task.setStatus(Status.FAILED);
e.printStackTrace();
}
});
return taskId;
}
public static ExportTask get(String taskId) {
return TASKS.get(taskId);
}
private static void doExport(ExportTask task, File file, int pageSize) throws Exception {
long lastId = 0L;
long written = 0L;
try (ExcelWriter writer = EasyExcel.write(file, UserExcelRow.class).build()) {
WriteSheet sheet = EasyExcel.writerSheet("用户数据").build();
while (true) {
List<UserExcelRow> list = ExportService.queryPage(lastId, pageSize);
if (list.isEmpty()) {
break;
}
writer.write(list, sheet);
lastId = list.get(list.size() - 1).getId();
written += list.size();
task.setWritten(written); // 更新进度,前端轮询可见
}
}
}
}
生产增强点(本文不展开,但面试会问):
- 任务状态存 Redis,支持多实例部署和 TTL 自动过期
- 文件存 OSS / S3,返回带签名的临时 URL,而非本地路径
- 加 分布式锁 / 幂等键,防止用户狂点导出把数据库打挂
- 加 定时清理任务,删除 7 天前的导出文件
- 加 限流(每用户同时只能有 1 个导出任务)
九、完整可运行入口
ExportBenchmark.java
package com.example.export;
import java.io.File;
import java.util.concurrent.TimeUnit;
public class ExportBenchmark {
public static void main(String[] args) throws Exception {
int total = args.length > 0 ? Integer.parseInt(args[0]) : 1_000_000;
int pageSize = args.length > 1 ? Integer.parseInt(args[1]) : 5_000;
System.out.printf("==== 准备测试数据:%,d 条 ====%n", total);
DataSourceUtil.initData(total);
printMemory("灌数完成");
File dir = new File("output");
if (!dir.exists() && !dir.mkdirs()) {
throw new IllegalStateException("无法创建输出目录");
}
// ---------- 方案一:EasyExcel ----------
ExportService.exportByEasyExcel(new File(dir, "easyexcel.xlsx"), pageSize);
printMemory("EasyExcel 导出完成");
// ---------- 方案二:原生 SXSSF ----------
ExportService.exportBySxssf(new File(dir, "sxssf.xlsx"), pageSize);
printMemory("SXSSF 导出完成");
// ---------- 方案三:CSV ----------
ExportService.exportByCsv(new File(dir, "users.csv"), pageSize);
printMemory("CSV 导出完成");
// ---------- 方案四:异步导出 + 进度轮询 ----------
String taskId = AsyncExportService.submit(
"task-" + System.currentTimeMillis(),
new File(dir, "async.xlsx"),
total,
pageSize);
while (true) {
AsyncExportService.ExportTask task = AsyncExportService.get(taskId);
System.out.printf("异步任务进度:%d%%(%,d/%,d)%n",
task.getPercent(), task.getWritten(), task.getTotal());
if (task.getStatus() != AsyncExportService.Status.RUNNING) {
System.out.println("最终状态:" + task.getStatus()
+ ",文件:" + task.getFilePath()
+ (task.getError() != null ? ",错误:" + task.getError() : ""));
break;
}
TimeUnit.MILLISECONDS.sleep(800);
}
System.out.println("\n==== 全部完成,输出目录:" + dir.getAbsolutePath() + " ====");
}
private static void printMemory(String tag) {
Runtime rt = Runtime.getRuntime();
long usedMb = (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024;
System.out.printf("[内存] %s:已用 %d MB%n", tag, usedMb);
}
}
运行方式
# 打包
mvn clean package
# 跑 100 万条,每批 5000
java -Xmx1g -Xms1g -cp target/classes:target/dependency/* com.example.export.ExportBenchmark 1000000 5000
# 或者用 maven exec
mvn exec:java -Dexec.mainClass=com.example.export.ExportBenchmark -Dexec.args="1000000 5000"
我本机的实测参考(仅供参考,因机器而异)
| 方案 | 100 万条耗时 | 峰值堆内存 | 文件大小 |
|---|---|---|---|
| EasyExcel | ~35 s | ~120 MB | ~22 MB |
| 原生 SXSSF | ~32 s | ~110 MB | ~21 MB |
| CSV | ~9 s | ~60 MB | ~18 MB |
| 异步 EasyExcel | ~35 s(不阻塞主线程) | ~120 MB | ~22 MB |
关键结论:CSV 快 3~4 倍,内存占用低一半;Excel 系列耗时主要在 XML 序列化和临时文件 IO。
十、生产落地 Checklist
导出功能上线前,逐条对一遍:
十一、常见面试考点与答案
Q1:百万数据导出 Excel 为什么会 OOM?
答:POI 的 XSSFWorkbook 是全内存 DOM 模型。每行对应一个 XSSFRow 对象,每格对应一个 XSSFCell 对象,100 万行 × 5 列就是 500 万个 Cell 对象 + 100 万个 Row 对象,堆内存占用 500MB 起步;而且这些对象在 write() 之前无法被 GC 回收。write() 时内部还会构建完整的 OPCPackage,峰值再翻倍。加上如果前面用 selectAll() 把数据全捞进 List,双重压力必然 OOM。
解决:改用流式写(SXSSF / EasyExcel)+ 分批查询。
Q2:SXSSF 和 XSSF 的区别是什么?
答:
| 维度 | XSSF | SXSSF |
|---|---|---|
| 内存模型 | 全内存 | 滑动窗口(默认 100 行)+ 磁盘临时文件 |
| 能否读取 | 能 | 不能(只写) |
| 能否随机写 | 能 | 不能,只能顺序追加,已 flush 的行不可改 |
| 内存占用 | 极高 | 低 |
| 临时文件 | 无 | 有,必须 dispose() 清理 |
| 适用场景 | 小文件、需要读写 | 大数据量导出 |
SXSSF 是 XSSF 的包装,内部把超出窗口的行序列化成 XML 落到 java.io.tmpdir 下,最后 write() 时按顺序拼接成 zip。
Q3:EasyExcel 的底层原理是什么?
答:
- 读:用 SAX 解析 xlsx 的
sheet.xml,逐行回调,不在内存构建 DOM,所以能读超大文件。 - 写:底层封装 SXSSF,默认滑动窗口流式写,配合注解(
@ExcelProperty)做字段映射。 - 相比原生 POI,EasyExcel 还做了:更小的对象开销、共享字符串优化、更简洁的 API。
- 注意它不是银弹:写 100 万行仍然要几十秒,本质瓶颈在 XML 序列化和磁盘 IO。
Q4:为什么不能深分页?怎么优化?
答:LIMIT 1000000, 5000 需要扫描并丢弃前 100 万行,耗时随 offset 线性增长。
优化方案:
- 游标法(首选):
WHERE id > lastId ORDER BY id LIMIT 5000,走主键索引,每页 O(log n)。 - 延迟关联:
SELECT t.* FROM t JOIN (SELECT id FROM t ORDER BY id LIMIT 1000000,5000) x ON t.id = x.id,先在覆盖索引上翻页。 - 业务限制:超过 N 页强制走导出/异步任务,不让用户翻。
Q5:EasyExcel 和 CSV 怎么选?
答:
| EasyExcel | CSV | |
|---|---|---|
| 速度 | 中 | 最快(3~4 倍) |
| 内存 | 低 | 极低 |
| 行数上限 | 单 Sheet 104 万 | 无 |
| 样式/公式 | 支持 | 不支持 |
| 中文兼容 | 好 | 需加 BOM |
| 单元格内换行 | 支持 | 需转义 |
结论:运营后台的"数据导出",99% 场景只需要数据本身,直接给 CSV 最快最省事;如果要求格式、多 Sheet、给客户看的报表,用 Excel。
Q6:导出请求超时怎么办?
答:改同步为异步任务化:
- 用户点导出 → 后端落一条任务记录(DB 或 Redis),状态
RUNNING,立即返回 taskId。 - 线程池异步执行导出,写文件到 OSS,过程中更新进度。
- 前端轮询
/progress?taskId=xxx(或 WebSocket 推送)。 - 状态变
SUCCESS后返回带签名 URL 下载。
同时要处理:限流(每用户同时 1 个)、幂等(重复提交返回同一个 taskId)、清理(定时删过期文件)。
Q7:多线程能加速导出吗?需要注意什么?
答:可以,但不能多线程往同一个 Workbook 写——SXSSFWorkbook 不是线程安全的,而且它要求顺序追加行。
正确做法有两种:
方案 A:分片写多个文件,最后打 ZIP 包
线程1: id ∈ [1, 250000] → part1.xlsx
线程2: id ∈ [250001, 500000] → part2.xlsx
...
打包 → result.zip
方案 B:单线程读 + 多线程转换(意义不大,瓶颈在 IO)
方案 C:分片写多个 Sheet(也不行,同一个 workbook 不能并发写;但可以每个线程各写一个 workbook,最后用 EasyExcel 读回来合并——成本高,不推荐)
实践中,导出加速的收益通常来自:CSV 替代 Excel、减少查询列、加索引、异步化,而不是多线程写 Excel。
Q8:SXSSF 的 dispose() 和 close() 有什么区别?
答:
close():关闭工作簿,释放内存中的 POI 对象,但不会删除磁盘临时文件。dispose():删除/tmp/poi-sxssf-*下的临时文件。- 正确顺序:
workbook.write(out)→workbook.dispose()→workbook.close()(或close()后dispose(),但dispose()一定要调)。
不调用 dispose() 会导致磁盘被临时文件占满,是线上事故的常见原因。
Q9:导出时如何避免 Full GC / 内存抖动?
答:
- 每批数据(5000~10000)用完立即丢弃,不要累积到大 List。
- 不要在循环里创建大量临时对象(比如每行
new SimpleDateFormat,应提到循环外)。 - 合理设置批大小:太小 → SQL 次数多;太大 → 单批对象多,Young GC 频繁。
- 不要用
LongestMatchColumnWidthStyleStrategy这类需要缓存全量数据的策略(它会保存每列最大宽度,虽然内存可控,但仍有额外开销)。 -Xmx给足(1G 起),别让导出和业务抢堆。
Q10:Excel 的行数限制是多少?
答:
.xlsx(Excel 2007+):单 Sheet 1,048,576 行 × 16,384 列.xls(Excel 2003):单 Sheet 65,536 行 × 256 列- 超过限制需要分 Sheet(如本文 SXSSF 示例)或分文件打包 ZIP。
- CSV 没有行数限制(取决于磁盘)。
Q11:如何防止导出被恶意刷?
答:
- 限流:Guava RateLimiter / Sentinel,按用户维度,比如每分钟 1 次。
- 并发限制:每个用户同时只能有 1 个 RUNNING 任务,重复提交返回已有 taskId。
- 数据量校验:单次导出上限(如 50 万行),超了提示缩小时间范围。
- 权限校验:导出条件必须带数据权限,防止越权导出全表。
- 审计日志:记录谁在什么时候导出了多少数据。
Q12:MyBatis 怎么做流式查询?
答:
方式一:Cursor
@Select("SELECT * FROM t_user WHERE id > #{lastId} ORDER BY id LIMIT #{size}")
Cursor<User> selectByCursor(@Param("lastId") long lastId, @Param("size") int size);
try (Cursor<User> cursor = mapper.selectByCursor(0, 5000)) {
cursor.forEach(user -> { /* 处理 */ });
}
注意 Cursor 必须放在事务里,且用完要关闭。
方式二:ResultHandler
mapper.selectAll(new ResultHandler<User>() {
@Override
public void handleResult(ResultContext<? extends User> ctx) {
// 逐条处理,但注意这里整个查询期间会占用连接
}
});
方式三:MySQL 真流式
statement.setFetchSize(Integer.MIN_VALUE); // MySQL 专属
connection.setAutoCommit(false);
配合 useCursorFetch=true 效果更好。
⚠️ 流式查询期间会独占数据库连接,千万不能和"每批新开连接"混用,否则连接池会被打爆。
Q13:如果让你设计一个通用的导出组件,你会怎么设计?
答:
接口层:POST /export/submit → {taskId}
GET /export/progress → {status, percent, url}
抽象层:ExportHandler<T>
├── buildQuery() 查询条件 & 游标
├── convert(T) 实体 → Excel 行模型
└── getFileName() 文件名规则
执行层:ExportTemplate
├── 游标分页拉数据
├── 流式写(策略:EasyExcel / CSV / SXSSF)
├── 进度上报
└── 上传 OSS
存储层:任务表(MySQL)+ 状态缓存(Redis)+ 文件(OSS)
关键抽象点:把"查询"和"写出"解耦,业务方只需要关心"给我什么数据"和"列头叫什么",导出方式由框架决定。
全文完。
核心记住三句话:
- 查询要游标,不要 offset;
- 写 Excel 要流式(SXSSF / EasyExcel),永远别用 XSSF 导大数据;
- 同步必超时,导出一定要异步化 + 任务化。
浙公网安备 33010602011771号