在后台管理系统中,"导出 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 控制下载权限
任务指纹避免重复执行
监控任务积压和执行耗时
同步导出适合小数据量和短耗时场景。
当导出已经开始影响接口稳定性时,不应该继续延长超时时间,而应该把它改造成一个具备状态、进度、重试和结果管理能力的异步任务。

