一、核心痛点与前置认知
1. 千万级数据导出的核心矛盾
- 内存瓶颈:一千万条数据(按每条 1KB 计算,约 10GB)远超 JVM 堆内存上限(常规配置 4-8GB),全量加载必触发 OOM。
- 性能瓶颈:同步读取 + 写入会占用大量 CPU、IO 资源,单线程处理耗时久(可能超分钟级),导致接口超时、服务可用性下降。
- 数据库压力:全量查询或分页查询不合理,会引发数据库全表扫描、锁表,影响线上业务正常访问。
- 文件传输瓶颈:千万级数据导出文件体积大(10GB+),浏览器直接下载易中断,需考虑分片、断点续传等机制。
2. 前置设计原则
核心原则:避免全量加载内存、异步化处理、分阶段拆分任务、最小化数据库影响。所有方案均围绕这四大原则展开,根据架构规模(单体/分布式)灵活选型。
- 数据层面:优先选择 CSV 格式(比 Excel 更轻量、解析快,无行数限制),避免使用 POI 直接生成大 Excel(易内存溢出)。
- 流程层面:同步接口仅触发导出任务,异步执行导出逻辑,通过状态通知(短信/站内信)告知用户下载结果。
- 资源层面:单独分配导出专用线程池、数据库只读副本,隔离线上业务与导出任务的资源竞争。
二、分层解题思路:从单体到分布式
方案一:单体架构适配(千万级入门方案)
适用场景:单体服务、数据量≤1000 万条、服务器资源充足(CPU/内存/磁盘),核心思路是'流式读取 + 分批写入 + 异步处理',全程控制内存占用在合理范围。
1. 核心步骤拆解
- 异步任务触发:
- 前端发起导出请求,后端接口不直接执行导出,而是生成唯一任务 ID,将任务信息(查询条件、用户 ID、导出格式)存入数据库或 Redis,返回'任务已受理,等待通知'。
- 通过 Spring @Async、ThreadPoolExecutor 创建专用导出线程池(核心线程数=CPU 核心数,最大线程数=CPU 核心数×2,避免线程过多切换),异步执行导出逻辑。
- 拒绝使用
SELECT * FROM table全量查询,也避免传统LIMIT offset, size分页(offset 过大时,数据库需扫描前 offset 条数据,性能指数级下降)。 - 采用游标分页(Cursor Pagination):基于主键 ID 或时间戳分页,每次查询以上一批次的最后一条数据为条件,避免全表扫描,示例:
- 进阶优化:使用 JDBC 流式查询(
Statement.setFetchSize(Integer.MIN_VALUE)),让数据库逐行返回数据,而非一次性加载到内存,配合 MyBatis 的ResultHandler逐行处理,内存占用可控制在 MB 级。 - 使用
BufferedWriter(缓冲区默认 8KB,可调整为 64KB 提升效率),每次写入一个批次(1000-10000 条)数据,写完立即刷新缓冲区,避免数据积压在内存。 - 优先写入本地磁盘(比写入分布式存储更快),后续再同步到 OSS 等对象存储,示例:
- 任务状态管理与结果通知:
- 在数据库中维护导出任务表(字段:任务 ID、用户 ID、查询条件、文件路径、状态、进度、创建时间、完成时间),异步任务执行过程中实时更新进度(如'已处理 100 万条/共 1000 万条')。
- 导出完成后,将文件同步到 OSS,生成临时下载链接(有效期 12-24 小时),通过短信、站内信或 WebSocket 通知用户下载;导出失败则记录失败原因,支持用户重试。
分批写入 CSV 文件:
// 分批写入 CSV 文件
public void writeToCsv(File exportFile, Long lastId, batchSize) {
( ( (exportFile), )) {
writer.write();
writer.newLine();
streamQueryData(lastId, batchSize, dataList -> {
(DataDO data : dataList) {
String.join(, String.valueOf(data.getId()), data.getName(), ().format(data.getCreateTime()), ...);
writer.write(csvLine);
writer.newLine();
}
writer.flush();
});
} (IOException e) {
log.error(, e);
updateExportTaskStatus(taskId, ExportStatus.FAIL);
}
}

