ARTICLE DETAIL

建站实战干货

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

Java处理CSV转Excel:大文件不OOM的完整实战方案

2026/9/16 2:50:59 拓冰建站 浏览量
Java处理CSV转Excel:大文件不OOM的完整实战方案 做后端或者数据处理的兄弟十有八九都遇到过这个需求运营或者业务同事丢过来一个 CSV 文件少则几十兆多则几百兆开口就是一句“帮我转成 Excel”。第一次听到时觉得简单写个程序把逗号分隔的文本拼成表格就行。真正动手才发现CSV 转 Excel 这活儿坑一个接一个中文乱码、数字变科学计数法、大文件直接内存溢出、Excel 打开提示文件损坏。今天我就把 Java 生态里处理这件事的完整思路、技术选型、核心代码和实战经验一次性讲清楚。无论你是在写数据导入导出功能还是被面试官追问“大文件 Excel 导出怎么设计”这篇都能给你实打实的参考。1. 为什么这个转换没有想象中简单1.1 需求看着简单坑全在后头先看一个最常见的场景。你的上级丢来一个 CSV 文件说“转成 Excel 就行”。听起来就是读取、按逗号拆开、写到 Excel 里三步走但你一旦开始实现会发现有一堆细节在等着你。第一是编码问题。CSV 就是一个纯文本文件同样的内容可能是 UTF-8 编码也可能是 GBK 编码。Windows 上老系统导出的文件十有八九是 GBKMac 或者 Linux 上生成的又大多是 UTF-8。如果读取时用错了编码中文直接乱码而且有些字符编码错误后是不可逆的文件内容就废了。更恶心的是有些系统导出的 UTF-8 文件带着 BOM 头EF BB BFJava 读出来的第一列第一个字符会多出一个\ufeff肉眼看不见但字符串比较、写库全都会出问题。第二是数据格式问题。CSV 文件里有一串订单号12345678901234567890你看起来是数字但实际它是文本。如果直接按数字写入 Excel不仅会变成科学计数法1.23457E19后面几位还会变成 0因为 Excel 的数值精度只有 15 位。身份证号比这个还要典型一旦被当做数字写进去数据直接损失而且无法恢复。第三是内容本身的结构问题。真正的 CSV 规范允许在字段里包含逗号、换行符和引号。比如某一行内容是张三,北京,朝阳区,第一行\n第二行你如果拿String.split(,)去切当场就废了。这还不是最要命的最要命的是文件太大。几百 MB 的 CSV 如果用Files.readAllLines()一口气读进内存JVM 的堆很快就撑爆。CSV 转 Excel 这个需求真正考验的不是会不会写代码而是能不能写出内存可控、异常情况兜得住的生产级代码。1.2 先想清楚你的 CSV 到底长什么样我踩过不少次坑之后形成了第一反应接到需求先别急着写代码先搞清楚输入文件的真实情况。拿到文件先打开看一眼但这里有个有意思的现象同一个 CSV 文件你用记事本、VS Code、WPS、Excel 打开效果可能完全不一样。因为 WPS 和 Excel 会自动做编码识别和类型转换掩盖了文件本身的复杂度而 Java 的库不会替你判断。我的做法是先检查三个东西文件首行是表头还是数据、每列的分隔符是什么、文件的真实编码是什么。分隔符未必是逗号很多时候是 Tab或者分号比如某些统计软件导出用;爬虫抓下来的数据常用\t。最简单的判断方式是用文本编辑器或者xxd命令看原始字节如果是 UTF-8 的 BOMxxd的前三字节是efbbbf。还有一个必须提前确认的文件字段里是否包含引号转义和换行。这一步决定了你要不要引入正经的 CSV 解析库。如果只是极其规整的纯逗号分隔、没有任何转义那自己写 split 勉强能跑但只要数据里有一个人为换行你那步 split 就废了。所以我在文末的一个建议是如果对方没有提供 CSV 的生成规范先在代码里做好“最坏情况”的防护用成熟的解析库处理别赌数据格式一定规整。2. 技术选型读 CSV 用什么写 Excel 用什么2.1 CSV 读取库怎么选Java 生态里读取 CSV 的方案很多按成熟度排我常用的是 OpenCSV、Apache Commons CSV、FastCSV 这几个。它们都能正确处理引号、内嵌换行符、转义等细节不会像手动 split 一样翻车。我个人的选型偏好是项目里没有其他约束时用 OpenCSV。原因有三个API 简单上手成本低文档和案例多遇到问题容易搜到支持CSVReader的流式读取不会把整个文件一次性塞进内存。Commons CSV 也很强尤其在列数较多的时候性能表现不错但它的 API 写起来稍微繁琐一点加载表头需要额外的CSVParser处理。唯一想提醒的是不要在循环里做字符串拼接也不要用readAll()方法一次性读取整个文件。CSV 转 Excel 的核心诉求是“大文件不 OOM”而 OOM 的主要来源就是一次性加载全部数据。老老实实逐行读读一行处理一行内存才能稳得住。2.2 Excel 写入库怎么选写 Excel 这块的选择直接决定了你的程序能不能撑住大文件。老牌方案是 Apache POI。POI 里的XSSFWorkbook是 DOM 模型所有数据在内存中构建几万行就几百 MB 内存几十万行直接 OOM所以只适合小文件。后来 POI 推出了SXSSFWorkbook这个类实现了“滑动窗口”式的写入窗口之外的行会被刷到磁盘临时文件里内存占用大大降低这是纯 POI 方案下的最优解。另一个方案是阿里开源的 EasyExcel。它的核心卖点就是流式写底层做了很多内存优化写几十万行数据时堆内存可以控制在几十 MB 量级。API 设计上非常贴近业务开发者的习惯几行代码就能完成一次写入。我的结论是如果项目里已经大量使用 POI那就用 POI 的 SXSSFWorkbook保持技术栈统一如果是从零开始做这个小工具直接用 EasyExcel 更省心。EasyExcel 在写 Excel 时对样式支持得不如 POI 全面但是 CSV 转 Excel 这种场景压根不需要复杂样式轻量反而是优势。读 CSV 用 OpenCSV写 Excel 用 EasyExcel是我目前见过最舒服的组合。下面第三节的代码也基于这个组合展开。2.3 为什么不建议用 CSV 改名成 Excel有一种偷懒方案在论坛上也能看到把 CSV 文件改一下扩展名直接变成.xlsx。严格来说这根本不是一个 Excel 文件只是换了后缀的文本文件。用 Excel 打开的时候大概率会弹出“文件格式与扩展名不匹配”的警告而且文件里的长数字、前导零、日期格式根本不可控。一旦后续需求变成“再加一列汇总公式”或者“设置列宽、冻结表头”改名方案就彻底没戏了。我的经验是这种临时方案看着省了几分钟但后面会花几小时填坑。写一个可复用的转换工具一次性解决后面遇到同类需求直接调用就行。3. 核心实现一个不 OOM 的转换工具3.1 Maven 依赖先看依赖。我用的是 Maven主要引入 OpenCSV 和 EasyExcel。版本建议用当前较新的稳定版这里给出的是我实测可用的组合。dependencies dependency groupIdcom.opencsv/groupId artifactIdopencsv/artifactId version5.9/version /dependency dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.4/version /dependency /dependenciesOpenCSV 5.x 的包名是com.opencsv类名叫CSVReader。EasyExcel 3.x 的入口是EasyExcel工具类。如果你用的 Spring Boot 项目注意检查依赖冲突比如 POI 版本冲突最好在mvn dependency:tree里看一眼。3.2 读取 CSV 的细节读取这一步的重点是编码、BOM 和流式读取。我在InputStreamReader中显式传入字符集并把CSVReader包在BufferedReader外层这样每次只读一行内存不会随文件变大而增长。Reader reader new BufferedReader( new InputStreamReader(new FileInputStream(csvFile), charset), 8192 ); CSVReader csvReader new CSVReader(reader);关于编码遇到不确定编码的文件一种办法是用工具库探测比如juniversalchardet但它是概率性判断偶尔还是会猜错。更稳妥的方式是让调用方显式传入编码或者提供一个参数让用户在命令行里指定。例如中文环境下的默认取值可以是GBK因为从 Excel 或某些老旧系统导出的 CSV 通常是 GBK但从互联网下载的数据集大多是 UTF-8所以我在代码里加了一个判断逻辑如果读取前 3 个字节能匹配到 UTF-8 的 BOM就强制使用 UTF-8 并去掉 BOM否则按调用方传入的字符集处理。去掉 BOM 的代码很简单读取首行后判断第一个字符是不是\uFEFF是的话就substring(1)。这个细节救过我很多次尤其是接第三方系统导出文件时BOM 问题几乎每个项目都会遇到。3.3 懒人版列类型推断CSV 里所有字段在没有解析前都是字符串。如果直接全部按字符串写入 Excel倒也能用但不方便后续筛选和汇总因为 Excel 里存的都是文本类型。如果全部转成数字又会踩中订单号、身份证号这种长数字变科学计数法的坑。所以最简单安全的策略是默认按字符串写入只有遇到“看起来像普通整数或小数”时才转成数值。我的实现方法是写一个inferValue方法。先去掉首尾空格空字符串统一转 null然后判断是不是纯数字只含数字、小数点、正负号如果是再进一步判断有没有小数点或指数符号有就转 Double没有就转 Long。但这个判断有一个例外如果数字长度超过 15 位或者以 0 开头比如电话号码、编号转成数值类型会失真这时候我会保留字符串原样。很多人在这一步偷懒结果就是身份证号后几位变成 0然后被业务方骂得狗血淋头。private static Object inferValue(String raw) { if (raw null) { return null; } String value raw.trim(); if (value.isEmpty()) { return null; } if (isNumeric(value) value.length() 15 !value.startsWith(0)) { try { if (value.contains(.) || value.contains(e) || value.contains(E)) { return Double.parseDouble(value); } return Long.parseLong(value); } catch (NumberFormatException ignored) { return value; } } return value; } private static boolean isNumeric(String s) { if (s null || s.isEmpty()) { return false; } for (int i 0; i s.length(); i) { char c s.charAt(i); boolean valid (c 0 c 9) || c . || c - || c || c e || c E; if (!valid) { return false; } } return true; }3.4 完整核心代码下面这段代码是转换工具的核心方法。流程是创建 CSVReader 逐行读取读取首行作为表头每攒够一定数量的行就批量写入 Excel防止单次 List 过大最后收尾调finish()。public static void csvToExcel(File csvFile, File excelFile, String charsetName) throws Exception { Charset charset Charset.forName(charsetName); int batchSize 5000; ListListString head new ArrayList(); ListListObject dataCache new ArrayList(batchSize); ExcelWriter excelWriter EasyExcel.write(excelFile).build(); WriteSheet writeSheet EasyExcel.writerSheet(数据).build(); try (CSVReader csvReader new CSVReader( new BufferedReader(new InputStreamReader(new FileInputStream(csvFile), charset)))) { String[] line; boolean headerProcessed false; while ((line csvReader.readNext()) ! null) { if (!headerProcessed) { for (String col : line) { head.add(Collections.singletonList(col)); } writeSheet.setHead(head); headerProcessed true; continue; } ListObject row new ArrayList(line.length); for (String cell : line) { row.add(inferValue(cell)); } dataCache.add(row); if (dataCache.size() batchSize) { excelWriter.write(dataCache, writeSheet); dataCache.clear(); } } if (!dataCache.isEmpty()) { excelWriter.write(dataCache, writeSheet); } } finally { excelWriter.finish(); } }这个实现里有一个特别容易被忽略的点CSVReader 的readNext()返回的是String[]数组长度可能跟实际列数不一致。尤其当文件中某行末尾有连续多个分隔符时OpenCSV 默认会丢弃末尾的空字段。如果业务上必须保留“空列”占位你得额外处理比如按表头长度补齐否则整行数据会错位。下面第五节的常见问题里会再提。3.5 内存和批次的调参思路很多人写完上面这段代码测一个小文件没问题就丢到生产环境跑大文件结果还是 OOM。原因往往出在两个地方一个是 EasyExcel 虽然流式写但如果你一次性传入一个包含几十万行的 ListEasyExcel 也没办法内存照样被撑爆。所以我专门加了batchSize这个参数每攒 5000 行写一次List 及时清空JVM 堆内存就能稳在一个很低的水平。另一个是 JVM 堆本身设置。如果启动参数是默认值物理内存的 1/4大文件可能没问题但如果线上服务同时处理其他业务堆不够用了就会频繁 Full GC。我自己的经验是独立小工具运行时设置-Xms256m -Xmx256m完全够用50 万行、30 列的测试文件用 256 MB 堆跑完没有一次 OOM。如果你在 Web 容器里跑建议隔离一个线程池避免转换任务占用太多堆空间影响主业务。batchSize 不是越大越快也不是越小越稳。理论上大 batch 减少 IO 次数但 List 本身占用的内存也会增大batch 太小比如几百行写一次写 Excel 的次数增多速度会下降。折中下来我用 3000 到 5000 这个区间实测效果比 500、10000 都舒服。4. 实操过程与生产环境落地4.1 命令行小工具接收参数跑一次单纯一个方法还不够我给这个工具加了一个main方法直接接收参数运行。这样既不依赖 Spring 环境也能快速集成进定时任务或脚本系统。public static void main(String[] args) { if (args.length 2) { System.err.println(Usage: java CsvToExcelTool input.csv output.xlsx [charset]); return; } File input new File(args[0]); File output new File(args[1]); String charset args.length 3 ? args[2] : UTF-8; try { long start System.currentTimeMillis(); csvToExcel(input, output, charset); System.out.println(转换完成耗时: (System.currentTimeMillis() - start) ms); } catch (Exception e) { e.printStackTrace(); } }这一步能让非技术人员也觉得“这东西是个正经工具”。我把 jar 打包好后运维在服务器上一条命令就能执行不用打开 IDE 跑测试方法。如果你的使用方是运营同事甚至可以把这个 main 封装成 bat 或 sh 脚本拖拽文件到脚本上就能转换。4.2 大数据量压测结果参考分享一组我本地测试的数据。文件是 50 万行、30 列的结构化 CSV大小约 180 MB内容是模拟订单数据包含中文、超长数字、空字段和少量带换行符的字段。JVM 参数-Xms256m -Xmx256m。OpenCSV 逐行读 EasyExcel 批量写整体耗时约 18 秒。内存曲线非常平稳GC 次数少没有出现 Old Gen 暴涨的趋势。相比之下如果用 POI 的 XSSFWorkbook 做同样的事写入阶段还没结束堆内存就到了 1.5 GB 以上50 万行已经非常勉强。这个差距就是“DOM 全量构建”和“流式写入”之间的本质区别也是面试官最爱问的“如何设计大文件导出功能”的考点。如果你的文件超过 100 万行还有一个硬性限制要提前知道xlsx 格式最大支持 1048576 行。超过这个数Excel 是打不开的。这时候要么拆分成多个 sheet每个 sheet 最多 100 万行要么直接输出多个 Excel 文件。我在代码里没有自动处理分 sheet 的逻辑因为业务上通常要指定分拆规则但这是一个值得预留的扩展点。4.3 与 Spring Boot 集成接口触发转换如果你的场景是在 Web 系统里提供“上传 CSV返回 Excel 下载”的功能直接在 Controller 里调用转换方法然后让浏览器下载即可。但要注意文件上传后尽量先落盘到本地临时目录再用文件路径去读。不要直接把 MultipartFile 的 InputStream 传给转换方法因为大文件上传时 InputStream 会占用连接和内存容易出问题。一个简化的 Controller 示例逻辑PostMapping(/convert) public ResponseEntitybyte[] convert(MultipartFile file) throws Exception { File tempCsv File.createTempFile(upload_, .csv); file.transferTo(tempCsv); File tempExcel File.createTempFile(result_, .xlsx); CsvToExcelTool.csvToExcel(tempCsv, tempExcel, GBK); byte[] data Files.readAllBytes(tempExcel.toPath()); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, attachment; filenameresult.xlsx) .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(data); }如果你要处理超大文件Files.readAllBytes又会在内存中复制一份还是建议用StreamingResponseBody或直接写临时文件让前端通过 URL 下载。这一步容易被人忽略文件转换时内存可控但输出下载时又把整个文件读入内存前面的优化就白做了。4.4 样式和列宽要不要做很多业务方拿到转换好的 Excel第一句话就是“列宽太窄了数字都显示不全”。这时候有人会在代码里给每一列设置宽度。我的建议是小文件可以大文件千万别。ExcelWriter的流式写模式下自动列宽是非常消耗性能的因为它要遍历整列数据去计算最大字符宽度大文件下会让耗时翻几倍而且 EasyExcel 的自动列宽本身也只是近似值。我一般只做三件事表头加粗、表头背景色、表头冻结首行。这些都是写入前就能确定的一次性操作对内存和性能影响几乎为零。如果你确实想让列宽自适应更好的做法是在转换完成后用 POI 重新打开 Excel对列宽做一次性的后处理。不过这是一个额外成本值不值得取决于你的使用频率。我在绝大多数需求里选择什么都不设置直接把数据先转出来再交给业务方自己在 Excel 里调整格式。5. 常见问题与排查技巧实录5.1 乱码、首列多一个字符症状一用默认 UTF-8 读取 Windows 导出的 CSV中文全部变成乱码。原因基本可以锁定为文件是 GBK 编码。解决办法是让调用方显式指定字符集或者在代码里读取前几个字节探测编码。没有必杀技因为编码识别本质上是猜测最可靠的就是外部传入。症状二第一列所有单元格最前面多了一个不可见字符导致字符串比较和筛选失败。这个是 UTF-8 BOM 的经典表现。处理方式前面提过读取表头后判断line[0].startsWith(\uFEFF)然后把头字符去掉。因为 BOM 只出现在文件开头所以处理一次就够了。5.2 身份证号变成科学计数法后几位变成 0这是所有 CSV 转 Excel 需求里最坑的一个。Excel 数值类型最多保留 15 位有效数字身份证号是 18 位超出部分会被舍入。如果你的代码把所有看起来像数字的字符串都转成 Long那身份证号就废了。我的解决方案在inferValue里已经体现超过 15 位或者以 0 开头的字符串直接保持字符串原样。这样不用让调用方单独指定“哪一列是文本”只要数据类型符合直觉就能自动规避。但这里有一个 Trade-off如果某一列真的是超过 15 位的数值而且后续要用它做计算转成字符串后就没法在 Excel 里直接计算了。这种情况很少见因为真正参与计算的数字很少超过 15 位。真遇到的话可以通过配置灵活处理而不是硬编码。5.3 超过 1048576 行怎么办Excel 的 xlsx 格式硬性上限是 1048576 行包括表头。如果你的 CSV 超过这个行数EasyExcel 写入时会直接抛异常。两种常见解法第一种是按业务字段拆分到多个 sheet例如按月份、按地区分 sheet第二种是拆成多个 Excel 文件打包成 zip 下载。哪种更好取决于使用方怎么消费数据。如果对方只是要一份汇总表大多不会这么做如果是数据入库我更建议直接写数据库而不是转 Excel。很多人忽略了这个点Excel 本身就不适合承载百万行级别的数据。5.4 单元格里有换行和逗号手工 split 出错在 CSV 规范里包含逗号、换行符、引号的字段应该用双引号括起来内部的双引号用两个连续双引号转义。比如产品说明,第一行 第二行,他说你好这种情况下String.split(,)必然出错因为换行被切断了。正确做法是引入 OpenCSV 这类解析库让它处理引号和转义逻辑。但还有一个隐藏细节OpenCSV 默认的引号字符是双引号如果你的文件里用了单引号或者中文引号需要在初始化CSVReader时指定CSVParserBuilder。这类文件通常不是标准 CSV建议先和对方确认格式规范。5.5 转换很慢瓶颈到底在哪如果转换耗时很长先别急着优化代码先定位瓶颈。我的排查思路分三步先看文件读取耗时再算 CSV 解析耗时最后看 Excel 写入耗时。最简单的方法是三步计时每一步打印耗时。一般大文件慢在读取和写入的 IO 上CSV 解析本身不会太慢。如果确认是 Excel 写入慢检查有没有开启自动列宽、有没有创建大量样式对象、batchSize 是不是过小。如果你用的是 POI 的 XSSFWorkbook慢是必然的换成 SXSSFWorkbook 或者 EasyExcel 会立竿见影。如果文件读取慢可以考虑加大 BufferReader 的缓冲区大小从 8192 调到 16384往往能减少大量系统调用。5.6 转换后的 Excel 在手机上打不开这个现象也挺常见。手机上用 WPS 打开转换出的 xlsx提示文件异常但在电脑上打开正常。不是代码生成的 Excel 结构错误大多数情况是文件太大或者 sheet 数过多手机端 WPS 内存不足。如果是这种问题只能从源头控制减少单个 sheet 的数据量、减少表头样式。还有一个冷门原因文件名里带了特殊符号比如冒号、问号部分手机端应用解析文件名会卡住。反正遇到这类“手机打不开”的反馈先让用户确认文件大小再把文件复制到另一个位置尝试大概率不是程序 bug。最后的一点实践心得最后再聊一点我自己的习惯。做这种数据处理工具我最看重的是“可配置”和“可预期”。可配置是指表头要不要跳过、哪些列保持文本、分隔符是逗号还是 Tab这些最好都做成参数而不是写死在代码里。可预期是指同一份文件在 Java 工具里转出来的结果要稳定同一个 CSV 今天转和明天转内容一致不会因为环境或编码探测差异发生变化。另外一个小建议生产环境用这个工具之前拿几份特征不同的文件先跑一遍回归。我会准备三份测试文件一份中文 GBK 编码、一份 UTF-8 带 BOM、一份包含换行符和引号转义。这三份都通过基本就可以放心让业务方用了。CSV 这个格式看着简单不同系统导出来的风格千奇百怪工作做得细致的同事已经在文件里加上了“生成规范”和“样例文件”这才是从源头减少问题的关键。祝你在处理这个需求时一把通过别再被乱码和科学计数法折磨。