百万数据导出 Excel 实战:从 OOM 到丝滑落地的完整演进

本文面向有 Java 基础、做过业务开发的同学。我们从“为什么 100 万行导出会 OOM”讲起,逐层递进到 SXSSF、EasyExcel、CSV、游标分页、异步导出,最后给出可直接运行的完整工程代码和面试考点。


目录

  1. 先看问题:为什么百万导出会 OOM
  2. 方案演进全景图
  3. 环境准备:可运行工程
  4. Step 1:游标分页查询(地基)
  5. Step 2:EasyExcel 流式写
  6. Step 3:原生 POI SXSSF
  7. Step 4:CSV —— 最快的方案
  8. Step 5:异步导出 + 进度查询
  9. 完整可运行入口
  10. 生产落地 Checklist
  11. 常见面试考点与答案

一、先看问题:为什么百万导出会 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 个坑:

  1. 只能顺序写:createRow(n) 的行号必须递增,不能回头改已 flush 的行。
  2. 不能读:SXSSF 是只写模型,读文件要用 XSSF / SAX。
  3. 临时文件必须 dispose:否则磁盘爆满。
  4. 单 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 线性增长。

优化方案:

  1. 游标法(首选):WHERE id > lastId ORDER BY id LIMIT 5000,走主键索引,每页 O(log n)。
  2. 延迟关联:SELECT t.* FROM t JOIN (SELECT id FROM t ORDER BY id LIMIT 1000000,5000) x ON t.id = x.id,先在覆盖索引上翻页。
  3. 业务限制:超过 N 页强制走导出/异步任务,不让用户翻。

Q5:EasyExcel 和 CSV 怎么选?

答:

EasyExcel CSV
速度 中 最快(3~4 倍)
内存 低 极低
行数上限 单 Sheet 104 万 无
样式/公式 支持 不支持
中文兼容 好 需加 BOM
单元格内换行 支持 需转义

结论:运营后台的"数据导出",99% 场景只需要数据本身,直接给 CSV 最快最省事;如果要求格式、多 Sheet、给客户看的报表,用 Excel。


Q6:导出请求超时怎么办?

答:改同步为异步任务化:

  1. 用户点导出 → 后端落一条任务记录(DB 或 Redis),状态 RUNNING,立即返回 taskId。
  2. 线程池异步执行导出,写文件到 OSS,过程中更新进度。
  3. 前端轮询 /progress?taskId=xxx(或 WebSocket 推送)。
  4. 状态变 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 / 内存抖动?

答:

  1. 每批数据(5000~10000)用完立即丢弃,不要累积到大 List。
  2. 不要在循环里创建大量临时对象(比如每行 new SimpleDateFormat,应提到循环外)。
  3. 合理设置批大小:太小 → SQL 次数多;太大 → 单批对象多,Young GC 频繁。
  4. 不要用 LongestMatchColumnWidthStyleStrategy 这类需要缓存全量数据的策略(它会保存每列最大宽度,虽然内存可控,但仍有额外开销)。
  5. -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:如何防止导出被恶意刷?

答:

  1. 限流:Guava RateLimiter / Sentinel,按用户维度,比如每分钟 1 次。
  2. 并发限制:每个用户同时只能有 1 个 RUNNING 任务,重复提交返回已有 taskId。
  3. 数据量校验:单次导出上限(如 50 万行),超了提示缩小时间范围。
  4. 权限校验:导出条件必须带数据权限,防止越权导出全表。
  5. 审计日志:记录谁在什么时候导出了多少数据。

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)

关键抽象点:把"查询"和"写出"解耦,业务方只需要关心"给我什么数据"和"列头叫什么",导出方式由框架决定。


全文完。

核心记住三句话:

  1. 查询要游标,不要 offset;
  2. 写 Excel 要流式(SXSSF / EasyExcel),永远别用 XSSF 导大数据;
  3. 同步必超时,导出一定要异步化 + 任务化。
posted on 2026-10-17 17:07  北风2017  阅读(11)  评论(0)    收藏  举报