前言

“运营又要导出100万条订单数据,系统又崩溃了!”、“每次导出大量数据都会出现 OutOfMemoryError”、“分页导出太慢,有没有更好的方案?”

在后台管理系统中,大数据量导出是一个常见的需求,但也是最容易出现问题的地方。一次性查询百万级数据到内存,然后生成Excel,往往会导致OOM(Out Of Memory)崩溃。

本文将详细介绍如何使用流式查询 + 流式写入Excel的方式,优雅地解决大数据量导出问题,让你的系统能够稳定导出百万级数据。

一、问题分析

1.1 传统导出方式的问题

传统导出方式

一次性查询

全部加载到内存

生成Excel

SELECT * FROM orders

返回100万条数据

创建List

占用大量内存

写入Excel

内存峰值过高

结果

OOM崩溃

响应超时

服务器不可用

传统导出方式的问题:

  1. 内存占用过高
  • 一次性查询100万条数据到内存
  • 每条数据占用内存,加上对象开销
  • 假设每条数据1KB,100万条就是1GB
  • 加上Excel生成时的临时对象,内存可能达到数GB
  1. 响应时间过长
  • 查询时间:数据库查询100万条数据需要时间
  • 传输时间:数据从数据库传输到应用服务器
  • 生成时间:生成Excel需要时间
  • 总时间可能超过5分钟
  1. 系统资源耗尽
  • CPU占用高:处理大量数据
  • 内存占用高:导致OOM
  • 数据库连接占用高:长时间占用连接
  1. 用户体验差
  • 导出时间长,用户不知道进度
  • 系统崩溃,用户无法获得数据
  • 导出失败,需要重新操作

1.2 传统导出代码示例

/**
 * 传统导出方式(不推荐)
 * 问题:一次性查询所有数据到内存,导致OOM
 */
public class OrderExportService {

    @Autowired
    private OrderMapper orderMapper;

    /**
     * 导出所有订单(传统方式)
     */
    public void exportAllOrders(HttpServletResponse response) throws IOException {
        // 问题1:一次性查询所有订单
        List<Order> orders = orderMapper.selectAll();

        // 问题2:全部加载到内存
        // 假设100万条数据,每条1KB,就是1GB内存
        // 加上对象开销,可能达到2-3GB

        // 问题3:生成Excel时需要更多内存
        ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), Order.class).build();
        excelWriter.write(orders);
        excelWriter.finish();
    }
}

执行结果:

java.lang.OutOfMemoryError: Java heap space
    at com.example.OrderExportService.exportAllOrders(OrderExportService.java:25)

二、解决方案:流式查询 + 流式写入

2.1 核心思路

流式导出方案

流式查询

流式写入

分批处理

游标查询

分页查询

每次查询少量数据

逐条写入

不占用内存

实时输出

批次大小控制

内存可控

避免OOM

优势

内存占用低

响应时间短

系统稳定

核心思路:

  1. 流式查询
  • 使用数据库游标或分页查询
  • 每次只查询少量数据(如1000条)
  • 逐批处理,不一次性加载所有数据
  1. 流式写入
  • 使用EasyExcel的流式写入
  • 逐条写入Excel,不占用大量内存
  • 实时输出到HTTP响应流
  1. 分批处理
  • 控制每批数据的大小
  • 处理完一批后释放内存
  • 继续处理下一批

2.2 技术选型

技术选型

数据库查询

Excel写入

响应输出

MyBatis游标

JPA分页

EasyExcel

FastExcel

HTTP响应流

实时输出

技术选型:

  1. 数据库查询
  • MyBatis游标:使用Cursor方式流式查询
  • MyBatis分页:使用PageHelper分页查询
  • JPA流式查询:使用Stream方式查询
  1. Excel写入
  • EasyExcel:阿里巴巴开源,支持流式写入
  • FastExcel:高性能Excel库,支持流式处理
  1. 响应输出
  • HttpServletResponse:直接写入响应流
  • 实时输出:边查询边写入,减少内存占用

三、MyBatis游标查询

3.1 MyBatis游标配置

/**
 * MyBatis游标查询配置
 */
public interface OrderMapper {

    /**
     * 游标查询所有订单
     * 使用Cursor方式,流式查询
     */
    @Select("SELECT * FROM orders ORDER BY id")
    @Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)
    Cursor<Order> selectAllWithCursor();

    /**
     * 游标查询订单(带条件)
     */
    @Select("SELECT * FROM orders WHERE status = #{status} ORDER BY id")
    @Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)
    Cursor<Order> selectByStatusWithCursor(@Param("status") String status);

    /**
     * XML配置方式
     */
    Cursor<Order> selectAllCursor();
}

