EasyExcel复杂表头和百万级数据导出
·
EasyExcel复杂表头和百万级数据导出
一、EasyExcel介绍
- EasyExcel是阿里巴巴开源的一个基于Java的Excel处理框架,以简单、高效、低内存消耗著称。
- 它是对Apache POI的增强和优化,它在处理大文件读写时具有更高的性能和更低的内存占用
- 官网:https://alibaba-easyexcel.github.io/index.html
二、为什么需要 EasyExcel
在后台管理系统中,Excel 导入导出是高频需求。但当数据量达到 10万+甚至百万行 时(如用户订单表、用户信息表),传统方案(如 Apache POI)会面临严重问题:
- 内存溢出 (OOM):POI 默认将整个 Excel 文件加载到内存,处理大文件极易导致服务崩溃。
- 开发效率低:POI API 繁琐,需手动创建
Workbook、Sheet、Row、Cell,并处理格式转换,代码冗余且易错。
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") |
将 Date 或 LocalDateTime 等时间类型按指定格式(如 "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 |
| 内存管理 | 同样存在内存问题 | 专门优化大文件处理 |
| 注解支持 | 不支持注解映射 | 支持注解映射,开发便捷 |
| 功能丰富度 | 功能相对简单 | 支持复杂表头、数据校验等 |
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)