2026/10/16 3:23:23

千万级CSV导出内存溢出?Java流式查询与GC优化全解

千万级CSV导出内存溢出?Java流式查询与GC优化全解 简介面向Java后端开发者这份资源聚焦千万级数据量CSV导出时内存溢出的典型难题给出了分批处理、多线程并行写入、内存映射文件等实用优化思路并涉及逐行读写、线程池任务拆分等底层实现适用于报表导出、数据迁移、日志转储等大数据量交互场景对中级及以上工程师尤其有参考价值。资源包共10个文件其中9个Java源码文件与1个说明文档压缩包整体仅10KB体量虽小却覆盖了线程池管理、批量导出任务拆分、CSV工具封装及Zip打包工具等关键模块同时提供异常捕获与重试机制的参考设计目录结构清晰核心类划分明确可直接复用或按业务调整后嵌入项目。已有15562人学习下载适合正在排查导出性能瓶颈、希望以低侵入方式升级导出能力的Java工程师参考。1. 千万级 CSV 导出撑爆内存我先说一个最劝退的现场在 Java 后端里导出 CSV 本来是最不起眼的活儿但数据量一旦到千万级它就变成了最折磨人的故障源。我接手过很多次这种事故现场服务运行得好好的一跑导出接口java.lang.OutOfMemoryError: Java heap space直接砸下来紧接着是频繁 Full GC接口超时甚至整个节点被拖垮。更诡异的是你去看代码逻辑似乎没毛病把数据查出来用StringBuilder拼一拼写进文件再输出给前端。问题就出在这个“似乎没毛病”上——数据量小时这套写法能用数据量到千万级别时它会把 JVM 堆内存吃干抹净。这篇文章要解决的就是这一类故障CSV 大数据量导出时内存溢出、导出过程太慢、生产环境一跑就崩溃。不绕弯子直接从报错现场开始把每一步的根因、排查顺序和能抄走的解决方案全部拆开讲清楚。2. 堆内存溢出为什么“查出来再写文件”在千万级数据下必挂2.1 根因一次全量查询 全量拼接 内存翻倍爆炸最常见的翻车代码长这样SELECT * FROM 大表然后while(rs.next())把每一行拼进一个StringBuilder或者List最后一次性getBytes()写入文件。这看起来没毛病但千万级数据下内存消耗是成倍叠加的——数据库驱动本身要把查询结果缓存在内存里你这边再把每一行转成字符串对象存进集合一条数据同时存在于驱动缓存、Java 字符串对象、StringBuilder 内部数组三个地方。假设一行数据平均 200 字节一千万行原始数据约 2GB加上字符串对象头、字符数组扩容损耗、StringBuilder 的倍数增长最后吃掉七八 GB 堆内存是很正常的。某次生产事故里导出接口一调用jstat显示老年代直接被打满Full GC 每两秒一次整个服务不可用。解决这件事的核心思路不是调大-Xmx——调大了可能换来更长的 GC 停顿而是杜绝“一次性把全量数据装进内存”这个动作。标准做法是数据库侧用游标/流式查询应用侧逐行写入文件让数据像水管一样流过 JVM 而不是蓄在水池里。以 MySQL 为例JDBC 连接参数里加上useCursorFetchtrue和defaultFetchSize可以让驱动不把整个结果集拉进客户端内存而是每次从服务端取一批。配合CSVWriter比如opencsv或者你自己封装一个缓冲写入器就能把内存占用从 GB 级降到几十 MB 级。// 1. 建立连接时启用游标流式查询 String url jdbc:mysql://localhost:3306/bigdata?useCursorFetchtruedefaultFetchSize1000useSSLfalse; try (Connection conn DriverManager.getConnection(url, user, password)) { // 2. 不写 SELECT *只查需要的列减少行宽 String sql SELECT id, name, create_time FROM biz_order WHERE create_time ? AND create_time ?; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setTimestamp(1, startTime); ps.setTimestamp(2, endTime); // 3. 把结果集包装成流式读取 try (ResultSet rs ps.executeQuery(); BufferedWriter writer new BufferedWriter(new OutputStreamWriter( new FileOutputStream(/data/export/orders.csv), StandardCharsets.UTF_8))) { CSVWriter csvWriter new CSVWriter(writer); // 表头 csvWriter.writeNext(new String[]{id, name, create_time}); while (rs.next()) { String[] row new String[]{ rs.getString(id), rs.getString(name), rs.getTimestamp(create_time).toString() }; csvWriter.writeNext(row); // 逐行写入不攒在内存里 } csvWriter.flush(); } } }这段代码里的关键点有两个。第一useCursorFetchtrue配合defaultFetchSize缺一不可——只加useCursorFetch不设置 fetchSize 的话MySQL 驱动可能仍然会按默认的Integer.MAX_VALUE一次性把结果集拉全。第二CSVWriter内部有缓冲区但不是把一千万行全部放到内存而是攒够一个 buffer 大小就刷到磁盘。这里的缓冲行数怎么定fetchSize设 1000 到 5000 之间比较合适设太小会导致客户端和 MySQL 服务端频繁交互网络往返次数大大增加设太大则每次取回的数据量还是偏多失去流式意义。如果用的是 PostgreSQL需要在连接参数里写preparedStatementCacheQueries0之类的配置具体参数名不同但思路一致。注意如果你的数据源不是 MySQL 而是老版本的 Oracle 驱动或者你用的是 MyBatis那还需要检查 MyBatis 的fetchSize配置是否被拦截。常见的错误做法是只在 MyBatis 的select标签里写了fetchSize1000但 JDBC 连接 URL 里没加useCursorFetch结果配置根本没生效。排查方法是打开 MySQL 通用日志看导出期间服务端是否在持续发送结果集如果发现驱动一次性发了全部数据那基本就是 fetchSize 没传到驱动层。2.2 明明用了流式查询还是 OOM问题出在结果集包装和字符串转换有同学照着网上的教程改了流式查询导出时内存还是暴涨。这时候先别急着怀疑 stream 方案本身很可能中间环节又造了新的“大对象”。最常见的是把ResultSet转成了List实体对象或者ListMap——这一转等于又把全量数据放回了堆里流式白做了。还有人在循环里把整行数据拼成一个超长字符串再写文件比如String line rs.getString(1) , rs.getString(2) ...这里每拼接一次就产生一个新的中间字符串对象一千万行下来大量垃圾对象堆积Young GC 都来不及回收。另一个容易被忽略的坑是rs.getString()本身。如果查询结果里包含大字段比如TEXT列、BLOB列流式查询下这些大字段仍然会被完整加载到内存。千万级导出的场景里如果表里有TEXT或者JSON类型列除非业务确实需要否则绝对不要SELECT出来。我见过一个案例导出一张日志表明明只需要 ID 和时间戳结果因为用了SELECT *把每行里 4KB 的请求报文也拖了出来内存直接翻了几十倍。解决套路是在 SQL 里只列必要的字段同时在 Java 侧循环里避免任何形式的字符串中间变量复用。// 错误示范循环里拼接字符串造成大量临时对象 while (rs.next()) { String line rs.getString(id) , rs.getString(name); // 每行都产生多个字符串对象 writer.write(line); } // 正确做法直接用 String[] 交给 CSVWriter while (rs.next()) { String[] row new String[2]; row[0] rs.getString(id); row[1] rs.getString(name); writer.writeNext(row); // opencsv 内部自行处理转义和拼接字符串生命周期在循环内结束 }为什么要用String[]而不是自己拼字符串因为CSVWriter会处理字段里的逗号、引号、换行符这些特殊字符避免你导出的 CSV 被 Excel 打开时列错位同时它的内部写入路径是经过优化的比手写字符串拼接更少产生垃圾。如果你已经导入了opencsv建议直接用它如果没有引入任何 CSV 库也可以用 Java 的BufferedWriter手写但必须保证每条记录在循环内完成拼接和写入不能让line变量逃逸到循环外被引用。参数方面BufferedWriter的缓冲区开 8KB 到 64KB 之间足够不要贪大因为这里是文件 I/O 缓冲不是数据缓存它解决的是减少磁盘写入次数而不是解决数据量问题。2.3 方言问题分页查询 vs 游标查询谁更适合千万级导出除了流式查询另一种常见方案是分页查询——每次查一万条写完再查下一批。这个方法听起来可行但有几个隐藏的坑。第一个是深分页问题LIMIT 5000000, 10000这种写法在 MySQL 里需要扫描并丢弃前五百万行越到后面越慢整个导出过程的时间会随进度急剧拉长。第二种是数据一致性如果导出过程中源表有数据插入或更新分页查询可能拿到重复数据某条记录在后一页出现了两次而游标查询基于一次快照不会有这个问题。所以当数据量突破百万级、并且你希望导出期间不要对线上数据库造成太大压力时优先选游标流式查询。但是游标查询也有适用边界如果你的数据库是只读从库或者表数据在导出期间保持静止分页查询配合一个合理的批次大小也能用。只是要注意分页查询的批次大小不能是固定值——理想情况下每页的容量应该让 JVM 内存稳定在一个安全水位。怎么判断看-Xmx和每行平均大小如果每行约 200 字节-Xmx是 1GB那分页批次最好在 10 万行以内如果每行是 2KB 的大宽表批次应该降到一万行。我一般会先用一条SELECT COUNT(*)预估总行数再根据可用堆内存动态算批次。另外一个值得注意的现象是很多人把“生成 CSV 文件”和“把 CSV 文件响应给前端”两件事合成一个动作边查边写response.getOutputStream()。如果响应通道的写入速度跟不上数据生成速度OutputStream底层缓冲会堆积同样可能造成内存问题。千万级别导出建议先落盘生成完整文件再走文件下载接口这样磁盘是缓冲区JVM 内存永远是安全的。3. 导出慢得像死循环GC 频繁、数据库压力大、CSV 格式错乱的连锁反应3.1 根因StringBuilder 拼接 每行 flush两头不到岸当内存问题暂时缓解你把-Xmx调到 4GB 强行压住了 OOM新的问题出现了——导出速度慢到让人怀疑程序进入了死循环。这时候最常见的原因有两个一个是代码里在循环内调用了writer.flush()每行刷一次磁盘另一个是前面提到的深分页查询导致数据库不停地做排序和回表。先说第一个。BufferedWriter存在的意义就是减少物理 I/O 次数一次flush()会触发一次系统调用写入磁盘一千万行就有一千万次系统调用这比数据库查询本身还要耗时几个数量级。正确做法是只在写完表头后、关闭前各 flush 一次或者设置BufferedWriter的缓冲区大小并依赖其自动刷盘。第二个原因更微妙。如果你用的还是分页查询并且ORDER BY列上没有索引那 MySQL 需要额外做 filesort每页都要排序一千万行数据的相关字段。这种情况下你看到的慢不一定是 GC而是数据库 CPU 被打爆。排查方法是看数据库的慢查询日志确认分页查询语句的执行时间是不是在持续增长。# 打开 MySQL 慢查询日志观察导出期间 SQL 耗时 SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1; # 导出完成后查看 mysqldumpslow /var/lib/mysql/xxx-slow.log如果你发现每条查询时间都在 10 秒以上那问题不在 Java 层而在查询模式本身。把分页改成游标查询去掉ORDER BYCSV 导出大多数场景下不需要全局有序如果需要排序SQL 里只对索引列排序让查询走顺序扫描速度会快很多。这里有个取舍ORDER BY去掉之后导出数据的顺序可能不稳定但对于导出/归档类需求顺序基本不影响结果。3.2 单元格里本身有换行和逗号CSV 为什么被 Excel 打开就乱了导出不崩了、速度也快了但用户拿到文件用 Excel 打开一看有的行断成了两行有的列错位——这个故障的隐蔽性很强因为它不会在 Java 日志里留下任何异常只在文件内容层面体现。根因是 CSV 格式里的RFC 4180转义规则没有被遵守当字段内包含逗号、双引号、换行符时整个字段必须用双引号包围并且字段内部的引号要写成两个双引号。opencsv的writeNext(String[])会自动处理这些转义但如果你用的是自己手写的拼接几乎必然踩坑。比如备注字段里包含了一个用户输入的英文双引号你的拼接代码没有转义生成的 CSV 里就多了一个裸双引号Excel 解析时就会错乱。更坑的是字段末尾的换行——你拼了一条记录但字段里有换行文件里看起来就是多了一行数据行数对不上用户的 Excel 模板可能直接报错。// 手工拼接必须做转义否则必踩坑 public static String escapeCsvField(String field) { if (field null) { return ; } // 包含逗号、双引号或换行符时需要包一层双引号 if (field.contains(,) || field.contains(\) || field.contains(\n) || field.contains(\r)) { // 字段内的双引号要翻倍 return \ field.replace(\, \\) \; } return field; }这里有个容易忽略的细节\r\n和\n都要处理不能只判断\n。如果直接从数据库取出一个带格式的文本字段它可能是\r\n你的判断包含\r才能命中。还有如果你生成的 CSV 要给 Excel 打开文件最好带 BOM\uFEFF否则 Excel 用默认编码打开 UTF-8 文件时会乱码——openCSV默认不写 BOM需要你自己在BufferedWriter开头写一下。参数上BOM 头就是三个字节EF BB BF在 Java 里直接用writer.write(\uFEFF)即可。3.3 HS 错误理解给 JVM 加内存不是后悔药而是催命符生产环境上只要一出现OutOfMemoryError很多同事的第一反应就是改JAVA_OPTS把-Xmx从 2GB 改到 8GB。这个动作短期内可能掩盖了症状但不会解决根因而且往往会带来新的灾难——堆内存越大单次 Full GC 的停顿时间越长。原来 2GB 堆 Full GC 停 300ms8GB 堆可能停 2 秒以上。更糟的是如果应用还有其他业务流量大堆内存下的 GC 停顿会让所有接口的响应时间飙升引发连锁超时。正确做法是内存参数不应该拍脑袋改应该先通过jmap -heap pid或者-Xlog:gc查看堆使用情况判断是年轻代晋升太快还是老年代本身被打满。如果是流式查询没做对导致数据堆在内存里调大-Xmx只是把爆炸时间从 5 分钟后推迟到 20 分钟后。如果已经用了流式查询GC 依然频繁那可能要考虑是不是每个String[]对象的生命周期意外变长了——例如你把row对象放进了某个 List导致它无法被回收。4. MyBatis 多线程导出千万级数据拆分成并行任务后反而更慢了4.1 根因线程数开大不等于快数据库连接池被耗尽数据量太大单线程逐行写文件还是慢有同学想到用多线程把数据按 ID 范围拆成几段每个线程查一段写完各自的临时文件最后合并。这个思路用于导出几百万行时表现不错但到千万级时常常会翻车。翻车现场一般是这样的导出任务启动后数据库连接池被占满其他业务查询全部超时或者每个线程都在跑大查询数据库 CPU 冲到 100%导出反而比单线程更慢。根因有两层。第一层是连接池配置很多项目默认maximum-pool-size是 10你开了 8 个导出线程每个线程占一个连接那其他业务还能用的连接就只剩 2 个线上必然受影响。第二层是数据库侧的资源争用8 个线程同时对一张千万级大表做全表扫描InnoDB的缓冲池会被查询风暴打穿磁盘 I/O 成为瓶颈所有查询都在等 I/O。解决方法是限制导出并发度通常 2 到 3 个线程足够同时务必给导出任务单独分配一个专用的DataSource不要和业务共享连接池。另一个关键技术是把导出用的线程池和业务线程池隔离比如单独建一个ThreadPoolExecutor核心线程数设为 CPU 核数的四分之一最大线程数不要超过 4。参数上队列容量用有界队列比如ArrayBlockingQueue(100)拒绝策略用CallerRunsPolicy避免导出任务直接丢给业务线程池。// 为导出任务建立独立线程池避免和业务互相影响 ThreadPoolExecutor exportPool new ThreadPoolExecutor( 2, // 核心线程数 3, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲线程存活时间 new ArrayBlockingQueue(100), new ThreadPoolExecutor.CallerRunsPolicy() // 队列满了就由调用线程执行不丢弃任务 ); // 按 ID 范围拆分任务注意分片字段要有索引 long totalMinId 1L; long totalMaxId 20_000_000L; int shardSize 5_000_000; // 每片 500 万用 count 预评估保证均匀 for (long start totalMinId; start totalMaxId; start shardSize) { final long shardStart start; final long shardEnd Math.min(start shardSize - 1, totalMaxId); exportPool.submit(() - exportShardToCsv(shardStart, shardEnd)); }这里shardSize怎么定不是拍脑袋而是要看数据分布。如果 ID 均匀递增按范围分片没问题但如果数据有热点比如某个时间段写入量巨大按 ID 范围划分会导致每个线程处理的数据量差异悬殊。更稳妥的方式是先用SELECT MIN(id), MAX(id)拿到边界再结合COUNT(*)把总行数和 ID 跨度做换算确保每个分片的实际数据量接近。我一般还会在分片 SQL 里加AND id ? AND id ?强制走主键索引避免全表扫描代价被乘以线程数。分片边界尽量左闭右开防止片与片之间数据重叠。4.2 分片后合并文件NIO 通道比逐字节拷贝快一个量级分片导出完成后下一个坑是合并文件。如果直接用InputStream逐字节拷贝一千万行 CSV 合并可能要耗时几分钟如果用 NIO 的FileChannel做零拷贝传输同样的数据量几秒钟就能完成。原理上FileChannel.transferTo会尝试利用操作系统内核态的 DMA 能力数据不需要经过用户态缓冲区的搬运。合并顺序要注意先写完表头的文件再按分片顺序追加各片内容同时确保分片文件的边界不会出现多读或少读一个字节的情况。// 顺序合并分片 CSV 文件 try (FileChannel destChannel FileChannel.open( Path.of(/data/export/result.csv), StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND)) { // 先写入表头 String header id,name,create_time\n; destChannel.write(ByteBuffer.wrap(header.getBytes(StandardCharsets.UTF_8))); // 按顺序追加各分片文件 for (int i 0; i shardFileCount; i) { Path shardPath Path.of(String.format(/data/export/shard_%d.csv, i)); try (FileChannel srcChannel FileChannel.open(shardPath, StandardOpenOption.READ)) { long size srcChannel.size(); long transferred 0L; while (transferred size) { transferred srcChannel.transferTo(transferred, size - transferred, destChannel); } } } }注意transferTo有个隐蔽的坑它不保证一次调用传输完所有数据尤其是源文件较大时可能只传输了一部分。所以必须用循环累加偏移量直到确认所有字节都过去了。如果你看到合并后的文件行数不对大概率就是这个原因。另一个容易踩的坑是合并前各个分片都写了表头结果合并后出现多个重复表头——分片导出函数里应该只让第一个分片写表头其他分片只写数据行。这个可以通过传递一个isFirstShard标志来控制。4.3 临时文件的清理与磁盘空间预判分片导出看着高效但有一个容易被忽略的隐患临时分片文件占用的磁盘空间。一千万行 CSV假设平均每行 200 字节最终文件约 2GB再加上分片文件同时存在峰值可能占用 4GB 磁盘空间。如果服务器/data分区只有 5GB 可用空间导出到一半磁盘写满导出线程抛IOException临时文件还残留满磁盘。这个故障在测试环境几乎不会暴露因为测试数据量小生产环境一跑就炸。解决思路分为两步。第一步导出前用Files.getFileStore(path).getUsableSpace()检查可用空间第二步合并完成后立即删除分片文件并且使用 try-with-resources 确保异常时也能触发清理。我一般会在合并循环结束后用一个try-catch-finally做清理或者直接用一个ListPath收集所有临时文件路径在 finally 里逐个删除。还要注意一个反直觉的现象删除文件时不看业务表数据量而看文件系统配额。如果服务器上还跑着日志服务或其他任务磁盘空间可能被别人占掉所以检查可用空间要在导出开始前做一次、每个分片写入完成后做一次双保险。5. 避坑导出一千万行 CSV 时最常见的 6 个翻车点5.1 逆天用法循环内查数据库导致查询次数等于数据行数现象导出代码里循环查数据库每处理一行数据就执行一次SELECT导出一万行就执行一万次查询导出耗时从分钟级变成小时级。原因没有一次性把所需数据查出来而是用单行 ID 逐条查询辅助信息比如在while(rs.next())里又调用了select ... where id ?。这种写法在数据量小时看不出问题但一千万行的场景下数据库每秒处理的查询量暴增连接池被打满CPU 接近 100%。解决需要关联的字段全部放在主查询的JOIN里或者先查一次映射表缓存到HashMap再在循环里用内存查询替代数据库查询。如果是跨库关联没法JOIN就分两次查询把第一次的结果集映射成 Map第二次循环时按 key 取。5.2 表头与数据行编码不一致Excel 打开乱码现象CSV 文件用 Notepad 打开正常用 Excel 打开中文全部变成乱码。原因文件以 UTF-8 编码写入但没有 BOM 头Excel 在 Windows 下默认按 ANSIGBK解析。加上 BOM 之后Excel 才会识别为 UTF-8。这个坑在 Linux 服务器上用浏览器直接下载时通常不暴露因为浏览器自己会探测编码但 Windows 桌面端 Excel 的探测逻辑比较死板。解决在写入任何内容之前先写入\uFEFF字符或者用字节方式写入EF BB BF。用opencsv时不能直接在构造函数里加 BOM需要在构建OutputStreamWriter之前先往OutputStream里写 BOM 字节。5.3 session 或线程上下文导致导出线程内存泄漏现象第一次导出正常第二次导出变慢第三次直接 OOM。原因某些 Web 框架比如 Spring 的request作用域 Bean会被误用在导出线程里导致HttpSession或ThreadLocal持有大量中间数据。导出线程可能被线程池复用ThreadLocal里的对象不会自动清理每执行一次导出就残留一份数据多次执行后堆内存被慢慢吃光。解决导出线程里严禁使用ThreadLocal保存业务数据在finally块里显式调用remove()清理。如果必须使用请求作用域的 Bean把它作为方法参数传入不要放入静态字段或线程上下文。5.4 列宽无限制单行数据超大导致内存尖峰现象导出的某一行有几百个字段单个字段值十几 KB虽然总行数不多但内存占用依然很高。原因字段数量过多或单字段内容过大导致每个String[]对象的体积远超平均值。如果一千万行数据里有一小部分行特别宽比如备注字段塞了几万字符那循环内创建的大字符串对象会让 Young GC 压力剧增甚至直接晋升到老年代。解决对超大字段做截断或忽略处理比如只导出前 1000 个字符或者在 SQL 里用LEFT(content, 1000)做截断。另外CSVWriter本身会为每一行分配数组如果你的数据天然有宽行可以考虑在写入前清理字段里的空白字符减少无效体积。5.5 HTTP 响应超时文件没生成完用户就断了连接现象调用导出接口时浏览器等待 5 分钟后报网关超时但服务端线程还在后台跑。原因千万级数据导出本身耗时可能超过 10 分钟远超网关的响应超时时间。一旦客户端断开服务端继续写文件其实没有意义但如果你把数据流直接写进HttpServletResponse的输出流里断连会导致IOException甚至写坏文件。解决导出接口应该拆成两个动作——第一步提交导出任务立即返回一个下载链接或任务 ID第二步由后台线程池生成文件用户通过另一个接口轮询下载。如果无法改造接口至少要确保导出时先落盘文件生成完成后再返回文件路径前端用XMLHttpRequest轮询文件状态而不是等待一个长时间连接。5.6 导出千万行后OutOfMemoryError: GC overhead limit exceeded的误导性现象日志里出现java.lang.OutOfMemoryError: GC overhead limit exceeded看起来是 GC 的问题但实际上是因为堆内存几乎被占满JVM 反复进行 Full GC 仍回收不到足够空间于是主动抛出这个异常。原因这个报错和Java heap space本质相同区别在于触发时机。GC overhead limit exceeded出现在 GC 效率极低的场景——超过 98% 的时间都花在 GC 上但回收到的内存不足 2%。这说明你的程序在大量创建短命对象且这些对象很快成为垃圾。解决不要针对这个报错去调 GC 参数应该回头看业务代码里是否在循环内创建了不必要的大对象。用jmap -histo:live pid查看堆内存里实例数最多的类一般总会看到byte[]、char[]或者String大量堆积顺着调用栈去找源头。6. 快速定位导出故障一套十分钟内完成排查的固定顺序遇到导出相关的异常我会严格按照下面的顺序排查不走弯路。第一件事不是看代码而是确认 JVM 堆内存是否被打满执行jmap -heap pid看老年代和年轻代的使用率再配合jstat -gcutil pid 1000看 GC 频率。如果老年代使用率持续高于 90%Full GC 频繁直接定位为内存问题。第二步看数据库侧确认是不是查询本身把资源耗尽了——打开慢查询日志或者用SHOW PROCESSLIST看当前执行的 SQL 有没有长时间运行的全表扫描。确认内存问题后再决定是否看代码。看代码时有几个固定的检查点是否用了游标查询/流式查询useCursorFetch、fetchSize是否在循环内把数据累积到了集合里写入文件时是否每个循环都 flushCSV 字段是否经过转义处理。99% 的导出故障都埋在这四个位置。如果代码没问题那就看系统层面磁盘空间是否充足、服务器的可用内存是否被其他进程抢占、导出线程的数量是否被错误地调大。最后如果以上都查过了还没解决我会在导出代码的关键路径打上System.currentTimeMillis()耗时日志用时间花在哪一段来反推问题——比如数据库查询耗时 8 分钟那是 SQL 问题写入文件耗时 8 分钟那是 I/O 或格式转义问题。对于 JVM 参数的快速验证我建议用这个命令组合导出任务启动后每 10 秒打印一次 GC 日志同时记录堆使用率这样肉眼就能看出内存曲线是平稳的还是在爬坡。如果内存曲线随时间线性增长几乎可以断定是把数据累积到了堆里——这是流式导出失效的明确信号。如果曲线平稳但导出极慢那大概率是 I/O 或数据库查询模式的问题。# 打印 GC 详细信息到日志文件 java -Xlog:gc*:filegc_export.log:time,uptime,level -jar your-export-app.jar # 每隔 5 秒采样一次堆内存使用率 jstat -gcutil pid 5000在排查过程中jmap -histo:live这个命令也很有用它能直接告诉你堆里存活对象最多的类型。如果你看到char[]或byte[]数量特别巨大说明有大量字符串数据驻留在内存中如果看到com.mysql.cj.jdbc.result.ResultSetImpl的实例特别多说明结果集没被及时释放。记住一个关键点jmap -histo:live会触发一次 Full GC在低峰期使用不要在导出高峰期用否则反而可能诱发长时间停顿。我从那以后每次写导出模块都会强制自己走一遍这套检查顺序连接 URL 里有没有加useCursorFetch和fetchSize、SELECT是不是全列、写入是不是逐行且不 flush、最终有没有落盘而不是直接写输出流。这套流程救了我很多次也让很多同事的“玄学故障”变成了三分钟定位的确定性事件。希望帮到你。本文还有配套的精品资源点击获取