Mapper XML配置:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.OrderMapper">

    <!-- 游标查询 -->
    <select id="selectAllCursor" resultType="com.example.entity.Order" 
            resultSetType="FORWARD_ONLY" fetchSize="1000">
        SELECT * FROM orders ORDER BY id
    </select>

</mapper>

3.2 游标查询示例

/**
 * 游标查询示例
 */
@Service
public class OrderQueryService {

    @Autowired
    private OrderMapper orderMapper;

    /**
     * 游标查询订单
     */
    public void queryWithCursor() {
        try (Cursor<Order> cursor = orderMapper.selectAllWithCursor()) {
            cursor.forEach(order -> {
                // 逐条处理订单
                processOrder(order);
            });
        }
    }

    /**
     * 游标查询并导出
     */
    public void exportWithCursor(HttpServletResponse response) throws IOException {
        // 设置响应头
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setCharacterEncoding("utf-8");
        String fileName = URLEncoder.encode("订单导出.xlsx", "UTF-8").replaceAll("\\+", "%20");
        response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName);

        // 创建ExcelWriter(流式写入)
        ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), Order.class).build();

        try (Cursor<Order> cursor = orderMapper.selectAllWithCursor()) {
            // 逐条写入Excel
            cursor.forEach(order -> {
                excelWriter.write(order);
            });
        }

        excelWriter.finish();
    }

    /**
     * 处理订单
     */
    private void processOrder(Order order) {
        // 业务逻辑
        System.out.println("处理订单: " + order.getId());
    }
}

四、MyBatis分页查询

4.1 分页查询配置

/**
 * MyBatis分页查询配置
 */
public interface OrderMapper {

    /**
     * 分页查询订单
     */
    @Select("SELECT * FROM orders ORDER BY id LIMIT #{offset}, #{pageSize}")
    List<Order> selectByPage(@Param("offset") int offset, @Param("pageSize") int pageSize);

    /**
     * 统计订单总数
     */
    @Select("SELECT COUNT(*) FROM orders")
    long count();
}

4.2 分页查询示例

/**
 * 分页查询导出示例
 */
@Service
public class OrderExportService {

    @Autowired
    private OrderMapper orderMapper;

    private static final int BATCH_SIZE = 1000; // 每批1000条

    /**
     * 分页查询导出
     */
    public void exportByPage(HttpServletResponse response) throws IOException {
        // 设置响应头
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setCharacterEncoding("utf-8");
        String fileName = URLEncoder.encode("订单导出.xlsx", "UTF-8").replaceAll("\\+", "%20");
        response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName);

        // 创建ExcelWriter(流式写入)
        ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), Order.class).build();

        // 查询总数
        long total = orderMapper.count();
        int totalPages = (int) Math.ceil((double) total / BATCH_SIZE);

        // 分页查询并写入
        for (int pageNum = 0; pageNum < totalPages; pageNum++) {
            int offset = pageNum * BATCH_SIZE;
            List<Order> orders = orderMapper.selectByPage(offset, BATCH_SIZE);

            // 写入Excel
            excelWriter.write(orders);

            // 释放内存
            orders.clear();
        }

        excelWriter.finish();
    }
}

五、完整实现方案

5.1 实体类

package com.example.entity;

import com.alibaba.excel.annotation.ExcelIgnore;
import com.alibaba.excel.annotation.ExcelProperty;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
 * 订单实体
 */
@Data
public class Order {

    @ExcelProperty("订单ID")
    private Long id;

    @ExcelProperty("订单号")
    private String orderNo;

    @ExcelProperty("用户ID")
    private Long userId;

    @ExcelProperty("用户名")
    private String username;

    @ExcelProperty("商品名称")
    private String productName;

    @ExcelProperty("商品数量")
    private Integer quantity;

    @ExcelProperty("订单金额")
    private BigDecimal amount;

    @ExcelProperty("订单状态")
    private String status;

    @ExcelProperty("创建时间")
    private LocalDateTime createTime;

    @ExcelProperty("支付时间")
    private LocalDateTime payTime;

    @ExcelIgnore  // 忽略不需要导出的字段
    private String remark;
}

5.2 Mapper接口

package com.example.mapper;

