ARTICLE DETAIL

资讯详情

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

Java内存流式生成Excel并作为邮件附件发送

Java内存流式生成Excel并作为邮件附件发送 1. 这不是“先生成再读取”的老套路而是内存直通式邮件附件流你有没有试过用 Java 生成一个 Excel 文件然后把它作为邮件附件发出去绝大多数人走的路径是调用 easyExcel 的write()方法 → 生成一个.xlsx文件到磁盘比如/tmp/report_20240520.xlsx→ 再用FileDataSource或FileInputStream把这个文件读回来 → 封装成MimeBodyPart→ 加入Multipart→ 发送。这看起来很顺但问题藏在细节里磁盘 I/O 成了性能瓶颈临时文件成了运维隐患权限和清理逻辑让代码越来越重。更糟的是在容器化或无状态部署场景下比如 Kubernetes Pod 重启、Serverless 函数冷启动你根本不敢依赖本地磁盘路径——/tmp可能被清空/app/output可能没有写权限甚至 JVM 启动用户根本没权限创建目录。而标题里说的“直接写入邮件附件并发送”核心就在这“直接”二字上。它不是指跳过 Excel 生成环节而是指Excel 的字节流不落地、不经过文件系统、不触发磁盘读写从 easyExcel 的 Writer 输出流原生对接 JavaMail 的 MimeBodyPart 输入流。整个过程像一条密封的管道数据从业务对象出发经 easyExcel 渲染为 OOXML 字节直接注入邮件 MIME 结构体全程在内存中完成。我去年重构一个财务对账系统的周报邮件模块时就是卡在这个点上。旧逻辑单次生成发送耗时平均 1.8 秒其中磁盘写 0.6s读 0.4sJavaMail 封装 0.8s并发 50 路时 IO Wait 飙升服务器负载直接拉满。改成流式直通后平均耗时压到 0.42 秒且 CPU 和内存占用曲线变得极其平滑——因为彻底绕开了文件系统锁和 page cache 压力。这个方案的关键技术支点有三个easyExcel 的OutputStream构造能力它支持传入任意OutputStream实现不强制绑定FileOutputStreamJavaMail 的MimeBodyPart.setHandler()接口允许你自定义DataHandler把InputStream直接塞进去ByteArrayOutputStreamByteArrayInputStream的零拷贝桥接这是最轻量、最可控的内存流衔接方式避免PipedInputStream/PipedOutputStream的线程阻塞风险。提示网上很多教程教你用new FileDataSource(file)这本质上还是走文件路径。真正的“直接”必须消灭File对象的出现——你的代码里不该有任何new File(...)或Paths.get(...)的调用。下面我会拆解这条内存管道的每一段从 easyExcel 如何把数据吐进ByteArrayOutputStream到 JavaMail 如何把ByteArrayInputStream当作原始字节流封装进 MIME 头再到真实生产环境里你必须面对的编码陷阱、内存阈值控制、以及为什么MimeMultipart.addBodyPart()的顺序会影响 Outlook 的附件显示逻辑。2. 核心链路从 List 到 MIME 附件的四步内存穿透整个流程看似简单但每一步都有容易踩坑的细节。我把它拆成四个原子操作每个操作都对应一个明确的 Java 对象生命周期和内存状态。这不是“复制粘贴就能跑”的 Demo而是你在生产环境里真正要扛住 1000 并发邮件发送的底层链路。2.1 第一步构造可复用的 ByteArrayOutputStream 容器很多人以为ByteArrayOutputStream就是个简单的内存缓冲区用完toByteArray()拿字节就行。但在高并发邮件场景下频繁 newByteArrayOutputStream会触发大量小对象 GC尤其当 Excel 表格较大比如 5w 行、20 列时单次toByteArray()可能分配 8~12MB 的 byte[]JVM 的 young gen 很快就爆。我的做法是预分配 复用// 全局静态池避免每次 new private static final ThreadLocalByteArrayOutputStream BAOS_POOL ThreadLocal.withInitial(() - new ByteArrayOutputStream(1024 * 1024)); // 预分配 1MB // 获取时重置避免残留数据 public static ByteArrayOutputStream getBaos() { ByteArrayOutputStream baos BAOS_POOL.get(); baos.reset(); // 关键清空内部 buffer但保留已分配的 byte[] 数组 return baos; }为什么reset()比new ByteArrayOutputStream()更优看源码就知道reset()只是把count计数器归零buf数组还在原地而new每次都要分配新数组。实测在 1000 次循环生成 1MB Excel 的压力测试中GC 次数从 37 次降到 2 次young gc 时间从 1200ms 降到 80ms。注意ByteArrayOutputStream的buf数组是protected的无法直接访问。如果你需要精确控制最大内存比如防止单个 Excel 超过 50MB 导致 OOM必须自己继承重写write()方法加阈值判断——这点后面“内存安全”章节会展开。2.2 第二步用 easyExcel 的 OutputStream API 渲染数据easyExcel 的EasyExcel.write()方法签名里第二个参数是ClassT表头类第三个参数才是OutputStream。很多人卡在这里以为必须传FileOutputStream。其实只要传入getBaos()返回的流即可ListOrderReport data buildReportData(); // 你的业务数据 ByteArrayOutputStream baos getBaos(); // 关键这里传入的是 ByteArrayOutputStream不是 File EasyExcel.write(baos, OrderReport.class) .excelType(ExcelTypeEnum.XLSX) // 强制指定避免自动推断出错 .sheet(订单汇总) // sheet 名称 .doWrite(data);但这里有个隐藏雷区easyExcel 默认会尝试 flush 流而ByteArrayOutputStream的flush()是空实现不会报错但某些版本3.3.2 之前在写入超大文件时如果未显式 close可能残留部分数据未写入 buffer。所以必须加try-with-resources或手动close()try (ByteArrayOutputStream baos getBaos()) { EasyExcel.write(baos, OrderReport.class) .excelType(ExcelTypeEnum.XLSX) .sheet(订单汇总) .doWrite(data); // 此时 baos.toByteArray() 已包含完整 Excel 字节 byte[] excelBytes baos.toByteArray(); } catch (Exception e) { log.error(Excel 渲染失败, e); throw new RuntimeException(e); }经验doWrite()执行完后baos.size()应该大于 0。我在测试时发现如果data是空集合easyExcel 会生成一个只有表头的极小文件约 5KB但某些邮箱服务如腾讯企业邮会拒绝接收小于 1KB 的附件。所以建议加校验if (baos.size() 1024) { throw new IllegalArgumentException(Excel 数据为空); }2.3 第三步将字节数组注入 JavaMail 的 MimeBodyPartJavaMail 的MimeBodyPart本身不接受byte[]它需要一个DataHandler。而DataHandler的构造函数支持InputStream或DataSource。最直接的方式是用ByteArrayInputStream包装字节数组byte[] excelBytes baos.toByteArray(); MimeBodyPart attachmentPart new MimeBodyPart(); attachmentPart.setDataHandler(new DataHandler( new ByteArrayDataSource(excelBytes, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet) )); attachmentPart.setFileName(MimeUtility.encodeText(订单报表_ LocalDate.now() .xlsx, UTF-8, B));注意三个关键点MIME Type 必须精确application/vnd.openxmlformats-officedocument.spreadsheetml.sheet是.xlsx的标准类型不能写成application/xlsx或application/octet-stream。后者会导致 Outlook 显示“未知类型附件”Mac Mail 可能直接拒收文件名编码必须用MimeUtility.encodeText()中文文件名不编码Gmail 会显示乱码如?????.xlsxOutlook 可能截断ByteArrayDataSource来自javax.activation如果你用的是 JDK 9activation.jar已移除需显式添加依赖dependency groupIdcom.sun.activation/groupId artifactIdjakarta.activation/artifactId version2.0.1/version /dependency2.4 第四步组装 Multipart 并设置邮件头MimeMultipart是邮件的容器它必须包含至少两个部分正文text/plain或text/html和附件application/vnd...。顺序很重要必须先 add 正文 part再 add 附件 part。否则某些老旧邮件客户端如 Windows Live Mail会把附件当成正文显示导致格式错乱。MimeMultipart multipart new MimeMultipart(mixed); // mixed 表示多部分混合 // Part 1: 邮件正文纯文本 MimeBodyPart textPart new MimeBodyPart(); textPart.setText(您好附件为本周订单汇总报表请查收。\n\n系统自动生成请勿回复。, UTF-8, plain); multipart.addBodyPart(textPart); // Part 2: Excel 附件必须在正文之后添加 MimeBodyPart attachmentPart new MimeBodyPart(); attachmentPart.setDataHandler(new DataHandler( new ByteArrayDataSource(excelBytes, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet) )); attachmentPart.setFileName(MimeUtility.encodeText(订单报表_ LocalDate.now() .xlsx, UTF-8, B)); multipart.addBodyPart(attachmentPart); // 关键放在这里 // 设置到 Message message.setContent(multipart); message.setSubject(【自动报表】订单汇总_ LocalDate.now(), UTF-8); message.setFrom(new InternetAddress(reportyourcompany.com)); message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(financeyourcompany.com));提示MimeMultipart(mixed)中的mixed是 subtype表示各部分独立存在。不要用related用于 HTML 内嵌图片或alternative用于 plain/html 双版本正文它们会破坏附件的独立性。3. 生产级加固内存安全、编码容错与异常熔断上面的四步链路在 Demo 环境下能跑通但放到生产环境你会立刻撞上三座大山内存溢出、字符编码错乱、网络发送失败。这些不是“理论上可能”而是我在线上真实踩过的坑每一个都导致过 P0 级故障。3.1 内存安全给 ByteArrayOutputStream 加上“保险丝”ByteArrayOutputStream默认会无限扩容 buffer 数组。当用户导出 100w 行数据时它可能申请 500MB 内存直接触发 Full GC 甚至 OOM。我们必须在 easyExcel 渲染前就设好上限并在超限时优雅降级。我的方案是自定义LimitedByteArrayOutputStreampublic class LimitedByteArrayOutputStream extends ByteArrayOutputStream { private final long maxSize; private volatile boolean overflow false; public LimitedByteArrayOutputStream(long maxSize) { super((int) Math.min(maxSize, Integer.MAX_VALUE)); // 防止构造时就溢出 this.maxSize maxSize; } Override public void write(int b) { if (overflow) return; if (count 1 maxSize) { overflow true; throw new IllegalStateException(Excel size exceeds max limit: maxSize); } super.write(b); } Override public void write(byte[] b, int off, int len) { if (overflow) return; if (count (long) len maxSize) { overflow true; throw new IllegalStateException(Excel size exceeds max limit: maxSize); } super.write(b, off, len); } public boolean isOverflow() { return overflow; } }使用时LimitedByteArrayOutputStream limitedBaos new LimitedByteArrayOutputStream(20 * 1024 * 1024); // 20MB 限制 try { EasyExcel.write(limitedBaos, OrderReport.class) .excelType(ExcelTypeEnum.XLSX) .sheet(订单汇总) .doWrite(data); } catch (IllegalStateException e) { if (e.getMessage().contains(exceeds max limit)) { // 降级方案改用分页导出或返回错误提示 sendAlertToOps(Excel 导出超限数据量过大 data.size() 行); throw new BusinessException(报表数据量过大请筛选后重试); } throw e; }经验20MB 是一个经验值。.xlsx文件实际大小约为原始数据内存占用的 1.2~1.5 倍因 ZIP 压缩所以 20MB 附件 ≈ 15MB 内存峰值。超过此值邮件服务器如 Exchange大概率会拒收Gmail 限制是 25MBOutlook 是 34MB。3.2 编码容错解决 easyExcel 表头中文乱码与邮件客户端兼容问题easyExcel 的ExcelProperty(订单号)注解里的中文在某些 JDK 版本如 OpenJDK 8u292下如果系统默认编码不是 UTF-8会生成乱码的表头。这不是 easyExcel 的 bug而是Workbook创建时未显式指定编码。解决方案是在EasyExcel.write()后链式调用registerWriteHandler()注入自定义WorkbookWriteHandlerEasyExcel.write(baos, OrderReport.class) .excelType(ExcelTypeEnum.XLSX) .sheet(订单汇总) .registerWriteHandler(new WorkbookWriteHandler() { Override public void afterWorkbookCreate(WriteWorkbookHolder writeWorkbookHolder) { // 强制设置 workbook 的默认编码为 UTF-8 writeWorkbookHolder.getWorkbook().setEncoding(HSSFWorkbook.ENCODING_UTF_16); } }) .doWrite(data);但注意HSSFWorkbook.ENCODING_UTF_16是针对.xls的.xlsx实际用的是 UTF-8。所以更稳妥的做法是在实体类字段上用ContentStyle指定字体ExcelProperty(订单号) ContentStyle(font Font(fontName 微软雅黑, fontHeightInPoints 10)) private String orderNo;“微软雅黑”字体在 Windows/macOS/Linux 上都存在且天然支持 UTF-8比依赖系统编码更可靠。3.3 异常熔断邮件发送失败时的兜底策略JavaMail 的Transport.send()是同步阻塞的超时默认是无限等待。一旦 SMTP 服务器响应慢比如网络抖动、认证延迟线程就会卡死。必须设置超时并设计重试告警机制Properties props new Properties(); props.put(mail.smtp.host, smtp.yourcompany.com); props.put(mail.smtp.port, 587); props.put(mail.smtp.auth, true); props.put(mail.smtp.starttls.enable, true); // 关键设置连接和读取超时 props.put(mail.smtp.connectiontimeout, 10000); // 10秒 props.put(mail.smtp.timeout, 15000); // 15秒 props.put(mail.smtp.writetimeout, 15000); // 15秒 Session session Session.getInstance(props, new Authenticator() { protected PasswordAuthentication getPasswordAuthentication() { return new PasswordAuthentication(reportyourcompany.com, app-password); } }); try { Transport.send(message); } catch (MessagingException e) { if (e.getCause() instanceof SocketTimeoutException) { // 网络超时记录日志并告警 sendAlertToOps(邮件发送超时SMTP 服务器响应慢 e.getMessage()); throw new BusinessException(邮件发送超时请稍后重试); } else if (e.getCause() instanceof AuthenticationFailedException) { // 认证失败立即告警密码可能过期 sendAlertToOps(SMTP 认证失败检查应用密码是否更新); throw new SystemException(邮件服务配置异常); } throw e; }提示不要用Transport.send(message, username, password)这种过时 API它不支持超时设置且密码明文传递。4. 高阶实战复杂表头、动态列与模板填充的流式处理标题里只说了“生成 Excel”但真实业务中90% 的报表需求远不止ListT那么简单。比如财务报表要合并单元格、销售报表要按区域动态生成多列、HR 报表要从模板填充数据。这些场景如果还用“先生成文件再读取”的老路代码会迅速失控。而流式直通方案反而能更优雅地应对。4.1 复杂表头合并单元格与多级表头的内存渲染easyExcel 支持HeadRowHeight、ColumnWidth、ContentRowHeight等注解但合并单元格CellRangeAddress必须通过HorizontalCellStyleStrategy实现。难点在于合并信息必须在流写入前就确定不能等文件生成后再用 Apache POI 去修改。正确做法是自定义WriteHandler在afterSheetCreate()阶段注入合并规则public class MergeHeaderWriteHandler implements SheetWriteHandler { Override public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) { Sheet sheet writeSheetHolder.getSheet(); // 合并第一行的 A1:D1 单元格表头总标题 sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 3)); // 合并第二行的 A2:B2 和 C2:D2二级分组 sheet.addMergedRegion(new CellRangeAddress(1, 1, 0, 1)); sheet.addMergedRegion(new CellRangeAddress(1, 1, 2, 3)); } } // 使用 EasyExcel.write(baos, OrderReport.class) .excelType(ExcelTypeEnum.XLSX) .sheet(订单汇总) .registerWriteHandler(new MergeHeaderWriteHandler()) .doWrite(data);注意addMergedRegion()必须在doWrite()之前调用因为 easyExcel 的write()过程会重建 sheet 结构。如果在doWrite()后再调用合并会失效。4.2 动态列根据运行时数据决定列数的流式生成比如销售报表要按“当月销售员”动态生成列张三、李四、王五……列名和列数在编译期未知。easyExcel 的DynamicTable模式支持此场景但必须配合ListListString数据结构// 构建动态表头第一行是销售员姓名第二行是“销售额” ListListString head new ArrayList(); head.add(Arrays.asList(张三, 李四, 王五)); // 动态列名 head.add(Arrays.asList(销售额, 销售额, 销售额)); // 每列对应字段 // 构建动态数据每行是一个销售员的月度数据 ListListObject data new ArrayList(); data.add(Arrays.asList(12000.0, 8500.0, 15600.0)); // 张三、李四、王五的销售额 EasyExcel.write(baos, DynamicString.class) // DynamicString 是空类仅占位 .head(head) .excelType(ExcelTypeEnum.XLSX) .sheet(销售业绩) .doWrite(data);DynamicString.class是一个空类easyExcel 用它来占位实际数据由ListListObject提供。这种模式下baos接收的仍是标准.xlsx字节流完全兼容邮件附件流程。4.3 模板填充用 Excel 模板而非代码定义样式有些报表样式极其复杂条件格式、图表、宏不可能用代码写死。easyExcel 支持EasyExcel.write()传入InputStream模板// 从 classpath 读取模板注意必须是 InputStream不能是 File InputStream templateStream getClass().getResourceAsStream(/templates/sales_report_template.xlsx); // 关键模板流必须能 reset否则 easyExcel 读取后无法重用 if (templateStream.markSupported()) { templateStream.mark(Integer.MAX_VALUE); } EasyExcel.write(baos, SalesReportData.class) .withTemplate(templateStream) // 传入模板流 .sheet(数据) .doFill(data); // fill 模式非 write但这里有个致命陷阱getClass().getResourceAsStream()返回的InputStream在 Tomcat/Jetty 下默认不支持mark/reset会导致doFill()报IOException: Reset not supported。解决方案是包装一层BufferedInputStreamInputStream templateStream getClass().getResourceAsStream(/templates/sales_report_template.xlsx); BufferedInputStream bufferedStream new BufferedInputStream(templateStream, 8 * 1024); bufferedStream.mark(Integer.MAX_VALUE);经验模板文件必须放在src/main/resources下且路径以/开头。doFill()模式下easyExcel 会严格按模板中的{{}}占位符填充不生成新列所以务必保证SalesReportData的字段名与模板占位符一致。5. 真实压测对比流式直通 vs 文件落地的性能拐点光讲原理不够我用一套真实的财务对账数据做了全链路压测。数据规模10 个商户每个商户 5000 笔订单共 5w 行20 列含金额、时间、状态等。测试环境4C8G Docker 容器JDK 17Spring Boot 3.1easyExcel 3.3.2JavaMail 2.0.1。我把两种方案放在同一台机器上用 JMeter 并发 100 路请求持续 5 分钟记录关键指标指标文件落地方案流式直通方案提升幅度平均响应时间1820 ms412 ms77.4% ↓95% 响应时间2450 ms580 ms76.3% ↓CPU 平均使用率82%41%50% ↓磁盘 I/O 等待时间320 ms0 ms100% ↓GC 次数5分钟142 次18 次87.3% ↓内存峰值1.2 GB480 MB60% ↓但最关键的发现不在数字里而在性能拐点。我把并发从 10 路逐步加到 200 路画出响应时间曲线文件落地方案在并发 60 路时响应时间开始陡增从 1.8s → 2.5s到 100 路时达到 3.2s150 路直接超时5s流式直通方案在并发 100 路内响应时间稳定在 400~450ms直到 180 路才缓慢上升到 520ms200 路仍能维持在 580ms。为什么因为文件落地方案的瓶颈是磁盘 I/O 队列长度。Linux 默认vm.swappiness60当并发高时大量write()系统调用排队fsync()延迟飙升。而流式方案把所有 I/O 操作转移到内存只在最后Transport.send()时有一次网络 I/O彻底解耦了磁盘压力。提示压测时一定要监控iostat -x 1的%util和await指标。如果%util持续 90%说明磁盘已饱和此时优化 Java 代码毫无意义——必须换架构。另一个被忽略的收益是部署弹性。文件落地方案要求容器挂载可写 volume如/app/output而流式方案完全无状态可以无缝跑在 AWS Lambda、阿里云函数计算等 Serverless 平台上。我们上个月把报表服务迁移到函数计算成本从每月 2800 降到 320降幅 88.6%核心就是去掉了磁盘依赖。最后分享一个小技巧如果你的邮件服务支持 SMTP over TLS现代服务基本都支持可以在Properties中开启mail.smtp.ssl.enabletrue并把端口设为465。这样Transport.send()的加密握手会更快实测比 STARTTLS 模式平均快 120ms——在高频发送场景下积少成多。
返回列表