
简介这是一份面向Java开发者的Apache POI导出Excel实战资源围绕poi导出、java导出等核心需求展开。资源以zip压缩包形式提供共519个文件大小567KB内部包含java源码、class编译文件、xml与yml配置、sample示例及log日志等类型并带有Git对象文件适合作为项目级参考。已有387人学习。资源内容以教程代码为主演示了从引入POI Maven依赖、通过JDBC从数据库查询数据到创建Workbook、Sheet、表头与数据行的完整流程同时利用反射机制实现实体类字段到Excel单元格的自动映射便于读者快速复用。压缩包中还包含相关配置与日志可帮助理解项目运行环境与排错思路。整体上这份资料能帮助Java开发者快速掌握POI导出Excel的核心方法并在此基础上扩展样式设置、数据验证或公式计算等高级功能。1. 拆开 poi_export_excel.zip这个导出项目该有的骨架事情得从这么一个场景说起你从同事那边拷来一个压缩包名字叫poi_export_excel.zip解压一看里面是一个 Java 工程代码不算多但把 Excel 导出的整套逻辑都串起来了。包名里同时出现了 poi、export、excel 三个词识货的人一眼就能猜到这八九不离十是用 Apache POI 做 Excel 导出的项目。先说一个容易混淆的点。你在搜索POI数据集的时候可能会搜到一堆地理信息相关的兴趣点数据那个 POI 是 Point of Interest跟本文要讲的 Apache POI 完全是两码事。Apache POI 是 Apache 基金会出品的 Java 库用来读写 Microsoft Office 格式的文件其中操作 Excel 的组件叫 HSSF处理 .xls、XSSF处理 .xlsx、SXSSF流式写 .xlsx。日常业务系统里导出报表导出明细这类需求基本都是靠它撑起来的。这个 zip 项目解决的就是数据库里有数据怎么高效、稳定、不把内存撑爆地导出成 Excel 文件这件事。适合正在做后端开发、被导出功能折磨过或者即将被折磨的同学参考。把项目打开扫一遍你会发现它没有把全部逻辑堆在一个类里而是清楚地分了三块接口层接收前端传过来的查询条件、导出字段、文件名之类的参数做基础校验。服务层真正的业务逻辑在这里查数据、组装数据、调用导出工具最后把文件写到输出流。工具层封装了 POI 的 Workbook 创建、单元格样式、列宽、日期格式化等操作。这个分层看着简单却是个很务实的决定。如果你把 POI 的 API 调用直接散落在每一个业务方法里等导出需求多了以后改一个通用样式就要满世界找代码。这个 zip 项目把导出这件事抽象成了一个独立模块新业务接入的时候只需要准备好数据把表头和数据行丢给工具类就行。我第一次看到这个结构时觉得有点小题大做直到自己维护了十几个导出接口之后才明白这种分层能省下多少事。项目的核心依赖也很典型——Maven 的poi-ooxml包。它会把poi、poi-ooxml-schemas、xmlbeans等一串依赖拉进来版本选择上建议直接跟 Apache POI 官网的稳定版走不要用老掉牙的 3.x 版本很多奇奇怪怪的样式兼容问题都是版本太老导致的。2. 选对 WorkbookXSSF 还是 SXSSF直接决定内存曲线打开项目的工具类第一个值得琢磨的地方就是创建 Workbook 的代码。POI 里有三个核心类可以创建 Excel 文档HSSFWorkbook、XSSFWorkbook、SXSSFWorkbook。这个项目里用的是 SXSSFWorkbook这个选型不是随手写的是有讲究的。先说共性三者都实现了 Workbook 接口都能创建 Sheet、Row、Cell基本 API 一致。区别在于底层的数据组织方式。HSSFWorkbook对应 Excel 2003 的 .xls 格式最大行数是 65536 行。放到现在来看这个限制太局促了稍微导点业务数据就超了。而且它是把整个工作簿都放在内存里操作行数稍多就卡。XSSFWorkbook对应 Excel 2007 起的 .xlsx 格式行数上限百万级能满足绝大多数业务场景但同样是把整个文档结构放在内存中。问题在于 .xlsx 内部是一堆 XML 片段POI 要在内存里维护整棵 XML 树导个几万行数据内存占用就非常可观。SXSSFWorkbook是 XSSFWorkbook 的流式版本它引入了滑动窗口机制内存里只保留最近 N 行数据更早的行被写进磁盘临时文件。这样一来不管导 10 万行还是 20 万行内存曲线都是平稳的。项目中遇到的典型场景是从数据库查出一批订单明细可能有几万条每条记录有二十多个字段需要全部导成 Excel。如果直接用 XSSFWorkbook 来写JVM 堆内存给到 512MB 甚至 1GB 都不一定扛得住——因为除了数据本身POI 还会维护大量的样式对象、单元格对象、XML 字符串。实际测试过导 5 万行 × 20 列的 .xlsxXSSFWorkbook 的内存增量可能到 300MB 以上而 SXSSFWorkbook 能把增量压在几十 MB 的量级。所以看到new SXSSFWorkbook()这行代码基本就清楚这个项目是冲着数据量去的。如果你只是导几百行配置数据用 XSSFWorkbook 也没问题代码还更简单但凡是正经报表掂量一下数据量我建议直接上 SXSSF。有一点必须注意SXSSFWorkbook 生成的 .xlsx 里单元格的聚合公式如 SUM 之类的函数结果可能不会预先计算好因为流式写入没把计算引擎带全。如果业务方要求导出文件里公式的值直接可见要么在服务端自己算好再写入要么导出后用 Excel 重新打开时让它自动计算。这是一个容易被忽视的坑。3. 大数据量导出的内存账本与调优实践光换成 SXSSFWorkbook 还不够大数据量导出是一个系统工程内存、CPU、临时文件、GC 都会成为瓶颈。这个项目里针对大数据量场景做了一些很有意思的处理值得展开聊。3.1 为什么导 10 万行时内存还是爆了很多人以为用了 SXSSFWorkbook 就万事大吉结果导 10 万行还是 OOM排查了半天没头绪。问题往往出在几个地方。第一个是查询数据时一次性把全量结果加载进内存。SELECT * FROM orders WHERE xxx然后list.size()可能就有 20 万条记录每条记录映射成一个实体对象再来一层 DTO 转换堆内存早就在数据到达 Excel 之前就吃满了。SXSSFWorkbook 只是解决了 Excel 写入端的内存问题数据源这一端不控制内存照样爆。更好的做法是分页查询或者用 JDBC 的流式结果集一次只拿一批数据处理完写入后再查下一批。流式结果集要注意对Statement设置fetchSize不同数据库驱动行为不一样MySQL 需要加useCursorFetchtrue参数配合。第二个是为每一行创建新的 CellStyle。POI 里创建 CellStyle 是一个比较重的操作内部要维护样式记录和缓存。如果循环里对每个单元格都createCellStyle()对象数量会爆炸。正确的做法是复用样式相同字体、边框、对齐方式的单元格共用一个 CellStyle 实例可以在循环外面创建好循环里setCellStyle引用进去。第三个是单元格里塞了超长字符串。比如把用户的一段大文本评论也导进 Excel一条记录十几 KB10 万行就是几个 GB 的文本量谁来都扛不住。遇到这种情况要跟业务方确认是截断、省略还是单独处理。3.2 rowAccessWindowSize 的合理值怎么定SXSSFWorkbook 的构造函数可以传一个rowAccessWindowSize参数表示内存窗口内保留的行数。默认是 100调到多少合适这个值不是越大越好也不是越小越好。窗口越小内存占用越低但数据会频繁被刷到临时文件增加磁盘 IO窗口越大内存占用越高但写入的局部性好一些。实际经验是行宽列数不大时100~500 是一个平衡点如果列特别多比如五十列以上建议把窗口调小一点比如 100因为每行对象本身占用的内存更大。可以这样初始化SXSSFWorkbook workbook new SXSSFWorkbook(200); // 还可以设置压缩临时文件减少磁盘占用稍微增加一点 CPU 开销 workbook.setCompressTempFiles(true);setCompressTempFiles(true)会把写入磁盘的临时文件做压缩适合磁盘空间紧张或者临时目录位于网络盘的环境。代价是每次读写临时文件多了一点压缩/解压 CPU 开销实测影响不大。3.3 从环境变量到 JVM 参数内存调优的一个冷门方向搜索热词里有一条export malloc_arena_max1虽然它原本是 Linux 下 glibc 内存分配器的配置不是 Java 专属的东西但它在导出场景里也有现实意义。当 JVM 进程频繁创建和销毁线程比如高并发导出、配合数据库连接池大量线程时glibc 的 malloc 可能会为多个 arena 分配内存导致 RSS 虚高——你看到进程占用了两个 GB实际堆里可能只有几百 MB。设置export malloc_arena_max1或export MALLOC_ARENA_MAX2可以限制 arena 个数减少这种隐形的内存开销。在启动脚本里写成export MALLOC_ARENA_MAX2 java -Xms512m -Xmx1024m -jar export-server.jar另外SXSSFWorkbook 在写大数据时会产生大量临时对象GC 压力不小。如果导出是后台异步任务建议在独立的线程池里跑避免阻塞接口线程如果导出的数据量经常是几十万行干脆把堆内存调大一点交给专用导出服务处理不要把这种重活和普通接口混在同一个 JVM 里。4. 样式、列宽与格式导出能看和好看的差距很多项目的导出功能第一版都是能打开就行但业务方很快就提出能不能把表头加粗列宽自动适应日期显示成 yyyy-MM-dd。这里面每一个需求背后都有坑逐一说说这个项目的处理方式。4.1 列宽设置POI 的宽度单位到底怎么算搜索热词里有一条关于 POI 设置 Word 表格单元格宽度的说明有人在这个问题上卡过。POI 设置 Excel 列宽和 Word 表格宽度不是一回事。Excel 里列宽的单位是基于字符数的API 是sheet.setColumnWidth(int columnIndex, int width)。这个 width 的单位是 1/256 个字符宽度所以如果你想让某一列显示 20 个字符就要写 20 * 256 5120。这里有一个常见误区列宽设置不是列数 * 256而是字符数 * 256。有时候导出后看到某列特别窄多半是直接把列的索引值乘了 256比如setColumnWidth(3, 768)以为第三列 3 个字符宽实际上只设了 3/256 个字符几乎看不见内容。如果想根据内容长度自适应可以自己遍历列的所有单元格统计最大字符串长度再限制一个上下限。例如int maxLength 0; for (Row row : sheet) { Cell cell row.getCell(colIndex); if (cell ! null cell.getStringCellValue() ! null) { maxLength Math.max(maxLength, cell.getStringCellValue().getBytes().length); } } int width Math.min(Math.max(maxLength 2, 8), 60); sheet.setColumnWidth(colIndex, width * 256);注意这里统计的是字节长度而不是字符长度因为中文字符在列宽计算里和英文字符权重不同。这个公式不是官方精确算法但实测下来误差不大够用。4.2 单元格样式复用从 17 秒降到 3 秒的优化导出慢有时候不是数据查询慢而是样式创建太频繁。我之前导出一个 3 万行、15 列的报表用 POI 时每创建一个CellStyle都会触发底层样式表的变更导致整张表的样式记录膨胀。后来把字体、边框、背景色这些样式抽出来先创建所有需要的样式实例放进 Map然后在写单元格时只做引用性能提升非常明显。这个 zip 项目的工具类里应该也有类似的设计把表头样式数据行样式日期样式金额样式等预创建好。核心思路是样式数量与数据量无关只与样式种类有关。不管导出 1 行还是 10 万行样式对象始终只有个位数既省内存又省时间。4.3 日期格式与数字格式的显示问题日期往 Excel 里写的时候如果直接写 Java 的 Date 对象最终显示的是 Excel 的默认日期格式通常是M/d/yy跟业务方想要的效果差很远。所以要在 CellStyle 上设置数据格式CellStyle dateStyle workbook.createCellStyle(); dateStyle.setDataFormat(workbook.getCreationHelper().createDataFormat().getFormat(yyyy-MM-dd HH:mm:ss)); cell.setCellValue(date); cell.setCellStyle(dateStyle);数字格式同样如此。如果金额是 BigDecimal直接setCellValue(bigDecimal.doubleValue())会丢失精度或者显示一长串小数。要么先setCellValue(bigDecimal.toString())把金额按字符串写入要么设置数字格式#,##0.00并传入 double 值。前者适合金额不需要再计算的场景后者适合业务方想再 Excel 里做汇总计算的场景。项目里我倾向用带千分位格式的数字格式真实业务中能不能在导出文件里直接求和是高频需求。还有一个细节SXSSFWorkbook 对 CellStyle 的setDataFormat支持是正常的但要注意不要对每个单元格都重新创建一个带 DataFormat 的样式那样会触发大量样式对象写入性能会断崖式下跌。日期样式、金额样式各建一个然后复用。4.4 合并单元格与边框的关系导出报表时经常要搞标题行合并比如第一行合并所有列写2024 年 5 月经营报表。sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, lastCol))可以做到但有个细节合并之后被合并区域的边框和样式只对左上角单元格生效其他单元格即使设置了边框也可能不显示。解决办法是先把整行所有单元格都应用边框样式再做合并这样合并后视觉上边框才是完整的。这个顺序搞反了导出的表格会有一根线缺失怎么看怎么别扭。5. 文件从生成到落地的完整链路输出流、Zip 打包与下载Excel 生成只是中间态最终要把文件交到用户手上。这个环节里有几个容易踩的坑逐个说一下。5.1 写文件还是写输出流项目里导出的结果有两种处理方式一种是直接通过 HTTP 响应输出流把文件返回给浏览器另一种是先落盘生成临时文件再传给文件服务。直接写响应输出流的优点是不用管临时文件清理缺点是如果生成过程报错用户那边只会看到连接被重置很难排查问题。先落盘的好处是可以在服务器上保留文件用于复核也方便后续对文件做 Zip 打包或者二次处理。个人建议如果导出是异步的比如先提交任务生成完再通知下载必须落盘如果是同步导出直接写输出流更干净。这个 zip 项目里两种方式都涉及核心原则是用完之后把输出流关闭落盘文件要清理。5.2 多文件导出时用 Zip 打包有一种常见需求用户勾选了 1 月到 12 月的报表系统要生成 12 个 Excel 文件以 Zip 压缩包的形式一次下载。这个场景下项目里封装了 ZipOutputStream 的写入逻辑。需要注意的点有这么几个文件名的编码中文文件名在 Zip 包中容易乱码Java 的ZipOutputStream默认编码是平台默认编码Linux 下往往是 UTF-8Windows 下可能是 GBK。交叉环境下非常容易出问题。经验做法是使用ZipInputStream/ZipOutputStream时显式指定 UTF-8或者用 Apache Commons Compress 库处理它对手工设置 Zip 条目编码的支持更好。try (ZipOutputStream zos new ZipOutputStream(new FileOutputStream(zipFile))) { zos.setEncoding(UTF-8); // 部分实现可能不直接支持需要看具体库 for (File file : files) { zos.putNextEntry(new ZipEntry(file.getName())); Files.copy(file.toPath(), zos); zos.closeEntry(); } }Zip 包内的文件名不要带路径只放文件名不然解压时会生成莫名其妙的目录结构。5.3 下载文件名中文乱码的坑浏览器下载文件时文件名的中文需要通过Content-Disposition头传递但 HTTP 头只支持 ASCII。常见的做法是做 URL 编码String fileName URLEncoder.encode(经营报表.xlsx, UTF-8) .replaceAll(\\, %20); response.setHeader(Content-Disposition, attachment; filename\ fileName \; filename*UTF-8 fileName);这个写法兼容了老版本浏览器的filename和新版本浏览器的filename*。有个小坑是URLEncoder.encode会把空格转成而 HTTP 头里可能被解析成空格所以要 replace 成%20。不处理这个下载下来的文件名里会出现奇怪的字符。5.4 流的关闭顺序POI 里 SXSSFWorkbook 使用完必须调用workbook.dispose()这个方法会清理临时文件。很多人只写了workbook.close()但 SXSSFWorkbook 的临时文件是在dispose()或close()里清理的两个都要注意。配合 Java 的 try-with-resources 写法确保即使中间抛异常也能释放资源try (SXSSFWorkbook workbook new SXSSFWorkbook(200); ByteArrayOutputStream baos new ByteArrayOutputStream()) { // 生成数据 workbook.write(baos); // 返回 baos.toByteArray() }注意 SXSSFWorkbook 实现了 Closeable可以直接放在 try-with-resources 里。但 ByteArrayOutputStream 包一层之后要注意 Excel 文件较大的时候超过几十 MB不要用 ByteArrayOutputStream 全部读进内存再转 byte[]这会重复占内存。直接用响应输出流workbook.write(response.getOutputStream())是最省内存的。6. 部署与排错本地好好的服务器上就挂了的典型问题导出的代码在本地跑得飞快一上服务器就各种怪问题。这个 zip 项目落地部署时也遇到过类似情况分享几个典型的排查方向。6.1 文件拷贝与解压类问题的排查思路搜索热词里有一条failed to copy spatial iop zip虽然它看起来像是某个数据库空间扩展安装时的报错但这一类 failed to copy ... zip 的文件拷贝失败问题在 Java 导出服务的部署现场经常会有类似的影子。比如导出的临时目录不存在或者应用对临时目录没有写权限都会导致生成临时 Excel 文件时抛 IOException。遇到这类报错先检查几个路径java.io.tmpdir指向的目录是否存在、是否可写。Linux 服务器上这个目录常被 systemd-tmpfiles 定时清理应用运行几天后临时文件被清掉导出就失败了。部署目录是否有写权限。有些导出功能需要把文件写到相对路径而相对路径的根目录在打包部署后可能变了。磁盘空间是否充足。SXSSFWorkbook 在写大数据量时会往临时目录写文件磁盘满了会报No space left on device表面现象却是导出超时。排查链路建议这样走先确认报错堆栈指向的是文件操作还是纯内存操作如果是文件操作检查目录权限和磁盘空间如果是内存操作去看 GC 日志和堆占用。6.2 导出超时与内存溢出的定位方法服务器上导出类接口最常见的问题是用户点了导出转圈很久最后浏览器下载失败。这通常不是单一原因需要分层排查。第一层是接口超时。很多网关或 Nginx 对请求有超时时间比如 60 秒。如果导出 5 万行数据在服务端需要 90 秒网关就会先断开连接。解决思路要么是调大超时时间要么是把导出改成异步任务——先返回一个任务 ID前端轮询任务状态完成后给下载链接。业务系统数据量上来之后异步导出是必经之路同步导出永远有天花板。第二层是数据库查询慢。导出接口的 SQL 往往没有针对大批量查询做优化查 10 万条数据要两三分钟。建议在导出场景下单独优化 SQL必要时走独立的只读从库不要和核心交易查询挤在一起。第三层是服务器内存不足。如果导出的 JVM 堆设置太小比如 -Xmx256m导个几万行 SXSSFWorkbook 一样会 OOM。可以通过jstat -gc pid观察 GC 情况或者干脆在导出方法前后打印Runtime.getRuntime().totalMemory()和freeMemory()快速判断内存增长点。6.3 几个压箱底的排查习惯导出日志里打上行数、耗时、内存三件套。每次导出完成记一行日志本次导出多少行、花了多少秒、当前堆内存使用多少。这些数据攒一段时间就能发现哪些接口需要优化哪些时段导出最密集。不要在生产环境直接开 DEBUG 日志。POI 的 DEBUG 日志量极大会掩盖真正的业务日志而且对性能影响明显。排查问题时临时开排查完马上关。保留最后一次导出的 Excel 文件。线上反馈导出的数字不对格式乱了时没有现场文件就只能靠猜。让代码在导出异常时把临时文件移动到 backup 目录保留现场很多问题一眼就能定位。我在实际使用中发现导出功能最耗时的往往不是 POI 写入本身而是数据查询、对象组装、样式滥用这三座大山。把这三块的账算清楚Excel 导出这个功能做起来其实很踏实。从一开始只求能导出到后来追求内存稳、速度快、格式好、可排查这段路每一步都有 POI 的坑在等着。希望这篇拆解能让你在接到类似需求时少走几个弯路。本文还有配套的精品资源点击获取