import com.example.entity.Order;
import org.apache.ibatis.annotations.*;
import org.apache.ibatis.cursor.Cursor;
import org.apache.ibatis.mapping.ResultSetType;

import java.util.List;

/**
 * 订单Mapper
 */
@Mapper
public interface OrderMapper {

    /**
     * 游标查询所有订单
     */
    @Select("SELECT * FROM orders ORDER BY id")
    @Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)
    Cursor<Order> selectAllWithCursor();

    /**
     * 游标查询订单(带条件)
     */
    @Select("SELECT * FROM orders WHERE status = #{status} ORDER BY id")
    @Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)
    Cursor<Order> selectByStatusWithCursor(@Param("status") String status);

    /**
     * 分页查询订单
     */
    @Select("SELECT * FROM orders ORDER BY id LIMIT #{offset}, #{pageSize}")
    List<Order> selectByPage(@Param("offset") int offset, @Param("pageSize") int pageSize);

    /**
     * 统计订单总数
     */
    @Select("SELECT COUNT(*) FROM orders")
    long count();

    /**
     * 统计订单总数(带条件)
     */
    @Select("SELECT COUNT(*) FROM orders WHERE status = #{status}")
    long countByStatus(@Param("status") String status);
}

5.3 Service实现

package com.example.service;

import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.example.entity.Order;
import com.example.mapper.OrderMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.cursor.Cursor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.RequestParam;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;
import java.util.List;

/**
 * 订单导出服务
 */
@Slf4j
@Service
public class OrderExportService {

    @Autowired
    private OrderMapper orderMapper;

    /**
     * 导出所有订单(游标方式)
     */
    public void exportAllOrders(HttpServletResponse response) throws IOException {
        log.info("开始导出所有订单");

        long startTime = System.currentTimeMillis();

        // 设置响应头
        setResponseHeaders(response, "订单导出.xlsx");

        // 创建ExcelWriter(流式写入)
        ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), Order.class).build();

        int count = 0;

        try (Cursor<Order> cursor = orderMapper.selectAllWithCursor()) {
            cursor.forEach(order -> {
                excelWriter.write(order);
                count++;

                // 每10000条记录一次日志
                if (count % 10000 == 0) {
                    log.info("已导出 {} 条订单", count);
                }
            });
        }

        excelWriter.finish();

        long endTime = System.currentTimeMillis();
        log.info("订单导出完成,共导出 {} 条,耗时 {} 秒", 
                 count, (endTime - startTime) / 1000);
    }

    /**
     * 导出订单(带条件)
     */
    public void exportOrdersByStatus(HttpServletResponse response, String status) throws IOException {
        log.info("开始导出订单,状态: {}", status);

        long startTime = System.currentTimeMillis();

        // 设置响应头
        setResponseHeaders(response, "订单导出_" + status + ".xlsx");

        // 创建ExcelWriter
        ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), Order.class).build();

        int count = 0;

        try (Cursor<Order> cursor = orderMapper.selectByStatusWithCursor(status)) {
            cursor.forEach(order -> {
                excelWriter.write(order);
                count++;

                if (count % 10000 == 0) {
                    log.info("已导出 {} 条订单", count);
                }
            });
        }

        excelWriter.finish();

        long endTime = System.currentTimeMillis();
        log.info("订单导出完成,共导出 {} 条,耗时 {} 秒", 
                 count, (endTime - startTime) / 1000);
    }

    /**
     * 分页导出订单
     */
    public void exportOrdersByPage(HttpServletResponse response, int pageSize) throws IOException {
        log.info("开始分页导出订单,每页大小: {}", pageSize);

        long startTime = System.currentTimeMillis();

        // 设置响应头
        setResponseHeaders(response, "订单导出.xlsx");

        // 创建ExcelWriter
        ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), Order.class).build();

        // 查询总数
        long total = orderMapper.count();
        int totalPages = (int) Math.ceil((double) total / pageSize);

        log.info("订单总数: {}, 总页数: {}", total, totalPages);

        int count = 0;

        // 分页查询并写入
        for (int pageNum = 0; pageNum < totalPages; pageNum++) {
            int offset = pageNum * pageSize;
            List<Order> orders = orderMapper.selectByPage(offset, pageSize);

            // 写入Excel
            excelWriter.write(orders);
            count += orders.size();

            // 释放内存
            orders.clear();

            log.info("已处理第 {} 页,累计 {} 条", pageNum + 1, count);
        }

        excelWriter.finish();

        long endTime = System.currentTimeMillis();
        log.info("订单导出完成,共导出 {} 条,耗时 {} 秒", 
                 count, (endTime - startTime) / 1000);
    }

    /**
     * 设置响应头
     */
    private void setResponseHeaders(HttpServletResponse response, String fileName) throws IOException {
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setCharacterEncoding("utf-8");
        String encodedFileName = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20");
        response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + encodedFileName);
    }
}

