Java千万级CSV导出:流式分批处理与内存优化实战 简介针对Java处理千万级CSV导出易内存溢出的痛点这份源码示例包提供了多线程分批导出的完整参考实现适合有Java基础、正在设计大数据量导出模块的中高级开发者。包内共10个文件以9个Java源文件为主包含CsvExportBatch、ExecutorThread、ThreadPools等核心类另有1个txt说明文档梳理实现要点整体压缩包仅10KB轻量易读。目前已有15559人学习。通过该资源可了解如何结合Executors线程池、BufferedWriter逐行写入及分块任务提交来降低内存占用同时借鉴异常处理与重试补偿思路CsvExport、CSVUtils等文件展示了CSV读写与工具类封装方式ZipUtil和DownLoad还兼顾压缩下载场景便于直接改造到生产项目中。 干Java后端这么多年导出功能写过无数次但真正让我印象深刻的是那个千万级的CSV导出需求。java csv大数据量导出听起来不就是查数据库、拼字符串、写文件嘛可真当数据量到了千万级别常规写法直接OOM服务挂掉两分钟才缓过来。后来我沉淀了一套流式导出的方案再跑千万级数据内存稳稳的堆内存控制在512M以内毫无压力。这篇文章就把完整的设计思路、代码实现和排坑过程分享出来给正被这个需求折磨的朋友一个参考。网上很多方案讲得云里雾里动不动就上各种中间件其实核心就两句话数据不要一次性拿全文件不要一次性写完。理解了这个后面所有代码都是围绕这句话展开的。1. 千万级CSV导出的核心思路拆解1.1 常规写法为什么必炸先看一段最常见的代码我相信很多人第一版就是这么写的// 错误示范全量加载 全量拼接 ListUser users userMapper.selectAll(); // 千万行直接进内存 StringBuilder sb new StringBuilder(); for (User user : users) { sb.append(user.getId()).append(,) .append(user.getName()).append(,) .append(user.getEmail()).append(\n); } response.getWriter().write(sb.toString());这段代码有三个致命问题任何一个都足以让服务宕机。第一selectAll()会把一千万行数据一次性装载到内存里。假设每行数据映射成Java对象后占用256字节一千万行就是2.5G这还没算List本身的开销和对象头。很多服务的堆内存也就配2G到4G光这一步就已经到临界点了。第二StringBuilder在拼接过程中会不断扩容底层数组要反复拷贝GC压力巨大。字符串拼接本质上是不断创建新对象千万级别拼接下来Young GC会非常频繁CPU大量消耗在垃圾回收上业务接口跟着遭殃。第三一次性write出去如果网络传输慢数据会先堆积在输出缓冲区里又是一个隐形的内存黑洞。1.2 流式处理加分批写出核心思路把问题拆开看有一个核心矛盾数据生产的规模大但内存承载体量小。解决思路也就是把“大”拆成“小”。数据源侧要做的事情是不要让数据库一次性返回千万行数据而是用游标方式或者ID分页方式一次只拿一小批比如5000行这一批数据在内存里占用的空间可以精确控制。输出侧要做的事情是拿到一批就写一批写完立刻flush到磁盘或网络流绝不在内存里攒着。我习惯用搬砖来打比方。一千万块砖要从A仓库搬到B仓库正常人肯定是一车一车拉一次拉个几十块到了就卸货。怎么可能会有人想着用瞬间传送一次性搬完内存就那么大硬塞的下场就是直接爆掉。这个思路落实下来整个方案就三个关键点分批查询、逐行写出、及时释放。2. 技术选型与参数设计2.1 写CSV到底用不用现成库很多人一上来就问用什么库OpenCSVEasyExcel还是Apache Commons CSV我先把我的结论说清楚纯CSV场景原生BufferedWriter完全够用不需要引入任何额外依赖。为什么这么说CSV的格式本身太简单了没有复杂的单元格样式、没有合并单元格、没有公式本质上就是纯文本。用框架反而有额外开销。比如OpenCSV的CSVWriter虽然把转义和引号处理封装得很好但内部走了反射机制在千万级数据量下反射调用的开销会被放大得非常明显。如果你要导出的是.xlsx格式那EasyExcel确实是个好选择它本身也实现了流式写入不会把数据全部加载进内存。但如果是.csv我的建议是方案优点缺点适用场景原生BufferedWriter零依赖、性能最好、内存可控需要自己处理转义纯CSV导出追求极致性能OpenCSV转义处理完善、API友好反射开销大数据量性能略差中小数据量图省事EasyExcel流式写入、功能丰富依赖较重杀鸡用牛刀导出xlsx或同时处理复杂表头2.2 分批大小怎么定这是整个方案里最需要动脑子的参数。分批大小不是拍脑袋定的它直接影响查询次数和单次内存占用。如果每批查1000条一千万数据要查一万次网络往返时间会被拉得很长。如果每批查50000条单次查询返回的数据量太大JDBC驱动在读取ResultSet时占用的内存会显著增加而且数据库端也要一次性准备好这么多行。我自己测下来5000条一批是比较平衡的值。原因有两点。第一5000条User对象映射后大约一两兆内存完全可以接受。第二一千万数据只需要查两千次数据库的压力可控。当然这个值不是固定的如果你的单行数据特别宽比如有20个字段可以适当调低如果行数据很窄可以调到10000。提示MySQL JDBC连接串上加useCursorFetchtruedefaultFetchSize5000配合PreparedStatement的游标机制数据库会分批输送结果集服务端内存占用非常平稳。但注意这个参数只能配MySQLOracle有自己的一套游标方案。2.3 编码、分隔符和特殊字符处理CSV导出的坑有一大半是藏在编码和特殊字符里的。第一编码一定要用带BOM的UTF-8。Excel在打开UTF-8编码的CSV文件时如果没有BOM头会默认按ANSI解码中文直接乱码成一堆问号。解决方案是在文件开头写入三个字节的BOMwriter.write(\uFEFF)这样Excel就能正确识别编码。第二行分隔符统一用\r\nCRLF这是CSV规范里的标准换行符。如果用Unix风格的\n部分Windows下的软件打开时会出现错乱。第三字段内容里如果包含逗号、双引号、换行符必须做转义处理。规则不复杂字段内容用双引号包起来字段内部的引号替换成两个连续的引号。这个不处理的后果很隐蔽文件打开看起来正常但导入数据库时行数对不上、字段错位全是这个原因。3. 核心代码实现千万级流式导出完整方案3.1 查询层用ID分页代替一次性查询先看Mapper层的写法。这里有一个很关键的设计思路就是分页方式的选择。最常见的分页是LIMIT offset, size但这在千万级数据下性能极差。因为MySQL的LIMIT偏移量越大扫描的行数越多offset到了五百万时单条查询可能就要好几秒。更好的方案是基于ID的键集分页每次记住上次查询的最大ID下一批查询只取ID大于这个值的记录配合主键索引走的是范围扫描性能非常稳定。Mapper public interface UserMapper { // 键集分页每次只查 id 大于 lastId 的 limit 条 ListUser selectUsersAfterId(Param(lastId) Long lastId, Param(limit) int limit); }对应XMLselect idselectUsersAfterId resultTypecom.example.User SELECT id, name, email, phone, create_time FROM user WHERE id #{lastId} ORDER BY id ASC LIMIT #{limit} /select注意使用键集分页的前提是表的主键或者排序列有索引而且ID不能有空洞空洞导致漏数据。如果业务表有逻辑删除记得把过滤条件加上否则会把Deleted的数据也导出去。3.2 写出层完整的流式导出工具这是整个方案的核心部分直接上完整代码Component public class StreamingCsvExporter { private static final int BATCH_SIZE 5000; private static final String SEPARATOR ,; private static final String LINE_END \r\n; private final UserMapper userMapper; public StreamingCsvExporter(UserMapper userMapper) { this.userMapper userMapper; } public void exportUsers(OutputStream outputStream) throws IOException { long lastId 0L; boolean hasMore true; try (BufferedWriter writer new BufferedWriter( new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) { // 1. 写入UTF-8 BOM防止Excel打开乱码 writer.write(\uFEFF); // 2. 写入表头 writer.write(id,name,email,phone,create_time); writer.write(LINE_END); // 3. 分批查询边读边写 while (hasMore) { ListUser users userMapper.selectUsersAfterId(lastId, BATCH_SIZE); if (users.isEmpty()) { hasMore false; } else { for (User user : users) { writer.write(buildRow(user)); writer.write(LINE_END); } // 每批写完立即flush避免数据积压在缓冲区 writer.flush(); // 记录本批最后一条ID作为下一批的起点 lastId users.get(users.size() - 1).getId(); // 及时释放引用帮助GC回收 users.clear(); } } } } private String buildRow(User user) { StringBuilder row new StringBuilder(128); row.append(user.getId()).append(SEPARATOR); row.append(escapeCsvField(user.getName())).append(SEPARATOR); row.append(escapeCsvField(user.getEmail())).append(SEPARATOR); row.append(escapeCsvField(user.getPhone())).append(SEPARATOR); row.append(user.getCreateTime() null ? : user.getCreateTime()); return row.toString(); } /** * CSV字段转义规则 * 字段含逗号、引号、换行符时用双引号包裹字段内的引号写成两个双引号。 */ private String escapeCsvField(String field) { if (field null) { return ; } if (field.contains(,) || field.contains(\) || field.contains(\n) || field.contains(\r)) { return \ field.replace(\, \\) \; } return field; } }这个代码有几个细节值得琢磨。第一BufferedWriter默认缓冲区是8192个字符对写入性能影响很大。如果是海量写入建议初始化时指定更大的缓冲区比如new BufferedWriter(writer, 1024 * 1024)1M的缓冲区能显著减少IO次数。第二buildRow里用的是StringBuilder初始容量我给了128因为要拼接5个字段这个容量刚好够用避免扩容。虽然单行数据很小但在千万级循环里每一处细节都会被放大。第三users.clear()这行看起来多余但实际上对于JVM的GC是有帮助的。虽然list在下一轮循环被重新赋值但显式clear能让对象更快地失去引用尤其在老年代比较紧张的情况下能降低触发Full GC的概率。3.3 Controller层如何调用调用端其实也很讲究。很多人的接口是同步返回文件但千万级数据导出耗时通常在几十秒甚至几分钟HTTP连接根本等不了那么久。这里有两种方案。如果公司允许同步下载就直接把OutputStream传进去结果直接写回响应GetMapping(/export/users.csv) public void exportUsers(HttpServletResponse response) throws IOException { response.setContentType(text/csv;charsetUTF-8); response.setHeader(Content-Disposition, attachment; filenameusers_ System.currentTimeMillis() .csv); streamingCsvExporter.exportUsers(response.getOutputStream()); }如果导出量大耗时长更推荐异步导出先发起任务生成文件到服务器磁盘完成后通过消息通知用户下载。这个方案能避免HTTP超时的尴尬但实现复杂度高一些需要额外的任务调度模块。4. 常见问题与排查技巧实录4.1 导出慢的根源SQL和IO千万级导出慢大多数人第一反应是Java代码写得不好其实绝大多数瓶颈在SQL和IO上。键集分页的SQL如果没走索引那没救了——MySQL会在千万行里做全表扫描排序每次查询都是几秒起步。建好主键索引后WHERE id ? ORDER BY id LIMIT 5000走的是range扫描查询时间稳定在几十毫秒。还有一个容易忽略的点是数据库连接串。JDBC连接必须开启流式读取也就是useCursorFetchtruedefaultFetchSize5000。如果不加这两个参数MySQL驱动的默认行为是一次性把所有结果集拉进内存你代码里虽然只查了5000条但驱动层可能已经默默加载了50万行。4.2 常见问题速查表我把实际工作中别人踩过和我自己踩过的坑整理成了一个速查表现象原因解决方案导出到一半GC overhead limit exceeded查询端没有流式读取数据全进了内存JDBC加useCursorFetch或改用键集分页Excel打开中文乱码缺少UTF-8 BOM头文件开头写入\uFEFF文件行数对不上导入数据库错位字段里有逗号或换行符没转义用escapeCsvField做转义处理导出速度越来越慢LIMIT大偏移量导致全表扫描改用ID键集分页下载到一半连接断开文件太大超过网关超时时间异步导出生成文件后通知下载导出的手机号以科学计数法显示CSV中数字太长被Excel自动转换字段前加\t或写成xxxx这里面最坑的是最后一个。手机号在CSV里是纯数字用Excel打开时会自动转成科学计数法看起来就是一堆1.23E10。解决方式是在数字前面加一个制表符\t或者用手机号这种公式写法但这样会污染数据本身。我的做法是导出时在字段前后加不可见字符或者直接跟业务方确认这种数据本来就是给程序用的Excel看着奇怪不影响导入。4.3 千万行级别的Excel显示限制这里必须提醒一句Excel单表最多只能存储1048575行数据也就是别超过104万行。你费劲半天导出了千万级别的CSV用户如果用Excel打开看到的只有前104万行。所以千万级导出接收方通常是数据库导入工具、Python脚本或者数据分析平台。在交付方案时一定要跟业务方确认清楚数据的使用场景如果对方就是要用Excel看的那再大的导出能力也白搭得考虑按维度拆分文件或者压缩成zip。4.4 多线程导出要不要上很多人看到千万级数据第一反应就是上多线程并发查询并发写入觉得这样会快很多。我劝你别急着上先想清楚瓶颈在哪。CSV导出的瓶颈通常不在CPU而在数据库查询和磁盘IO。单线程顺序写已经能把磁盘IO打满多线程写入同一个文件还需要加锁线程间竞争反而降低了效率。而且多线程分页查询如果边界处理不好很容易出现数据重复或遗漏排查起来非常痛苦。如果确实觉得太慢合理的方向是按ID范围做分区每个线程负责一个区间分别写到独立的临时文件全部完成后使用cat或者Java的Files.write合并文件。这样做到了并行又不会产生锁竞争但代码复杂度上一个台阶。我的建议是先在单线程下把功能跑通确认性能瓶颈确实在查询端再考虑并行化。实际操作中最后再分享一个技巧我拿这套方案压测过三千万行的数据导出堆内存控制在512M以内一点问题没有。整个导出过程中堆内存曲线非常平稳老年代几乎没有增长。这要归功于两个坚持查询永远分批取数据永远不滞留内存。还有个小细节想补充一下。测试的时候记得关注一下数据库连接池的配置有些连接池默认的maxAllowedPacket很小大批量查询的时候会报PacketTooBigException。真遇到这个问题别慌调大连接参数值就好。另外如果你把文件直接写到服务器本地磁盘再给前端下载磁盘空间也要提前规划好。一千万行的CSV大概在300M到800M之间取决于字段宽度别导到一半把磁盘塞满了。这方案我已经在不同项目里复用过三次每次都是只改Mapper和字段映射就能直接上线。希望这套思路对你也有用。本文还有配套的精品资源点击获取