大数据量 Excel 导出怎么优化?一套可落地的异步化方案

在后台管理系统中,"导出 Excel"几乎是一个必备功能。

数据量较小时,实现通常很简单:

复制代码
前端请求导出接口
→ 后端查询数据库
→ 生成 Excel 文件
→ 通过 HTTP 返回

但当数据量从几千条增长到几十万甚至上百万条后,原本简单的导出功能就可能变成线上风险点。

常见问题包括:

复制代码
接口执行几分钟后超时
JVM 内存持续升高
数据库连接长时间不释放
服务实例频繁发生 Full GC
多个用户同时导出导致系统卡顿
导出失败后需要从头重新执行

此时,继续增加接口超时时间通常治标不治本。

更合理的方式是将大文件导出改造成异步任务,把数据查询、文件生成和文件下载拆成不同阶段。


一、同步导出为什么容易出问题?

一个典型的同步导出接口可能这样写:

复制代码
@GetMapping("/orders/export")
public void exportOrders(
    HttpServletResponse response
) throws IOException {

    List<Order> orders =
        orderService.queryAllOrders();

    response.setContentType(
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
    );

    EasyExcel.write(
        response.getOutputStream(),
        OrderExportRow.class
    )
    .sheet("订单")
    .doWrite(orders);
}

这段代码在数据量较小时可以正常运行,但它存在几个明显问题。

1. 一次性加载全部数据

下面这行代码会把所有订单放进 JVM 内存:

复制代码
List<Order> orders =
    orderService.queryAllOrders();

如果导出 50 万条数据,每条对象占用几百字节,再加上字符串、集合和 Excel 中间对象,实际内存消耗可能远高于预期。

数据量再大一些,就可能出现:

复制代码
java.lang.OutOfMemoryError: Java heap space

2. HTTP 连接长时间占用

同步导出期间,浏览器与服务端的连接必须一直保持。

如果文件生成需要 3 分钟,用户就要等待 3 分钟。

在这段时间里,链路中的任何一层都可能提前超时:

复制代码
浏览器
Nginx
API 网关
负载均衡
应用服务器

即使后端仍然在生成文件,前端也可能已经显示"请求失败"。

3. 数据库连接占用时间过长

如果使用单次大查询,数据库需要扫描和返回大量数据。

如果使用事务包裹整个导出过程,连接可能几分钟都无法释放。

多个用户同时执行导出时,数据库连接池很容易被占满。

4. 失败后无法恢复

同步任务执行到 90% 时发生异常,通常只能从头开始。

用户也无法知道失败发生在哪个阶段:

复制代码
查询数据失败
生成文件失败
上传文件失败
网络传输失败

二、异步导出的整体流程

异步化之后,流程可以改为:

复制代码
用户提交导出请求
        ↓
服务端创建任务记录
        ↓
立即返回任务 ID
        ↓
后台消费者执行导出
        ↓
分页查询数据并写入文件
        ↓
文件上传至对象存储
        ↓
更新任务状态与下载地址
        ↓
前端轮询或接收完成通知

前端第一次请求得到的不是 Excel 文件,而是一个任务 ID:

复制代码
{
  "taskId": "export_20260611_10001",
  "status": "PENDING"
}

之后再查询任务状态:

复制代码
GET /api/export/tasks/export_20260611_10001

任务完成后返回:

复制代码
{
  "taskId": "export_20260611_10001",
  "status": "SUCCESS",
  "progress": 100,
  "downloadUrl": "https://files.example.com/export/orders-10001.xlsx"
}

这样 HTTP 请求只负责创建任务,不需要一直等待文件生成。


三、设计导出任务表

可以先建立一张任务表:

复制代码
CREATE TABLE export_task (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    task_id VARCHAR(64) NOT NULL,
    user_id BIGINT NOT NULL,
    task_type VARCHAR(64) NOT NULL,
    status VARCHAR(32) NOT NULL,
    progress INT NOT NULL DEFAULT 0,
    request_params TEXT,
    file_url VARCHAR(512),
    error_message VARCHAR(1000),
    created_at DATETIME NOT NULL,
    started_at DATETIME,
    finished_at DATETIME,
    UNIQUE KEY uk_task_id (task_id)
);

常见状态可以定义为:

复制代码
public enum ExportTaskStatus {
    PENDING,
    RUNNING,
    SUCCESS,
    FAILED,
    CANCELLED
}

状态流转如下:

复制代码
PENDING
   ↓
RUNNING
   ├── SUCCESS
   ├── FAILED
   └── CANCELLED

不要只保存"成功"和"失败"。

任务执行中、等待中和已取消,对前端展示和问题排查都很重要。


四、创建任务接口

创建任务时,不直接执行导出。