5.4 Controller实现

package com.example.controller;

import com.example.entity.Order;
import com.example.service.OrderExportService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 订单导出控制器
 */
@Slf4j
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderExportController {

    private final OrderExportService orderExportService;

    /**
     * 导出所有订单
     */
    @GetMapping("/export")
    public void exportAllOrders(HttpServletResponse response) throws IOException {
        log.info("收到导出所有订单请求");
        orderExportService.exportAllOrders(response);
    }

    /**
     * 导出订单(带状态)
     */
    @GetMapping("/export")
    public void exportOrdersByStatus(
            HttpServletResponse response,
            @RequestParam String status) throws IOException {
        log.info("收到导出订单请求,状态: {}", status);
        orderExportService.exportOrdersByStatus(response, status);
    }

    /**
     * 分页导出订单
     */
    @GetMapping("/export")
    public void exportOrdersByPage(
            HttpServletResponse response,
            @RequestParam(defaultValue = "10000") int pageSize) throws IOException {
        log.info("收到分页导出订单请求,每页大小: {}", pageSize);
        orderExportService.exportOrdersByPage(response, pageSize);
    }
}

六、性能优化

6.1 优化策略

性能优化策略

查询优化

写入优化

内存优化

只查询需要的字段

使用索引

避免关联查询

流式写入

批量写入

异步处理

控制批次大小

及时释放内存

使用弱引用

6.2 优化后的Mapper

/**
 * 优化后的Mapper
 */
public interface OrderMapper {

    /**
     * 游标查询订单(只查询需要的字段)
     */
    @Select("SELECT id, order_no, user_id, username, product_name, quantity, amount, status, create_time, pay_time " +
            "FROM orders ORDER BY id")
    @Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)
    Cursor<Order> selectAllWithCursorOptimized();

    /**
     * 分页查询订单(只查询需要的字段)
     */
    @Select("SELECT id, order_no, user_id, username, product_name, quantity, amount, status, create_time, pay_time " +
            "FROM orders ORDER BY id LIMIT #{offset}, #{pageSize}")
    List<Order> selectByPageOptimized(@Param("offset") int offset, @Param("pageSize") int pageSize);

    /**
     * 统计订单总数
     */
    @Select("SELECT COUNT(*) FROM orders")
    long count();
}

6.3 优化后的Service

/**
 * 优化后的导出服务
 */
@Slf4j
@Service
public class OrderExportServiceOptimized {

    @Autowired
    private OrderMapper orderMapper;

    /**
     * 批量大小
     */
    private static final int BATCH_SIZE = 1000;

    /**
     * 优化后的分页导出
     */
    public void exportOptimized(HttpServletResponse response) throws IOException {
        log.info("开始优化导出订单");

        long startTime = System.currentTimeMillis();

        // 设置响应头
        setResponseHeaders(response, "订单导出.xlsx");

        // 创建ExcelWriter
        ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), Order.class).build();

        // 查询总数
        long total = orderMapper.count();
        int totalPages = (int) Math.ceil((double) total / BATCH_SIZE);

        log.info("订单总数: {}, 总页数: {}", total, totalPages);

        int count = 0;

        // 分页查询并写入
        for (int pageNum = 0; pageNum < totalPages; pageNum++) {
            int offset = pageNum * BATCH_SIZE;

            // 查询当前页数据
            List<Order> orders = orderMapper.selectByPageOptimized(offset, BATCH_SIZE);

            // 写入Excel
            excelWriter.write(orders);
            count += orders.size();

            // 及时释放内存
            orders.clear();
            orders = null;

            // 每处理100000条记录一次日志
            if (count % 100000 == 0) {
                log.info("已导出 {} 条订单,进度: {}/{}", count, pageNum + 1, totalPages);
            }

            // 建议JVM进行GC
            if (pageNum % 10 == 0) {
                System.gc();
            }
        }

        excelWriter.finish();

        long endTime = System.currentTimeMillis();
        log.info("订单导出完成,共导出 {} 条,耗时 {} 秒", 
                 count, (endTime - startTime) / 1000);
    }
}

