EasyExcel复杂表头和百万级数据导出

一、EasyExcel介绍

  • EasyExcel是阿里巴巴开源的一个基于Java的Excel处理框架,以简单、高效、低内存消耗著称。
  • 它是对Apache POI的增强和优化,它在处理大文件读写时具有更高的性能和更低的内存占用
  • 官网:https://alibaba-easyexcel.github.io/index.html

二、为什么需要 EasyExcel

在后台管理系统中,Excel 导入导出是高频需求。但当数据量达到 10万+甚至百万行 时(如用户订单表、用户信息表),传统方案(如 Apache POI)会面临严重问题:

  • 内存溢出 (OOM):POI 默认将整个 Excel 文件加载到内存,处理大文件极易导致服务崩溃。
  • 开发效率低:POI API 繁琐,需手动创建 WorkbookSheetRowCell,并处理格式转换,代码冗余且易错。

EasyExcel 的核心价值

  • 流式读写:采用“读一行,处理一行”或“写一行,刷一行”的策略,内存占用稳定在 MB 级别,轻松应对百万级数据。
  • 极简 API:通过注解 (@ExcelProperty) 映射字段,核心导入导出代码仅需几行,大幅降低开发成本。

三、百万级数据导出实现(核心:分页 + 多线程+流式写入)

在这里插入图片描述

导入依赖
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>easyexcel</artifactId>
    <version>4.0.0</version> <!-- 最新稳定版 -->
</dependency>

核心架构:生产者-消费者模式 + 分页查询 + 多线程处理

关键代码解析 (OrderExportServiceImpl.java)
// 配置分页和多线程参数
private static final int PAGE_SIZE = 2000; // 每次从DB查询2000条
private static final int ROWS_PER_SHEET = 50000; // 每个Sheet最多5万行
private static final int QUERY_THREAD_POOL_SIZE = 4; // 查询线程池大小
private static final int QUEUE_CAPACITY = 10; // 缓冲区队列容量

private static final class PoisonPillMarker extends ArrayList<OrderEasyExcel> {
    private static final PoisonPillMarker INSTANCE = new PoisonPillMarker();
    private PoisonPillMarker() {
        // 私有构造,确保单例
    }
    @Override
    public int size() { return 0; }
    @Override
    public boolean isEmpty() { return true; }
}

private static final List<OrderEasyExcel> POISON_PILL = PoisonPillMarker.INSTANCE;

