百万数据easyExcel导出优化
百万数据easyExcel导出优化
依赖
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.2</version>
</dependency>
postgresql
背景
支付记录导出!
导出某时间点之后的百万数据
-- ----------------------------
-- 表 4:支付记录表(t_payment_record)
-- ----------------------------
CREATE TABLE IF NOT EXISTS t_payment_record (
id VARCHAR(64) PRIMARY KEY NOT NULL, -- 支付订单号(业务侧生成的outTradeNo)
item_id VARCHAR(64), -- 缴费项目ID
item_name VARCHAR(255), -- 订单标题(缴费项目名称)
description VARCHAR(500), -- 新增字段:项目详细说明
person_id VARCHAR(64), -- 支付人员ID
person_name VARCHAR(64), -- 支付人员姓名
amount NUMERIC(19,4), -- 支付金额(结算后金额)
status VARCHAR(50), -- 支付状态(PENDING:待支付, SUCCESS:成功, FAILED:失败)
pay_user_app_ip VARCHAR(45), -- 支付用户终端IP(IPv4/IPv6)
user_agent VARCHAR(512), -- 用户浏览器代理信息
pay_success_url VARCHAR(1024), -- 支付成功跳转URL
create_time TIMESTAMP WITH TIME ZONE, -- 记录创建时间
update_time TIMESTAMP WITH TIME ZONE, -- 记录更新时间(状态变更时更新)
school_class VARCHAR(255), -- 学校班级信息(数据库存组织路径名称,前端解析展示)
original_amount NUMERIC(19,4), -- 项目原金额(未减免前的金额)
reduction_item_details TEXT, -- 减免项目详情(JSON数组格式,包含ID、名称、金额)
error_msg VARCHAR(1024)
);
方案三 游标分页导出(需某字段逻辑有序)推荐
3.1核心思路
“create_time + id联合索引 + 游标分页 + 生产者 - 消费者模式”
摒弃offset分页,基于 “有序字段游标” 直接定位下一页起点(如自增主键id、创建时间create_time),彻底避免扫描前序无关数据。
采用生产者-消费者模式 与 多线程并发处理 的组合方案
查询-写入解耦:通过队列解耦两个阶段,充分利用数据库和磁盘 I/O 的并行处理能力
3.4若依赖有序字段不唯一
当前场景的核心限制:id是 UUID 字符串(无序,无法作为游标字段),但create_time是有序的时间戳(可作为游标字段)。因此,方案需基于create_time实现游标分页,结合id解决create_time重复问题,同时通过联合索引优化查询效率。
1. 索引设计与数据查询逻辑
索引
由于id是 UUID(无序),create_time是有序时间戳(可能重复),需创建联合索引确保分页有序性和查询效率
联合索引: 百万占73M
-- 联合索引:(create_time, id)
-- 作用:1. 利用create_time的有序性作为游标基础;2. 用id解决create_time重复时的排序唯一性
CREATE INDEX idx_payment_create_time_id ON t_payment_record (create_time, id);
覆盖索引:百万占154M 但提升不大
create index idx_payment_fix_end on
t_payment_record (create_time, id, item_name, person_id, person_name, amount, status);
数据查询逻辑
基于create_time和id作为游标条件
普通查询
-- 上一页最后一条记录的create_time = #{lastCreateTime},id = #{lastId}
select *
from t_payment_record
where
-- 核心条件:时间在后 或 时间相同但id在后(解决时间重复)
(create_time > #{lastCreateTime})
or (create_time = #{lastCreateTime} and id > #{lastId})
order by create_time asc, id asc -- 与联合索引顺序一致,确保索引生效
limit #{batchSize}; -- 批量大小(如1000)
查询某时间点之后
select create_time, id, item_name, person_id, person_name, amount, status
from t_payment_record
where create_time >= #{lastCreateTime}
and (
create_time > #{lastCreateTime}
or (
create_time = #{lastCreateTime}
and (
#{lastId,jdbcType=VARCHAR} is null
or id > #{lastId,jdbcType=VARCHAR}
)
)
)
order by create_time asc, id asc
limit #{batchSize}
会先按照
create_time排序,对于create_time完全相同的记录,会进一步按照id进行字典序排序(UUID 本质是字符串,按字符串比较规则排序)
外层
create_time >= #{lastCreateTime}:划定总查询范围不能去掉外层, 索引利用效率会下降:PostgreSQL 的查询优化器在解析
create_time >= ... and (...)时,能更明确地定位到联合索引(create_time, id)中create_time >= lastCreateTime的范围,扫描效率更高;而缺少外层条件时,优化器可能需要更复杂的判断,影响索引使用。 实测去掉特别慢 直接400多秒了
{lastId,jdbcType=VARCHAR}
postgresql对null参数的类型敏感 需明确参数类型
2.本地性能实测
不加索引 258秒 -》》 联合索引 119秒 ->> 覆盖索引 104秒
分页大小1000 ->>>2000: 86秒 ->>>>2300:83秒->>>>2500:85秒(提升已不大)
仅联合索引 Index Scan
Limit (cost=0.55..8.58 rows=1 width=124)
-> Index Scan using idx_payment_create_time_id on t_payment_record (cost=0.55..8.58 rows=1 width=124)
Index Cond: (create_time >= '2025-09-05 19:19:57.19+08'::timestamp with time zone)
Filter: ((create_time > '2025-09-05 19:19:57.19+08'::timestamp with time zone) OR ((create_time = '2025-09-05 19:19:57.19+08'::timestamp with time zone) AND ((id)::text > 'MOCK_36472235-4553-461f-b682-a61cb7ca8b80'::text)))
覆盖索引 Index Only Scan
Limit (cost=0.55..8.58 rows=1 width=124)
-> Index Only Scan using idx_payment_fix_end on t_payment_record (cost=0.55..8.58 rows=1 width=124)
Index Cond: (create_time >= '2025-09-05 19:19:57.19+08'::timestamp with time zone)
Filter: ((create_time > '2025-09-05 19:19:57.19+08'::timestamp with time zone) OR ((create_time = '2025-09-05 19:19:57.19+08'::timestamp with time zone) AND ((id)::text > 'MOCK_36472235-4553-461f-b682-a61cb7ca8b80'::text)))
3. Java 代码改造(生产者 - 消费者模式)
沿用原方案的 “生产者 - 消费者 + 阻塞队列” 模式(避免内存溢出),但需适配create_time和id作为游标字段,核心改造如下:
(1)实体类定义
// Excel导出DTO(按需映射,避免直接用实体类)
@Data
public class PaymentRecordExcel {
@ExcelProperty("支付订单号")
private String id;
@ExcelProperty("支付时间")
private String createTime; // 格式化后的字符串(如"yyyy-MM-dd HH:mm:ss")
/**
* 订单标题(缴费项目名称)
*/
@TableField(value = "item_name")
private String itemName;
/**
* 身份证
*/
@ExcelProperty("身份证")
private String personId;
/**
* 支付人员姓名
*/
@ExcelProperty("支付人员姓名")
private String personName;
@ExcelProperty("金额")
private BigDecimal amount;
/**
* 支付状态(PENDING:待支付, SUCCESS:成功, FAILED:失败)
*/
@ExcelProperty("支付状态")
private String status;
}
(2)Mapper 接口与 XML(分页查询)
/**
* 查询指定时间及之后的数据,处理时间重复时的ID续查
* @param lastCreateTime 起始时间(包含)
* @param lastId 起始ID(首次查询为null,续查为上一批最后一个ID)
* @param batchSize 批量大小
*/
List<TPaymentRecord> listByCursor(
@Param("lastCreateTime") Date lastCreateTime,
@Param("lastId") String lastId,
@Param("batchSize") int batchSize
);
<select id="listByCursor" resultType="com.yuvision.dvsa.entity.TPaymentRecord">
select id, create_time, item_name, person_id, person_name, amount, status
from t_payment_record
where
create_time >= #{lastCreateTime} -- 包含起始时间及之后的数据
and (
create_time > #{lastCreateTime} -- 时间在起始时间之后,不限制ID
or (
create_time = #{lastCreateTime} -- 时间等于起始时间
and (
#{lastId,jdbcType=VARCHAR} is null -- 首次查询:包含所有同时间的记录
or id > #{lastId,jdbcType=VARCHAR} -- 续查:只包含ID大于上一批最后一个ID的记录
)
)
)
order by create_time asc, id asc -- 与联合索引顺序一致
limit #{batchSize}
</select>
(3)核心导出逻辑(生产者 - 消费者模式)
/**
* 基于create_time游标分页的百万级数据导出
* 导出指定时间及之后的所有支付记录(优化:lastCreateTime从参数传入,非null)
*
* @param lastCreateTime 起始时间(包含该时间)
* @param lastId 起始ID(可选,用于分页续查,首次查询传null)
* @return 导出文件路径
*/
public void exportMillionData(Date lastCreateTime, String lastId) throws InterruptedException {
// 校验参数:lastCreateTime不可为null
if (lastCreateTime == null) {
throw new IllegalArgumentException("lastCreateTime不能为空");
}
// 1. 初始化参数
String filePath = "D://temp//" + System.currentTimeMillis() + ".xlsx";
//分页大小1000 ->>>2000: 86秒 ->>>>2300:83秒->>>>2500:85秒(提升已不大)
int batchSize = 2300; // 批量大小(可根据测试调整,建议1000-2000)
BlockingQueue<List<TPaymentRecord>> queue = new ArrayBlockingQueue<>(10); // 缓冲队列(避免内存溢出)
ExecutorService executor = Executors.newFixedThreadPool(2); // 1个生产者+1个消费者
// 关键:CountDownLatch计数=1,用于等待消费者完成所有写入
CountDownLatch allDoneLatch = new CountDownLatch(1);
// 游标跟踪:从传入的lastCreateTime和lastId开始
AtomicReference<Date> currentCreateTime = new AtomicReference<>(lastCreateTime);
AtomicReference<String> currentId = new AtomicReference<>(lastId);
// // 2. 游标跟踪(上一页最后一条记录的createTime和id,初始为null)
// AtomicReference<Date> lastCreateTime = new AtomicReference<>(null);
// AtomicReference<String> lastId = new AtomicReference<>(null);
long startTime = System.currentTimeMillis();
// 3. 生产者线程:查询数据并放入队列
executor.submit(() -> {
try {
int batchNum = 0;
while (true) {
// 3.1 调用mapper查询下一批数据
List<TPaymentRecord> data = paymentRecordMapper.listByCursor(
currentCreateTime.get(),
currentId.get(),
batchSize
);
// 3.2 若查询结果为空,说明数据已全部导出,退出循环
if (data == null || data.isEmpty()) {
// 数据查询完毕,放入空数组作为结束标记
queue.put(new ArrayList<>());
log.info("所有数据查询完成,共导出{}批", batchNum);
break;
}
// 3.3 更新游标(记录当前批次最后一条数据的createTime和id)
TPaymentRecord lastRecord = data.get(data.size() - 1);
currentCreateTime.set(lastRecord.getCreateTime());
currentId.set(lastRecord.getId());
// 3.4 将数据放入队列(阻塞等待,避免OOM)
queue.put(data);
batchNum++;
log.info("生产第{}批数据,大小:{},最后一条时间:{}",
batchNum, data.size(), lastRecord.getCreateTime());
}
} catch (Exception e) {
log.error("生产者查询数据异常", e);
// 异常时也放入结束标记,避免消费者卡死
try {
queue.put(new ArrayList<>());
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
});
// 4. 消费者线程:从队列取数据并写入Excel
executor.submit(() -> {
// 4.1 初始化EasyExcel写入器
try (ExcelWriter excelWriter = EasyExcel.write(filePath, PaymentRecordExcel.class).build();
) {
WriteSheet writeSheet = EasyExcel.writerSheet("支付记录").build();
int writeBatchNum = 0;
log.info("消费者开始写入Excel...");
// 4.2 循环从队列取数据,直到生产者完成且队列空
while (true) {
// 从队列取数据(超时5秒,避免无限阻塞)
List<TPaymentRecord> data = queue.poll(5, TimeUnit.SECONDS);
if (data == null || data.isEmpty()) {
// 收到结束标记,退出循环
log.info("消费者完成,共写入{}批数据", writeBatchNum);
break;
}
// 转换为ExcelDTO(格式化时间等)
List<PaymentRecordExcel> excelData = data.stream().map(record -> {
PaymentRecordExcel excel = new PaymentRecordExcel();
BeanUtil.copyProperties(record, excel);
Date createTime = record.getCreateTime();
excel.setCreateTime(DateUtil.format(createTime, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
return excel;
}).collect(Collectors.toList());
// 写入Excel
excelWriter.write(excelData, writeSheet);
writeBatchNum++;
log.info("写入第{}批数据,大小:{}", writeBatchNum, data.size());
}
log.info("Excel写入完成,文件路径:{}", filePath);
} catch (Exception e) {
log.error("Excel写入异常", e);
} finally {
// 无论成功失败,都通知主线程“所有写入完成”
allDoneLatch.countDown();
}
});
// 5. 等待所有任务完成,关闭资源
//
allDoneLatch.await(); // 等待生产者完成
long endTime = System.currentTimeMillis();
log.info("导出完成,总耗时:{}秒", (endTime - startTime) / 1000);
executor.shutdown();
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
log.warn("线程池未正常关闭,强制终止");
executor.shutdownNow();
}
// 睡眠几秒的作用是跑测试用例时候,以免最后一次的countdown唤醒主线程后,excelWriter还没有来得及关闭程序就退出了
Thread.sleep(10000);
log.info("已结束");
}
mysql
通过easyExcel 导出1,000,001条数据
需要考虑以下问题
1.需要分页,如果一次性导出,内存会溢出,需要分页批量导出。
2.批量导出,如果采用分页查询,需要考虑是否出现深分页带来的性能问题。
3.考虑采用多线程处理
4.采用阿里巴巴开源的EasyExcel组件生成excel文件,且EasyExcel不支持多线程导出
方案一 单线程分页导出(基础方案)
核心思路
通过分页查询(LIMIT #{offset}, #{size})批量获取数据,逐批写入 Excel,避免一次性加载全部数据导致内存溢出。
适用场景
适用于数据量较小(非深分页)的场景,或作为性能基准测试(用于定位瓶颈)
本地性能实测
...
数据库查询耗时830豪秒
写入excel耗时11毫秒
第982次写入1000条数据成功
数据库查询耗时751豪秒
写入excel耗时11毫秒
...
生成excel总耗时464秒 db耗时406843毫秒 excel写入耗时10025毫秒
瓶颈分析:数据库 IO 耗时占比极高(约 87.7%),因分页查询未利用索引,导致全表扫描
核心sql
select t.*
from t_eeg_record_mock t
limit #{offset}, #{size}
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | t | ALL | 1008210 | 100.0 |
(注:type=ALL 表示全表扫描,无索引可用,随offset增大,扫描行数激增)
方案二 覆盖索引优化分页导出
2.1核心思路
数据库采用 “子查询先获取主键 ID,再关联查询全字段” 的方式,利用主键索引减少无效数据扫描,优化深分页性能。
采用生产者-消费者模式 与 多线程并发处理 的组合方案
查询-写入解耦:通过队列解耦两个阶段,充分利用数据库和磁盘 I/O 的并行处理能力
2.2使用场景
- 业务必须支持 “随机跳页”,无法接受方案三的 “只能顺序分页”
- 有序字段不稳定或不存在
2.3本地性能实测
生成excel总耗时109秒
2.4核心sql
子查询先通过主键索引查id,再关联获取,避免扫描大量无关行
select t.*
from t_eeg_record_mock t,
(select id from t_eeg_record_mock limit #{offset}, #{size}) t2
where t.id = t2.id
order by t.id asc
优化原理
- 子查询仅扫描
id(主键,通常包含在索引中),减少字段读取的 IO 成本; - 主查询通过
id = t2.id利用主键索引快速定位全字段,避免全表扫描。
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | PRIMARY | ALL | 2000 | 100.0 | Using temporary; Using filesort | ||||||
| 1 | PRIMARY | t | eq_ref | PRIMARY | PRIMARY | 4 | t2.id | 1 | 100.0 | ||
| 2 | DERIVED | t_eeg_record_mock | index | idx_create_time | 6 | 1008210 | 100.0 | Using index |
(注:子查询通过idx_create_time索引(含主键id)实现覆盖扫描,避免全表读取)
方案三 游标分页导出(需某字段逻辑有序)推荐
3.1核心思路
摒弃offset分页,基于 “有序字段游标” 直接定位下一页起点(如自增主键id、创建时间create_time),彻底避免扫描前序无关数据。
采用生产者-消费者模式 与 多线程并发处理 的组合方案
查询-写入解耦:通过队列解耦两个阶段,充分利用数据库和磁盘 I/O 的并行处理能力
3.2适用场景
- 数据量极大,存在深分页问题(如导出百万级数据),需要避免传统
limit offset因offset过大导致的全表扫描效率问题; - 分页字段是 “有序的”(字段值本身可比较大小,如时间、自增 ID 等),无论插入顺序如何,只要字段值逻辑上有先后顺序即可!
- 不需要 “跳页” 查询(如只能从第 1 页→第 2 页→…→第 N 页,无法直接跳到第 100 页),因为游标分页依赖上一页的最后一条数据定位下一页;
- 字段允许存在重复值(需结合唯一字段如主键,通过联合索引保证排序唯一性)。
3.3若依赖有序字段唯一(如主键id)
3.3.1本地性能实测
生成excel总耗时11秒
3.3.2核心sql
select t.*
from t_eeg_record_mock t
where t.id > #{lastId} -- 上一页最后一条数据的id
order by id asc
limit #{size};
3.3.3代码
/**
* 使用id分页优化,前提条件id是有序的,50万条数据大约5秒
* 100万约11秒
* @throws InterruptedException
*/
public void generateExcelAsyncAndIdMax() throws InterruptedException {
BlockingQueue<List<EegRecordMock>> queue = new ArrayBlockingQueue<>(10);// 添加缓冲队列
String filePath = "D://temp//" + System.currentTimeMillis() + ".xlsx";
ExecutorService executorService = Executors.newFixedThreadPool(2);
//批量大小 可根据测试结果调整
int batchNum0 = 1000;
int total0 = (int) this.count();
int num = total0 / batchNum0;
num = total0 % batchNum0 == 0 ? num : num + 1;
CountDownLatch countDownLatch = new CountDownLatch(num);
long startTime = System.currentTimeMillis();
executorService.submit(() -> {
try {
int batchNum = batchNum0;
int total = total0;
int current = 1;
AtomicInteger index = new AtomicInteger(0);
//数据库以2为起始id idIndex为2-1=1
AtomicReference<Integer> idIndex = new AtomicReference<>(1);
log.info("开始数据生产任务,总数据量:{}", total); // 规范日志级别
while (total > 0) {
int offset = (current - 1) * batchNum;
batchNum = total > batchNum ? batchNum : total;
int size = batchNum;
int maxId = idIndex.get();
List<EegRecordMock> data = eegRecordMockMapper.listBetweenId(maxId, size);
//取本批次最后一条记录的id作为下次查询起点
idIndex.set(data.get(data.size() - 1).getId());
try {
queue.put(data);
log.info("生产第{}批次数据,最大ID:{},批次大小:{}",
index.incrementAndGet(), maxId, data.size()); // 结构化日志
} catch (InterruptedException e) {
log.error("数据放入队列中断异常,剩余数据量:{}", total, e);
Thread.currentThread().interrupt(); // 重置中断状态
}
total -= batchNum;
current++;
}
log.info("数据生产任务完成"); // 规范日志级别
} catch (Exception e) {
log.error("生产线程发生未预期异常,异常信息:{}", e.getMessage(), e);
}
});
executorService.submit(() -> {
try (ExcelWriter excelWriter = EasyExcel.write(filePath, EegRecordMockExcel.class).build()) {
WriteSheet writeSheet = EasyExcel.writerSheet("模板").build();
log.info("开始Excel写入任务--------");
try {
while (countDownLatch.getCount() > 0) {
List<EegRecordMock> data = queue.poll(5, TimeUnit.SECONDS); // 添加超时机制
if (data == null) {
log.error("获取数据超时,剩余批次:{}", countDownLatch.getCount());
break;
}
if (!data.isEmpty()) {
excelWriter.write(data, writeSheet);
log.debug("成功写入第{}批次,数据量:{}",
countDownLatch.getCount(), data.size()); // 调试级别日志
}else{
log.warn("data为空");
}
countDownLatch.countDown();
}
log.info("结束Excel写入任务--------");
} catch (InterruptedException e) {
log.error("数据消费中断异常", e);
Thread.currentThread().interrupt(); // 重置中断状态
}
} catch (Exception e) {
log.error("Excel写入失败,文件路径:{},异常信息:{}", filePath, e.getMessage(), e);
}
});
countDownLatch.await();
long endTime = System.currentTimeMillis();
log.warn("生成excel总耗时{}秒", (endTime - startTime) / 1000);
// 添加线程池关闭
executorService.shutdown();//优雅关闭
if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) {//等待10秒
log.warn("线程池未正常关闭,强制终止");
executorService.shutdownNow();
}
// 睡眠三秒的作用是跑测试用例时候,以免最后一次的countdown唤醒主线程后,excelWriter还没有来得及关闭程序就退出了
Thread.sleep(10000);
log.info("已结束");
}
<select id="listBetweenId" resultType="com.dake.excel.demo.dao.entity.EegRecordMock">
select t.*
from t_eeg_record_mock t
where t.id > #{id}
order by id asc
limit #{size}
</select>
@TableName("t_eeg_record_mock")
@Data
public class EegRecordMock implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键id
*/
@TableId(type = IdType.AUTO)
private Integer id;
/**
* 唯一标识uuid
*/
private String uuid;
/**
* 用户id
*/
private Integer userId;
/**
* 实验设置表id
*/
private Integer algoId;
/**
* 设备名称
*/
private String deviceName;
/**
* 系统生成数据时间
*/
private LocalDateTime createTime;
}
@ContentRowHeight(20)
@HeadRowHeight(35)
@ColumnWidth(20)
@Data
public class EegRecordMockExcel implements Serializable {
private static final long serialVersionUID = 1L;
@ExcelProperty({"脑电主数据id"})
private Integer id;
@ExcelProperty({"uuid"})
private String uuid;
/**
* 用户id
*/
@ExcelProperty("用户id")
private Integer userId;
/**
* 算法id
*/
@ExcelProperty("算法id")
private Integer algoId;
/**
* 设备名称
*/
@ExcelProperty("设备名称")
private String deviceName;
/**
* 系统生成数据时间
*/
@ExcelProperty("创建时间")
private LocalDateTime createTime;
}
3.4若依赖有序字段不唯一
若分页依赖有序字段不唯一(如非主键: order by create_time),需确保排序字段有索引,然后结合主键保证唯一性创建联合索引,如 order by create_time, id
比如导出支付记录, id为uuid, 需要按时间先后展示
3.4.1本地性能实测
3.4.2优化步骤
1 创建联合索引:(确保排序字段在前,主键在后,保证唯一性)
对于
create_time完全相同的记录,会进一步按照id进行字典序排序
create index idx_create_time_id on t_eeg_record_mock(create_time, id);
2 基于排序字段的游标分页:(基于上一页末尾的create_time和id)
-- 上一页最后一条数据的 create_time = last_time,id = last_id
select *
from t_eeg_record_mock
where create_time > #{last_time}
or (create_time = #{last_time} and id > #{last_id}) -- 处理create_time相同的情况
order by create_time, id
limit #{size};
通过联合索引实现 “范围 + 精确匹配”,既保证排序一致性,又避免扫描无关数据。

浙公网安备 33010602011771号