6.4 批量写入优化

/**
 * 批量写入优化
 */
@Slf4j
@Service
public class OrderExportBatchService {

    @Autowired
    private OrderMapper orderMapper;

    private static final int BATCH_SIZE = 1000;

    /**
     * 批量写入导出
     */
    public void exportBatch(HttpServletResponse response) throws IOException {
        log.info("开始批量写入导出");

        long startTime = System.currentTimeMillis();

        // 设置响应头
        setResponseHeaders(response, "订单导出.xlsx");

        // 创建ExcelWriter
        ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), Order.class).build();

        // 查询总数
        long total = orderMapper.count();
        int totalPages = (int) Math.ceil((double) total / BATCH_SIZE);

        log.info("订单总数: {}, 总页数: {}", total, totalPages);

        int count = 0;

        // 分页查询并批量写入
        for (int pageNum = 0; pageNum < totalPages; pageNum++) {
            int offset = pageNum * BATCH_SIZE;

            // 查询当前页数据
            List<Order> orders = orderMapper.selectByPageOptimized(offset, BATCH_SIZE);

            // 批量写入Excel
            if (!orders.isEmpty()) {
                excelWriter.write(orders, getWriteSheet());
            }

            count += orders.size();

            // 释放内存
            orders.clear();
            orders = null;

            log.info("已处理第 {} 页,累计 {} 条", pageNum + 1, count);
        }

        excelWriter.finish();

        long endTime = System.currentTimeMillis();
        log.info("订单导出完成,共导出 {} 条,耗时 {} 秒", 
                 count, (endTime - startTime) / 1000);
    }

    /**
     * 获取写入Sheet配置
     */
    private WriteSheet getWriteSheet() {
        return EasyExcel.writerSheet("订单").build();
    }
}

七、异步导出

7.1 异步导出实现

/**
 * 异步导出服务
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class AsyncOrderExportService {

    private final OrderMapper orderMapper;
    private final AsyncTaskExecutor asyncTaskExecutor;

    /**
     * 异步导出订单
     */
    @Async("asyncTaskExecutor")
    public void exportAsync(String exportId, String status) {
        log.info("开始异步导出,导出ID: {}, 状态: {}", exportId, status);

        long startTime = System.currentTimeMillis();

        // 更新导出状态为"处理中"
        updateExportStatus(exportId, "PROCESSING");

        try {
            // 创建临时文件
            String fileName = "订单导出_" + exportId + ".xlsx";
            String filePath = "/tmp/exports/" + fileName;

            // 创建ExcelWriter
            ExcelWriter excelWriter = EasyExcel.write(filePath, Order.class).build();

            int count = 0;

            try (Cursor<Order> cursor = orderMapper.selectByStatusWithCursor(status)) {
                cursor.forEach(order -> {
                    excelWriter.write(order);
                    count++;

                    if (count % 10000 == 0) {
                        log.info("已导出 {} 条订单", count);
                    }
                });
            }

            excelWriter.finish();

            // 更新导出状态为"完成"
            updateExportStatus(exportId, "COMPLETED", fileName);

            long endTime = System.currentTimeMillis();
            log.info("异步导出完成,导出ID: {}, 共导出 {} 条,耗时 {} 秒", 
                     exportId, count, (endTime - startTime) / 1000);

        } catch (Exception e) {
            log.error("异步导出失败,导出ID: {}", exportId, e);
            updateExportStatus(exportId, "FAILED");
        }
    }

    /**
     * 更新导出状态
     */
    private void updateExportStatus(String exportId, String status) {
        updateExportStatus(exportId, status, null);
    }

    /**
     * 更新导出状态和文件名
     */
    private void updateExportStatus(String exportId, String status, String fileName) {
        // 更新数据库中的导出状态
        // orderExportMapper.updateStatus(exportId, status, fileName);
    }
}

7.2 异步导出配置

/**
 * 异步配置
 */
@Configuration
@EnableAsync
public class AsyncConfig {

    /**
     * 异步任务执行器
     */
    @Bean("asyncTaskExecutor")
    public AsyncTaskExecutor asyncTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

        // 核心线程数
        executor.setCorePoolSize(5);

        // 最大线程数
        executor.setMaxPoolSize(10);

        // 队列容量
        executor.setQueueCapacity(100);

        // 线程名称前缀
        executor.setThreadNamePrefix("async-export-");

        // 拒绝策略
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

        executor.initialize();

        return executor;
    }
}