// 多线程导出核心逻辑
private void exportWithMultiThread(ExcelWriter excelWriter, OrderMainVo orderMainVo) {
    // 1. 创建优化的线程池(避免使用Executors.newFixedThreadPool)
    ExecutorService queryExecutor = new ThreadPoolExecutor(
        QUERY_THREAD_POOL_SIZE, // 核心线程数
        QUERY_THREAD_POOL_SIZE, // 最大线程数
        60L, TimeUnit.SECONDS,   // 空闲线程存活时间
        new LinkedBlockingQueue<>(10), // 工作队列
        new ThreadFactory() {
            private final AtomicLong counter = new AtomicLong(1);
            @Override
            public Thread newThread(Runnable r) {
                Thread thread = new Thread(r, "OrderExport-Query-" + counter.getAndIncrement());
                thread.setDaemon(true); // 设置为守护线程,避免阻止JVM关闭
                return thread;
            }
        },
        new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:由调用线程执行
    );

    // 2. 创建有界阻塞队列作为缓冲区
    BlockingQueue<List<OrderEasyExcel>> dataQueue = new LinkedBlockingQueue<>(QUEUE_CAPACITY);

    // 3. 启动查询线程(生产者)
    Future<?> queryFuture = queryExecutor.submit(() -> {
        Long lastOrderId = null;
        boolean hasError = false;
        try {
            while (true) {
                // 游标分页查询数据库
                List<OrderMainVo> pageList = orderMainMapper.selectOrderMainListForExport(
                    orderMainVo, lastOrderId, PAGE_SIZE
                );
                if (pageList.isEmpty()) break;

                // 数据转换
                List<OrderEasyExcel> excelList = new ArrayList<>(pageList.size());
                for (OrderMainVo vo : pageList) {
                    excelList.add(convertToExcel(vo));
                    lastOrderId = vo.getOrderId(); // 更新游标
                }

                // 放入队列(阻塞等待如果队列满)
                dataQueue.put(excelList);
            }
        } catch (Exception e) {
            logger.error("查询线程异常", e);
            hasError = true;
            throw new RuntimeException("查询数据失败", e);
        } finally {
            // 关键:无论是否异常,都必须放入毒丸标记
            try {
                dataQueue.put(POISON_PILL);
                if (hasError) {
                    logger.warn("查询线程异常结束,已发送结束信号");
                }
            } catch (InterruptedException ie) {
                Thread.currentThread().interrupt();
                logger.warn("放入毒丸时被中断", ie);
            }
        }
    });

    // 4. 写入线程(消费者) - 单线程写入保证顺序
    int sheetIndex = 0;
    WriteSheet writeSheet = EasyExcel.writerSheet(sheetIndex, "订单数据_1").build();
    int currentSheetRowCount = 0;
    long writeBatchCount = 0;
    long writeRowCount = 0;

    while (true) {
        // 从队列获取数据(阻塞等待)
        List<OrderEasyExcel> excelList = dataQueue.take();
        
        // 检查是否是毒丸标记(使用正确的单例比较)
        if (excelList == POISON_PILL || excelList == PoisonPillMarker.INSTANCE) {
            logger.info("收到结束信号,写入线程退出");
            break;
        }

        writeBatchCount++;
        writeRowCount += excelList.size();
        
        // 写入Excel(分Sheet处理)
        int fromIndex = 0;
        while (fromIndex < excelList.size()) {
            int remainingInSheet = ROWS_PER_SHEET - currentSheetRowCount;
            int rowsLeftInPage = excelList.size() - fromIndex;
            int writeSize = Math.min(remainingInSheet, rowsLeftInPage);

            List<OrderEasyExcel> subList = excelList.subList(fromIndex, fromIndex + writeSize);
            excelWriter.write(subList, writeSheet);

            currentSheetRowCount += writeSize;
            fromIndex += writeSize;

            // Sheet切换逻辑
            if (currentSheetRowCount >= ROWS_PER_SHEET) {
                sheetIndex++;
                writeSheet = EasyExcel.writerSheet(sheetIndex, buildSheetName(sheetIndex)).build();
                currentSheetRowCount = 0;
            }
        }
    }

    // 5. 等待查询线程完成(设置超时)
    try {
        queryFuture.get(10, TimeUnit.MINUTES);
    } catch (TimeoutException e) {
        logger.error("导出超时,请缩小查询范围", e);
        throw new RuntimeException("导出超时");
    }
}
将 OrderMainVo 转换为导出实体 OrderEasyExcel
/**
 * 将 OrderMainVo 对象转换为用于 Excel 导出的 OrderEasyExcel DTO 对象。
 * 此方法负责:
 * 1. 复制基础字段(如订单ID、金额、时间等);
 * 2. 将订单状态码(数字)转换为可读的中文描述;
 * 3. 填充订单明细相关字段(商品信息、货道等)。
 *
 * @param vo 原始订单数据对象(包含主表和明细信息)
 * @return 转换后的 Excel 导出专用 DTO 对象
 */