复制代码
@PostMapping("/export/orders")
public ExportTaskResponse createTask(
    @RequestBody OrderExportRequest request
) {
    String taskId =
        "export_" + UUID.randomUUID();

    ExportTask task = new ExportTask();

    task.setTaskId(taskId);
    task.setUserId(getCurrentUserId());
    task.setTaskType("ORDER_EXPORT");
    task.setStatus(
        ExportTaskStatus.PENDING.name()
    );
    task.setProgress(0);
    task.setRequestParams(
        JSON.toJSONString(request)
    );
    task.setCreatedAt(
        LocalDateTime.now()
    );

    exportTaskMapper.insert(task);

    exportTaskProducer.send(taskId);

    return new ExportTaskResponse(
        taskId,
        ExportTaskStatus.PENDING.name()
    );
}

接口只完成两件事:

复制代码
保存任务
投递消息

因此通常可以在很短时间内返回。


五、为什么要使用消息队列?

后台任务可以直接提交到线程池:

复制代码
executorService.submit(
    () -> executeExport(taskId)
);

但这种方式存在明显限制:

复制代码
服务重启后任务丢失
无法跨实例分配任务
线程池队列只存在于本机内存
缺少可靠的失败重试机制
难以控制整体消费速度

对于重要或耗时较长的导出任务,更适合使用:

复制代码
RabbitMQ
RocketMQ
Kafka
Redis Stream

消息只需要携带任务 ID:

复制代码
{
  "taskId": "export_20260611_10001"
}

具体查询参数仍然保存在数据库中。

这样可以避免消息体过大,也方便任务重试时重新读取最新状态。


六、分页查询不能直接使用深分页

一种常见写法是:

复制代码
SELECT *
FROM orders
ORDER BY id
LIMIT 1000 OFFSET 500000;

随着 OFFSET 增大,数据库需要跳过越来越多的数据。

导出后半段时,查询速度可能明显下降。

更适合大数据导出的是游标分页。

第一次查询:

复制代码
SELECT id, order_no, user_id, amount, created_at
FROM orders
WHERE id > 0
ORDER BY id
LIMIT 1000;

假设最后一条记录的 ID 为 1580,下一次查询:

复制代码
SELECT id, order_no, user_id, amount, created_at
FROM orders
WHERE id > 1580
ORDER BY id
LIMIT 1000;

Java 伪代码:

复制代码
long lastId = 0L;
int pageSize = 1000;

while (true) {
    List<OrderExportRow> rows =
        orderMapper.queryExportRows(
            lastId,
            pageSize
        );

    if (rows.isEmpty()) {
        break;
    }

    writeRows(rows);

    lastId = rows
        .get(rows.size() - 1)
        .getId();
}

游标分页的优势是:

复制代码
查询性能相对稳定
不会随着页码增加明显变慢
适合按主键连续扫描

七、不要把所有数据放进一个 List

即使使用分页查询,如果又把每一页数据加入同一个集合,内存问题仍然存在。

错误示例:

复制代码
List<OrderExportRow> allRows =
    new ArrayList<>();

while (hasMore) {
    List<OrderExportRow> rows =
        queryNextPage();

    allRows.addAll(rows);
}

writeExcel(allRows);

正确方式是:

复制代码
查一批
写一批
释放一批

例如使用 EasyExcel:

复制代码
ExcelWriter excelWriter =
    EasyExcel.write(
        file,
        OrderExportRow.class
    ).build();

WriteSheet sheet =
    EasyExcel.writerSheet("订单").build();

try {
    long lastId = 0L;

    while (true) {
        List<OrderExportRow> rows =
            orderMapper.queryExportRows(
                lastId,
                1000
            );

        if (rows.isEmpty()) {
            break;
        }

        excelWriter.write(rows, sheet);

        lastId = rows
            .get(rows.size() - 1)
            .getId();

        rows.clear();
    }
} finally {
    excelWriter.finish();
}

这种方式不会一次性把全部数据保留在内存中。


八、导出进度怎么计算?

前端通常希望看到:

复制代码
正在生成:35%

计算进度最简单的方式是先查询符合条件的数据总量:

复制代码
SELECT COUNT(*)
FROM orders
WHERE created_at >= ?
  AND created_at < ?;

每处理一批后计算:

复制代码
int progress = (int) Math.min(
    processedCount * 100L / totalCount,
    99
);

生成并上传文件完成后,再将进度更新为 100。

为什么执行过程中最多显示 99?

因为写完数据并不代表任务已经完全完成,后面可能还有:

复制代码
关闭 Excel Writer
上传对象存储
生成下载地址
更新任务状态

如果过早显示 100%,用户点击下载时可能发现文件还不存在。

需要注意,频繁更新数据库也会带来额外压力。

没有必要每处理一条数据就更新进度,可以选择:

复制代码
每处理 5000 条更新一次
进度每增加 5% 更新一次
每隔 3 秒更新一次