7.3 异步导出Controller

/**
 * 异步导出控制器
 */
@Slf4j
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class AsyncOrderExportController {

    private final AsyncOrderExportService asyncOrderExportService;

    /**
     * 创建异步导出任务
     */
    @PostMapping("/export/async")
    public Result<String> createAsyncExport(
            @RequestParam(required = false) String status) {
        log.info("收到异步导出请求,状态: {}", status);

        // 生成导出ID
        String exportId = UUID.randomUUID().toString();

        // 创建导出记录
        OrderExport export = new OrderExport();
        export.setId(exportId);
        export.setStatus("PENDING");
        export.setCreateTime(LocalDateTime.now());
        orderExportMapper.insert(export);

        // 异步执行导出
        asyncOrderExportService.exportAsync(exportId, status);

        return Result.success(exportId);
    }

    /**
     * 查询导出状态
     */
    @GetMapping("/export/status/{exportId}")
    public Result<OrderExport> getExportStatus(@PathVariable String exportId) {
        OrderExport export = orderExportMapper.selectById(exportId);
        return Result.success(export);
    }

    /**
     * 下载导出文件
     */
    @GetMapping("/export/download/{exportId}")
    public void downloadExport(
            HttpServletResponse response,
            @PathVariable String exportId) throws IOException {
        OrderExport export = orderExportMapper.selectById(exportId);

        if (export == null || !"COMPLETED".equals(export.getStatus())) {
            throw new BusinessException("导出未完成或不存在");
        }

        // 设置响应头
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setCharacterEncoding("utf-8");
        String fileName = URLEncoder.encode(export.getFileName(), "UTF-8").replaceAll("\\+", "%20");
        response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName);

        // 读取文件并写入响应
        String filePath = "/tmp/exports/" + export.getFileName();
        Files.copy(Paths.get(filePath), response.getOutputStream());
    }
}

八、进度反馈

8.1 进度跟踪实现

/**
 * 导出进度服务
 */
@Slf4j
@Service
public class ExportProgressService {

    @Autowired
    private OrderMapper orderMapper;

    /**
     * 带进度的导出
     */
    public void exportWithProgress(HttpServletResponse response) throws IOException {
        log.info("开始带进度的导出");

        long startTime = System.currentTimeMillis();

        // 设置响应头
        setResponseHeaders(response, "订单导出.xlsx");

        // 创建ExcelWriter
        ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), Order.class).build();

        // 查询总数
        long total = orderMapper.count();

        int count = 0;

        try (Cursor<Order> cursor = orderMapper.selectAllWithCursor()) {
            cursor.forEach(order -> {
                excelWriter.write(order);
                count++;

                // 每处理10000条记录更新一次进度
                if (count % 10000 == 0) {
                    int progress = (int) ((double) count / total * 100);
                    log.info("导出进度: {}/{} ({}%)", count, total, progress);

                    // 可以通过WebSocket等方式实时推送进度
                    // webSocketService.sendProgress(exportId, progress);
                }
            });
        }

        excelWriter.finish();

        long endTime = System.currentTimeMillis();
        log.info("订单导出完成,共导出 {} 条,耗时 {} 秒", 
                 count, (endTime - startTime) / 1000);
    }
}

8.2 WebSocket进度推送

/**
 * WebSocket进度推送
 */
@ServerEndpoint("/export/progress/{exportId}")
@Component
@Slf4j
public class ExportProgressWebSocket {

    private static Map<String, Session> sessions = new ConcurrentHashMap<>();

    @OnOpen
    public void onOpen(Session session, @PathParam String exportId) {
        sessions.put(exportId, session);
        log.info("WebSocket连接建立,导出ID: {}", exportId);
    }

    @OnClose
    public void onClose(Session session, @PathParam String exportId) {
        sessions.remove(exportId);
        log.info("WebSocket连接关闭,导出ID: {}", exportId);
    }

    /**
     * 发送进度
     */ 
    public static void sendProgress(String exportId, int progress) {
        Session session = sessions.get(exportId);
        if (session != null && session.isOpen()) {
            try {
                session.getAsyncRemote().sendText("{\"progress\": " + progress + "}");
            } catch (IOException e) {
                log.error("发送进度失败,导出ID: {}", exportId, e);
            }
        }
    }
}

九、前端实现

9.1 导出按钮

/**
 * 导出按钮实现
 */