private OrderEasyExcel convertToExcel(OrderMainVo vo) {
    // 创建目标 Excel 导出对象
    OrderEasyExcel excel = new OrderEasyExcel();

    // ====== 1. 填充订单基本信息(来自 OrderMainVo 的主表字段)======
    excel.setOrderId(vo.getOrderId());           // 用户订单号
    excel.setUserOpenId(vo.getUserOpenId());     // 用户唯一标识(如微信 openId)
    excel.setDeviceSn(vo.getDeviceSn());         // 设备序列号
    excel.setTotalAmount(vo.getTotalAmount());   // 订单总金额
    excel.setPaymentAmount(vo.getPaymentAmount()); // 实际支付金额
    excel.setOrderTime(vo.getOrderTime());       // 下单时间
    excel.setPaymentTime(vo.getPaymentTime());   // 支付时间

    // ====== 3. 转换订单状态(将数据库中的状态码转为中文描述)======
    if (vo.getOrderStatus() != null) {
        switch (vo.getOrderStatus()) {
            case 0:
                excel.setOrderStatus("待支付");
                break;
            case 1:
                excel.setOrderStatus("已支付");
                break;
            case 2:
                excel.setOrderStatus("已完成");
                break;
            case 3:
                excel.setOrderStatus("已取消");
                break;
            case 4:
                excel.setOrderStatus("已退款");
                break;
            default:
                excel.setOrderStatus("未知");
                break;
        }
    } else {
        excel.setOrderStatus("无状态"); // 可选:处理 null 情况(当前逻辑已覆盖)
    }

    // ====== 4. 填充订单明细信息(来自关联的 order_detail 表)======
    excel.setChannelId(vo.getChannelId());       // 货道 ID / 层数 ID
    excel.setCommodityName(vo.getCommodityName()); // 商品名称
    excel.setUnitPrice(vo.getUnitPrice());       // 商品单价
    excel.setQuantity(vo.getQuantity());         // 购买数量

    // 返回转换完成的 Excel 导出对象
    return excel;
}
总结
要点 说明
生产者-消费者模式 查询线程(生产者)负责数据获取,写入线程(消费者)负责数据写入,通过阻塞队列解耦
分页查询 使用 lastOrderId 作为游标进行分页,比 OFFSET 更高效,避免深度分页性能问题。
小批量处理 PAGE_SIZE 设置为 2000-50000 之间,平衡 I/O 次数和内存占用。
多 Sheet 分割 Excel 单个 Sheet 有行数限制(约104万),通过 ROWS_PER_SHEET 控制,自动创建新 Sheet。
DTO 转换 convertToExcel 方法负责将数据库实体 (OrderMainVo) 转换为导出专用的 DTO (OrderEasyExcel),在此处完成枚举转文字、日期格式化等逻辑。
资源管理 使用 try-with-resources 或手动调用 excelWriter.finish() 确保流被正确关闭。

四、多线程实现的关键配置

1. 线程池配置
//使用ThreadPoolExecutor并提供合理的配置
ExecutorService queryExecutor = new ThreadPoolExecutor(
    QUERY_THREAD_POOL_SIZE, // 核心线程数
    QUERY_THREAD_POOL_SIZE, // 最大线程数
    60L, TimeUnit.SECONDS,   // 空闲线程存活时间
    new LinkedBlockingQueue<>(10), // 有界工作队列
    new ThreadFactory() {           // 命名线程工厂
        private final AtomicLong counter = new AtomicLong(1);
        @Override
        public Thread newThread(Runnable r) {
            Thread thread = new Thread(r, "OrderExport-Query-" + counter.getAndIncrement());
            thread.setDaemon(true); // 设置为守护线程
            return thread;
        }
    },
    new ThreadPoolExecutor.CallerRunsPolicy() // 合理的拒绝策略
);
2. 异常处理机制
// 关键:finally块中必须放入毒丸标记,防止写入线程永久阻塞
finally {
    try {
        dataQueue.put(POISON_PILL);
        if (hasError) {
            logger.warn("查询线程异常结束,已发送结束信号");
        }
    } catch (InterruptedException ie) {
        Thread.currentThread().interrupt();
        logger.warn("放入毒丸时被中断", ie);
    }
}
**3. **资源泄漏预防
@Override
public void exportOrderExcel(HttpServletResponse response, OrderMainVo orderMainVo) throws IOException {
    ExcelWriter excelWriter = null;
    try {
        excelWriter = EasyExcel.write(response.getOutputStream(), OrderEasyExcel.class)
                .autoCloseStream(false)
                .build();
        exportWithMultiThread(excelWriter, orderMainVo);
    } catch (Exception e) {
        logger.error("导出过程异常", e);
        throw new IOException("导出失败", e);
    } finally {
        // 确保ExcelWriter正确关闭
        if (excelWriter != null) {
            try {
                excelWriter.finish();
            } catch (Exception e) {
                logger.warn("关闭ExcelWriter异常", e);
            }
        }
    }
}