九、生成的文件应该存在哪里?

不建议将文件长期保存在应用服务器本地磁盘。

原因包括:

复制代码
服务可能运行多个实例
请求不一定再次落到同一台机器
容器重启后本地文件可能丢失
本地磁盘空间有限
扩缩容时难以迁移

更适合使用对象存储:

复制代码
Amazon S3
阿里云 OSS
腾讯云 COS
MinIO

上传完成后,将文件地址写入任务表。

下载地址最好使用有时效的签名 URL,例如 30 分钟或 24 小时后失效,避免导出文件长期公开访问。


十、文件下载权限不能只依赖 URL

导出文件可能包含:

复制代码
用户信息
订单数据
财务数据
内部运营数据
会议记录

因此,不能认为"知道下载地址的人都可以下载"。

一种做法是:

复制代码
用户请求下载接口
        ↓
服务端校验任务归属
        ↓
生成临时签名 URL
        ↓
返回给当前用户

例如:

复制代码
@GetMapping("/export/tasks/{taskId}/download")
public String getDownloadUrl(
    @PathVariable String taskId
) {
    ExportTask task =
        exportTaskMapper.findByTaskId(taskId);

    if (!task.getUserId()
        .equals(getCurrentUserId())) {
        throw new ForbiddenException();
    }

    if (!ExportTaskStatus.SUCCESS.name()
        .equals(task.getStatus())) {
        throw new BizException(
            "导出任务尚未完成"
        );
    }

    return objectStorageService
        .generateSignedUrl(
            task.getFileUrl(),
            Duration.ofMinutes(30)
        );
}

十一、如何避免用户重复创建任务?

用户连续点击"导出",可能创建多个相同任务。

如果每个任务都扫描几十万条数据,会造成不必要的资源消耗。

可以基于以下内容生成任务指纹:

复制代码
用户 ID
导出类型
查询条件
导出字段

例如:

复制代码
String fingerprint = DigestUtils.md5DigestAsHex(
    (
        userId
        + JSON.toJSONString(request)
    ).getBytes(StandardCharsets.UTF_8)
);

创建前先检查是否存在相同任务:

复制代码
同一指纹
状态为 PENDING 或 RUNNING
创建时间在最近 10 分钟内

如果存在,直接返回原任务 ID。

这样既能避免重复执行,也能让用户继续查看原任务进度。


十二、失败重试要注意幂等

异步任务失败后通常需要重试。

但重试前要考虑:

复制代码
临时文件是否已经生成
对象存储是否已经上传
任务状态是否已经更新
消息是否被重复消费

可以为每次任务使用固定文件路径:

复制代码
exports/{taskId}/result.xlsx

这样重复上传同一任务时,不会产生大量不同文件。

任务执行前也要检查状态:

复制代码
if (
    ExportTaskStatus.SUCCESS.name()
        .equals(task.getStatus())
) {
    return;
}

对于同一个任务,还要避免被多个消费者同时执行。

可以使用数据库条件更新抢占任务:

复制代码
UPDATE export_task
SET status = 'RUNNING',
    started_at = NOW()
WHERE task_id = ?
  AND status = 'PENDING';

只有影响行数为 1 的消费者才获得执行权。


十三、任务失败后应该记录什么?

不要只记录:

复制代码
导出失败

更有价值的任务信息包括:

复制代码
失败阶段
已经处理的数据量
最后一个游标 ID
异常类型
异常摘要
重试次数
执行实例
开始与结束时间

例如:

复制代码
{
  "taskId": "export_10001",
  "stage": "UPLOAD_FILE",
  "processedRows": 320000,
  "lastId": 583020,
  "retryCount": 2,
  "errorCode": "OBJECT_STORAGE_TIMEOUT"
}

是否支持断点续传,要根据业务复杂度决定。

普通 Excel 文件一旦写入失败,通常从头重新生成更简单。

如果导出量非常大,可以考虑先生成多个 CSV 分片,再压缩成 ZIP,这种方式更容易进行分片重试。


十四、Excel 并不适合无限大的数据量

即使技术上可以生成几十万行 Excel,也不代表这是最合理的交付形式。

当数据量达到数百万行时,需要考虑:

复制代码
用户是否真的能打开文件
Excel 是否便于分析
生成时间是否过长
单文件是否过大

可选方案包括:

1. 按数量拆分多个 Sheet

例如每个 Sheet 保存 20 万行。

2. 拆分为多个文件

复制代码
orders-part-1.xlsx
orders-part-2.xlsx
orders-part-3.xlsx

最后打包为 ZIP。

3. 改用 CSV

CSV 结构简单,生成速度更快,占用内存和磁盘通常也更少。

4. 提供离线分析能力

如果用户的真实需求是统计分析,可能更适合:

复制代码
数据看板
BI 系统
定时报表
数据仓库查询