function exportOrders() {
    // 显示加载状态
    const btn = document.getElementById('exportBtn');
    btn.disabled = true;
    btn.textContent = '导出中...';

    // 调用导出接口
    window.location.href = '/api/orders/export';

    // 5秒后恢复按钮状态
    setTimeout(() => {
        btn.disabled = false;
        btn.textContent = '导出订单';
    }, 5000);
}

9.2 异步导出实现

/**
 * 异步导出实现
 */
async function exportOrdersAsync() {
    try {
        // 显示加载状态
        const btn = document.getElementById('exportBtn');
        btn.disabled = true;
        btn.textContent = '创建导出任务中...';

        // 创建导出任务
        const response = await fetch('/api/orders/export/async?status=PAID', {
            method: 'POST'
        });
        const result = await response.json();

        if (result.code === 200) {
            const exportId = result.data;

            // 显示进度
            showProgress(exportId);

            // 轮询导出状态
            pollExportStatus(exportId);
        } else {
            alert('创建导出任务失败:' + result.message);
            btn.disabled = false;
            btn.textContent = '导出订单';
        }
    } catch (error) {
        console.error('导出失败', error);
        alert('导出失败,请重试');
        btn.disabled = false;
        btn.textContent = '导出订单';
    }
}

/**
 * 显示进度条
 */
function showProgress(exportId) {
    const progressDiv = document.createElement('div');
    progressDiv.innerHTML = `
        <div class="progress-container">
            <div class="progress-bar" id="progressBar-${exportId}"></div>
            <div class="progress-text" id="progressText-${exportId}">0%</div>
        </div>
    `;
    document.body.appendChild(progressDiv);
}

/**
 * 轮询导出状态
 */
async function pollExportStatus(exportId) {
    const interval = setInterval(async () => {
        const response = await fetch(`/api/orders/export/status/${exportId}`);
        const result = await response.json();

        if (result.code === 200) {
            const export = result.data;

            // 更新进度
            const progressBar = document.getElementById(`progressBar-${exportId}`);
            const progressText = document.getElementById(`progressText-${exportId}`);
            progressBar.style.width = export.progress + '%';
            progressText.textContent = export.progress + '%';

            // 导出完成
            if (export.status === 'COMPLETED') {
                clearInterval(interval);

                // 自动下载
                window.location.href = `/api/orders/export/download/${exportId}`;

                // 显示成功消息
                alert('导出完成!');

                // 恢复按钮
                const btn = document.getElementById('exportBtn');
                btn.disabled = false;
                btn.textContent = '导出订单';
            }

            // 导出失败
            if (export.status === 'FAILED') {
                clearInterval(interval);

                alert('导出失败,请重试');

                const btn = document.getElementById('exportBtn');
                btn.disabled = false;
                btn.textContent = '导出订单';
            }
        }
    }, 2000);
}

十、性能测试

10.1 性能对比

/**
 * 性能测试
 */
@Slf4j
@Service
public class ExportPerformanceTest {

    @Autowired
    private OrderMapper orderMapper;

    /**
     * 测试传统方式
     */
    public void testTraditionalExport() {
        log.info("测试传统导出方式");

        long startTime = System.currentTimeMillis();

        try {
            // 一次性查询所有数据
            List<Order> orders = orderMapper.selectAll();

            log.info("查询到 {} 条订单", orders.size());

            // 模拟内存占用
            log.info("内存占用: {} MB", 
                     (Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()) / 1024 / 1024);

        } catch (Exception e) {
            log.error("传统导出方式失败", e);
        }

        long endTime = System.currentTimeMillis();
        log.info("传统方式耗时: {} 秒", (endTime - startTime) / 1000);
    }

    /**
     * 测试流式方式
     */
    public void testStreamExport() {
        log.info("测试流式导出方式");

        long startTime = System.currentTimeMillis();
        int count = 0;

        try (Cursor<Order> cursor = orderMapper.selectAllWithCursor()) {
            cursor.forEach(order -> {
                count++;

                if (count % 10000 == 0) {
                    log.info("已处理 {} 条订单,内存占用: {} MB", 
                             count, 
                             (Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()) / 1024 / 1024);
                }
            });
        }

        long endTime = System.currentTimeMillis();
        log.info("流式方式耗时: {} 秒,共处理 {} 条", (endTime - startTime) / 1000, count);
    }
}

10.2 性能测试结果

性能对比

传统方式

流式方式

内存占用: 2-3GB

导出时间: 5-10分钟

成功率: 低

内存占用: 50-100MB

导出时间: 1-3分钟

