1. 引言
在业务系统中,数据导出是高频需求。当数据量达到百万级别时,传统的 POI 直接导出方式往往会面临内存溢出(OOM)或导出耗时过长的问题。EasyExcel 是阿里巴巴开源的一款基于 SAX 模式解析的 Excel 工具,它通过流式读写大幅降低了内存占用,配合 MyBatis 的游标查询(Cursor),可以轻松实现百万级数据的平滑导出。
本文将带你从零开始,完成 MyBatis 与 EasyExcel 的整合,实现百万数据的高效导出,并给出完整的代码示例与性能优化建议。
2. 技术选型与核心思路
2.1 为什么选择 EasyExcel
- 低内存占用:EasyExcel 采用 SAX 模式逐行读写,不将整个 Excel 加载到内存。
- API 简洁:提供注解模型,几行代码即可完成导出。
- 社区活跃:阿里巴巴开源,文档完善,遇到问题容易排查。
2.2 为什么配合 MyBatis 游标查询
MyBatis 3.4.0 及以上版本支持Cursor<T>返回值。游标查询不会一次性将全部结果加载到内存,而是按需从数据库逐条获取,配合 EasyExcel 的流式写入,形成「边查边写」的流水线,避免百万数据同时驻留内存。
2.3 整体流程
3. 环境准备
3.1 依赖引入
以 Spring Boot 2.7 + MyBatis 3.5 为例,在pom.xml中添加依赖:
<!-- MyBatis Spring Boot Starter --><dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>2.3.1</version></dependency><!-- EasyExcel --><dependency><groupId>com.alibaba</groupId><artifactId>easyexcel</artifactId><version>3.3.2</version></dependency><!-- MySQL 驱动 --><dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-java</artifactId><version>8.0.33</version></dependency>3.2 数据库准备
以用户表为例,建表语句如下:
CREATETABLE`t_user`(`id`bigint(20)NOTNULLAUTO_INCREMENT,`name`varchar(50)NOTNULL,`phone`varchar(20)DEFAULTNULL,`email`varchar(100)DEFAULTNULL,`create_time`datetimeDEFAULTNULL,PRIMARYKEY(`id`))ENGINE=InnoDBDEFAULTCHARSET=utf8mb4;4. 代码实现
4.1 定义导出模型
使用 EasyExcel 注解定义导出列:
importcom.alibaba.excel.annotation.ExcelProperty;importlombok.Data;importjava.util.Date;@DatapublicclassUserExportModel{@ExcelProperty("用户ID")privateLongid;@ExcelProperty("姓名")privateStringname;@ExcelProperty("手机号")privateStringphone;@ExcelProperty("邮箱")privateStringemail;@ExcelProperty("创建时间")privateDatecreateTime;}4.2 Mapper 层游标查询
在 Mapper 接口中定义返回Cursor<T>的查询方法:
importorg.apache.ibatis.cursor.Cursor;importorg.apache.ibatis.annotations.Mapper;importorg.apache.ibatis.annotations.Select;@MapperpublicinterfaceUserMapper{@Select("SELECT id, name, phone, email, create_time FROM t_user")Cursor<UserExportModel>selectAllForExport();}注意:使用游标查询时,必须保持数据库连接处于开启状态,直到游标遍历完毕。因此查询和写入必须在同一个事务或同一个 SqlSession 内完成。
4.3 Service 层实现导出
importcom.alibaba.excel.EasyExcel;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.stereotype.Service;importorg.springframework.transaction.annotation.Transactional;importjavax.servlet.http.HttpServletResponse;importjava.io.IOException;importjava.net.URLEncoder;@ServicepublicclassUserExportService{@AutowiredprivateUserMapperuserMapper;@Transactional(readOnly=true)publicvoidexportMillionUsers(HttpServletResponseresponse)throwsIOException{// 设置响应头response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");response.setCharacterEncoding("utf-8");StringfileName=URLEncoder.encode("百万用户数据","UTF-8").replaceAll("\\+","%20");response.setHeader("Content-disposition","attachment;filename*=utf-8''"+fileName+".xlsx");// 使用 try-with-resources 确保游标和写入器正确关闭try(Cursor<UserExportModel>cursor=userMapper.selectAllForExport()){EasyExcel.write(response.getOutputStream(),UserExportModel.class).sheet("用户数据").doWrite(()->cursor);}}}4.4 Controller 层接口
importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RequestMapping;importorg.springframework.web.bind.annotation.RestController;importjavax.servlet.http.HttpServletResponse;importjava.io.IOException;@RestController@RequestMapping("/api/export")publicclassUserExportController{@AutowiredprivateUserExportServiceuserExportService;@GetMapping("/users")publicvoidexportUsers(HttpServletResponseresponse)throwsIOException{userExportService.exportMillionUsers(response);}}5. 关键点解析
5.1 为什么必须加@Transactional
游标查询依赖底层 JDBC 连接保持打开。如果不加事务,MyBatis 在查询结束后可能立即关闭连接,导致游标无法继续读取。加上@Transactional(readOnly = true)可以保证整个导出过程中连接不释放。
5.2 游标查询的 fetchSize 优化
对于 MySQL,可以在 Mapper 中设置fetchSize为Integer.MIN_VALUE,让驱动使用流式读取:
@Select("SELECT id, name, phone, email, create_time FROM t_user")@Options(fetchSize=Integer.MIN_VALUE)Cursor<UserExportModel>selectAllForExport();5.3 分批写入与内存控制
EasyExcel 默认每 100 条写入一次,可通过inMemory参数控制:
EasyExcel.write(response.getOutputStream(),UserExportModel.class).inMemory(false)// 使用临时文件,降低内存占用.sheet("用户数据").doWrite(()->cursor);6. 性能对比与优化建议
6.1 性能对比
| 方案 | 内存占用 | 导出 100 万条耗时(参考) | 适用场景 |
|---|---|---|---|
| POI 一次性加载 | 极高,易 OOM | 60s+ | 小数据量 |
| POI + 分批查询 | 中等 | 40s+ | 中等数据量 |
| MyBatis 游标 + EasyExcel | 低 | 20s~30s | 百万级数据 |
6.2 优化建议
- 关闭自动提交:在导出过程中关闭事务自动提交,减少磁盘 IO。
- 合理设置 fetchSize:根据数据库类型调整,MySQL 使用
Integer.MIN_VALUE触发流式读取。 - 异步导出:百万数据导出耗时较长,建议改为异步任务,导出完成后通知用户下载。
- 限制导出条件:如果业务允许,尽量通过时间范围等条件分批导出,降低单次压力。
7. 常见问题排查
7.1 导出时提示 “Streaming result set … is still active”
这是因为游标未关闭或连接被提前释放。检查是否添加了@Transactional,以及是否使用了 try-with-resources 正确关闭游标。
7.2 导出文件为空
检查 Mapper 查询是否返回了数据,以及 EasyExcel 的模型字段与查询列是否对应。
7.3 内存仍然很高
确认是否设置了inMemory(false),并检查是否误用了List接收查询结果(应使用Cursor)。
8. 总结
通过 MyBatis 游标查询与 EasyExcel 流式写入的结合,我们可以在较低的内存占用下完成百万级数据的 Excel 导出。核心要点有三个:
- 使用
Cursor<T>而非List<T>,避免全量加载到内存。 - 保持数据库连接不释放,通过
@Transactional保证游标可用。 - EasyExcel 流式写入,边读边写,形成流水线。
希望本文能帮助你解决大数据量导出的难题。如果你在实际项目中遇到了其他问题,欢迎在评论区交流讨论。