而不是反复导出超大文件。


十五、AI 与语音产品也需要异步任务

异步任务并不只用于 Excel 导出。

以下功能都适合放入任务中心:

复制代码
会议总结生成
长音频转写
字幕文件导出
多语言记录整理
文档批量翻译
视频处理
报表生成

例如**同言翻译(Transync AI)**这类实时翻译产品,实时字幕需要尽快输出,但会后会议总结、记录整理和文件导出不一定要阻塞实时会话。

可以将业务拆成两条链路:

复制代码
实时链路:
音频 → 识别 → 翻译 → 字幕展示

异步链路:
会议记录 → 内容整理 → 生成总结 → 文件导出

这样可以避免高耗时的会后处理影响实时翻译体验。


十六、前端如何展示导出任务?

前端可以采用轮询方式:

复制代码
async function waitForExport(taskId) {
  while (true) {
    const task = await fetchExportTask(taskId);

    updateProgress(task.progress);

    if (task.status === 'SUCCESS') {
      showDownloadButton(task.downloadUrl);
      return;
    }

    if (task.status === 'FAILED') {
      showError(task.errorMessage);
      return;
    }

    await sleep(3000);
  }
}

轮询间隔一般不需要太短。

对于执行几分钟的任务,每 2~5 秒查询一次已经足够。

也可以使用:

复制代码
WebSocket
SSE
站内通知
邮件通知

在任务完成后主动通知用户。

不过无论是否使用推送,都建议保留任务查询接口,因为推送消息可能丢失,用户刷新页面后仍然需要恢复任务状态。


十七、异步导出需要监控哪些指标?

建议监控:

复制代码
等待中的任务数量
正在执行的任务数量
平均执行时间
P95 和 P99 执行时间
任务成功率
任务失败率
重试次数
单任务处理数据量
对象存储上传耗时
数据库分页查询耗时
任务队列积压数量

如果等待任务持续增加,通常说明:

复制代码
消费者数量不足
单任务执行时间过长
数据库查询变慢
对象存储响应异常
短时间创建了过多任务

这时不应盲目增加消费者。

如果瓶颈在数据库,增加消费者只会进一步提高数据库压力。


十八、异步导出检查清单

上线前可以逐项确认:

复制代码
1. 大数据量导出是否已经异步化?
2. 是否建立独立的任务状态表?
3. 是否使用消息队列投递任务?
4. 是否避免一次性加载全部数据?
5. 是否使用游标分页代替深分页?
6. 是否采用分批查询、分批写入?
7. 是否限制同时执行的任务数量?
8. 是否避免用户重复创建相同任务?
9. 是否记录任务进度和失败阶段?
10. 任务消费是否具备幂等性?
11. 文件是否存储在对象存储中?
12. 下载地址是否有时效和权限控制?
13. 过期文件是否会自动清理?
14. 前端刷新后是否能恢复任务状态?
15. 是否监控任务积压和平均执行时间?

总结

大数据量导出的核心问题,不是选择哪个 Excel 库,而是避免把高耗时、高内存的任务放在同步 HTTP 请求中执行。

一套相对稳定的导出方案通常包括:

复制代码
任务表管理状态
消息队列异步执行
游标分页查询数据
分批写入文件
对象存储保存结果
临时 URL 控制下载权限
任务指纹避免重复执行
监控任务积压和执行耗时

同步导出适合小数据量和短耗时场景。

当导出已经开始影响接口稳定性时,不应该继续延长超时时间,而应该把它改造成一个具备状态、进度、重试和结果管理能力的异步任务。

相关推荐
伊灵eLing2 小时前
GoLang 语言高级(1)
开发语言·后端·golang
掘金者阿豪2 小时前
PDO连金仓数据库,我把踩过的坑整理了一下(上篇)
后端
zzz_23682 小时前
【Java基础】泛型的门道:伪泛型的真相
java·开发语言
用户34232323763172 小时前
大规模采集架构——从单台网关到千点集群
后端
我登哥MVP2 小时前
SpringCloud 核心组件解析:服务链路追踪
java·spring boot·后端·spring·spring cloud·java-ee·maven
PixelBai2 小时前
JSON差异比较高级用法技巧
java·服务器·json
晓杰在写后端2 小时前
从0到1实现Balatro游戏后端(7):Boss Blind与特殊规则实现
后端·游戏开发
iiiiyu2 小时前
IO流相关编程题
java·大数据·开发语言·数据结构·数据库·mysql
ANnianStriver2 小时前
PetLumina 06 — 图片上传全链路
java·ai·ai编程·文件上传·cos·腾讯云对象存储
这个DBA有点耶3 小时前
核心系统的高可用与容灾架构:从主从到两地三中心全面解析
java·开发语言·数据库·sql·mysql·架构·运维开发