拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Java + POI 高性能 Excel 导出实战:List、MyBatis Cursor 流式与 VO 适配配 TaoToken

1. 从一次导出把服务打挂说起Java 后端做 Excel 导出最容易被低估的就是数据量。我见过一个儿童信息管理后台运营点了一下「导出全部」接口跑了 40 秒然后整个服务开始疯狂 Full GC最后 OOM 挂掉——因为代码里写的是childMapper.selectAll()一次性把 30 万行塞进ArrayList再交给XSSFWorkbook全量构建。内存里同时存在数据库结果集、PO 对象列表、POI 的 XML 树、最终字节数组四份数据叠加堆再大也扛不住。这篇要解决的就是这件事Java POI 高性能 Excel 导出覆盖三条路径——普通List全量导出、MyBatisCursor流式导出、以及 VO 字段适配。核心结论先给出来万级以下用 List 无所谓十万级以上必须上SXSSFWorkbookCursor而 VO 导出的列错位问题本质是「字段声明顺序、表头数组顺序、SQL 查询顺序」三者没对齐。适合谁看正在写导出接口、被 OOM 或列错位折磨过的 Java 后端。下面所有代码都可以直接复制进 SpringBoot 项目跑。2. 前置准备依赖、Cursor 配置与 TaoToken 接入2.1 Maven 依赖POI 版本选择上3.10-FINAL 是很多老项目的稳定选择但如果你是新项目建议用 4.x 或 5.xAPI 更规范。这里以 3.10-FINAL 为主线因为它的SXSSFWorkbook已经足够用且兼容性极好。dependency groupIdorg.apache.poi/groupId artifactIdpoi/artifactId version3.10-FINAL/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version3.10-FINAL/version /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-lang3/artifactId version3.12.0/version /dependency2.2 MyBatis Cursor 配置Cursor的本质是保持数据库连接打开逐行拉取结果而不是一次性把 ResultSet 转成 List。用之前确认两点Mapper 方法返回类型是CursorT且调用方必须在事务里否则连接会提前关闭。Mapper public interface ChildMapper { CursorChild selectAllCursor(); ListChildExcelVo selectExcelList(ChildQuery query); }对应的 XMLselect idselectAllCursor resultTypecom.skm.entity.Child fetchSize-2147483648 SELECT id, name, gender, grade, class_name, birth_date, enroll_date, allergy_info, diet_restrictions, parent_phone FROM child /selectfetchSize-2147483648是 MySQL 流式读取的约定值Integer.MIN_VALUE告诉驱动不要一次性缓存全部结果。PostgreSQL 则用fetchSize1000配合autoCommitfalse。2.3 关于 TaoToken 的接入位置导出接口本身不依赖大模型但如果你在导出前后要做字段语义映射、表头智能生成、或者把导出任务交给 Agent 编排就需要一个统一的模型调用入口。TaoToken 在这里的角色是提供兼容 OpenAI 协议的 API 网关你可以在导出服务里加一个「智能表头」能力把 VO 字段名丢给模型让它生成中文表头。接入方式很简单拿到 API Key 后配置 base_urltaotoken: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} model: claude-sonnet-4-5API Key 在控制台的 API Keys 页面创建https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentapi_keys如果你只是想做导出不涉及模型这一节可以跳过直接看第 3 节。但如果你打算把「导出字段适配」做成半自动的建议先跑通模型对话验证一下返回格式https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentmodels3. 可复制配置POI 导出工具类骨架3.1 核心设计思路工具类要解决四个问题双格式兼容xls/xlsx、流式写入SXSSF、反射方法缓存避免每行重复 getMethod、数据类型自动适配。下面给出精简后的骨架去掉了冗余样式代码保留关键逻辑。package com.skm.utils; import org.apache.poi.ss.usermodel.*; import org.apache.poi.xssf.streaming.SXSSFWorkbook; import org.apache.poi.xssf.usermodel.*; import org.apache.poi.hssf.usermodel.*; import org.apache.poi.hssf.util.HSSFColor; import org.apache.commons.lang3.StringUtils; import java.io.IOException; import java.io.OutputStream; import java.lang.reflect.*; import java.text.SimpleDateFormat; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; public class ExportExcelUtilT { public static final String EXCEL_FILE_2003 2003; public static final String EXCEL_FILE_2007 2007; private static final int DEFAULT_STREAM_WINDOW_SIZE 1000; private static final MapClass?, Method[] METHOD_CACHE new ConcurrentHashMap(); private static final Pattern NUMBER_PATTERN Pattern.compile(^-?\\d(\\.\\d)?$); private int streamWindowSize DEFAULT_STREAM_WINDOW_SIZE; public void setStreamWindowSize(int size) { this.streamWindowSize size; } /** 流式导出入口支持 Iterator适配 Cursor */ public void exportExcel(String title, String[] headers, IteratorT dataIt, OutputStream out, String version) { if (StringUtils.isBlank(version) || EXCEL_FILE_2003.equals(version.trim())) { exportExcel2003(title, headers, dataIt, out, yyyy-MM-dd HH:mm:ss); } else { exportExcel2007(title, headers, dataIt, out, yyyy-MM-dd HH:mm:ss); } } /** 普通 List 导出入口 */ public void exportExcel(String title, String[] headers, CollectionT dataset, OutputStream out, String version) { exportExcel(title, headers, dataset null ? null : dataset.iterator(), out, version); } }3.2 SXSSF 流式写入实现SXSSFWorkbook的关键参数是rowAccessWindowSize它决定内存里保留多少行超出的行会被刷到磁盘临时文件。设 1000 意味着内存里最多 1000 行百万级数据也只占这么多。public void exportExcel2007(String title, String[] headers, IteratorT dataIt, OutputStream out, String pattern) { if (dataIt null || !dataIt.hasNext()) { return; } SXSSFWorkbook workbook new SXSSFWorkbook(streamWindowSize); workbook.setCompressTempFiles(true); try { Sheet sheet workbook.createSheet(title); sheet.setDefaultColumnWidth(20); XSSFCellStyle headerStyle buildHeaderStyle(workbook); XSSFCellStyle dataStyle buildDataStyle(workbook); if (headers ! null headers.length 0) { Row headerRow sheet.createRow(0); for (int i 0; i headers.length; i) { Cell cell headerRow.createCell(i); cell.setCellStyle(headerStyle); cell.setCellValue(new XSSFRichTextString(headers[i])); } } SimpleDateFormat sdf new SimpleDateFormat(pattern); int index (headers null || headers.length 0) ? 0 : 1; while (dataIt.hasNext()) { T t dataIt.next(); Row row sheet.createRow(index); writeRow(row, t, dataStyle, sdf); } workbook.write(out); out.flush(); } catch (IOException e) { throw new RuntimeException(导出 Excel 失败, e); } finally { workbook.dispose(); } } private void writeRow(Row row, T t, XSSFCellStyle dataStyle, SimpleDateFormat sdf) { Method[] methods getGetterMethods(t.getClass()); for (int i 0; i methods.length; i) { Cell cell row.createCell(i); cell.setCellStyle(dataStyle); setCellValue(cell, invokeGetter(methods[i], t), sdf); } }3.3 反射缓存与类型适配反射是导出性能的隐形杀手。每行都调getClass().getMethod()的话10 万行就是 10 万次方法查找。用ConcurrentHashMap缓存Method[]按字段声明顺序排列只查一次。private Method[] getGetterMethods(Class? clazz) { return METHOD_CACHE.computeIfAbsent(clazz, k - { Field[] fields k.getDeclaredFields(); Method[] methods new Method[fields.length]; for (int i 0; i fields.length; i) { String fieldName fields[i].getName(); String getter get fieldName.substring(0, 1).toUpperCase() fieldName.substring(1); try { methods[i] k.getMethod(getter); } catch (NoSuchMethodException e) { if (fields[i].getType() boolean.class || fields[i].getType() Boolean.class) { try { methods[i] k.getMethod(is fieldName.substring(0, 1).toUpperCase() fieldName.substring(1)); } catch (NoSuchMethodException ex) { throw new RuntimeException(字段 fieldName 缺少 getter, ex); } } else { throw new RuntimeException(字段 fieldName 缺少 getter, e); } } } return methods; }); } private void setCellValue(Cell cell, Object value, SimpleDateFormat sdf) { if (value null) return; if (value instanceof Number) { cell.setCellValue(((Number) value).doubleValue()); } else if (value instanceof Boolean) { cell.setCellValue((Boolean) value ? 是 : 否); } else if (value instanceof Date) { cell.setCellValue(sdf.format((Date) value)); } else { String text value.toString(); if (NUMBER_PATTERN.matcher(text).matches()) { cell.setCellValue(Double.parseDouble(text)); } else { cell.setCellValue(text); } } }3.4 VO 字段顺序对齐规则这是列错位的根因。工具类不读 SQL 顺序也不读表头顺序它只认VO 字段的声明顺序。所以三者必须严格一致对齐项作用出错后果VO 字段声明顺序反射取值依据列数据整体错位headers 数组顺序表头显示表头与数据不匹配SQL 查询字段顺序建议对齐冗余字段干扰非必须public class ChildExcelVo { private Long id; // 第1列 private String name; // 第2列 private String grade; // 第3列 private String gender; // 第4列 private Date birthDate; // 第5列 private Date enrollDate; // 第6列 private String className; // 第7列 private String allergyInfo; // 第8列 private String dietRestrictions; // 第9列 private String parentPhone; // 第10列 // getter/setter 省略 }对应表头String[] headers {Id, 姓名, 年级, 性别, 出生日期, 入园日期, 班级, 过敏史, 饮食禁忌, 家长手机号};4. 验证请求与成功结果4.1 Cursor 流式导出接口GetMapping(/export/stream) Transactional public void exportStream(HttpServletResponse response) throws IOException { ExportExcelUtilChild util new ExportExcelUtil(); util.setStreamWindowSize(1000); String[] headers {Id, 姓名, 性别, 年级, 班级, 出生日期, 入园日期, 过敏史, 饮食禁忌, 家长手机号}; response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filenamechildren.xlsx); try (CursorChild cursor childMapper.selectAllCursor()) { util.exportExcel(儿童信息, headers, cursor.iterator(), response.getOutputStream(), 2007); } }注意Transactional不能省Cursor 依赖事务保持连接。另外try-with-resources确保 Cursor 关闭。4.2 验证内存与耗时启动时加上 JVM 参数观察堆变化java -Xms512m -Xmx512m -XX:PrintGCDetails -jar app.jar导出 50 万行数据用jstat观察jstat -gcutil pid 1000预期结果老年代占用稳定在 30% 以下Full GC 次数为 0。如果看到老年代持续上涨说明某处还在全量持有数据检查是不是Cursor被转成了List。耗时方面50 万行 xlsx 导出在普通 SSD 机器上约 15-25 秒其中大部分时间花在磁盘临时文件写入。把streamWindowSize调到 2000 可以略微提速但内存占用翻倍。4.3 用模型辅助生成表头如果你不想手写 headers可以把 VO 字段名发给模型让它返回中文表头数组。调用 TaoToken 的对话接口curl https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: claude-sonnet-4-5, messages: [{role: user, content: 把以下 Java 字段名转成中文表头只返回 JSON 数组id,name,grade,gender,birthDate,enrollDate,className,allergyInfo,dietRestrictions,parentPhone}] }返回结果直接塞进headers数组即可。这个能力在字段多、变动频繁的场景下很省事。想先在线试一下返回格式可以用模型对话页面https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentchat5. 本篇常见错排查5.1 导出文件打不开或内容为空最常见的原因是workbook.dispose()没调或者out.flush()缺失。SXSSF 的临时文件在dispose()之前不会合并成最终文件如果提前关了流文件就是坏的。另一个原因是dataIt.hasNext()为 false 时直接 return导致空文件——这时候应该至少写一个表头。5.2 列数据整体错位回到第 3.4 节的规则VO 字段声明顺序是唯一依据。如果你在 VO 中间插了一个字段但没更新 headers后面所有列都会右移。排查方法打印getGetterMethods返回的方法名数组和 headers 逐项对比。5.3 Cursor 报「Connection is closed」两个原因一是方法上没有Transactional二是 Cursor 在事务外被消费。确保cursor.iterator()的遍历发生在事务方法内部不要把 Iterator 返回给上层。5.4 数字被当成文本setCellValue里用正则判断了数字字符串但像身份证号、手机号这种「看起来是数字但不该参与计算」的字段会被转成 Double导致科学计数法。解决办法是在 VO 里把这类字段声明为String并在正则判断前加白名单排除。5.5 临时文件占满磁盘SXSSFWorkbook默认在java.io.tmpdir下写临时文件百万行数据可能产生几百 MB。两个动作workbook.setCompressTempFiles(true)压缩以及确保dispose()在 finally 里执行。如果导出频繁考虑把java.io.tmpdir指向大容量分区。6. 选型边界与后续动作三条路径的边界很清晰数据量在 1 万行以内普通ListXSSFWorkbook完全够用代码简单1 万到 10 万行用SXSSFWorkbook但数据源可以是 List10 万行以上必须SXSSFWorkbookCursor否则内存迟早出问题。如果你正在做的是长期运行的导出服务或者想把导出任务编排进 Agent 流程比如定时导出 模型生成报表摘要建议了解一下 Coding Plan它更适合这种持续性的编码与任务编排场景https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcoding_plan接入文档在这里包含完整的 API 参数和错误码说明https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentdoc最后给一个实操建议先把streamWindowSize设成 500跑一次 10 万行导出用jstat看老年代曲线。如果平稳再逐步调到 1000、2000找到内存和速度的平衡点。这个值没有标准答案取决于你的堆大小和磁盘 IO。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门