我们面临的问题和需求:
- 数据量百万级,直接查询数据库可能导致数据库压力大,甚至长时间锁表。
- 一次性加载到内存中会导致内存溢出(OOM)。
- Excel文件太大,用传统方式(如POI的XSSFWorkbook)直接生成会导致内存OOM,并且生成的文件可能过大,导致用户无法打开。
- 导出耗时较长,需要优化导出速度。
- 导出成功后如何通知用户。
解决方案:
- 数据查询:采用分页查询,每次从数据库中读取一部分数据(比如每页5000条),减少对数据库的压力。
- 数据导出:使用Excel的流式写入(如SXSSFWorkbook),它支持逐行写入数据,不会一次性将全部数据加载到内存中。
- 文件存储:生成的文件可以考虑存储到服务器磁盘,然后提供下载链接。如果文件特别大,可以考虑压缩成zip提供下载。
- 异步处理:导出任务提交后,使用异步处理(如使用线程池或消息队列),避免长时间占用HTTP请求线程,同时需要记录任务状态。
- 通知用户:导出完成后,可以通过前端轮询、WebSocket、邮件等方式通知用户。
详细步骤:
步骤1:设计导出任务表
我们可以设计一个导出任务表,用于记录导出任务的状态和进度。
字段包括:任务ID、任务名称、任务状态(等待中、处理中、完成、失败)、文件路径、创建时间、完成时间、创建用户等。
步骤2:用户发起导出请求
前端提交导出请求,后端生成一个导出任务,并返回任务ID。然后前端可以通过这个任务ID轮询任务状态。
步骤3:异步处理导出任务
使用线程池或消息队列(如RabbitMQ、RocketMQ)来处理导出任务。这里我们以线程池为例。
步骤4:分页查询数据并写入Excel
使用SXSSFWorkbook(POI的流式API)来生成Excel,设置一个适当的窗口大小(比如1000行),这样在写入时只会保持一定数量的行在内存中,之前写入的行会被刷新到磁盘临时文件。
步骤5:存储生成的Excel文件
将生成的Excel文件存储到服务器的指定目录,或者上传到云存储(如OSS、S3等),并记录文件路径到导出任务表。
步骤6:通知用户
更新导出任务状态为完成,并记录文件路径。前端轮询到任务完成时,可以展示下载链接。
步骤7:提供文件下载接口
根据文件路径,提供文件下载。
步骤8:清理临时文件(可选)
可以设置一个定时任务,定期清理过期的导出文件。
代码结构示例:
-
导出任务实体类(ExportTask)
-
导出任务DAO层(ExportTaskMapper)
-
导出服务(ExportService)
- 创建导出任务
- 异步执行导出任务
-
导出任务执行器(ExportTaskRunner)
- 分页查询数据
- 使用SXSSFWorkbook写入Excel
- 更新任务状态
详细代码示例:
- 导出任务实体类
java
public class ExportTask {
private Long id;
private String taskName;
private Integer status; // 0:等待中 1:处理中 2:完成 3:失败
private String filePath;
private Long createUserId;
private Date createTime;
private Date finishTime;
// ... getters and setters
}
- Mapper接口
java
public interface ExportTaskMapper {
int insert(ExportTask task);
int updateStatus(@Param("id") Long id, @Param("status") Integer status);
ExportTask selectById(Long id);
// ... 其他方法
}
- 导出服务
java
@Service
public class ExportService {
@Autowired
private ExportTaskMapper exportTaskMapper;
@Autowired
private DataService dataService; // 用于查询数据的服务
// 线程池,用于异步执行导出任务
private ExecutorService executorService = Executors.newFixedThreadPool(5);
public Long createExportTask(String taskName, Long userId) {
ExportTask task = new ExportTask();
task.setTaskName(taskName);
task.setStatus(0); // 等待中
task.setCreateUserId(userId);
task.setCreateTime(new Date());
exportTaskMapper.insert(task);
return task.getId();
}
public void asyncExport(Long taskId) {
executorService.submit(new ExportTaskRunner(taskId, exportTaskMapper, dataService));
}
}
- 导出任务执行器
java
public class ExportTaskRunner implements Runnable {
private Long taskId;
private ExportTaskMapper exportTaskMapper;
private DataService dataService;
public ExportTaskRunner(Long taskId, ExportTaskMapper exportTaskMapper, DataService dataService) {
this.taskId = taskId;
this.exportTaskMapper = exportTaskMapper;
this.dataService = dataService;
}
@Override
public void run() {
// 更新任务状态为处理中
exportTaskMapper.updateStatus(taskId, 1);
// 查询导出任务
ExportTask task = exportTaskMapper.selectById(taskId);
if (task == null) {
return;
}
// 生成文件路径
String fileName = "export_" + taskId + "_" + System.currentTimeMillis() + ".xlsx";
String filePath = "/export/files/" + fileName; // 存储路径
// 使用SXSSFWorkbook导出
SXSSFWorkbook workbook = null;
FileOutputStream out = null;
try {
workbook = new SXSSFWorkbook(1000); // 设置窗口大小为1000
Sheet sheet = workbook.createSheet("数据");
// 创建表头
Row headerRow = sheet.createRow(0);
// ... 设置表头
int pageNum = 1;
int pageSize = 5000;
int rowIndex = 1; // 从第1行开始(0是表头)
while (true) {
// 分页查询数据
List<Data> dataList = dataService.getDataByPage(pageNum, pageSize);
if (dataList.isEmpty()) {
break;
}
for (Data data : dataList) {
Row row = sheet.createRow(rowIndex++);
// ... 设置行数据
}
// 每页写完,可以清空一下数据列表,帮助GC
dataList.clear();
pageNum++;
}
// 写入文件
out = new FileOutputStream(filePath);
workbook.write(out);
// 更新任务状态为完成,并记录文件路径
task.setStatus(2);
task.setFilePath(filePath);
task.setFinishTime(new Date());
exportTaskMapper.updateTask(task);
} catch (Exception e) {
// 更新任务状态为失败
exportTaskMapper.updateStatus(taskId, 3);
e.printStackTrace();
} finally {
// 关闭流和workbook,删除临时文件(SXSSFWorkbook会产生临时文件)
if (workbook != null) {
workbook.dispose();
}
if (out != null) {
try {
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
- 控制器
java
@RestController
public class ExportController {
@Autowired
private ExportService exportService;
@PostMapping("/export")
public ResponseEntity<?> createExportTask(@RequestParam String taskName, @RequestParam Long userId) {
Long taskId = exportService.createExportTask(taskName, userId);
// 异步执行导出任务
exportService.asyncExport(taskId);
return ResponseEntity.ok(taskId);
}
@GetMapping("/export/status/{taskId}")
public ResponseEntity<?> getExportStatus(@PathVariable Long taskId) {
ExportTask task = exportService.getExportTask(taskId);
return ResponseEntity.ok(task);
}
@GetMapping("/export/download/{taskId}")
public void downloadFile(@PathVariable Long taskId, HttpServletResponse response) {
// 根据taskId获取文件路径,然后读取文件并写入response
// ... 省略具体代码
}
}
优化和注意事项:
-
内存优化:
- 使用SXSSFWorkbook,设置合适的窗口大小,避免内存溢出。
- 分页查询数据,每页数据量不宜过大,避免数据库压力和大结果集的内存占用。
- 及时清空每页的数据列表,帮助GC。
-
性能优化:
- 数据库查询优化:确保分页查询的字段有索引,避免全表扫描。
- 如果数据量特别大,可以考虑使用游标(Cursor)方式查询,但要注意游标可能占用数据库连接。
- 使用多线程导出:可以将数据分成多个区间,每个线程处理一个区间,然后合并成多个sheet或多个文件。但要注意线程安全和资源竞争。
-
文件过大问题:
- 如果单个Excel文件过大,可以考虑分成多个Excel文件,然后打包成zip。
- 也可以考虑导出为CSV格式,CSV格式比Excel更轻量,但功能简单。
-
通知用户:
- 前端轮询:前端定期查询任务状态,当状态为完成时,显示下载链接。
- WebSocket:当导出完成时,通过WebSocket推送消息给前端。
-
异常处理:
- 在导出过程中,可能会发生异常,需要捕获并更新任务状态为失败,以便前端知道任务失败。
-
资源清理:
- SXSSFWorkbook会产生临时文件,需要调用dispose()方法清理。
- 服务器磁盘空间管理,定期清理旧的导出文件。
-
安全性:
- 确保用户只能下载自己导出的文件,防止越权访问。
面试难点问题总结:
-
如何解决内存溢出问题?
- 采用流式写入(SXSSFWorkbook)和分页查询,避免一次性加载所有数据到内存。
-
如何提高导出速度?
- 数据库层面优化查询,使用索引。
- 考虑使用多线程分段导出,但要注意数据库连接数和线程池的管理。
-
如何保证导出过程的稳定性?
- 异步处理,避免长时间占用HTTP线程。
- 异常捕获和任务状态更新,确保用户知道导出结果。
-
如何处理超大文件?
- 分文件存储,打包下载。
- 考虑使用CSV格式。
-
如何通知用户导出完成?
- 前端轮询或WebSocket推送。
-
如何防止导出任务过多导致服务器资源耗尽?
- 使用线程池控制并发数,队列积压任务,避免资源耗尽。
-
如何管理导出文件?
- 设置文件保留时间,定期清理。
通过以上方案,我们可以实现一个稳定、高效、支持百万级数据导出的系统。
1. 整体架构设计
1.1 技术栈选择
java
// 核心依赖
- Spring Boot 2.7+
- Apache POI 5.2+ (使用SXSSFWorkbook)
- MyBatis Plus 3.5+
- Redis (状态管理)
- 消息队列 (RabbitMQ/Kafka)
- 对象存储 (OSS/S3) 或 文件服务器
1.2 架构分层
text
用户层 → 控制层 → 异步服务层 → 数据层 → 文件存储层 → 通知层
2. 详细实现步骤
2.1 数据库查询优化
java
@Service
public class DataExportService {
/**
* 分页游标查询,避免深度分页性能问题
*/
public void exportLargeData(Long exportTaskId, ExportCondition condition) {
Long cursorId = 0L; // 基于ID的游标
int batchSize = 2000; // 每批处理数量
try (SXSSFWorkbook workbook = new SXSSFWorkbook(1000)) { // 保持1000行在内存中
Sheet sheet = workbook.createSheet("数据");
createHeader(sheet); // 创建表头
int rowNum = 1;
while (true) {
// 使用索引覆盖查询,避免回表
List<DataEntity> batchData = dataMapper.selectByCursor(
cursorId,
batchSize,
condition
);
if (batchData.isEmpty()) {
break;
}
for (DataEntity data : batchData) {
Row row = sheet.createRow(rowNum++);
fillRowData(row, data);
// 更新游标
cursorId = data.getId();
}
// 每处理一批数据,更新进度
updateProgress(exportTaskId, rowNum);
// 手动触发GC,避免内存积累
if (rowNum % 10000 == 0) {
System.gc();
}
}
// 生成文件
saveWorkbook(workbook, exportTaskId);
} catch (Exception e) {
updateExportStatus(exportTaskId, ExportStatus.FAILED);
throw new RuntimeException("导出失败", e);
}
}
}
2.2 MyBatis查询优化
xml
<!-- 使用覆盖索引 + 游标分页 -->
<select id="selectByCursor" resultType="DataEntity">
SELECT id, field1, field2, field3 -- 只查询需要的字段
FROM large_data_table
WHERE id > #{cursorId}
AND create_time BETWEEN #{startTime} AND #{endTime}
AND status = 1
ORDER BY id ASC -- 保证游标顺序
LIMIT #{batchSize}
</select>
java
// 数据库索引建议
CREATE INDEX idx_large_data_export ON large_data_table
(status, create_time, id);
2.3 异步处理与状态管理
java
@Component
public class ExportTaskManager {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 创建导出任务
*/
public ExportTask createExportTask(ExportRequest request) {
ExportTask task = new ExportTask();
task.setTaskId(UUID.randomUUID().toString());
task.setStatus(ExportStatus.PENDING);
task.setCreateTime(new Date());
task.setCreateUser(request.getUserId());
// 存储任务信息到Redis,设置24小时过期
redisTemplate.opsForValue().set(
getTaskRedisKey(task.getTaskId()),
task,
Duration.ofHours(24)
);
// 发送到消息队列异步处理
rabbitTemplate.convertAndSend(
"export.task.exchange",
"export.task.routingKey",
new ExportTaskMessage(task.getTaskId(), request)
);
return task;
}
/**
* 更新导出进度
*/
public void updateProgress(String taskId, int processedRows) {
String progressKey = getProgressRedisKey(taskId);
redisTemplate.opsForValue().set(progressKey, processedRows);
redisTemplate.expire(progressKey, Duration.ofHours(24));
}
}
2.4 内存优化与流式处理
java
@Component
public class ExcelStreamExporter {
/**
* 使用SXSSFWorkbook进行流式导出
*/
public void streamExport(ExportTask task, OutputStream outputStream) {
// 设置SXSSF,内存中只保留1000行,其余写入临时文件
SXSSFWorkbook workbook = new SXSSFWorkbook(1000);
try {
// 启用压缩,减少文件大小
workbook.setCompressTempFiles(true);
Sheet sheet = workbook.createSheet("数据");
// 创建样式(提前创建,避免重复创建消耗内存)
CellStyle headerStyle = createHeaderStyle(workbook);
CellStyle dataStyle = createDataStyle(workbook);
// 写入表头
createSheetHeader(sheet, headerStyle);
// 流式处理数据
processDataStreamingly(task, sheet, dataStyle);
// 写入输出流
workbook.write(outputStream);
} finally {
// 清理临时文件
workbook.dispose();
workbook.close();
}
}
private void processDataStreamingly(ExportTask task, Sheet sheet, CellStyle style) {
int rowNum = 1;
Long cursorId = 0L;
while (true) {
List<DataDTO> batchData = fetchDataBatch(cursorId, 2000);
if (batchData.isEmpty()) break;
for (DataDTO data : batchData) {
Row row = sheet.createRow(rowNum++);
populateRow(row, data, style);
cursorId = data.getId();
// 定期刷新进度
if (rowNum % 1000 == 0) {
updateProgress(task.getTaskId(), rowNum);
}
}
// 手动清理,避免内存泄漏
batchData.clear();
}
}
}
2.5 文件分片与压缩
java
@Service
public class FileSplitService {
private static final int MAX_ROWS_PER_FILE = 500000; // 每个文件最大50万行
/**
* 大文件分片导出
*/
public List<String> splitExport(ExportTask task) {
List<String> filePaths = new ArrayList<>();
int totalProcessed = 0;
int fileIndex = 1;
while (totalProcessed < task.getTotalCount()) {
String filePath = exportSingleFile(task, fileIndex, totalProcessed);
filePaths.add(filePath);
totalProcessed += MAX_ROWS_PER_FILE;
fileIndex++;
}
// 如果多个文件,打包成zip
if (filePaths.size() > 1) {
return compressToZip(filePaths, task.getTaskId());
}
return filePaths;
}
/**
* 压缩文件
*/
private List<String> compressToZip(List<String> filePaths, String taskId) {
String zipPath = "/export/files/" + taskId + ".zip";
try (ZipOutputStream zos = new ZipOutputStream(
new FileOutputStream(zipPath))) {
for (String filePath : filePaths) {
File file = new File(filePath);
zos.putNextEntry(new ZipEntry(file.getName()));
Files.copy(file.toPath(), zos);
zos.closeEntry();
// 删除临时分片文件
file.delete();
}
} catch (IOException e) {
throw new RuntimeException("压缩文件失败", e);
}
return Collections.singletonList(zipPath);
}
}
2.6 用户通知机制
java
@Component
public class ExportNotifyService {
@Autowired
private WebSocketHandler webSocketHandler;
@Autowired
private EmailService emailService;
@Autowired
private MessageService messageService;
/**
* 通知用户导出完成
*/
public void notifyUser(ExportTask task) {
String downloadUrl = generateDownloadUrl(task.getFileKey());
// WebSocket实时通知(如果用户在线)
webSocketHandler.sendMessage(
task.getCreateUser(),
new ExportCompleteMessage(task.getTaskId(), downloadUrl)
);
// 站内信
messageService.sendSystemMessage(
task.getCreateUser(),
"数据导出完成",
"您的数据导出任务已完成,点击下载",
downloadUrl
);
// 邮件通知
if (StringUtils.isNotBlank(task.getEmail())) {
emailService.sendExportCompleteEmail(
task.getEmail(),
task.getTaskId(),
downloadUrl
);
}
}
/**
* 生成带签名的下载链接(有效期24小时)
*/
private String generateDownloadUrl(String fileKey) {
long expireTime = System.currentTimeMillis() + 24 * 60 * 60 * 1000;
String signature = generateSignature(fileKey, expireTime);
return String.format(
"%s/download/export?fileKey=%s&expire=%d&sign=%s",
domain, fileKey, expireTime, signature
);
}
}
3. 核心问题解决方案
3.1 内存OOM问题解决
java
// 1. 使用SXSSFWorkbook流式API
SXSSFWorkbook workbook = new SXSSFWorkbook(1000); // 内存中只保留1000行
// 2. 分页查询,避免一次性加载所有数据
// 3. 及时清理批次数据
batchData.clear();
// 4. 手动GC触发
if (rowNum % 10000 == 0) {
System.gc();
}
// 5. 关闭资源
finally {
workbook.dispose();
workbook.close();
}
3.2 导出耗时优化
java
// 1. 异步处理
@Async("exportTaskExecutor")
public CompletableFuture<String> asyncExport(ExportRequest request) {
// 导出逻辑
}
// 2. 数据库查询优化
- 使用覆盖索引
- 避免SELECT *
- 游标分页代替传统分页
// 3. 并行处理(如果数据可分割)
List<CompletableFuture<Void>> futures = dataSegments.stream()
.map(segment -> CompletableFuture.runAsync(() ->
exportSegment(segment), executor))
.collect(Collectors.toList());
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
3.3 文件过大问题
java
// 1. 文件分片
private static final int MAX_ROWS_PER_FILE = 500000;
// 2. 压缩下载
if (fileSize > 100 * 1024 * 1024) { // 超过100MB
return compressToZip(filePaths);
}
// 3. 格式选择 - 对于超大数据考虑CSV
public void exportAsCsv(ExportTask task) {
// CSV格式更轻量,支持流式写入
}
3.4 失败重试与断点续传
java
@Service
public class ExportResumeService {
/**
* 断点续传
*/
public void resumeExport(String taskId) {
ExportTask task = findTask(taskId);
Integer lastProcessed = getLastProgress(taskId);
if (lastProcessed > 0) {
// 从断点处继续处理
continueFromBreakpoint(task, lastProcessed);
} else {
// 重新开始
startNewExport(task);
}
}
/**
* 失败重试机制
*/
@Retryable(value = Exception.class, maxAttempts = 3)
public void exportWithRetry(ExportTask task) {
// 导出逻辑
}
}
4. 完整配置示例
4.1 线程池配置
java
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("exportTaskExecutor")
public Executor exportTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("export-task-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
executor.initialize();
return executor;
}
}
4.2 数据库连接池配置
yaml
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
connection-test-query: SELECT 1
5. 面试难点问题总结
问题1:如何解决百万数据导出时的内存溢出?
回答要点:
- 使用POI的SXSSFWorkbook进行流式导出,设置合理的rowAccessWindowSize
- 分页游标查询,避免一次性加载所有数据到内存
- 及时清理批次数据,手动触发GC
- 采用文件分片策略,单个文件不超过50万行数据
问题2:导出耗时太长如何优化?
回答要点:
- 异步处理,避免阻塞用户请求
- 数据库层面优化:覆盖索引、避免回表、游标分页
- 并行处理可分割的数据段
- 适当调整批次大小,平衡内存和IO效率
问题3:Excel文件过大导致无法打开怎么办?
回答要点:
- 文件分片:按数据量分割成多个文件
- 压缩下载:多个文件打包成zip
- 格式降级:超大数据使用CSV格式
- 提前告知用户数据量,建议使用专业工具打开
问题4:如何保证导出任务的可靠性?
回答要点:
- 任务状态持久化,支持进度查询
- 实现断点续传能力
- 失败重试机制,设置最大重试次数
- 完善的异常处理和日志记录
问题5:导出成功后如何有效通知用户?
回答要点:
- 多通道通知:WebSocket实时通知 + 站内信 + 邮件
- 生成有时效性的下载链接,保障安全性
- 提供清晰的进度反馈和预计完成时间
- 支持下载历史记录和重复下载
问题6:系统如何应对高并发导出请求?
回答要点:
- 线程池隔离,避免影响主要业务
- 队列缓冲,控制并发处理数量
- 资源限制,防止单个用户过度使用
- 服务降级策略,在系统压力大时提供友好提示
这个方案通过流式处理、异步架构、分片策略和多级优化,能够稳定高效地处理百万级数据导出,同时提供了良好的用户体验和系统可靠性。