ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

高性能Excel异步导出方案:从同步卡死到流式写入的实战架构

高性能Excel异步导出方案:从同步卡死到流式写入的实战架构 如果你维护过任何一个后台管理系统总有一天会遇到这种对话运营同事说我要导一份全量用户数据开发问大概多少行运营回也就四五十万吧很快就导出来吧。等真正实现的时候你会发现一个同步导出接口在这个数据量下能让整个服务在30秒内失去响应。这不是夸大其词而是我踩过的真实坑。后来我把导出流程彻底重构为异步任务 流式写Excel的方案才算是把这个问题按住了。这篇内容就是基于那次重构整理出来的完整方案怎么设计任务状态、怎么选型EasyExcel、怎么写代码、怎么处理前端的等待与取消、以及压测后的调优记录。适合正在做大文件导出、批量报表导出、或者被POI内存问题折磨过的后端开发同学参考。1. 为什么导出接口总是第一个挂的很多系统平时看起来挺稳一到月底运营要数就垮。垮得最多的不是支付接口不是登录接口往往是那个没人看好的Excel导出接口。1.1 一次线上事故同步导出拖垮了整个应用我印象很深的一次事故线上有一个订单导出功能用户点击之后前端一直在转圈。一开始大家没在意因为导出一向很慢。直到客服开始反馈系统打不开了我们查监控才发现Tomcat线程池被打满所有的线程都卡在导出请求上等数据库返回连健康检查都过不了服务直接判死。事后看日志那个导出请求查了80万条订单记录然后用POI的Workbook模式一把梭先在内存里建了一个巨大的Excel对象再循环往单元格里填数据。生成过程持续了40多秒堆内存峰值飙到1.2GBFull GC频繁到几乎停顿。这不仅仅是慢的问题它把整个服务的线程池和内存都拖下水了。这就是同步导出的三宗罪长时间占用Web线程。导出接口在返回响应之前Tomcat的那条线程一直被占着。如果同时进来三五个大导出请求线程池直接被吃光。内存被Excel对象塞爆。传统POI的HSSFWorkbook也好XSSFWorkbook也好一切都是Java对象一张表格几十万行光单元格对象就几百万个GC压力极大。数据库连接长时间不释放。同步导出的查询通常是一次性拉全量Connection长时间被占连接池也会被拖垮。很多团队一开始做的导出方案其实都是能用就行。等到数据量从几万涨到几十万问题就暴露了。这不是代码写得烂而是同步模型的瓶颈天然就摆在那里。1.2 高性能和异步到底解决了什么我在重构之前先跟团队把目标捋清楚了下。我们说的高性能Excel异步导出方案拆开来看其实是四个诉求对用户点击导出之后UI立刻有反馈而不是干等着浏览器转圈。对服务生成Excel的过程不在HTTP线程里执行不影响正常业务接口的响应。对数据源不能一次性把几十万行全加载到应用内存里要用分批查询或游标的方式降低对数据库和JVM的冲击。对文件最终产出的是体积合理、可稳定下载的XLSX文件而不是一个让内存爆炸的巨型对象。异步解决的是前两个问题高性能解决的是后两个问题。两者配合起来才是一套完整方案。这里要澄清一个容易混淆的概念异步不等于快它只是把响应时间换了个位置。用户点击后接口立刻返回一个任务ID后台慢慢生成文件。总耗时可能不比同步少但用户的体感是点击成功、可以离开页面了。对于后台管理系统来说这种体验远比傻等更好。2. 异步导出的整体架构状态机与任务通道整体思路不复杂前端发起导出请求 - 后端生成一个导出任务并入库 - 任务被异步线程池消费执行数据查询和Excel写入 - 任务完成或失败后状态更新 - 前端通过轮询或推送感知状态变化拿到文件下载地址。2.1 任务表的设计与状态流转异步导出的核心是任务表。没有任务状态的管理所谓异步就是空中楼阁。我用的表结构通常叫excel_export_task核心字段如下字段类型说明idbigint主键task_idvarchar(64)对外暴露的任务编号UUIDuser_idbigint发起导出的用户file_namevarchar(200)最终下载文件名param_jsontext导出参数快照用于后台执行时重新解析statustinyint0待处理 1处理中 2成功 3失败 4已取消progressint进度百分比0-100total_countint总记录数success_count / error_msg分别记录成功量和失败原因file_pathvarchar(300)生成文件的存储路径/OSSkeycreated_at / updated_atdatetime创建和更新时间状态流转要写清楚。我见过不少任务系统状态机混乱导致重复执行或者无法取消。正常流转只有几条路新建任务后状态为PENDING。任务被线程池拿到立即置为PROCESSING同时记录开始时间。执行过程中每写完一批数据更新一次progress。执行成功状态置为SUCCESS写入file_path、记录总耗时。执行异常状态置为FAILED写入error_msg方便前端展示给用户。用户主动取消状态置为CANCELED。这里有一个容易被忽略的点param_json一定要在创建任务时就序列化存好。因为后台执行时可能有重试、任务恢复等场景不能依赖前端再来传一次参数。参数不仅包括查询条件还包括导出用的表头、模板类型、是否压缩等。2.2 线程池隔离和队列策略避免导出挤占核心业务创建了任务之后就需要一个后台执行通道。最简单的做法是直接用Java的线程池但线程池参数不能拍脑袋。我强烈建议给导出单独建一个线程池不要和业务线程池混用。可以参考这种配置ThreadPoolTaskExecutor exportExecutor new ThreadPoolTaskExecutor(); exportExecutor.setCorePoolSize(2); exportExecutor.setMaxPoolSize(4); exportExecutor.setQueueCapacity(200); exportExecutor.setThreadNamePrefix(export-); exportExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());核心线程数不设大因为导出任务是IO密集型的绝大部分时间花在数据库查询和磁盘写入上线程等IO空转没有意义。队列长度也可以保守一点导出请求来得太猛直接拒绝或者走降级比把系统拖死强得多。队列策略上我踩过一个坑最初用的DiscardPolicy任务被静默丢弃用户那边进度永远卡在0%也不报失败。后来改成了CallerRunsPolicy当队列满时让提交任务的线程自己执行。这种方式能起到天然的背压效果导出的重活自然会放到调用方线程里缓慢推进但不会丢任务。对于进度和状态我这里有一个实际经验不要频繁更新数据库表。每写一批数据就update一次数据库在高频导出时会给数据库带来很大压力。我实践的方案是执行期间进度写入Rediskey为export:progress:{taskId}任务完成或失败后再一次性把最终状态写回数据库。这样数据库的写频率从每秒几次降到了每任务一次。2.3 要不要引入消息队列很多文章一提到异步就说用MQ。但我们要分场景。如果只是单个应用内部的导出用线程池就够了。MQ的好处是削峰填谷、生产者消费者解耦适合任务量极大或者需要跨系统协作的场景。我现在的项目里导出任务量日均几万单机Web应用完全扛得住所以没有强行引入MQ。只有当任务量上升到消息堆积会成为问题、或者希望失败任务做可靠重试时才值得考虑把任务投递到RocketMQ或RabbitMQ。如果团队规模不大用一个任务表加上线程池已经是性价比很高的方案了。别被架构焦虑绑架。3. Excel生成层用EasyExcel做到行级流式写入异步化解决的是什么时候生成的问题真正让导出性能上去的是怎么写Excel。这一层选型和实现直接决定内存占用和写入耗时。3.1 选型对比POI、SXSSF、EasyExcel怎么选做Java的都知道Apache POI是操作Excel的事实标准但原生API在数据量大时非常难受。我用过几种方案感受如下POI HSSFWorkbook老式二进制格式单表上限65536行超过就抛异常。数据量大直接被判死刑。POI XSSFWorkbook支持XLSX但整个文档树都在内存里50万行轻松吃满内存GC灾难。POI SXSSFWorkbookPOI自带的流式版本通过滑动窗口控制内存但API还是偏底层样式管理、日期处理这些细节都要自己控制。EasyExcel阿里开源本质是封装了POI的SAX模式写入时采用行级流式处理内存占用很低。同时提供了注解模型映射、模板填充等便捷功能是目前最省心的高性能方案。FastExcelEasyExcel的一个高性能fork如果在意极致性能可以关注但生态和文档不如EasyExcel丰富。我当时选EasyExcel的原因主要有三个一是流式写入开箱即用二是注解模型映射确实省事三是对已有POI项目的替换成本很低不需要重写数据组装逻辑。加一张对比表方案单表行数上限内存表现易用性生态成熟度HSSFWorkbook65536差一般高XSSFWorkbook约104万差一般高SXSSFWorkbook约104万好较低高EasyExcel约104万优秀高高3.2 分批查询 流式写的代码骨架先给一个可以落地的核心代码骨架我用的是MyBatis Plus分页查询 EasyExcel流式写入Async(exportExecutor) public void generateExportTask(String taskId, ExportParam param) { try { updateStatus(taskId, Status.PROCESSING, 0); String filePath buildFilePath(taskId); ExcelWriter writer EasyExcel.write(filePath, ExportRowVO.class) .excelType(ExcelTypeEnum.XLSX) .build(); WriteSheet writeSheet EasyExcel.writerSheet(数据).build(); long pageNo 1L; int pageSize 5000; int totalCount countTotal(param); int processed 0; boolean shouldContinue true; while (shouldContinue) { ListExportRowVO rows exportMapper.selectPage(param, pageNo, pageSize); if (rows null || rows.isEmpty()) { shouldContinue false; } else { writer.write(rows, writeSheet); processed rows.size(); int progress (int)(processed * 100L / totalCount); updateProgress(taskId, progress); pageNo; } } writer.finish(); updateSuccess(taskId, filePath); } catch (Exception e) { updateError(taskId, e.getMessage()); log.error(导出任务执行失败, taskId{}, taskId, e); } }这段代码的精髓在于永远只保留pageSize条数据在内存里。每次从数据库查出5000条写入Excel后这批数据就变成不再引用的垃圾对象可以被JVM回收。这里要注意一点分页查询不是唯一方式MyBatis的Cursor流式查询更彻底。Cursor方式不会一次性加载全部记录而是逐条从数据库取。但Cursor使用时有几个约束必须在一个事务里执行、必须显式关闭、连接不能归还连接池太早。如果没有把握直接用分页查询最稳妥。try (CursorExportRowVO cursor exportMapper.scanData(param)) { ListExportRowVO batch new ArrayList(pageSize); for (ExportRowVO row : cursor) { batch.add(row); if (batch.size() pageSize) { writer.write(batch, writeSheet); batch.clear(); } } if (!batch.isEmpty()) { writer.write(batch, writeSheet); } }3.3 样式、表头、合并单元格的性能权衡不少人用POI导Excel的时候特别喜欢写一堆样式每个单元格边框、背景色、字体、列宽挨个设置。在小数据量下毫无问题但在几十万行场景下每行都触发样式计算性能损耗会被放大很多倍。EasyExcel推荐的做法是样式尽可能复用不要每行都new。表头和列宽在writeSheet创建时设定一次数据行的样式能用默认就默认。另外要注意一个非常隐蔽的坑XLSX格式的单元格样式数量是有上限的大约64000个。如果你在循环里给每一行设置独立的样式对象写入几万行之后Excel文件可能直接损坏或者打开报错过于复杂的格式。我之前就遇到过这种情况排查了很久才发现是样式对象创建太疯狂。所以我的经验是表头样式设置一次数据行不设置样式性能最好。必须设置样式的用一个静态Map缓存Style对象按需复用。列宽、行高等通用配置统一在writeSheet层面搞定不要逐行设置。合并单元格能用文字前缀替代的就别合并。合并操作需要记录历史合并区间数据量大了之后对性能影响很大还会显著增大文件体积。还有一点容易被忽略导出字段中如果有大量长文本或者JSON字符串就要评估是否真的需要全量导出。比如用户备注、日志详情这种字段单条几百上千字50万行就是几百MB的字符数据即使流式写也极大地拖慢整体速度。必要时可以跟产品沟通对导出模板做精简。3.4 内存数据与类型转换的隐藏开销EasyExcel写入时有一步数据模型映射。如果你提供的是一个包含几十个字段的VO类每个字段都要做类型转换这部分CPU开销在50万行级别确实值得优化。我在做压测时发现导出的耗时很大一部分消耗在了从数据库查询结果DTO转成Excel导出VO的过程。后来做了三处优化数据库查询时直接从SQL层就投影出导出需要的字段而不是先查出全字段实体再转换。导出VO里的字段类型尽量和数据库返回类型一致减少类型转换次数。若前端不需要精确到秒的时间格式可以考虑数据库层面直接格式化成字符串返回避免Java侧一个个处理Date对象。// Mapper中直接投影减轻一层转换开销 Select( SELECT order_no AS orderNo, user_name AS userName, DATE_FORMAT(create_time, %Y-%m-%d %H:%i:%s) AS createTime FROM t_order WHERE create_time BETWEEN #{startTime} AND #{endTime} ) ListExportOrderVO selectExportPage(ExportParam param, Param(pageNo) long pageNo, Param(pageSize) int pageSize);这里不是说MVP架构不好而是在导出这种大批量场景下任何一点多余的对象复制都会被放大。能省则省。4. 文件落地与下载链路不只是写个文件那么简单Excel文件生成出来后接下来要做的是存储、提供下载、以及防止磁盘被撑爆。这一层很多人只做到写到本地给个链接就结束了后面问题不断。4.1 本地磁盘、共享存储还是对象存储如果服务是单机部署直接写本地磁盘没问题。但一旦服务上了两台以上的机器问题就来了用户第一次请求落在A机器文件写在A盘第二次下载时负载均衡把请求转发给B机器B机器上找不到文件下载404。所以文件存储策略要和部署架构匹配单机部署本地磁盘最简单。多机部署至少换成共享磁盘NFS或者把文件上传到对象存储阿里云OSS、腾讯云COS、MinIO。云上部署直接写对象存储是唯一推荐方案。我现在的做法是应用服务器先写到本地临时目录然后异步上传到OSS上传成功后删除本地临时文件下载链接直接返回OSS的预签名URL。这样既避免了应用服务器对HTTP下载请求的长连接占用又解决了多机部署的文件可见性问题。String ossKey filePath.replace(buildTempDir(), ); ossClient.putObject(OSS_BUCKET, ossKey, new File(filePath)); Files.deleteIfExists(Paths.get(filePath)); String downloadUrl ossClient.generatePresignedUrl( OSS_BUCKET, ossKey, new Date(System.currentTimeMillis() 3600_000L)) .toString();这段代码里有几个细节值得注意本地临时文件一定要在成功上传后删除否则日积月累磁盘必炸预签名URL有效期我一般设置为1小时过期自动失效也降低被恶意刷下载的风险。4.2 任务文件的生命周期与清理策略文件不能只创建不清理。我见过有的系统里临时导出文件堆积了几个月几百万个Excel文件把磁盘塞满。我的清理策略是这样任务成功生成文件后设置一个过期时间通常是24到72小时。每天凌晨用定时任务扫描任务表将超过过期时间且仍为SUCCESS状态的任务标记为EXPIRED。然后删除对应的OSS Key或本地文件最后更新任务记录的文件访问状态。清理顺序也很讲究必须先标记过期、再删文件、再更新数据库。因为如果先删文件万一删文件过程中失败DB里任务还是成功状态用户点击下载会发现文件已不存在。先标记过期即使文件删除失败用户侧看到的状态也是已过期不会产生有下载地址但文件不存在的诡异体验。4.3 下载接口与文件生成的解耦有一种做法容易踩坑就是下载时直接从数据库读文件路径然后用FileInputStream边读边写回响应。这个做法在本地文件场景下没问题但要注意文件流的生命周期必须放在finally或try-with-resources里释放。如果文件很大下载过程会长时间占用Web线程。最好把下载也改造为StreamingResponseBody在异步线程里写文件流。如果走OSS预签名URL那就更省事了应用服务器完全不参与文件传输前端直接跳转上去下载。我最终选择的是预签名URL方案前端拿到URL后用浏览器直接下载所有流量走OSS应用服务器零负担。这是个人认为最省心的处理方式。5. 前端配合进度查询、取消任务与超时兜底后端搞得再好前端体验拉胯也不行。异步导出的前端配套其实很简单核心是进度反馈和取消入口。5.1 最朴素的轮询2秒一次够用大多数后台系统不需要引入WebSocket或者SSE这种重武器轮询就够了。前端在发起导出请求后拿到taskId然后每隔2秒调用一次查询接口async function pollExportTask(taskId) { const resp await fetch(/api/export/task/${taskId}); const data await resp.json(); if (data.status 0 || data.status 1) { // 待处理或处理中继续轮询 renderProgress(data.progress); setTimeout(() pollExportTask(taskId), 2000); } else if (data.status 2) { // 成功跳转下载链接 window.location.href data.downloadUrl; } else if (data.status 3) { // 失败展示错误信息 showError(data.errorMsg); } }对应的后端查询接口很简单就是查一下任务表字段。因为进度在前一步已经写入了Redis所以查询时先查Redis查不到再回源数据库性能完全没问题。5.2 取消任务标记位比中断更可靠用户导了一半后悔了想取消怎么办我知道的最简单的做法是提供一个取消接口把任务状态从PROCESSING置为CANCELED。随后执行线程在下一次循环检查状态时会发现if (needCancel(taskId)) { shouldContinue false; // 清理临时文件 Files.deleteIfExists(Paths.get(filePath)); updateCanceled(taskId); return; }这里不能依赖Thread.interrupt()因为中断只是设置一个标志位异步任务里的数据库查询和Excel写入一般不会响应中断。最稳妥的方式就是在写批数据之前主动检查任务是否已取消主动退出循环。前端取消按钮在进度达到100%之后应该隐藏避免用户以为还能取消。取消接口最好加一个防重复操作的保护用RedisSETNX做一个简单幂等。5.3 异常兜底任务失败后如何通知用户任务失败不能只靠前端轮询发现。我的做法是在任务执行失败后把失败原因记录到task表的error_msg字段。同时针对运营高频使用的重要报表接入一个简单的站内信或群机器人通知任务导出失败附上失败原因和发起人。这里有个经验失败信息别只存导出失败四个字。把异常堆栈的关键类名和方法名截断存进去方便用户反馈给开发时直接定位问题。我见过有人说导不出来结果查半天日志才拿到taskId。现在我的失败信息格式是导出超时超过20分钟或服务异常请联系管理员。内部错误码EX20240612001日志IDxxx这样用户和研发都能快速对上下文。6. 实测数据与踩坑复盘最后一部分分享几个真实的压测数据和排查过程希望能帮你少走弯路。6.1 50万行导出的压测对比我们用相同的数据集50万行32列分别跑同步POI方案和异步EasyExcel方案结果如下指标同步POIXSSFWorkbook异步EasyExcel流式写接口响应时间32秒200ms内返回任务ID生成文件总耗时32秒18秒JVM堆内存峰值1.2GB180MB最大GC停顿4.2秒0.8秒数据库连接占用1个连接占用32秒每次查询5000条用完即放这个数据是单机8G堆内存环境下测的。可以看到异步化之后用户点击导出的体感从卡死变成秒回而后台生成文件的耗时甚至比同步还快一些。原因在于限制了并发和内存压力GC不再成为瓶颈。当时我顺手把JVM参数也调了一轮采用G1垃圾回收器适当调大了-XX:MaxGCPauseMillis的目标值把-Xmx稳定在4G左右避免堆无限上涨引起频繁Full GC。导出任务结束后System.gc()是不建议主动调的让JVM自己判断即可。6.2 高频踩坑实录第一个坑就是EasyExcel的Writer并发问题。我有一次压测时发现并发两个导出任务其中一个生成的文件打不开报文件损坏。排查下来发现代码里把ExcelWriter定义成了类级别的静态字段两个线程共用了一个writer实例。EasyExcel的writer并不是线程安全的正确答案是每个任务实例化独立的writer绝不能被多个线程共享。第二个坑是更新进度太频繁导致数据库压力大。最初我每写完5000行就update一次task表50万条数据就是100次update看起来不多。但一旦同时有几十个导出任务在跑数据库的写请求量立刻就上去了。后来按前面说的改成了Redis中转性能问题迎刃而解。第三个坑是MyBatis Cursor打开后连接不释放。使用Cursor流式查询时必须保证在finally里关闭游标。我当时在try-with-resources里写了cursor但内部的writer.write抛了一个异常游标没来得及关闭数据库连接池很快被耗尽。后来调整代码结构把writer.write放在另一个内层try块中确保外层游标无论如何都会关闭。第四个坑是文件下载路径与Content-Type。文件传到OSS之后下载时如果文件URL不带扩展名浏览器可能不识别为Excel。生成文件时要保证文件名以.xlsx结尾且正确设置Content-Type为application/vnd.openxmlformats-officedocument.spreadsheetml.sheet否则用户下载后打开会提示文件损坏。还有一个体会比较深的坑导出参数的过滤条件里如果有用户输入的时间区间一定要校验范围。有人一次性选了跨度3年的数据接近千万行EasyExcel虽然不会爆内存但生成文件花了几个小时用户等得毫无耐心。后来我给单次导出加了行数上限超过50万提示用户调整时间范围或使用分批导出。这是产品层面的决策但技术上必须有所约束。我个人在实际操作中的体会是异步导出的技术点不算复杂真正难的是把用户等待体验、服务稳定性、文件生命周期这几件事串起来。上面这套方案在我这边的线上系统已经稳定运行了一年多支撑了几十次集中的运营大促导出。后续如果你想继续扩展可以考虑做模板导出用户上传Excel模板后端按模板格式填充、导出失败自动重试、或者把任务推送到钉钉/企微群。技术链路是一样的难度主要在产品形态上。最后再提醒一句一定不要忘记清理临时文件磁盘被塞满的滋味不好受。
返回列表