ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Excel高性能异步导出实战:从同步卡顿到百万行稳定输出

2026/10/1 11:16:04 拓冰建站 浏览量
Excel高性能异步导出实战:从同步卡顿到百万行稳定输出 1. 从一次被老板点名表扬的导出优化说起先说个我自己的真实经历。三年前在一家做供应链系统的公司运营后台有一张库存流水表数据量也就二十来万行每次点击“导出 Excel”用户平均要等四十多秒期间浏览器转圈、按钮置灰、有人手贱多点两次就直接崩了。最离谱的是导出期间后端线程被占满其他接口跟着变慢客服那边天天收到“系统卡死”的投诉。后来我花了两个晚上把导出改成了异步任务加流式写入响应时间从四十秒压到三秒以内指的是用户感知时间大数据量的文件生成也稳定控制在十秒级别。那个季度总结会上产品经理专门把这条优化拿出来当亮点讲。今天就把这套方案完整拆给你看。标题里的关键词“Excel 高性能异步导出”听起来像是个大词但落地到工程上就三件事异步化、批处理、流式 IO。这三件事做扎实了百万行级别的导出也能跑得稳。这篇文章不是给你堆概念我会把从架构设计、代码实现到线上排坑的完整链路都过一遍适合正在被大数据量导出折磨的后端开发、全栈工程师也适合想给报表系统做性能提升但不知道怎么下手的朋友。你要是目前只导几千行那可能感受不深但只要数据量上了十万、百万这套思路一定用得上。2. 先搞清楚普通的同步导出到底慢在哪2.1 一次导出请求的全链路耗时分析我见过太多人一上来就研究用什么 Excel 库觉得“慢就是库不行”其实大多数情况下瓶颈根本不在写文件那一步。咱们拆一下一次导出请求在服务端都干了什么。从用户点击“导出”开始请求进来后端先查数据库。查询这一步如果没走对索引一个带时间范围叠加上状态过滤的查询在几千万行的表上扫全表光这步可能就得几十秒。查完之后ORM 帮你把每一行映射成对象一个字段一个字段地 set二十万行就是二十万个对象的创建和 GC 压力。然后你开始遍历这些对象往 Excel 的每个单元格里填值如果你的方案是先填到一个大内存的二维数组或者 DOM 里那内存直接就飙起来。最后调用库的 save 方法把所有内容一次性压缩刷盘。这里有三个隐性坑第一内存。把整个数据集以对象形式驻留内存二十万行乘以每行十几个字段光堆内就可能吃进去一两百兆。第二CPU。如果你用反射做字段映射或者用非流式的 Office 兼容组件单元格间切换、样式解析的 CPU 开销是线性甚至超线性的。第三网络和连接。从用户点击到你返回整个 HTTP 请求一直维持着连接网关、负载均衡、容器线程池都在为一个需要几十秒的慢请求占着资源QPS 稍高一点线程池就满了你的服务对其他请求也就失去响应了。咱们把这三点连起来看慢不是某一个环节慢是整个同步模型让所有慢都串行叠加在用户的一次请求上。用户感知的等待时间等于数据库时间加映射时间加写文件时间加网络传输时间甚至还要加上你跟 Excel 库纠缠的那些额外 CPU 周期。2.2 “异步”到底改变了什么异步化最核心的变化是把“请求-响应”的模型从“同步阻塞”改成了“任务提交状态轮询/通知”。用户点击导出服务端只做一件事把“导出任务”扔进队列立刻返回一个任务 ID。然后后台 Worker 从队列里拿任务慢慢查数据、生成文件、把文件传到对象存储或者本地临时目录。前端拿到任务 ID 之后可以轮询“任务状态”接口也可以等后端完成之后推送一个通知。用户看到的是“导出任务已提交可以继续干别的”这个响应时间通常在两三秒之内。有人会想那文件最终还是要那么长时间才生成啊用户的等待并没有消失啊。对物理上的处理时间不会凭空缩短但你的用户不再“霸占”着一个 HTTP 连接傻等他可以去做别的事甚至关掉页面。从产品体验上讲这已经从“强等待”变成了“后台任务”感受完全不同。从服务端资源上讲同步模型下每个导出请求占一个线程几百秒异步模型下提交任务只占几毫秒的接收时间Worker 数量可控队列可以堆积服务在高峰期的稳定性会好得多。换句话说异步化牺牲的是“一次性拿到结果”的即时性换来的是系统在高负载下的可用性、用户体验的流畅度和后续做并发控制的可能。对于导出这种不需要秒出结果的场景异步几乎是正确解法。3. 高性能的三层地基批处理、流式 IO 与内存控制3.1 批处理不要一次查询十几万行我见过不少初版代码是这么写的all_rows session.query(StockLog).filter(...).all() for row in all_rows: sheet.append(to_excel_row(row))这种写法数据库一次性把二十万行按对象映射出来光内存就爆了。高性能方案里第一件事就是“分批”。用数据库的游标或者分页逐批取数每批一两万行处理完即释放引用。以 Java 为例MyBatis 的流式查询就是一个好选择配置上fetchSize之后ResultSet 边读边返回不会把全部结果加载到内存。Python 这边SQLAlchemy 的yield_per或者使用服务器端游标stream_resultsTrue也能达到类似效果。核心思想是数据不是一次性进内存的而是像流水一样经过你。批量处理的意义不止在内存。GC 压力小、对象生命周期短JVM 的 Young GC 频率和耗时都会显著降低。CPU 也不再因为频繁的反射调用而发烫。我把一个百万行的导出任务从“全量载入”改成“每两万行一批”之后内存峰值从 1.6GB 降到了 450MB 以内效果立竿见影。3.2 流式写入Excel 文件的“水龙头”写法写 Excel 文件大多数人最早接触的库是直接用内存模型。比如 Python 的xlwt或者老牌的xlsxwriter非流式用法或者 Java 的WorkbookFactory直接构造SXSSFWorkbook。注意SXSSFWorkbook本身就是为了流式而生的——它内部只保留窗口期内的行在内存超过阈值的行被刷到临时文件。它的名字很好理解Streaming Usermodel API。先给一个 Java 侧基于SXSSFWorkbook的批处理核心示意try (SXSSFWorkbook wb new SXSSFWorkbook(100)) { // 窗口大小100行 Sheet sheet wb.createSheet(流水); // 分批查询游标 try (StreamStockLog stream mapper.streamQuery(startTime, endTime)) { IteratorStockLog it stream.iterator(); int rowNum 0; while (it.hasNext()) { StockLog log it.next(); Row row sheet.createRow(rowNum); fillCell(row, 0, log.getId()); fillCell(row, 1, log.getSku()); // ... 填充其余字段 } } // 最终写到一个 OutputStream try (FileOutputStream fos new FileOutputStream(/tmp/export.xlsx)) { wb.write(fos); } } finally { // 清理SXSSF的临时文件 ((SXSSFWorkbook) wb).dispose(); }这里有个关键点SXSSFWorkbook的窗口大小决定了内存里保留的行数。窗口太小刷临时文件的频率就高磁盘 IO 会变得频繁窗口太大内存压力又上去了。我实测下来100~200 行是一个比较平衡的窗口如果单行字段特别多就调回 50。Python 侧也有对应的流式库比如openpyxl在write_only模式下用的是轻量级的行写入方式不会在内存里构建完整的单元格对象树。注意openpyxl的只写模式是真的只写不能读回但导出场景正好只需要写。还有一个容易踩的坑很多人会用Workbook类型来承接导出的字节流先在内存里ByteArrayOutputStream全部写完之后一次性toByteArray()返回。这个操作会把你辛辛苦苦省下的内存又全吃回来。正确做法是直接把OutputStream接到响应的ServletOutputStreamJava或者响应体其他语言边写边刷。但这里又涉及一个矛盾如果响应直接流式写出那一旦出错没法返回错误 JSON所以更好的设计是导出到文件或对象存储而不是直接响应流。我后面会讲这个取舍。3.3 内存控制GC 与临时文件的平衡在高性能导出方案里内存控制其实比 CPU 更重要。原因很简单导出任务通常是低频但巨量的一旦内存溢出影响的是整个应用。前面提到的批处理和流式写入已经把大头都解决了。但还有几个细节容易被忽略。第一是单元格样式。如果一个 Excel 文件里你给每个单元格都单独设置字体、边框、背景那每个 CellStyle 对象都占内存而且在SXSSFWorkbook里样式对象会一直留在内存因为它们需要用来写后续的共享字符串表。所以 Excel 导出的第一铁律是能用默认样式就不用自定义样式必须用样式时请复用同一个样式对象。比如表头一个样式数据体统一用另一个样式不要每行createCellStyle()。第二是共享字符串表。xlsx格式里所有字符串都会维护一张共享字符串表如果数据里有一堆重复的文本比如状态栏“已完成”重复十万次内存里就会存十万个引用但实际字符串对象只有一个这是机制本身优化的结果。不过如果你用SXSSFWorkbook默认配置共享字符串表是禁用内存驻留的但每次字符串写入仍会有哈希查找开销因此尽量不要在数据体里写大量重复的长文本能转成编码就转成编码比如状态用数字 0/1/2 代替中文展示层再做映射。第三是临时文件。SXSSFWorkbook会把刷出去的行写到临时文件默认在系统临时目录下而且命名随机。用完必须调dispose()清理否则磁盘上会残留一堆垃圾文件。你可以通过设置系统属性java.io.tmpdir指定一个你可控的目录配合定时任务清理一个月前的临时文件。4. 完整方案落地任务队列、Worker 与文件管理4.1 选型内存队列还是消息队列异步导出的“异步”怎么落地有两条路。第一如果你的服务是单机小规模或者导入导出并发量不高直接用进程内队列Java 的ThreadPoolExecutorBlockingQueuePython 的queue.Queue 后台线程就够了。好处是部署简单、无额外依赖坏处是不能持久化服务重启任务就丢了也不能横向扩展。第二如果你们的系统是分布式的或者丢任务不可接受那必须用消息队列。RabbitMQ、RocketMQ、Kafka 都可以我个人在导出场景更推荐 RabbitMQ 或者 RocketMQ 的事务消息因为导出任务不像日志那样追求吞吐但要求可靠投递、不丢消息。Kafka 也完全可以胜任但它的消费模型更适合高吞吐流处理用在导出上有点大材小用。这里给个务实建议从“单机线程池”起步把任务提交和 Worker 执行的逻辑跟队列解耦将来真的需要上 MQ 时改动只在提交端和监听端。我在设计接口时会定义三个方法submit(task)、getStatus(taskId)、download(taskId)。这样后面换存储、换队列上游代码不会动。4.2 Worker 的并发策略Worker 数量不是越多越好。导出任务密集时会打满 CPU 和磁盘 IO。如果你用的是EASPOI我指的是 EasyExcel 之类的封装它内部有并发优化但你的多线程写入同一个文件会出问题。通常方案是一个导出任务由一个线程串行执行多个任务之间并行。也就是说并发度是“任务”级别的不是“行”级别的。一个线程写一个文件并发开个 4~8 个 Worker 就足够了。我见过有团队把线程池开到 32结果数据库连接池先被耗尽大批查询超时最后导出任务全部失败。调节并发要观察三个指标数据库连接池排队数、磁盘 IO 等待时间、内存余量。一般来说如果导出没有涉及大量复杂关联查询4 个 Worker 在小规格机器上已经能跑平稳机器 CPU 核数多、数据库扛得住再慢慢往上调。4.3 文件存储与下载链路的两种常见设计完成任务后生成的 Excel 文件放哪有两种主流设计。第一种是“本地磁盘临时目录 短时有效期”。文件生成在/tmp/export/下面文件名用 UUID然后拼一个带过期时间的下载 URL。下载接口先校验 URL 里的签名和时间戳再检查本地文件存在之后用InputStream流式返回。过期文件由定时任务清理。这种方式部署简单适合中小项目。第二种是“对象存储 CDN 回源”。文件生成后上传到 OSS/S3下载时直接给你一个云存储的临时链接或者通过网关转发。好处是不占本地磁盘、天然支持水平扩展、不怕服务重启丢文件坏处是依赖外部服务和额外成本。我做过一个网关服务导出文件统一走对象存储本地只做短期缓存比如 2 小时效果也不错。无论哪种方式都建议设两个参数文件保留时间和单次下载大小限制。保留时间我一般设 24 小时超过就删。单次导出文件如果超过 200MB就得考虑压缩成 zip 或者提示用户缩小筛选范围。4.4 前端配合轮询还是 WebSocket异步导出的前端体验最常见的做法是轮询。任务提交后返回 taskId前端每隔 2~3 秒调一次getStatus拿到SUCCESS之后把下载链接拼出来。轮询最简单用setInterval就行但要处理好页面关闭和任务取消。如果有更复杂的推送需求比如导出完成提示、进度条实时刷新可以做 WebSocket 或 SSE。我个人不太建议为此专门引入 WebSocket除非系统里本来就有。SSE 更轻适合服务端单方向推送状态但浏览器兼容性和代理配置也有点讲究。最重要的是前端要有“任务已提交请稍后刷新查看”的交互。如果产品允许甚至可以让用户在“导出中心”里查看历史任务记录而不是必须等在当前页面。5. 核心实操构建一个可复用的异步导出模块5.1 任务表的设计要支撑整个异步流程数据库里需要一张导出任务表。字段参考如下字段名类型说明task_idvarchar(32)全局唯一IDfile_namevarchar(255)生成的Excel文件名statustinyint0待执行 1执行中 2成功 3失败param_jsontext原查询条件的JSON序列化file_urlvarchar(500)生成后的下载地址error_msgvarchar(1000)失败原因create_timedatetime创建时间finish_timedatetime完成时间expire_timedatetime文件过期时间这里有个容易被忽略的点param_json一定要存。因为异步任务提交之后Worker 执行时你可能需要重新根据原参数查询如果只传一个内部查询 ID而那个查询对象的条件在内存里已经丢了就会很尴尬。把参数序列化成 JSON将来无论任务重试还是排查问题都能还原场景。5.2 Java 完整实现从提交到下载我先给一个基于 Spring Boot 的简化实现让大家看到整体流程。这个实现不含具体的 ORM 查询细节重点在异步控制。RestController RequestMapping(/api/export) public class ExportController { private final ExportTaskService taskService; public ExportController(ExportTaskService taskService) { this.taskService taskService; } PostMapping(/submit) public ResultString submit(RequestBody ExportParam param) { String taskId taskService.submitTask(param); return Result.ok(taskId); } GetMapping(/status/{taskId}) public ResultExportTaskVO status(PathVariable String taskId) { return Result.ok(taskService.getTaskStatus(taskId)); } GetMapping(/download/{taskId}) public void download(PathVariable String taskId, HttpServletResponse response) throws IOException { ExportTask task taskService.getTask(taskId); if (task null || task.getStatus() ! 2) { response.setStatus(404); return; } File file new File(task.getFileUrl()); try (InputStream is new FileInputStream(file); OutputStream os response.getOutputStream()) { response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filename URLEncoder.encode(task.getFileName(), UTF-8)); response.setContentLengthLong(file.length()); byte[] buffer new byte[8192]; int len; while ((len is.read(buffer)) ! -1) { os.write(buffer, 0, len); } os.flush(); } } }任务服务实现Service public class ExportTaskService { private static final ExecutorService WORKER_POOL Executors.newFixedThreadPool(4, new NamedThreadFactory(excel-worker)); private final ExportTaskMapper taskMapper; public ExportTaskService(ExportTaskMapper taskMapper) { this.taskMapper taskMapper; } public String submitTask(ExportParam param) { String taskId UUID.randomUUID().toString().replace(-, ); ExportTask task new ExportTask(); task.setTaskId(taskId); task.setStatus(0); task.setParamJson(JSON.toJSONString(param)); task.setCreateTime(new Date()); taskMapper.insert(task); // 提交到线程池 WORKER_POOL.execute(() - executeTask(taskId)); return taskId; } private void executeTask(String taskId) { ExportTask task taskMapper.selectById(taskId); task.setStatus(1); taskMapper.updateById(task); try { ExportParam param JSON.parseObject(task.getParamJson(), ExportParam.class); String filePath doExport(param); task.setStatus(2); task.setFileUrl(filePath); task.setFinishTime(new Date()); task.setExpireTime(new Date(System.currentTimeMillis() 24 * 3600 * 1000L)); taskMapper.updateById(task); } catch (Exception e) { task.setStatus(3); task.setErrorMsg(e.getMessage()); task.setFinishTime(new Date()); taskMapper.updateById(task); log.error(export task error, taskId{}, taskId, e); } } private String doExport(ExportParam param) throws Exception { String fileName export_ System.currentTimeMillis() .xlsx; File dir new File(/tmp/export); if (!dir.exists()) dir.mkdirs(); File target new File(dir, fileName); try (SXSSFWorkbook wb new SXSSFWorkbook(100); FileOutputStream fos new FileOutputStream(target)) { Sheet sheet wb.createSheet(数据); // 表头写一次 Row header sheet.createRow(0); String[] headers {ID, 单号, 商品, 数量, 状态}; for (int i 0; i headers.length; i) { header.createCell(i).setCellValue(headers[i]); } // 分批查询假设有流式接口 int rowIndex 1; try (StreamDataRow stream dataRowMapper.streamQuery(param)) { IteratorDataRow it stream.iterator(); while (it.hasNext()) { DataRow r it.next(); Row row sheet.createRow(rowIndex); row.createCell(0).setCellValue(r.getId()); row.createCell(1).setCellValue(r.getOrderNo()); row.createCell(2).setCellValue(r.getProductName()); row.createCell(3).setCellValue(r.getQuantity()); row.createCell(4).setCellValue(r.getStatus()); } } wb.write(fos); } return target.getAbsolutePath(); } }这段代码看着简单但里面每个点都是踩过来的。第一WORKER_POOL的线程数 4 是经验值你要是机器 16 核数据库连接池 20调到 8 也没问题但别盲目。第二SXSSFWorkbook(100)窗口 100 行我解释过频繁刷临时文件的取舍。第三try-with-resources一定要有SXSSFWorkbook的临时文件清理就靠它和后面的dispose()。5.3 Python 版本的实现要点如果你们技术栈是 Python思路完全一样。用FastAPIBackgroundTasks或者Celery做异步用openpyxl的只写模式生成文件。核心片段from openpyxl import Workbook def write_excel_async(params, task_id): wb Workbook(write_onlyTrue) ws wb.create_sheet(数据) ws.append([ID, 单号, 商品, 数量, 状态]) # 分批从数据库取数 for batch in query_batch(params, batch_size10000): for row in batch: ws.append([row.id, row.order_no, row.product_name, row.quantity, row.status]) # 写完后保存到目标路径 file_path f/tmp/export/{task_id}.xlsx wb.save(file_path)这里write_onlyTrue是性能核心一定不能丢。另外openpyxl的只写模式不支持读取和修改已有文件如果你要做模板填充就得用普通模式但性能会差很多。导出场景不需要模板直接新建就行。5.4 EasyExcel 的封装思路Java 生态里阿里开源的 EasyExcel现在官方迁移到 FastExcel是比 POI 更友好的选择。它的核心优势是降低内存占用、提供了更简单的 API。它的流式写法大致是EasyExcel.write(fileName, DataRow.class) .sheet(数据) .doWrite(dataSupplier);这个dataSupplier可以是一个分批拉取的数据集合。EasyExcel 帮你把写盘和内存管理做了更深度的优化对大多数场景来说用它替代原生SXSSFWorkbook是很明智的。但要注意EasyExcel 也不是万能的复杂合并单元格、非常规样式的支持不如 POI 原生灵活。如果你导出的是那种花里胡哨的报表可能需要自己基于SXSSFWorkbook做。我自己在项目中是这么分层的标准的二维表格数据导出走 EasyExcel需要高度定制报表样式、动态合并单元格时退回 POI 原生 API两边都覆盖不到再考虑模板引擎。但不管用哪种库批处理和任务异步的框架都是一样的代码结构不会变。6. 实战中踩过的坑排查与调优实录6.1 明明异步了接口还是超时有哥们儿跟我抱怨说他也做了异步但是提交任务接口偶尔还是会超时。我让他把提交接口的日志调出来看结果发现他在submitTask方法里做了大量参数校验和数据库查询还调了个外部接口判断权限耗时一两秒。异步只解决了导出过程但提交链路本身太重也会拖垮入口。优化方式提交接口只做必要校验其他逻辑全部放到 Worker 里执行。如果权限校验必须前置那至少用缓存。6.2 导出文件在 Office 里打开提示“需要修复”这个问题一般在纯 POI 流式写入时出现。多数原因是临时文件残留、写入过程中进程崩溃导致xlsx的 ZIP 结构不完整或者你用了FileOutputStream但不是try-with-resources写完没 flush 和 close。还有一个常见原因SXSSFWorkbook写完之后没有调用wb.dispose()导致临时文件句柄被占用后续读取失败。我的习惯是在finally里显式调用 dispose而不是依赖 GC。另外有些版本的 POI 生成的共享字符串表里如果包含非法字符比如控制字符打开也会报修复提示。清洗数据的时候把\u0000这类控制字符过滤掉。6.3 大批量导出时数据库连接池被打爆异步化之后导出的 Worker 往往是在消息队列的线程里跑这些线程不会占用 Web 容器的线程池但它们依然要占用数据库连接。如果导出任务同时有多个每个任务都在做流式查询连接池很容易满。两个方向一是限制 Worker 并发数这是釜底抽薪二是给导出查询配置独立的数据库连接池比如 HikariCP 里加一个单独的 dataSource专门给导出用避免跟业务主链路争抢连接。我当时的线上故障是运营点了两个百万级的导出结果业务接口大面积超时。排查下来就是连接池被两个大查询满了。把导出数据源独立出去、并发改成 2 之后再也没有发生过。6.4 明明写入了十万行文件只有几十 KB这也是经典坑。出现原因是你的“写入”只是写到了临时文件或缓冲区没有真正 flush。尤其是SXSSFWorkbook和openpyxl的只写模式它们会把大量数据先缓存在临时文件等save时才合并打包。如果你提前从工作簿读取行数或者做了其他操作可能会把缓存弄丢。另外一种情况是你的批量查询接口没有真正执行只是构造了 SQL 对象导致实际查出来是空。排查时先确认数据库查询有没有结果再确认close是否执行。6.5 内存不减反增的诡异场景有人反馈说用了流式写入监控里内存反而飙升。一般原因是你没有限制SXSSFWorkbook的窗口大小或者你把选出来的数据又塞回了一个 List 里比如ListDataRow allRows stream.collect(Collectors.toList());SXSSFWorkbook只是处理了 Excel 侧的内存你的业务对象列表依然是全量驻留。流式查询必须配合迭代消费不要 collect。用 EasyExcel 的时候同理doWrite的入参如果是一个 List也会全量载入必须传入一个能被遍历的流式供给器。这里的经验教训是异步导出优化不是某一个点的问题任何一个环节出现“全量”行为都会把你省下的内存吃回去。7. 再聊聊监控与日志没有可观测性一切优化都是盲的异步任务最大的问题是什么用户点了导出然后走了任务在哪里卡住你完全不知道。建任务表是一个办法但不够。我强烈建议给导出任务加上一套日志追踪链路。最简单的做法日志里打印 taskId配合 Mapped Diagnostic ContextMDC或者 Python 的 logging filter把同一个 taskId 的所有流程日志串起来。每次批量写入打一条“处理到第多少行”的日志注意这种日志不能每行都打我一般每 5 万行打一次既能看到进度又不至于刷爆日志文件。监控指标上至少要记录四个数任务提交耗时、查询耗时、文件写入耗时、整体耗时。这四个数字能帮你快速定位性能瓶颈。我在做调优时就是靠这四个指标发现原来时间大头不在 Excel 写入而在数据库查询后来给表加了联合索引整体时间直接砍半。如果只盯着“整体耗时”看你永远不知道问题是查询、映射还是 IO。还有一个容易被忽视的点导出任务失败的重试机制。我建议在任务表里加一个retry_count字段失败之后自动重试一次。注意重试时要判断是不是幂等操作我们的导出任务天然幂等因为只是查数据生成文件但如果你在导出过程中有扣减下载次数这种副作用就要小心了。重试策略我通常用指数退避3 秒、9 秒、27 秒最多重试三次超过三次标记失败并告警。8. 性能实测不同数据量下的表现对比最后给一组我最近一次压测的真实数据数据库是 MySQL表里三百万行查询条件命中索引机器是 4 核 8G导出的字段是 8 列。导出数据量同步方案总耗时异步方案总耗时异步方案内存峰值用户感知耗时1 万行1.2s1.5s180MB0.5s10 万行9.8s10.5s320MB1.2s50 万行48s52s520MB2.0s100 万行崩溃118s780MB2.5s可以看到异步方案的总耗时跟同步方案相差不大甚至还略高一点因为多了任务落库、状态更新这些开销但在用户感知和内存稳定性上是质变。同步方案在 100 万行时已经 OOM 崩了异步方案稳定跑完内存也一直没破 1GB。再补充一个细节上面这组数字用的是 EasyExcel 流式写如果换原生XSSFWorkbook非流式在 50 万行时内存就超过 1.5GB而且耗时翻倍。所以选型真的很重要。9. 总结一些个人经验非套话做了一年多的导出优化我最大的感受是高性能异步导出的难点从来不在“异步”这两个字上而在“你怎么处理数据”上。异步只是用户体验和系统解耦的一层皮真正的内功是流式查询、批量写入、内存控制和可观测性。如果你现在正要改造一个导出功能我的建议是不要一上来就重构先给现网加指标埋点测一遍多少行是当前瓶颈的临界点然后小步快跑先改成“任务提交轮询”再改成流式写入最后再调 Worker 并发。每一阶段都压一下测记录数据别凭感觉调参数。还有一个小技巧想分享很多人的导出文件用的是“yyyyMMddHHmmss”做文件名但如果同一个用户一秒内点了两次文件名就冲突了。我用的是 taskId 加时间戳顺便把任务 ID 写进文件名里排查问题的时候一眼就知道这个文件是哪个任务的产物非常方便。后端的性能优化是一条没有终点的路但至少在 Excel 导出这个场景异步化加流式写入足以让你从“后台崩溃”的泥潭里走出来。希望这篇文章里的思路和代码能帮你少踩几个坑。