成功率: 高

性能对比:

方式内存占用导出时间成功率适用场景
传统方式2-3GB5-10分钟小数据量
流式方式50-100MB1-3分钟大数据量

十一、最佳实践

11.1 最佳实践清单

最佳实践

查询优化

写入优化

内存管理

用户体验

只查询需要的字段

使用索引

避免关联查询

流式写入

批量写入

异步处理

控制批次大小

及时释放内存

定期GC

异步导出

进度反馈

下载通知

最佳实践:

  1. 查询优化
  • ✅ 只查询需要的字段
  • ✅ 使用索引优化查询
  • ✅ 避免复杂的关联查询
  • ❌ 不要使用 SELECT *
  1. 写入优化
  • ✅ 使用流式写入Excel
  • ✅ 批量写入提升性能
  • ✅ 异步处理不阻塞主线程
  • ❌ 不要一次性写入所有数据
  1. 内存管理
  • ✅ 控制每批数据的大小(1000-10000条)
  • ✅ 及时释放内存
  • ✅ 定期建议JVM进行GC
  • ❌ 不要一次性加载所有数据
  1. 用户体验
  • ✅ 使用异步导出
  • ✅ 提供进度反馈
  • ✅ 导出完成后通知用户
  • ❌ 不要让用户长时间等待

11.2 配置优化

# application.yml
mybatis:
  configuration:
    # 游标查询配置
    default-fetch-size: 1000
    # 批量大小
    default-statement-timeout: 300

# JVM配置
server:
  tomcat:
    threads:
      max: 200
      min-spare: 10

# 异步配置
spring:
  task:
    execution:
      pool:
        core-size: 5
        max-size: 10
        queue-capacity: 100

11.3 监控和告警

/**
 * 导出监控服务
 */
@Slf4j
@Service
public class ExportMonitorService {

    /**
     * 监控导出任务
     */
    public void monitorExport(String exportId) {
        ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);

        // 每30秒检查一次
        scheduler.scheduleAtFixedRate(() -> {
            try {
                OrderExport export = orderExportMapper.selectById(exportId);

                // 检查是否超时(超过30分钟)
                if (export != null && "PROCESSING".equals(export.getStatus())) {
                    Duration duration = Duration.between(export.getCreateTime(), LocalDateTime.now());
                    if (duration.toMinutes() > 30) {
                        log.warn("导出任务超时,导出ID: {}", exportId);
                        // 发送告警
                        sendAlert("导出任务超时", exportId);
                    }
                }

            } catch (Exception e) {
                log.error("监控导出任务失败", e);
            }
        }, 0, 30, TimeUnit.SECONDS);
    }

    /**
     * 发送告警
     */
    private void sendAlert(String message, String exportId) {
        // 发送邮件、短信或钉钉告警
        log.info("发送告警: {}, 导出ID: {}", message, exportId);
    }
}

十二、总结

12.1 核心要点

大数据量导出

流式查询

流式写入

内存控制

用户体验

游标查询

分页查询

只查需要的字段

EasyExcel流式写入

逐条写入

批量写入

控制批次大小

及时释放内存

定期GC

异步导出

进度反馈

下载通知

12.2 实施建议

  1. 小数据量(< 1万条)
  • 可以使用传统方式
  • 一次性查询和写入
  • 简单快速
  1. 中等数据量(1万 - 10万条)
  • 使用分页查询
  • 控制批次大小(1000-5000条)
  • 流式写入Excel
  1. 大数据量(> 10万条)
  • 使用游标查询
  • 流式查询和写入
  • 建议使用异步导出
  • 提供进度反馈

12.3 避坑指南

常见错误:

  • ❌ 使用 SELECT * 查询所有字段
  • ❌ 一次性查询所有数据到内存
  • ❌ 不控制批次大小
  • ❌ 不释放内存
  • ❌ 同步导出大数据量

正确做法:

  • ✅ 只查询需要的字段
  • ✅ 使用游标或分页查询
  • ✅ 控制批次大小(1000-10000条)
  • ✅ 及时释放内存
  • ✅ 大数据量使用异步导出

记住:大数据量导出的关键在于控制内存占用,使用流式查询和流式写入是避免OOM的最佳方案。选择合适的导出方式,既能满足业务需求,又能保证系统稳定。

希望这篇大数据量导出指南能帮助你解决导出100万条订单数据的问题!如果你有任何问题或使用技巧,欢迎在评论区讨论交流。

更多推荐