百万级数据Excel导出方案详解

132 阅读13分钟

我们面临的问题和需求:

  1. 数据量百万级,直接查询数据库可能导致数据库压力大,甚至长时间锁表。
  2. 一次性加载到内存中会导致内存溢出(OOM)。
  3. Excel文件太大,用传统方式(如POI的XSSFWorkbook)直接生成会导致内存OOM,并且生成的文件可能过大,导致用户无法打开。
  4. 导出耗时较长,需要优化导出速度。
  5. 导出成功后如何通知用户。

解决方案:

  1. 数据查询:采用分页查询,每次从数据库中读取一部分数据(比如每页5000条),减少对数据库的压力。
  2. 数据导出:使用Excel的流式写入(如SXSSFWorkbook),它支持逐行写入数据,不会一次性将全部数据加载到内存中。
  3. 文件存储:生成的文件可以考虑存储到服务器磁盘,然后提供下载链接。如果文件特别大,可以考虑压缩成zip提供下载。
  4. 异步处理:导出任务提交后,使用异步处理(如使用线程池或消息队列),避免长时间占用HTTP请求线程,同时需要记录任务状态。
  5. 通知用户:导出完成后,可以通过前端轮询、WebSocket、邮件等方式通知用户。

详细步骤:

步骤1:设计导出任务表
我们可以设计一个导出任务表,用于记录导出任务的状态和进度。
字段包括:任务ID、任务名称、任务状态(等待中、处理中、完成、失败)、文件路径、创建时间、完成时间、创建用户等。

步骤2:用户发起导出请求
前端提交导出请求,后端生成一个导出任务,并返回任务ID。然后前端可以通过这个任务ID轮询任务状态。

步骤3:异步处理导出任务
使用线程池或消息队列(如RabbitMQ、RocketMQ)来处理导出任务。这里我们以线程池为例。

步骤4:分页查询数据并写入Excel
使用SXSSFWorkbook(POI的流式API)来生成Excel,设置一个适当的窗口大小(比如1000行),这样在写入时只会保持一定数量的行在内存中,之前写入的行会被刷新到磁盘临时文件。

步骤5:存储生成的Excel文件
将生成的Excel文件存储到服务器的指定目录,或者上传到云存储(如OSS、S3等),并记录文件路径到导出任务表。

步骤6:通知用户
更新导出任务状态为完成,并记录文件路径。前端轮询到任务完成时,可以展示下载链接。

步骤7:提供文件下载接口
根据文件路径,提供文件下载。

步骤8:清理临时文件(可选)
可以设置一个定时任务,定期清理过期的导出文件。

代码结构示例:

  1. 导出任务实体类(ExportTask)

  2. 导出任务DAO层(ExportTaskMapper)

  3. 导出服务(ExportService)

    • 创建导出任务
    • 异步执行导出任务
  4. 导出任务执行器(ExportTaskRunner)

    • 分页查询数据
    • 使用SXSSFWorkbook写入Excel
    • 更新任务状态

详细代码示例:

  1. 导出任务实体类

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
}
  1. Mapper接口

java

public interface ExportTaskMapper {
    int insert(ExportTask task);
    int updateStatus(@Param("id") Long id, @Param("status") Integer status);
    ExportTask selectById(Long id);
    // ... 其他方法
}
  1. 导出服务

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));
    }
}
  1. 导出任务执行器

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();
                }
            }
        }
    }
}
  1. 控制器

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
        // ... 省略具体代码
    }
}

优化和注意事项:

  1. 内存优化:

    • 使用SXSSFWorkbook,设置合适的窗口大小,避免内存溢出。
    • 分页查询数据,每页数据量不宜过大,避免数据库压力和大结果集的内存占用。
    • 及时清空每页的数据列表,帮助GC。
  2. 性能优化:

    • 数据库查询优化:确保分页查询的字段有索引,避免全表扫描。
    • 如果数据量特别大,可以考虑使用游标(Cursor)方式查询,但要注意游标可能占用数据库连接。
    • 使用多线程导出:可以将数据分成多个区间,每个线程处理一个区间,然后合并成多个sheet或多个文件。但要注意线程安全和资源竞争。
  3. 文件过大问题:

    • 如果单个Excel文件过大,可以考虑分成多个Excel文件,然后打包成zip。
    • 也可以考虑导出为CSV格式,CSV格式比Excel更轻量,但功能简单。
  4. 通知用户:

    • 前端轮询:前端定期查询任务状态,当状态为完成时,显示下载链接。
    • WebSocket:当导出完成时,通过WebSocket推送消息给前端。
  5. 异常处理:

    • 在导出过程中,可能会发生异常,需要捕获并更新任务状态为失败,以便前端知道任务失败。
  6. 资源清理:

    • SXSSFWorkbook会产生临时文件,需要调用dispose()方法清理。
    • 服务器磁盘空间管理,定期清理旧的导出文件。
  7. 安全性:

    • 确保用户只能下载自己导出的文件,防止越权访问。

面试难点问题总结:

  1. 如何解决内存溢出问题?

    • 采用流式写入(SXSSFWorkbook)和分页查询,避免一次性加载所有数据到内存。
  2. 如何提高导出速度?

    • 数据库层面优化查询,使用索引。
    • 考虑使用多线程分段导出,但要注意数据库连接数和线程池的管理。
  3. 如何保证导出过程的稳定性?

    • 异步处理,避免长时间占用HTTP线程。
    • 异常捕获和任务状态更新,确保用户知道导出结果。
  4. 如何处理超大文件?

    • 分文件存储,打包下载。
    • 考虑使用CSV格式。
  5. 如何通知用户导出完成?

    • 前端轮询或WebSocket推送。
  6. 如何防止导出任务过多导致服务器资源耗尽?

    • 使用线程池控制并发数,队列积压任务,避免资源耗尽。
  7. 如何管理导出文件?

    • 设置文件保留时间,定期清理。

通过以上方案,我们可以实现一个稳定、高效、支持百万级数据导出的系统。

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:系统如何应对高并发导出请求?

回答要点:

  • 线程池隔离,避免影响主要业务
  • 队列缓冲,控制并发处理数量
  • 资源限制,防止单个用户过度使用
  • 服务降级策略,在系统压力大时提供友好提示

这个方案通过流式处理、异步架构、分片策略和多级优化,能够稳定高效地处理百万级数据导出,同时提供了良好的用户体验和系统可靠性。