
做后端开发这些年Excel导出算是我见过“翻车率”最高的功能之一。尤其是当数据量从几千行涨到几十万行时很多系统就像被卡住喉咙一样要么内存直接溢出要么前端页面卡死要么用户等了好几分钟最后下载下来一个损坏的文件。这类问题几乎每个团队都会遇到而EasyExcel的出现算是把“大数据量Excel导出”从“能不能做”变成了“怎么做才稳”的问题。这篇文章我不会讲那些网上到处都有的基础API调用而是结合我自己在几个真实项目中踩过的坑、调优过的参数、设计过的方案把“企业级大数据量导出”这件事从头到尾拆一遍。核心内容包括为什么选EasyExcel、内存优化的底层逻辑、百万级数据写入的完整代码骨架、异步导出的架构设计以及一套可以直接拿去排查问题的速查表。如果你正在维护那些包含大量历史数据、需要按条件筛选导出、动不动就是几万到几十万行数据的后台管理系统这篇文章应该能帮你少走不少弯路。1. 方案选型为什么 EasyExcel 能扛住大数据量1.1 传统 POI 在大数据量下的致命短板在EasyExcel出现之前大家普遍用Apache POI。那东西在数据量小的时候其实非常爽API写起来顺手样式控制也灵活。但数据量一旦上去问题就一个接一个地冒出来。核心在于POI的内存模型。它把整个工作簿的所有单元格都放在内存里维护一个单元格对象有几十个属性光对象头就不少字节。你想象一下一行数据假设有20列每列是一个Cell对象再加上Row对象、Sheet对象、样式对象这些对象之间还有复杂的引用关系。1万行数据可能就要占用一两百MB堆内存10万行数据基本可以让一台普通配置的服务器直接濒临OOM。我印象很深的一次事故是给某公司做一个报表系统用户筛选了三个月的订单数据大约25万行。用POI生成Excel的时候应用直接Full GC了十几次最后接口超时整个服务卡了将近两分钟才缓过来。那之后我们做的第一件事就是技术选型调研。POI其实提供了一个SXSSFWorkbook的流式版本解决了一部分问题。SXSSF的核心思路是“滑动窗口”只在内存中保留最近一定行数的数据超出窗口的数据会被刷到临时文件里。这个方案看起来不错但它仍然有以下明显问题对样式和格式的支持非常有限合并单元格、单元格样式在滑动窗口模式下处理得很别扭。临时文件频繁读写磁盘生成大文件时磁盘IO成为瓶颈。使用起来还是要写很多POI的样板代码对业务开发来说成本不低。对多次遍历同一个工作簿这种诉求支持不好内存兜底逻辑仍然容易触顶。所以在实测了性能、评估了代码复杂度之后我们几乎是一边倒地把票投给了EasyExcel。1.2 EasyExcel 的“一行一写”模型到底做了什么EasyExcel是阿里开源的Excel处理工具底层解决的就是POI在大数据量下的内存占用问题。它的官方定位是“快速、简洁、解决大文件内存溢出问题”。它的核心设计其实可以概括为“一行一写”模型读入一行数据解析一行数据然后立刻通过流写入文件写入后对象就可以被GC回收了。这样整个过程中内存里同时存活的数据量只是当前正在处理的那一小部分跟总数据量基本无关。光说理论可能不够直观。我直接说我们当时实测的一组对比数据方案导出数据量峰值堆内存生成文件耗时是否出现FGCPOI XSSFWorkbook5万行约1.8GB失败是OOMPOI SXSSFWorkbook100万行约250MB85秒是偶发EasyExcel100万行约65MB72秒否这组数据是在某台8核16G的测试机上跑的每行15列左右包含字符串、数字、日期类型。可以看到EasyExcel在内存上的优势非常明显。更重要的是处理100万行数据时EasyExcel完全没有出现Full GC这意味着服务的整体稳定性得到了保障。EasyExcel为什么能做到这一点我简单拆一下它的内部逻辑写入时直接操作OutputStream数据不走内存中转。通过分批刷写的方式每攒够一定数据量就写入一次IO流不会无限积压。大量复用了POI的SAX模式解析和对应的流式写入API但没有暴露繁琐的底层细节。对样式做了缓存和复用不会每行每列都创建全新的样式对象。所以如果你的项目里已经存在POI依赖那也没关系EasyExcel底层本身就用到了POI的注解和API两者在依赖上并不冲突但功能边界清晰得多。2. 核心设计几个必须想清楚的“前置问题”2.1 分页查还是全部查数据源怎么取大数据量导出第一个要决定的问题就是数据从哪来。很多人习惯写一个selectList然后一次性把几百万条数据丢到内存里再传给EasyExcel。这种做法本质上是把内存压力从Excel层转移到了数据查询层该爆还是会爆。正确的做法是分页查询、流式读取。在MyBatis的环境里最推荐的方式是用游标查询也就是Cursor。它不会一次性加载所有数据到内存而是逐条从数据库读取。EasyExcel的write方法配合Cursor迭代器可以做到真正的“边查边写”。如果你用的是MyBatis-Plus可以这样baseMapper.selectList(new QueryWrapper())其实还是全量查询正确姿势是用自定义Mapper方法返回List实体在XML里配合fetchSize-2147483648属性MySQL驱动的一种特殊标记让JDBC按游标逐行读取而不是把结果集全部拉到JVM里。还有一个更简单但同样有效的思路分页查询。按主键分页每次查询2万条处理完再查下一页。这种方式对代码的侵入小适合赶工期的情况。但要注意一个经典问题分页查询必须保证排序的绝对稳定否则数据可能重复或遗漏。最好用自增主键做排序条件而且查询条件要保持一致。2.2 同步导出还是异步导出这是个架构问题很多系统里Excel导出是同步接口也就是点击导出按钮后浏览器一直等待接口返回直到文件生成完毕。这个方案在数据量小于1万行时没什么感觉但到了大数据量场景就完全不行。30万行数据EasyExcel生成文件大约需要20到30秒。这个时间不算太长但对用户来说已经是一次很不友好的等待了。如果文件达到百万行生成时间可能超过一分钟。这时候HTTP连接早就断了或者服务器网关已经超时返回错误。企业级方案的标配做法是异步导出用户在页面上点击导出前端向后端发起一个“创建导出任务”的请求。后端把任务信息记录到数据库立即返回“任务已创建请稍后查看”。异步线程池处理导出任务完成后把文件上传到对象存储或本地磁盘。前端轮询任务状态发现完成之后直接跳转到下载链接。这个方案最大的好处是解耦。导出任务的耗时不再影响HTTP请求的响应时间超大规模的文件也不会因为网关超时导致下载失败。用户体验上就是先收到一个“正在生成”的提示几分钟后去“导出中心”下载文件。我后来在好几个项目里都直接采用了这个模式。因为只要数据量上了一定规模异步导出就不是可选项而是必须做的事。2.3 导出结果到底是一张表还是多个Sheet这个看似简单的问题实际操作中经常被忽略。大数据量导出的核心变量除了行数还有用户对Excel文件本身的期望。比如你要导出某个时间段的所有订单可能按月份分成多个Sheet每个Sheet存放一个月的数据。这在功能上没什么问题但你要知道一个工作簿里如果Sheet很多每个Sheet的数据量又很大对Excel客户端打开文件的速度有明显影响。另一个常见场景是按批次分Sheet。比如系统限制每条Sheet最多放50万行Excel老版本的单表行数上限是65536行新版本是1048576行超过这个限制自动切分到下一个Sheet。EasyExcel官方是支持这种多Sheet写法的实现时只需要注意WriteSheet的复用问题不能每次new一个WriteSheet对象还要重新指定表头。最推荐的做法是优先考虑单Sheet 超大文件的方案。只要单表行数不超过Excel上限就不要拆Sheet。因为拆Sheet对用户来说并没有降低使用复杂度反而让Excel本身的数据透视和批量筛选功能变得难以使用。只有当行数接近Excel上限时才考虑自动拆Sheet。3. 实战落地可复制的百万级数据导出代码骨架3.1 环境准备与依赖引入先说版本。EasyExcel目前最新的稳定版本是3.x。我从3.1.x用起一直到3.3.x整体API变化不算太大核心接口基本稳定。如果你的项目还在用2.x建议升级到3.x性能和API设计都有明显改善。dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.4/version /dependency如果你的项目里已经引了POI最好把POI的版本和EasyExcel的版本在Maven依赖中做一次统一。EasyExcel 3.x内部依赖POI 4.1.2或5.x版本冲突可能导致NoSuchMethodError之类的麻烦。最简单的办法是让Maven仲裁选择EasyExcel传递引入的POI版本业务代码里尽量不要直接硬coding POI的API。3.2 数据模型与关键注解EasyExcel允许通过注解把实体类映射成Excel的列。比如一个订单数据的导出模型可能是这样的public class OrderExportModel { ExcelProperty(订单编号) private String orderId; ExcelProperty(用户姓名) private String userName; ExcelProperty(商品名称) private String productName; ExcelProperty(value 订单金额, order 5) private BigDecimal amount; ExcelProperty(value 下单时间, dateFormat yyyy-MM-dd HH:mm:ss) private Date createTime; ExcelIgnore private String extField; }这里有两个值得注意的细节。第一ExcelProperty的value就是Excel表头名称order可以控制列的顺序。如果你希望通过动态列的方式处理表头则要用dynamicWrite相关的API。注解方式的好处是代码简单缺点是列顺序写死在类里如果需求频繁调整列显示顺序维护起来有点麻烦。第二ExcelIgnore标注的字段不会出现在Excel里。这个注解在从数据库实体类直接转导出模型时非常有用。比如实体里有一堆内部字段比如状态码、内部ID这些不需要让用户看到标准做法是单独建一个ExportModel而不是直接复用实体类。原因很简单导出模型的字段可能和数据库实体完全不同比如金额字段可能需要格式化时间字段可能需要转成字符串。混在一起后续需求变更时很容易互相踩踏。如果不想在实体类上写一堆注解EasyExcel 3.x也支持无注解方式。通过HeadGenerator或者SheetBuilder指定表头然后用ListListString表头 ListListObject数据行的方式导出。这个方案在处理动态表头比如用户自定义导出哪些字段时是唯一靠谱的选择。3.3 最核心的写入API边查询边写下面这段代码是我在项目里实际使用的“边查边写”核心逻辑。重点在于使用游标查询配合EasyExcel的write方法逐批写入。Service public class OrderExportService { Autowired private OrderMapper orderMapper; public void exportLargeData(OrderQuery query, HttpServletResponse response) throws IOException { String fileName URLEncoder.encode(订单数据_ System.currentTimeMillis(), UTF-8); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); response.setHeader(Content-disposition, attachment;filename*utf-8 fileName .xlsx); ExcelWriter excelWriter null; try { excelWriter EasyExcel.write(response.getOutputStream(), OrderExportModel.class) .inMemory(false) .build(); WriteSheet writeSheet EasyExcel.writerSheet(订单数据).build(); try (CursorOrderPO cursor orderMapper.scanList(query)) { IteratorOrderPO iterator cursor.iterator(); // 每10000条写入一次避免单批写入数据量过大 ListOrderExportModel cache new ArrayList(10000); while (iterator.hasNext()) { OrderPO po iterator.next(); cache.add(convert(po)); if (cache.size() 10000) { excelWriter.write(cache, writeSheet); cache.clear(); } } // 最后不足一批的剩余数据 if (!cache.isEmpty()) { excelWriter.write(cache, writeSheet); } } } finally { if (excelWriter ! null) { excelWriter.finish(); } } } private OrderExportModel convert(OrderPO po) { // 实体转导出模型的逻辑 } }这里有几个我特意加出来的细节都是踩过坑之后才明白的。第一EasyExcel.write(response.getOutputStream(), OrderExportModel.class)这个写法指的是输出到HTTP响应流。如果文件要上传到OSS或保存到本地换成EasyExcel.write(outputStream或File)即可。API设计上非常统一。第二inMemory(false)这个参数很容易被忽略。默认情况下EasyExcel在生成Excel文件时会使用临时文件来减少内存占用而inMemory(true)则会把文件内容放在内存中。大数据量场景必须设为false否则内存优势就没了。第三批处理的大小。我习惯设成10000条一批。太小了频繁IO影响速度太大了单批转换和写入时的内存又会上升。实测下来10000到20000是一个比较合理的区间。第四也是最容易犯的错误excelWriter.finish()必须放在finally里。如果中途发生异常没有执行finish()输出流不会被正确关闭用户下载到的文件大概率是一个损坏的.xlsx而且更麻烦的是这种问题往往不是必现的只在数据量大的时候偶发排查起来非常让人头大。3.4 动态表头与模板填充场景除了直接写入数据行还有一种很常见的企业场景根据模板导出。比如财务部门有一套固定格式的月度报盘模板里面预设了公式、合并单元格、特殊样式。这种情况下不能直接用write从头创建文件而是先用EasyExcel的fill功能填充模板。String templatePath /templates/report_template.xlsx; ExcelWriter excelWriter EasyExcel.write(response.getOutputStream()) .withTemplate(templatePath) .build(); WriteSheet writeSheet EasyExcel.writerSheet().build(); FillConfig fillConfig FillConfig.builder().forceNewRow(true).build(); // 逐批填充数据 ListReportRow data loadBatch(); while (data ! null !data.isEmpty()) { excelWriter.fill(data, fillConfig, writeSheet); data loadBatch(); } excelWriter.finish();模板填充有个常见坑如果模板里在数据区的下方还有合计行或者说明行当你使用fill填充数据时这些行可能会被数据覆盖或错位。解决方法是模板设计时在数据区和下方内容之间留一个空行或者使用fill的forceNewRow参数确保数据逐行追加。这种场景在金融和财务系统中非常常见比如每月的发票开具明细、银行对账单格式、工资条格式。EasyExcel的模板填充功能处理这些问题的能力是相当成熟的但模板本身的格式设计往往决定了最终效果这一点需要和业务方提前对齐。4. 性能调优从45秒压到25秒的几点实操4.1 线程池与异步任务设计异步导出不是简单地把操作丢到线程池里就完事。我见过很多系统在导出功能上线初期一切正常但数据量增长之后问题频发原因就是线程池参数没有按场景调优。导出任务是典型的IO密集型操作主要瓶颈在数据库查询和文件写入上。线程池的配置可以考虑以下几点核心线程数不宜过大避免同时多个导出任务抢占数据库连接池。队列容量需要限制否则大量导出请求堆积会导致OOM。拒绝策略应该是CallerRunsPolicy也就是让提交任务的线程自己执行。这样可以保证任务不丢失同时起到了流量控制的作用。最大线程数根据服务器CPU和预期并发量来设一般建议CPU核数 * 2 再加一些余量。Configuration public class ExportThreadPoolConfig { Bean(exportExecutor) public Executor exportExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(4); executor.setQueueCapacity(500); executor.setThreadNamePrefix(export-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }异步任务建议使用Spring的Async注解在方法上标注即可。但要注意Async方法不能是private而且必须在Spring管理的Bean中调用同类内部相互调用会绕过代理导致异步失效。这也是新手很常见的一个坑。任务状态建议落到数据库表表里至少要有这些字段字段说明task_id任务唯一ID雪花算法生成status状态PENDING/RUNNING/SUCCESS/FAILEDfile_url文件下载地址create_time任务创建时间finish_time任务完成时间error_msg失败原因前端通过task_id轮询任务状态完成之后从file_url下载文件。轮询间隔建议2到3秒一次太频繁对服务端是无效压力。4.2 写入调优参数实测EasyExcel有几个可以调优的参数我不建议直接照抄别人的数值因为服务器配置、数据复杂度、JVM参数都会影响表现。但可以分享一组我实测有效的初始值参数建议值影响批大小10000太小频繁IO太大单批耗时高自动关闭流false手动控制输出流避免提前关闭ExcelTypeXLSXXLS格式有行数限制大数据量不要用临时文件目录独立磁盘分区避免和数据盘抢IOJVM堆至少1GB导出任务独立运行时建议2GB还有一个非常容易忽略的优化点减少Excel单元格样式。每设置一个样式生成的XML中就会多一段描述信息直接导致文件体积变大和写入耗时增加。EasyExcel的注解模式下如果不指定列宽和样式生成的单元格是“裸”的这对大数据量导出反而是好事。如果你的业务确实需要列宽自适应千万不要用“根据内容计算最大宽度”这种逻辑。百万行数据逐行计算列宽光是字符串遍历就够喝一壶的。正确做法是把固定列宽写在注解或模板里确需动态调整也只计算前几行或者干脆不做。4.3 文件上传与清理策略本地文件方案适合小型系统但企业级系统我更建议把生成的文件上传到对象存储。文件生成后上传到OSS或MinIO数据库里只记录URL本地临时文件立刻删除。这个方案的好处是应用服务器无状态化导出任务可以分布到多台机器。文件不占用应用服务器磁盘避免长时间运行后磁盘被导出文件填满。下载走对象存储的CDN或直连带宽不占应用服务器的网络资源。上传的代码很简单比如用OSS SDKString localFilePath /tmp/export/xxx.xlsx; String objectKey export/2024/01/xxx.xlsx; ossClient.putObject(bucketName, objectKey, new File(localFilePath)); // 生成带签名的临时下载URL有效期默认5分钟 Date expiration new Date(System.currentTimeMillis() 5 * 60 * 1000); URL url ossClient.generatePresignedUrl(bucketName, objectKey, expiration);有一个企业级考量的点文件清理策略必须提前设计。导出的文件存放时间过长一是浪费存储空间二是有数据安全风险尤其是包含用户个人信息或者财务数据的文件。我的做法是导出成功的文件默认保留7天定时任务每天凌晨清理7天前的临时文件。如果业务上需要更长时间留档可以把文件转存到归档存储并做好权限管控。5. 常见问题与排查技巧实录5.1 实战中最常见的5个问题这里分享我实际遇到过的高频问题每个都给出了排查思路和解决方案。问题现象根本原因解决方案下载的文件打不开提示文件损坏输出流被提前关闭或finish()未调用确保finish()在finally中执行输出流由EasyExcel控制生命周期内存溢出FGC频发查询把所有数据load到内存或批大小设置过大用游标/分页查询控制批大小在10000-20000导出数据比数据库少了几行分页查询排序不稳定翻页时数据错位用自增主键做排序保证查询条件一致导出的Excel中文乱码文件名编码或响应头设置错误使用URLEncoder.encode编码文件名filename*utf-8并发导出时接口超时线程池参数不合理任务排队过久异步化 限制并发 任务状态持久化5.2 排查工具与方法论如果一张Excel导出的问题排查起来毫无头绪建议按照下面的顺序逐层排查先看日志。EasyExcel在DEBUG级别下会输出详细的写入过程包括当前的Sheet、写入行数、临时文件路径。如果行数没有递增说明数据源查询就有问题如果行数递增但最终文件还是损坏问题多半在输出流关闭阶段。再看内存。导出任务执行时用jstat -gcutil pid观察FGC次数和耗时。如果FGC次数密集重点检查数据查询层是否全量load如果老年代持续缓慢增长检查批大小和inMemory参数。最后看文件。用压缩工具打开.xlsx文件本质是一个zip检查xl/worksheets/sheet1.xml的大小和数据条数。如果xml为空说明数据根本没有写入成功。数据源的排查也很重要。如果用了MyBatis的游标查询记得在数据库连接串上加上合理的fetchSize。MySQL JDBC驱动默认会把fetchSize设为10如果不改相当于还是全量load到内存只是分批次从服务端取而已。参数设置示例jdbc:mysql://localhost:3306/test?useCursorFetchtruedefaultFetchSize1000注意useCursorFetchtrue这个参数不是所有MySQL版本都支持5.1.x及以上才兼容。另外游标查询期间数据库连接不能关闭所以必须保证在同一个事务或同一个连接上下文中完成整个查询和写入过程。很多人在try (Cursor cursor ...) {}外面又套了一个需要提前关闭连接的逻辑结果游标还没读完连接就没了直接抛异常。还有一个容易忽略的点如果你在循环里用cursor.forEach或者iterator.next()期间不能执行任何其他的数据库查询操作。因为游标是绑定在单一连接上的如果同一个线程中还有其他查询占用连接连接池会直接报“Connection is not available”错误。避免这个坑最简单的办法是导出用的Mapper方法不和其他查询混用单独开一个只服务于导出的Mapper接口。5.3 一个100万行导出的实际压测记录最后放一组我们当时压测的真实数据供你参考。测试环境是某云服务器8C16GMySQL数据库在另一台机器上网络是内网千兆。测试场景100万行订单数据每行20列含字符串时间日期数值导出为XLSX。阶段耗时数据库游标查询到读取完毕约15秒Excel文件生成含写入样式约50秒总耗时约65秒峰值堆内存约650MB生成文件大小约180MB最终下载URL有效期5分钟这个数据是在单线程、单批次、无其他业务并发的情况下测的。如果加上并发导出任务的场景建议把JVM堆设置为至少2GB并限制同时运行的导出任务数不超过3个。压测过程中还发现一个有意思的现象文件大小比预期的180MB要大不少的时候基本都是因为数据里有大量长文本字段比如订单备注。如果业务允许可以把这些字段截断或者从导出模板里剔除文件体积和导出耗时都会有明显改善。6. 写在最后的经验沉淀做大数据量导出这个功能技术本身并不复杂难的是把各种边界情况都考虑到。我在实际项目中体会到最容易出问题的往往不是EasyExcel本身而是周边的工程化细节数据是怎么查出来的任务是怎么异步化的文件是怎么存储和清理的异常是怎么兜底的。有一个经验是导出的代码里永远不要假设数据源是“干净”的。脏数据、超长字符串、NULL字段、特殊字符这些在普通接口里可能不会出问题但到了几百万行的Excel里任何一个异常都可能让整个导出任务失败。所以我建议在实体转换的方法里加上一层兜底容错比如对NULL值赋空串、对超长文本做截断对日期做统一格式化。虽然看起来不够“优雅”但生产环境的稳定性比代码好看重要得多。另外一个建议是导出功能上线前一定要做一次真实数据量级或略高于预期的压测。不要用测试环境的几千条数据来验证因为几千条和几百万条在内存模型、耗时、文件大小上完全不是一个量级。压测时重点观察的指标包括FGC次数、堆内存峰值、任务总耗时、下载文件是否完整可用。这五关都过了基本可以放心上生产。最后再分享一个小技巧。如果你的导出任务偶尔会失败但日志里没有明显的异常堆栈多半是响应输出流被应用服务器提前关闭了。解决办法是把导出的OutputStream不直接绑定到HttpServletResponse而是先生成到本地临时文件完成后再用Files.copy或者流式输出到响应。虽然多了一次磁盘读写但稳定性提升非常明显。这个取舍在大数据量场景下是完全划算的。