简介:千万级数据量的CSV导出是Java后端开发中的典型难题,一次性加载极易引发内存溢出。这套方案面向中高级Java开发者,提供了一种经实践验证的大数据量CSV导出思路,重点解决内存溢出与导出效率问题。实现上以分批处理和多线程并行为核心:通过ExecutorService管理线程池,将数据切分成多个批次并发写入;使用BufferedWriter逐行输出,避免数据全量驻留内存。代码中还包含CSVUtils、ZipUtil等工具类,以及CsvExportBatch、ExecutorThread、ThreadPools等具体类,覆盖分块调度、并发导出、压缩下载、异常捕获与重试机制,必要时还可通过内存映射文件进一步降低内存占用。资源共10个文件,包含9个Java源码文件和1个说明文档,压缩包仅10KB,代码精简,便于直接参考移植到实际项目中。已有15562人学习下载,适合希望突破大数据导出瓶颈、掌握多线程批量处理技巧的开发者学习借鉴。
1. 千万级 CSV 导出: 把「一次查全量」拆成「边查边写」, 内存占用从 GB 级降到几十 MB
运营同学在后台点「导出全部订单」,转圈五分钟之后直接抛 OutOfMemoryError,这是报表系统最常见的翻车现场。原因不在数据量大,而在实现方式:全表塞进 List、再一次性拼串写文件,千万行轻松吃掉几个 GB 堆内存,老年代一满就是连续的 Full GC。
这个项目给的是完全不同的路线——流式导出:数据库游标逐行吐出,CSV 逐行写盘,JVM 从头到尾只持有几千行的缓冲,千万级导出的堆内存稳定在几十 MB,和总数据量彻底解耦。这也是标题敢写「千万级别,不会内存溢出」的底气。
适合正在做报表导出、数据迁移,或被导出 OOM 折磨过的 Java 后端。架构设计、核心实现、跑通步骤、踩坑记录下面一次讲完,不调大堆内存也能跑起来,源码模板可以直接照着改。
2. 架构设计: 三个「不攒」, 让内存占用与数据总量彻底解耦
2.1 全量加载为什么在千万级必炸
先列最常见的三种写法,它们的问题不在写法本身,而在量级。第一种,select * from t查出全部,用List<OrderDO>装起来。假设一行 1KB,一千万行就是 10GB 原始数据;Java 对象还要加对象头、字段引用、ArrayList 扩容开销,堆里实际占用往往翻倍到 20GB 上下。堆给到 8GB 也扛不住,而且 Full GC 一启动,导出过程大半时间都在做无用功,接口响应彻底不可用。
第二种,分页查询limit offset, size循环取数。offset 越深,数据库扫描的无关行越多,offset 到五百万时单页查询已经慢到秒级;导出千万行等于让数据库做几十万次深翻页,速度慢且伤库,还会拖垮线上其他查询。有人会说那用并发分页,每个线程导一段——排序稳定性、去重、数据库压力都成了新问题,连接数还容易把库打满。
第三种,用StringBuilder把所有 CSV 行拼成一个巨型字符串再一次性写文件。字符串本身要完整存在堆里,和第一种一样炸;而且 String 不可变,拼接过程产生大量中间对象,GC 压力比单纯装 List 还高。
三种写法我都写过,血泪经验是:只要「先攒后写」,内存峰值就一定随数据量线性增长。千万级导出的核心,是把「攒」这个字从整条链路里彻底拿掉,让内存只跟批大小挂钩,跟总量无关。
2.2 三个「不攒」的流式设计
这个项目的核心设计可以概括成三个「不攒」。
不攒结果集。数据库侧开游标流式读取,以 MySQL 为例,连接串加useCursorFetch=true,PreparedStatement设fetchSize,ResultSet就不再一次性把所有行拉进客户端,而是按块从服务器取,取一块消费一块。这里有个容易被忽略的前提:连接必须手动关掉 autocommit,因为游标读取需要在一个事务里持续保持查询上下文,autocommit 状态下驱动会认为语句已结束,直接回退到全量加载。
注意:游标模式下连接会被长期占用,导出任务务必使用独立连接池,不要和线上业务共用。
不攒字符串。每一行数据转成 CSV 文本后立刻写进BufferedWriter,写几行就积累在缓冲区,达到阈值就flush到磁盘。堆里永远不会同时存在两万行以上的 CSV 文本,行对象在写出后立刻变成垃圾,YGC 就能回收,不会晋升到老年代。
不攒 IO。BufferedWriter默认 8KB 缓冲区,对千万级导出来说太小,我一般会调大到 1MB,减少write系统调用次数;每 N 行或者每写满一定字节才flush一次。flush 太勤,导出时间可能翻两三倍;完全不 flush,程序异常退出时数据全丢在缓冲区。折中是 5000 行或 1MB 触发一次,哪个先到先执行。
这三个「不攒」配合起来,内存占用只取决于「批大小」这个常数,和总数据量无关。一千万行和一亿行的内存曲线几乎一样,差别只在耗时。这也是流式导出能扛住千万级的根本原因。
2.3 技术选型: 能不引库就不引库
数据访问层用原生 JDBC 还是 MyBatis 都可以,关键就两点:ResultSet必须FORWARD_ONLY,且必须设置fetchSize。MyBatis 的Cursor<T>底层也是干这件事,封装更好用,但多一层框架就要多确认一层配置——比如resultSetType是否真正透传给了 JDBC 驱动。这个项目我见过两版,一版纯 JDBC 手写,一版 MyBatis + 自定义ResultHandler,下面核心代码会把 JDBC 版讲透,MyBatis 版给个对照。
CSV 生成我没有引库。导出场景的 CSV 格式极其简单,手写转义逻辑 30 行就够;引库反而要处理库内部的缓冲策略和版本兼容,对千万级场景来说,自己控制写出节奏更直接。至于 xlsx,那是另一条路,用 POI 的 SXSSFWorkbook 也能流式写。CSV 相比 xlsx 还有个天然优势:不受 Excel 单 sheet 104 万行上限约束,千万级数据只能选 CSV 这类纯文本格式。
依赖清单非常简单:
| 用途 | 依赖 | 说明 |
|---|---|---|
| JDBC 驱动 | mysql-connector-j 或 postgresql | 按数据库选,版本跟随数据库 |
| 可选 ORM | mybatis / mybatis-spring | 用 Cursor 模式时需要 |
| 日志 | slf4j + logback | 进度打印用 |
| 单元测试 | junit | 转义与行数校验 |
整条导出链路没有一个大数据组件,一个普通 Spring Boot 工程就能跑起来。这也是这项目最适合二次开发的原因——没有黑匣子,每一环都能打开看,出问题能定位到具体层。
3. 核心模块拆解: 一条数据从数据库到 CSV 文件的完整链路
3.1 数据库侧: 游标读取的两个关键开关
先看 JDBC 直连版。导出方法里,连接的获取、语句的创建、结果集的遍历,三件事要分开看。
public void exportOrders(OutputStream out, LocalDateTime start, LocalDateTime end) throws Exception { String sql = "SELECT id, order_no, user_name, amount, status, create_time FROM t_order " + "WHERE create_time BETWEEN ? AND ?"; Connection conn = dataSource.getConnection(); try { // 关键 1: 游标模式依赖非 autocommit 连接 conn.setAutoCommit(false); try (PreparedStatement ps = conn.prepareStatement( sql, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) { // 关键 2: 每次从服务器取 5000 行 ps.setFetchSize(5000); ps.setFetchDirection(ResultSet.FETCH_FORWARD); ps.setTimestamp(1, Timestamp.valueOf(start)); ps.setTimestamp(2, Timestamp.valueOf(end)); try (ResultSet rs = ps.executeQuery()) { CsvWriter csv = new CsvWriter(out, 1024 * 1024); csv.writeHeader("id,order_no,user_name,amount,status,create_time"); long count = 0; while (rs.next()) { csv.writeRow( rs.getLong("id"), rs.getString("order_no"), rs.getString("user_name"), rs.getBigDecimal("amount"), rs.getInt("status"), rs.getTimestamp("create_time")); count++; } csv.flush(); log.info("export finished, total={}", count); } } conn.rollback(); } finally { conn.close(); } }这里有几个参数必须讲清楚。TYPE_FORWARD_ONLY告诉驱动结果集只能向前滚动,这是 MySQL 启用流式读取的前提;CONCUR_READ_ONLY声明只读,避免驱动为可更新结果集做额外缓冲。setFetchSize(5000)是每次网络往返从服务器拉取的行数,这个值不是越大越好——它直接决定客户端单次缓冲量,我一般取 1000 到 5000 之间,行宽大就调小。
提示:MySQL 连接串必须带
useCursorFetch=true,否则setFetchSize会被驱动静默忽略,结果集依然全量载入。
MyBatis 版本对照:Mapper 方法返回Cursor<T>,配合@Options(fetchSize = 5000, resultSetType = ResultSetType.FORWARD_ONLY),调用侧用 try-with-resources 遍历即可,事务由SqlSession管理。底层原理和上面 JDBC 版完全一致,只是封装层帮你处理了连接和游标的生命周期。
3.2 CSV 写出: 转义是正确性的底线
CSV 不是「把字段用逗号连起来」那么简单。字段里一旦出现逗号、双引号、换行,直接拼就会让列错位、文件行数变多。所以每一列都得过一遍转义。
public final class CsvFieldEscaper { public static String escape(String field) { if (field == null) { return ""; } // 不需要引号包裹的快速路径,省掉 replace 的开销 if (field.indexOf(',') < 0 && field.indexOf('"') < 0 && field.indexOf('\n') < 0 && field.indexOf('\r') < 0) { return field; } // 按 RFC 4180: 用双引号包裹,内部双引号翻倍 return '"' + field.replace("\"", "\"\"") + '"'; } }转义规则就是 RFC 4180 那套:字段里含逗号、双引号、换行或回车时,整个字段用双引号包起来,字段内部的双引号替换成两个连续双引号。这段代码看起来简单,但它决定导出的文件能不能被 Excel、WPS、Python pandas 正确解析。我见过不止一次线上导出「列错位」事故,最后定位都是漏了转义。
写出端的缓冲策略同样重要。BufferedWriter默认 8KB,我一般直接指定 1MB:
public class CsvWriter implements Closeable { private final BufferedWriter writer; public CsvWriter(OutputStream out, int bufferSize) throws IOException { this.writer = new BufferedWriter( new OutputStreamWriter(out, StandardCharsets.UTF_8), bufferSize); // 写 UTF-8 BOM,否则 Excel 打开中文会乱码 writer.write('\uFEFF'); } public void writeHeader(String headerLine) throws IOException { writer.write(headerLine); writer.write('\n'); } public void writeRow(Object... fields) throws IOException { for (int i = 0; i < fields.length; i++) { if (i > 0) { writer.write(','); } writer.write(CsvFieldEscaper.escape( fields[i] == null ? null : fields[i].toString())); } writer.write('\n'); } public void flush() throws IOException { writer.flush(); } @Override public void close() throws IOException { writer.close(); } }注意writeRow的写法和性能的关系。逐字段toString再转义再写入,避免在方法里拼一个巨大的行字符串,行对象的生命周期被压到最短。\uFEFF这个 BOM 写在文件最前面,是解决 Excel 乱码的土办法,但非常有效——不加它,Excel 默认按 ANSI 解析 UTF-8 文件,中文全变问号或乱码。
3.3 导出服务模板: 把查询和写出解耦
实际项目里导出不止订单一张表,用户、日志、流水都可能要导出。核心代码里最好有一层模板,把「遍历结果集 + 写文件」这个固定流程抽出来,业务方只需要提供查询和行转换。
public abstract class StreamingCsvExporter<T> { private static final Logger log = LoggerFactory.getLogger(StreamingCsvExporter.class); private static final int FLUSH_ROWS = 5000; public final void export(OutputStream out, Object queryParam) throws IOException { try (CsvWriter csv = new CsvWriter(out, 1024 * 1024)) { csv.writeHeader(buildHeader()); long count = 0; long start = System.currentTimeMillis(); // 子类必须返回游标或迭代器,禁止返回 List for (T row : queryRows(queryParam)) { csv.writeRow(buildFields(row)); count++; if (count % FLUSH_ROWS == 0) { csv.flush(); log.info("export progress: {} rows, {} ms", count, System.currentTimeMillis() - start); } } csv.flush(); log.info("export done: {} rows, {} ms", count, System.currentTimeMillis() - start); } } protected abstract String buildHeader(); protected abstract Iterable<T> queryRows(Object queryParam); protected abstract Object[] buildFields(T row); }这个模板有几个设计约束值得说。queryRows的返回类型是Iterable<T>而不是List<T>,从类型上就堵死了「查全量」的写法——子类返回Cursor<T>或自定义ResultSet迭代器都行,就是不允许返回已经装进内存的集合。export方法用final修饰,固定整个流程的顺序:写表头、逐行写、定期刷盘、打印进度。
子类实现时只需要关心三件事:表头是什么、怎么查、一行怎么转成字段数组。新增一张表的导出,半小时内能完成,而且不会破坏流式约束。
3.4 批大小自适应: 别让 fetchSize 成为新的内存炸弹
fetchSize和flush行数虽然是常量,但不同表的行宽差异很大。用户表一行可能 2KB,日志表一行 200 字节,同样的 5000 批大小,内存占用能差 10 倍。
我一般会在第一次查询后采样估算行宽,再根据剩余堆内存动态决定批大小:
public int decideBatchSize(int sampleRowBytes) { long maxMemory = Runtime.getRuntime().maxMemory(); // 最多用堆的 5% 做结果集缓冲,留足余量给其他线程 long budget = maxMemory / 20; int batch = (int) Math.max(1000, Math.min(10000, budget / Math.max(sampleRowBytes, 256))); return batch; }这个逻辑不复杂,但解决了「一张宽表把内存打爆」的隐患。取样行宽可以在第一次rs.next()时用getMetaData()拿每列长度估算,也可以由子类直接传入常量。批大小上限我卡在 10000,超过这个值,单次网络往返的数据量太大,反而容易触发驱动内部缓冲的额外拷贝。把这些参数做成可配置的,项目落到不同环境时不用改代码。
4. 跑通与验证: 本地把一千万行导出来要准备什么
4.1 最小环境与启动参数
这个项目不需要大数据组件,一个本地数据库加一个 Java 工程就够。我常用的验证环境是 JDK 8+、MySQL 8、Maven 3,工程就是一个普通的 Spring Boot 或纯 Java 项目。
连接串是关键,MySQL 必须显式加上游标参数:
spring.datasource.url=jdbc:mysql://localhost:3306/test?useCursorFetch=true&socketTimeout=600000&useSSL=false&serverTimezone=Asia/Shanghai spring.datasource.username=root spring.datasource.password=123456JVM 参数我故意给得很小,用-Xmx256m启动导出任务。如果 256MB 堆能把一千万行导完,说明流式生效了;如果 OOM,说明某个环节还在攒数据,正好暴露问题。
| 参数 | 值 | 作用 |
|---|---|---|
| -Xmx256m | 堆上限 | 验证流式导出不依赖大堆 |
| useCursorFetch=true | URL 参数 | MySQL 开启游标读取 |
| socketTimeout=600000 | URL 参数 | 防止导出中途断连 |
| fetchSize=5000 | 语句参数 | 每次网络往返取 5000 行 |
造测试数据时,不要用应用层for循环 insert——一千万行单条插入能跑几个小时。用 MySQL 存储过程循环批量插入,或者用LOAD DATA导入预生成的 CSV,几分钟就能造完:
CREATE PROCEDURE gen_orders(IN p_count INT) BEGIN SET @i = 0; WHILE @i < p_count DO INSERT INTO t_order (order_no, user_name, amount, status, create_time) VALUES (CONCAT('NO', DATE_FORMAT(NOW(),'%Y%m%d'), LPAD(@i, 10, '0')), CONCAT('user_', @i), RAND() * 1000, @i % 4, DATE_ADD(NOW(), INTERVAL -@i SECOND)); SET @i = @i + 1; END WHILE; END注意存储过程在 mysql 客户端里执行要先用DELIMITER $$改分隔符,否则;会提前截断过程体。造数时可以故意在备注字段里掺逗号、引号、换行,用来验证后面的转义逻辑——真实业务数据里这些字符一定会出现。
4.2 正确性验证三板斧
导出完成后,先别急着交差,我习惯做三道验证。
第一道,数行数。Linux 下执行wc -l 导出文件.csv,结果应该等于「数据行数 + 1 表头行」。如果少了,多半是某行数据里带了换行符没转义,wc -l会把一条记录拆成两行——这时候要去看数据里是不是真有换行。
wc -l export.csv head -5 export.csv tail -5 export.csv sed -n '5000000p' export.csv第二道,抽查首尾中三段。head -5、tail -5、sed -n '5000000p'各看几行,重点检查字符串字段里的逗号、引号、中文,以及金额字段的精度。数值列导出后用 Python 读一遍,比对行数和金额合计,能快速发现列错位:
import pandas as pd df = pd.read_csv("export.csv", dtype=str) print(df.shape) print(df["amount"].astype(float).sum())第三道,Excel 打开看渲染。团队里随便找台装了 Excel 或 WPS 的机器,把文件拖进去看列对不对、中文乱不乱。这一步是给业务方看的,但也是检验 BOM 和转义是否生效的最直观方式。
4.3 性能基线与内存观察
我这边一版 20 列、一千万行的订单表,本地 SSD 上导出耗时约 40 秒到一分钟,-Xmx256m下堆占用峰值稳定在 100MB 以内。导出时间主要花在数据库扫描和 CSV 写盘上,JVM GC 几乎不构成瓶颈。
观察内存用jstat -gc <pid> 1000,隔一秒打印一次 GC 情况。流式导出时,FULL GC应该一次都不出现,YGC频率也不高。如果看到FULL GC频繁,回到 3.4 的批大小逻辑去查是不是 fetchSize 调太大,或者哪里又把结果集装进 List 了。
跑通这一步的意义在于建立基线:你知道什么参数下、什么量级的数据、大概多少内存、多少时间。后面做二次开发时,任何改动导致内存曲线异常,都能立刻对比出来。
5. 避坑手册: 千万级导出一路踩过的五个坑
5.1 读取与连接层的三个坑
坑一:设置了 fetchSize,内存还是爆。
现象:代码里明明setFetchSize(5000),导出千万行时堆内存依然飙到几 GB,最后 OOM。
原因:MySQL 的流式读取有三个前提缺一不可——连接串带useCursorFetch=true、连接不是 autocommit 状态、结果集类型是FORWARD_ONLY。任何一个不满足,驱动都会静默回退到全量加载。另外,连接池里如果复用了旧连接,连接串参数可能还是老配置。
解决:连接串加上参数后重启应用,setAutoCommit(false)写在获取连接后第一行,prepareStatement创建时显式传TYPE_FORWARD_ONLY, CONCUR_READ_ONLY。连接池配置里打开testOnBorrow,避免拿到残留旧参数的物理连接。
坑二:导出到一半,连接断开或报 socket 超时。
现象:导出跑了几百万行,突然抛CommunicationsException或SocketTimeoutException,任务中断。
原因:游标模式下连接会被占用很久,一千万行的导出可能持续几十秒甚至几分钟,超过驱动默认的socketTimeout。另外游标模式持有事务,数据库侧的锁等待或空闲超时也可能触发断开。
解决:连接串显式调大socketTimeout到 600000(10 分钟),数据库侧wait_timeout和interactive_timeout同步调大。如果单次导出实在太大,考虑按时间窗口分片跑,每片用独立任务,失败只重跑该片。
坑三:游标模式把连接池占满了。
现象:导出任务跑着,业务接口查询全部变慢,连接池监控里活跃连接数打满。
原因:游标读取期间,一个连接从查询开始到ResultSet关闭一直处于占用状态,如果导出和线上业务共用一个连接池,连接数会被导出任务吃光。
解决:给导出任务单独配一个连接池,maximumPoolSize设成 2 到 5 就够,同时限制导出并发数为一。另一个办法是查询前先rs.next()确认有数据再开始写文件,避免空结果集长时间占用连接。
5.2 文件生成层的两个坑
坑四:Excel 打开中文乱码,Notepad 打开正常。
现象:用记事本看文件中文正常,用 Excel 打开全是乱码或问号。
原因:文件是 UTF-8 编码,Excel 默认用系统 ANSI(GBK)解析,不认 UTF-8 的字节序标记。
解决:文件开头写入\uFEFF(UTF-8 BOM),Excel 会据此识别编码。注意 BOM 只能写在最前面,CsvWriter构造时写一次,后面不能重复写。排查时用xxd export.csv | head -1看文件头是不是ef bb bf。
坑五:数据里有逗号和换行,文件打开后列错位、行数变多。
现象:某一行包含备注: "这是, 一段话"或地址字段里带换行,导出后 Excel 里这一行被拆成多列、多行。
原因:CSV 规范要求字段内含逗号、引号、换行时必须用双引号包裹,并且内部引号要翻倍。漏掉这一步,解析器就会把字段里的逗号当作分隔符,把换行当作记录结束。
解决:所有字段统一过 3.2 节的escape()方法,不要让业务方手拼 CSV 行。最好在单元测试里构造一批含逗号、双引号、\n、\r、中文的脏数据,跑完导出后用pandas.read_csv断言行列数。
这五个坑我都踩过或帮人排查过,每一个都是「本地小数据没问题,上千万级才开始暴露」。把它们写进自动化验证清单,比临时查文档靠谱得多。
6. 二次开发: 从单表导出到断点续传的具体改法
拿到这套模板,最常见的改造有两类:导出格式升级,和导出稳定性升级。
先说稳定性升级里最实用的断点续传。流式导出的弱点是中断后从头重跑,千万级数据重跑成本很高。我一般把进度外置:每写一万行,把最后一条记录的 id 写进进度表。下次启动先查进度表,查询条件从create_time BETWEEN ? AND ?改成id > ?,游标读取不变,只是起点变了。这个改造半小时能完成,能避免导出任务半夜失败后全量重跑。
格式升级也不难。把CsvWriter换成 POI 的SXSSFWorkbook即可,它本身就是流式写 xlsx 的,思路一致:表头和行转换逻辑全部复用,只改写出层,每写 1000 行调一次flushRows()控制内存。多 sheet 报表加一个buildSheet()方法,按业务维度决定换 sheet。注意 xlsx 有单 sheet 104 万行上限,超了要么分 sheet,要么回头用 CSV。
改造完的验证固定两条:一是回归对比,改造前后跑同一批数据,wc -l和字段级 diff 确认输出一致;二是内存曲线对比,jstat看 GC 频率是否和基线一致。凡是改动引起堆内存异常上涨的,优先怀疑有人又在哪里把数据攒进了内存。
我后来接导出需求,都会先问三句:数据量级多少、会不会涨到千万、能否接受分片。改完导出代码,强制把「256MB 堆跑完一千万行」这个基线验证走一遍,过了才敢上线。这个项目的价值不在代码量,而在把大导出从玄学变成可验证的工程实践。希望帮到你。
本文还有配套的精品资源,点击获取