五、复杂表头实现(注解驱动)

EasyExcel 通过 @ExcelProperty 注解轻松实现多级表头。
在这里插入图片描述

DTO 定义 (OrderEasyExcel.java )
@Data
public class OrderEasyExcel {
    // 定义 Excel 表头为两级结构:一级表头“订单基本信息”,二级表头“用户订单号”
    /** 用户订单ID - 订单基本信息 */
    @ExcelProperty({"订单基本信息", "用户订单号"})
    @ColumnWidth(18)
    private Long orderId;

    /** 用户ID - 订单基本信息 */
    @ExcelProperty({"订单基本信息", "用户编码"})
    @ColumnWidth(18)
    private Long userOpenId;

    /** 设备 SN 号 - 订单基本信息 */
    @ExcelProperty({"订单基本信息", "设备 SN 号"})
    @ColumnWidth(20)
    private String deviceSn;

    /** 订单总金额 - 订单基本信息 */
    @ExcelProperty({"订单基本信息", "订单总金额"})
    @ColumnWidth(15)
    private BigDecimal totalAmount;

    /** 实付金额 - 订单基本信息 */
    @ExcelProperty({"订单基本信息", "实付金额"})
    @ColumnWidth(15)
    private BigDecimal paymentAmount;

    /** 下单时间 - 订单基本信息 */
    @ExcelProperty({"订单基本信息", "下单时间"})
    @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
    @ColumnWidth(20)
    private Date orderTime;

    /** 支付时间 - 订单基本信息 */
    @ExcelProperty({"订单基本信息", "支付时间"})
    @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
    @ColumnWidth(20)
    private Date paymentTime;

    /** 订单状态 - 订单基本信息 */
    @ExcelProperty({"订单基本信息", "订单状态"})
    @ColumnWidth(12)
    private String orderStatus;

    /** 货道 ID/层数ID - 订单明细 */
    @ExcelProperty({"订单明细", "货道 ID/层数ID"})
    @ColumnWidth(15)
    private Long channelId;

    /** 商品名称 - 订单明细 */
    @ExcelProperty({"订单明细", "商品名称"})
    @ColumnWidth(25)
    private String commodityName;

    /** 商品单价 - 订单明细 */
    @ExcelProperty({"订单明细", "商品单价"})
    @ColumnWidth(12)
    private BigDecimal unitPrice;

    /** 购买数量 - 订单明细 */
    @ExcelProperty({"订单明细", "购买数量"})
    @ColumnWidth(12)
    private Long quantity;
}
总结
注解 作用
@ExcelProperty({"一级表头", "二级表头"}) 定义 Excel 列的多级表头结构,数组顺序决定层级
@ColumnWidth(n) 设置该列在 Excel 中的显示宽度为 n 个字符
@DateTimeFormat("pattern") DateLocalDateTime 等时间类型按指定格式(如 "yyyy-MM-dd HH:mm:ss")写入 Excel

六、EasyExcel 与其他 Excel 处理技术的对比

Apache POI
特性对比 Apache POI EasyExcel
内存占用 全量加载,处理百万行数据时内存占用可达数百MB甚至GB级别 流式读写,内存占用稳定在MB级别
性能表现 大数据量时性能急剧下降,容易OOM 支持分页读取/写入,性能稳定
API 复杂度 API 繁琐,需要手动创建 Workbook、Sheet、Row、Cell 注解驱动,配置简单
开发效率 代码冗余,需要处理大量细节 核心代码仅需几行,开发效率高
功能特性 功能全面,支持所有 Excel 特性 专注导入导出,对大文件处理优化
社区生态 Apache 顶级项目,生态成熟 阿里巴巴开源,社区活跃
JExcel API (JXL)
特性对比 JExcel API EasyExcel
Excel 版本支持 仅支持 .xls (Excel 97-2003) 支持 .xls 和 .xlsx
内存管理 同样存在内存问题 专门优化大文件处理
注解支持 不支持注解映射 支持注解映射,开发便捷
功能丰富度 功能相对简单 支持复杂表头、数据校验等
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