简介:针对Java后端处理千万级CSV导出的常见痛点,这份资源提供了一套完整的多线程分批导出实现方案,适合有Java基础、正在应对大数据量报表导出或日志导出场景的开发者参考。资源压缩包共10个文件,以9个Java源码为主,覆盖数据分批拆分、线程池管理、CSV逐行读写、压缩与下载等核心模块,另附1个txt说明文档,梳理批量导出的整体设计思路与实现细节。包体仅10KB,代码精简、无冗余依赖,便于阅读和迁移改造,已有15562人学习下载。方案采用第三方CSV库或Java 8 Stream API逐行处理文件,通过ExecutorService构建线程池并行处理数据块,避免一次性加载全部数据,从根本上规避内存溢出问题;同时引入MappedByteBuffer内存映射文件降低内存占用,并加入异常捕获与失败重试的补偿机制,兼顾导出效率与稳定性。代码结构清晰,关键步骤包含注释,学习后可以掌握大数据量导出的分片策略、线程池参数配置、内存优化手段及异常处理思路,对生产环境的性能调优很有帮助,适合作为模块改造与方案设计的直接参考。
1. 千万级CSV导出:不动内存的流式方案,先解决“怎么不爆内存”
接到某公司月度报表导出的活儿时,我第一反应是把数据查出来塞进 List,再拼字符串写文件。结果一跑,千万行数据直接把堆内存干到溢出,GC 日志刷屏,服务直接假死。后来把整套方案重构成流式导出,核心就一句话:数据不驻留内存,读一条写一条,写满一个分片就收掉。这份资源里给的正是这个思路的完整落地代码,解决的就是 java csv大数据量导出 时最头疼的内存溢出问题。适用场景很明确:业务表几千万行、导出文件需要给运营或财务打开、服务器堆内存只有 2G 到 4G 的普通配置。适合正在被 OOM 折磨的后端开发者,也适合想把导出模块做稳的架构师。技术栈不复杂,JDBC 游标查询、BufferedWriter 流式写文件、ZIP 分卷压缩,三件事串起来就够了。
2. 读端:JDBC 游标查询,不要一次 List 装下全表
2.1 为什么 List<Entity> 在千万级下必炸
大多数业务代码里,导出功能都是这么写的:先SELECT * FROM 大表,然后 MyBatis 或 JPA 把结果集全部映射成实体对象,装进List<Entity>,再遍历拼 CSV。
这条路在十万行以内是通的,百万行勉强能跑,但千万行一定会出问题。一张宽表 30 个字段,每行对象加上 List 结构开销,内存占用轻松超过 2G。更隐蔽的是,JDBC 驱动默认会把查询结果一次性全部拉回客户端内存,也就是说SELECT执行完,服务端结果集已经完整躺在堆里了,后续你做什么都救不回来。
这个资源里的读端方案,根源上避开了“全量结果集”这个概念:用游标方式逐行拉取,每从数据库拿一条,处理一条,丢掉一条。JVM 堆里同一时刻只存在一行数据,内存占用恒定在几十 MB 级别,跟表的总行数彻底脱钩。
2.2 fetchSize 与游标读取的配置细节
游标读取不是简单地设置一个参数,不同数据库驱动行为差别很大。以最常见的 MySQL 连接串为例,关键配置长这样:
String jdbcUrl = "jdbc:mysql://127.0.0.1:3306/report_db" + "?useCursorFetch=true" + "&defaultFetchSize=1000" + "&useUnicode=true&characterEncoding=utf8"; try (Connection conn = DriverManager.getConnection(jdbcUrl, user, password); PreparedStatement ps = conn.prepareStatement( "SELECT id, user_name, amount, created_at FROM biz_order WHERE create_date >= ?", ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) { ps.setString(1, "2025-01-01"); ps.setFetchSize(1000); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { // 这里拿到当前行,立刻写入 CSV,不要累积 } } }这段代码里有三个决定成败的点。useCursorFetch=true是 MySQL 驱动开启服务端游标的总开关,不开它,setFetchSize基本是摆设。TYPE_FORWARD_ONLY表示结果集只前进不后退,这是游标模式的前提,MyBatis 里如果配了可以滚动的 ResultSet,游标直接失效。setFetchSize(1000)表示每次从服务端取 1000 条到客户端,这个值不是越大越好,建议在 500 到 2000 之间,太大会让单次网络传输阻塞过久,太小则请求次数暴增。
2.3 游标模式下能做什么不能做什么
游标读取有几个隐含约束,新手最容易在这上面翻车。第一,整个查询过程中,数据库连接不能被占用去做别的事,游标是绑定在连接上的,你用连接池时一旦连接被借走,游标就断了。所以在导出任务里,我一般直接DriverManager.getConnection单独开一条连接,不走连接池,导出完再关。
第二,游标模式下事务隔离级别要留意。默认的 REPEATABLE_READ 在 MySQL 下会长时间持有读视图,如果导出的是千万级大表,建议把事务隔离降到 READ_COMMITTED,否则 undo log 膨胀会把数据库实例拖垮。连接串上加一句&transactionIsolation=READ_COMMITTED就行。
第三,游标读取过程中,如果外部有并发写正在改这张表,你读到的是快照数据,一致性没问题,但导出时间过长会导致主从复制延迟报警。千万级数据的导出耗时基本在分钟级到小时级,尽量挑业务低峰期跑,或者直接走只读从库。
3. 写端:BufferedWriter + ZIP 输出,逐行刷盘不驻留
3.1 内存写 vs 流式写的本质差别
读端解决了“数据不堆内存”,写端同样不能把 CSV 内容先拼成一个巨大的字符串再一把写出去。很多 OOM 其实不是查数据撑爆的,而是StringBuilder.append()叠了一万行之后,字符串数组频繁扩容,旧数组刚被回收新数组又分配,Full GC 连续触发,CPU 飙高,服务卡死。
流式写的核心是 BufferedWriter 内部缓冲区,默认 8KB,写满自动刷到磁盘。配合flush()手动控制,可以做到最多几千行落一次盘。这样内存里永远只有一小段缓冲区内容,文件多大都不影响 JVM 堆。
这个资源里给的写入核心代码,我拆出来大概是这个骨架:
String filePath = "/data/export/order_202501.csv"; try (BufferedWriter writer = new BufferedWriter( new OutputStreamWriter(new FileOutputStream(filePath), StandardCharsets.UTF_8), 8192)) { // 先写列头 writer.write("id,user_name,amount,created_at"); writer.newLine(); int batchRows = 0; // rowData 是当前一行的字段数组,从 ResultSet 取完立刻拼 while ((rowData = rs.nextRow()) != null) { writer.write(joinCsvRow(rowData)); writer.newLine(); batchRows++; // 每 2000 行刷一次盘,防止宕机丢太多数据 if (batchRows >= 2000) { writer.flush(); batchRows = 0; } } writer.flush(); }joinCsvRow负责把一行字段拼成逗号分隔的字符串,注意字段里有逗号、引号、换行符时必须用双引号包裹,这是个常见的标准坑。8192是 BufferedWriter 缓冲区大小,写 2000 行刷一次盘,是磁盘 IO 和内存占用之间比较平衡的取值。flush()不是越频繁越好,每条都 flush 会变成逐行写盘,性能直接掉一个数量级。
3.2 千万行单文件还是分卷,取决于打开方
千万行 CSV 就算生成成功,直接给业务方用 Excel 打开也是灾难。Excel 单表上限 1048576 行,超过就打不开。所以写端方案必须支持分卷:每个 CSV 文件写到 100 万行就收掉,开下一个文件继续写,最后把所有分卷打包成一个 ZIP。这个资源里的实现就是这么干的,配套的拆分逻辑类似这样:
int maxRowsPerFile = 1_000_000; int fileIndex = 1; long totalRows = 0; BufferedWriter currentWriter = createNewFile(fileIndex); while (rs.next()) { currentWriter.write(joinCsvRow(currentRow)); currentWriter.newLine(); totalRows++; rowsInCurrentFile++; if (rowsInCurrentFile >= maxRowsPerFile) { currentWriter.flush(); currentWriter.close(); fileIndex++; currentWriter = createNewFile(fileIndex); writeHeader(currentWriter); // 每个分卷都要有列头 rowsInCurrentFile = 0; } }分卷大小这个参数要按业务实际调。运营要拿去 Excel 透视,100 万行一包最稳;如果是给程序做后续处理,单个文件 500 万行也扛得住。我一般会把分卷行数放到配置中心,线上出问题不用改代码重新发布。
3.3 ZIP 压缩与流式收口
分卷文件散落一地不是交付形态,最终要压成一个 ZIP。这里有个很多人踩过的逻辑坑:ZIP 不是把所有文件读进内存再压缩,而是要用 ZipOutputStream 包住文件写入流,边写边压。ZipEntry必须写在文件内容之前,closeEntry()必须在一个文件写完、下一个文件开始之前调用,顺序错了 ZIP 就是坏的。
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream("/data/export/order_202501.zip"))) { for (Path partFile : partFiles) { zos.putNextEntry(new ZipEntry(partFile.getFileName().toString())); Files.copy(partFile, zos); zos.closeEntry(); } }Files.copy(partFile, zos)内部也是流式复制,源文件按块读入,不会整个加载到堆里。压缩级别用 ZipOutputStream 默认值即可,没必要上DEFLATED最高压缩比,那个 CPU 开销在千万级数据量下会拖很久。真要压得狠,可以考虑 gzip 单文件输出,但业务方一般不会接受 .gz 格式。
4. 千万级导出的排序、深分页与分段拉取
4.1 深分页的坑:offset 越大越慢
部分团队做导出时不用游标,而是用分页查询,每页查 1 万条,页码往后翻。这在百万级内勉强能用,到了千万级就会暴露一个致命问题:LIMIT 8000000, 10000这种查询,数据库要先把前 800 万行扫出来丢掉,再取后面的 1 万行。越往后翻,单页查询越慢,整个导出过程呈二次曲线增长。
我接手过一个导出任务,前 100 万行跑了 40 秒,到 500 万行时一页要 20 秒,到剩下最后 100 万行时每页要 1 分多钟,整个任务跑了快两个小时,连接超时直接中断。后来把分页改成 keyset 分页,按主键定位游标位置,每一页都是从指定 ID 往后取,走主键索引,单页耗时恒定在几十毫秒。
long lastId = 0L; int pageSize = 10000; while (true) { String sql = "SELECT id, user_name, amount, created_at FROM biz_order " + "WHERE create_date >= ? AND id > ? ORDER BY id ASC LIMIT ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "2025-01-01"); ps.setLong(2, lastId); ps.setInt(3, pageSize); try (ResultSet rs = ps.executeQuery()) { int rowCount = 0; while (rs.next()) { writeCsvRow(rs); lastId = rs.getLong("id"); rowCount++; } if (rowCount < pageSize) break; // 最后一页 } } }WHERE id > ?利用主键索引直接跳到上次结束的位置,ORDER BY id ASC保证游标顺序单调递增,LIMIT ?圈定每页大小。这个方案唯一的要求就是表有连续或近似连续的主键,没有主键的表得先建一个带索引的排序列。keyset 分页比游标更适合跨系统调用,因为每次查询都是独立的短事务,不用长时间占着连接。
4.2 需要排序怎么办:文件内排序 + 归并
业务上常常要求导出的数据按金额降序或按时间排序。游标读取天然是按主键顺序扫的,你要是为了排序加一个ORDER BY amount DESC,那数据库得先做一次全表排序,产生临时文件,耗时翻倍。
更稳的做法是分两次处理:第一次按普通游标把数据全部导出到临时 CSV 分片文件;第二次用外部排序的思路,每个分片文件内部按目标字段排序,再做多路归并。这个资源里的工具包提供了一个简单的分片归并实现,核心逻辑是:每个分片文件的数据量控制在内存能容纳的范围内,读进 List 排序后写回,然后用PriorityQueue做多路归并输出到最终文件。
// 多路归并:每个分片文件一个 BufferedReader,按排序字段取最小 PriorityQueue<RowWithReader> queue = new PriorityQueue<>( (r1, r2) -> r1.sortValue.compareTo(r2.sortValue) ); for (BufferedReader reader : readers) { String line = reader.readLine(); if (line != null) { queue.offer(new RowWithReader(line, reader)); } } try (BufferedWriter writer = createFinalWriter()) { while (!queue.isEmpty()) { RowWithReader row = queue.poll(); writer.write(row.line); writer.newLine(); String nextLine = row.reader.readLine(); if (nextLine != null) { queue.offer(new RowWithReader(nextLine, row.reader)); } } }PriorityQueue每次弹出一个当前最小行,然后从对应的分片文件再读下一行进堆。整个归并过程内存里最多同时存在“分片数量”行数据,跟总量无关。排序字段如果是数字,注意在compareTo之前转成数字类型,否则字符串排序会按字典序,出现 100 排在 20 前面的情况。
4.3 多线程分段导出:按主键段并行,合并 ZIP
单线程导千万行,实测速度大概在每分钟 80 到 120 万行(取决于字段宽度和磁盘速度),全量跑完要 10 分钟左右。某些时效性要求高的场景等不了这么长,那就得上多线程分段。
分段思路很简单:先查一次MIN(id)和MAX(id),把主键区间切成 N 段,比如按每段 50 万行估算,每个线程负责一段,各自游标导出到独立分片文件,最后主线程把所有分片压成一个 ZIP。这个资源里给的线程池配置,我直接拿来用,参数是这样调出来的:
int segmentCount = 8; // 跟 CPU 核数对齐 int minId = queryMinId(conn); int maxId = queryMaxId(conn); int step = (maxId - minId) / segmentCount; ExecutorService pool = Executors.newFixedThreadPool(segmentCount); List<Future<Path>> futures = new ArrayList<>(); for (int i = 0; i < segmentCount; i++) { int segStart = minId + i * step; int segEnd = (i == segmentCount - 1) ? maxId : segStart + step; futures.add(pool.submit(() -> exportSegment(segStart, segEnd))); } for (Future<Path> f : futures) { partFiles.add(f.get()); }线程数不是越多越好。每个线程占用一个数据库连接,连接池最大连接数往往只有 20 到 50,分段过多会把连接池打满,影响线上其他业务。8 段是常规配置,实测百万行数据导出从单线程 12 分钟左右压到 2 分半以内。磁盘 IO 是另一个瓶颈,机械盘遇到多个线程并发写不同文件,磁头来回跳,速度反而不如单线程;SSD 上并行收益明显。另外分段导出后每个分片都要有列头,合并 ZIP 时文件顺序要按分段顺序命名,比如part_0001.csv到part_0008.csv,否则业务方解压出来顺序是乱的。
5. 避坑/常见问题排查:从 OOM 到半个文件的五大翻车现场
5.1 现象:堆内存没涨多少,GC 却把 CPU 吃满
导出任务跑起来之后,JVM 堆一直维持在 500MB 左右,但 CPU 持续 100%,GC 日志里 Full GC 间隔越来越短。原因是 CSV 写入阶段每行都做字符串拼接,StringBuilder扩容时旧字符数组要复制,复制产生的临时对象在新生代堆积,触发频繁 Minor GC,升代之后又触发 Full GC。解决手段是把行拼接从+改成固定容量的StringBuilder,并且控制flush()频率,减少单次 IO 的上下文切换。实际改完,GC 频率降了一个数量级。
5.2 现象:设置 fetchSize 后没有任何效果
代码里写了ps.setFetchSize(500),但查看数据库监控发现还是全量结果集一次拉回。原因是 MySQL 驱动在未开启useCursorFetch=true时,setFetchSize会被忽略。另一个隐蔽场景是 MyBatis 的ResultHandler搭配fetchSize,如果 Mapper 接口返回类型是List,游标配置也会失效,必须把查询方法改成void返回并用ResultHandler逐条消费。排查步骤:先看连接串有没有useCursorFetch=true,再看 Statement 是不是FORWARD_ONLY,最后确认没走 MyBatis 的 List 收集逻辑。
5.3 现象:导出到一半连接断开,任务中断
游标模式下一次查询长时间占用连接,数据库端的wait_timeout或者中间层的空闲连接回收机制会把连接干掉。表现就是跑了 5 分钟后抛Communications link failure或者Connection is closed。解决:连接串上加&socketTimeout=600000,单位是毫秒,设成 10 分钟;同时确保整个导出过程没有任何空闲时间,每读一行就写一行,游标在持续拉取数据时连接不会被视为空闲。跨网络环境还要排查防火墙对长连接的空闲策略,有些场景需要在 TCP 层加 keepalive。
5.4 现象:CSV 里长数字变成科学计数法
导出订单号、身份证这类超过 15 位的数字时,Excel 打开 CSV 会显示成1.23457E+14,精度还丢了。原因是 CSV 没有列类型信息,Excel 自动把纯数字列按数值处理。解决有两个手段,二选一:字段前后加制表符,在字段值前拼\t,Excel 会当成文本;或者字段拼接时用双引号包一层,比如"12345678901234567",Excel 也会按文本识别。\t方案对数据内容无影响,双引号方案要注意内容本身有转义。代码里统一用sanitizeCell()处理每个字段,遇到长数字强制拼\t,这个函数在资源包里已经封装好了。
5.5 现象:打开 CSV 文件中文全部乱码
文件用 UTF-8 写的,Windows 上 Excel 双击打开却显示乱码。原因是 Excel 默认按 GBK 解析无 BOM 的 CSV,UTF-8 内容少了 BOM 头就是乱码。解决:写文件时在第一个字节位置写入\uFEFF。OutputStreamWriter场景下,先writer.write('\uFEFF')再写列头。注意这个 BOM 只出现在第一个文件里,分卷文件的后续分卷不需要加。加了 BOM 之后,用 Excel 打开乱码问题直接消除,但用BufferedReader读回这个文件时第一行第一个字符会带着 BOM,程序里要removeBom()处理一下。
6. 收尾:BOM、列头校验与千行自查,导出功亏一篑的最后一公里
资源包里的代码功能完整,但真正上线前,我建议把下面三个校验动作加进去,它们救过我两次线上事故。第一个是导出完成后立刻消费一遍 ZIP 里的每个分卷,校验分卷数量、每个文件的列头是否一致、行数是否和数据库统计的行数对得上。不要相信导出过程中打的日志,日志说写入了 1024 万行不代表文件完整,只有解压出来逐个数才算数。第二个是抽样验证,解压出第一个分卷,用流式读取前 1000 行和后 1000 行,确认没有半行数据、没有字段错位。第三个是交付形态检查,业务方用的 Excel 版本不同,分卷行数上限要按最低版本设,老版本 Excel 只能承受 6 万行左右,这个信息要在需求阶段问清楚,不要默认对方用的是新版本。
加 BOM 的代码长这样,放在创建每个分卷文件之后:
public static void writeBom(BufferedWriter writer) throws IOException { writer.write('\uFEFF'); }列头校验我用一个简单方法,把每一分卷的第一行读出来和标准列头比对,不一致直接让任务失败并提示分卷序号,不要静默修复。
标准列头: id,user_name,amount,created_at 分卷第3个文件列头: id,user_name,amount,create_date → 校验失败那次的教训是这样的:某次上线前我把flush()改到每 5000 行一次,本地测了小文件没问题,线上跑了一千多万行,最后 ZIP 文件大小看起来也正常,结果业务方反馈第 7 个分卷最后缺了 300 行。查下来是磁盘写满导致 close 失败,但异常被吞了,ZipOutputStream 流关闭时报错,文件已经损坏。从那以后我每次导出任务结束都强制走一遍分卷行数校验和大小检查,用解压测试代替肉眼判断。希望帮到你,这一套跑通了,千万级导出就不再是玄学。
本文还有配套的精品资源,点击获取