
简介一套面向Java开发者的千万级CSV大数据量导出解决方案直击传统一次性加载导致内存溢出的痛点提供基于分批处理与多线程并行导出的完整实现思路。资源共有10个文件含9个Java源码文件和1个说明文档压缩包整体仅10KB代码轻量但结构清晰。源码覆盖CSV分批导出、线程池管理、ExecutorService并发调度、CSV工具封装以及压缩下载辅助类等模块可直接整合进实际项目。目前已有15000余人学习下载表明该方案在社区中具有较强参考价值。通过阅读这些代码开发者可以掌握利用BufferedWriter逐行写入、结合数据分块降低内存压力的方法并了解如何设计异常捕获与重试机制提升大数据导出场景下的稳定性和导出效率。适合具备Java基础、正在处理大规模数据导出的后端工程师参考使用。1. 千万级 CSV 导出不再内存溢出起因是一次全量装载引发的 JVM 假死当时运营提了一个不算过分的要求把 800 万行订单明细导成 CSV。按老思路写了个selectAll()加字符串拼接结果 JVM 4G 堆直接被撑爆服务假死半小时最后只能重启。这正是这个 Java CSV 大数据量导出项目要消灭的场景千万级别导出不在内存里一次装完。方案本身不复杂——用 JDBC 流式游标一条条拉数据攒够一批就刷盘文件照常完整生成堆内存占用稳定在 100MB 上下就能跑完千万行。适合报表系统的后台导出、管理后台的大批量下载以及每一个被大导出搞崩过的 Java 服务。2. 整体架构设计游标查询、分批落盘与任务线程模型先别急着看代码把方案和线程模型理清楚后面的代码才看得懂。这个导出引擎的设计核心只有一句话让内存里同时存在的数据量变成常量而不是随导出总量线性增长。围绕这个目标选型、模块划分和任务调度三件事缺一不可。2.1 三种导出方案对比为什么选游标而不是分页方案内存表现主要问题一次性查询装载千万行 × 200 字节约 2G直接堆爆只能支撑几十万行的小导出分页查询LIMIT offset, size单页内存占用小offset 越大扫描越慢800 万行后段翻页要几十秒流式游标Cursor fetchSize一次只保留一个批次常量内存需要手动管理连接生命周期第一种翻车现场大家都懂不再多解释。第二种分页查询看着内存稳了但 MySQL 的LIMIT offset, size是把前面 offset 条都扫出来再扔掉行数越大越慢我实测 800 万行表翻到后段时一条分页查询要跑 30 多秒整个导出变成蜗牛爬。第三种是这版导出引擎用的方案JDBC 底层拿到 ResultSet 后只按 fetchSize 从网络缓冲里取一个批次cursor.next()消费完一批再去数据库拉下一批堆里始终只有当前批次的对象。这就是「千万级别不会内存溢出」的根本原因。2.2 工程结构一个导出引擎拆成哪几个模块export-demo/ ├── pom.xml ├── src/main/java/com/export/demo/ │ ├── ExportApplication.java │ ├── controller/ExportController.java │ ├── service/ExportService.java │ ├── mapper/OrderMapper.java │ └── util/CsvWriter.java ├── src/main/resources/ │ ├── application.yml │ └── mapper/OrderMapper.xml └── sql/ └── init.sql代码量不大但职责边界很清晰。controller 只负责收请求、返回 taskId不碰任何业务逻辑service 管任务调度和导出循环是核心编排者mapper 是流式查询的入口只做取数CsvWriter 负责转义和刷盘不知道数据从哪来init.sql 是建表和造数脚本方便本地复现。二次开发时哪个阶段出问题就查哪个模块不用从头翻。2.3 导出主流程与线程模型从创建任务到文件落盘整个导出流程按下面几步走客户端 POST 创建任务controller 生成 taskId 并交给线程池。导出任务从「等待队列」进入「运行中」同时申请一个运行信号量。打开 SqlSession获取 Cursor按 fetchSize 从 MySQL 逐批拉数据。CsvWriter 逐行做转义并攒缓冲攒满 BATCH_SIZE 行刷一次盘。每导出一万行更新一次进度方便前端轮询。游标耗尽文件先写为 .tmp再原子改名成最终 .csv任务标记 SUCCESS。任一步抛异常任务标记 FAILED附上失败原因。线程模型上我踩过一次教训导出这类耗时任务千万别在请求线程里同步跑。HTTP 网关超时一般设 30~60 秒千万行导出至少要几分钟同步跑必断连。我一般用一个固定两个坑位的线程池加 Semaphore 限流最多同时跑两个导出任务既不会拖死请求线程也不会让并发导出打满数据库连接。任务状态先放在进程内 ConcurrentHashMap生产环境建议换 Redis 或任务表这个在避坑章节细说。3. 核心代码拆解从数据库游标到 CSV 文件刷盘的完整链路这一章把链路完整走一遍顺序是从文件写入端讲到数据库取数端最后看组装它们的导出循环。按这个顺序读你会先明白「写给谁」再理解「从哪取」最后把两端接起来。3.1 CsvWriter转义、缓冲区与刷盘时机public class CsvWriter implements Closeable { private static final int BATCH_SIZE 1000; private static final byte[] BOM {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}; private final Writer writer; private final ListString buffer new ArrayList(BATCH_SIZE); private long count; public CsvWriter(File file) throws IOException { OutputStream out new FileOutputStream(file); out.write(BOM); // Excel 打开不乱码的关键缺了它中文全乱 this.writer new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8)); } public void writeRow(ListString row) throws IOException { buffer.add(row.stream().map(CsvWriter::escape).collect(Collectors.joining(,))); if (buffer.size() BATCH_SIZE) flushBuffer(); count; } private void flushBuffer() throws IOException { if (buffer.isEmpty()) return; writer.write(String.join(\r\n, buffer)); writer.write(\r\n); buffer.clear(); } private static String escape(String field) { if (field null) return ; if (field.indexOf(,) 0 || field.indexOf() 0 || field.indexOf(\n) 0 || field.indexOf(\r) 0) { return \ field.replace(\, \\) \; } return field; } Override public void close() throws IOException { flushBuffer(); writer.close(); } }三个细节值得多说。BOM 三字节是 Excel 识别 UTF-8 的标记不加就会撞上微软系玄学乱码escape 方法对逗号、双引号、换行统一包引号并把内部引号翻倍这是 RFC 4180 标准处理不要偷懒省掉BATCH_SIZE 选 1000 行是个平衡点——太小刷盘频繁 IO 跟不上太大单次 flush 会拼出一个大字符串给 GC 增压1000 行乘平均 100 字节就是 100KB 左右很稳。行分隔符用\r\nExcel 兼容性最好。3.2 数据库取数层Cursor 流式查询的正确姿势public interface OrderMapper { Options(fetchSize 500) Select(select id, order_no, user_name, amount, create_time from t_order) CursorOrder streamAll(); }Mapper 返回CursorOrder是流式查询的入口MyBatis 看到返回类型是 Cursor 会走 selectCursor 逻辑底层保持 ResultSet 打开状态每次next()只取当前行。用过 XML 的等价resultType 直接写实体类就行。但千万级别导出不要走selectList那会把全量结果塞进一个 List 再返回和selectAll没有任何区别照样 OOM。配套的 JDBC 连接串配置才是重头戏spring: datasource: url: jdbc:mysql://127.0.0.1:3306/export_demo?useCursorFetchtruedefaultFetchSize500socketTimeout600000useSSLfalseserverTimezoneAsia/ShanghaiuseCursorFetchtrue是 MySQL Connector/J 开启流式结果集的开关少了它Mapper 上的 fetchSize 就是个装饰品驱动一次把全量结果拉到客户端内存。defaultFetchSize500和 Mapper 的 fetchSize 配一个就行结果集自己的 fetchSize 优先级更高。顺带提醒这类导出场景不要用分页插件分页插件会改写 SQL很容易把游标行为打断。3.3 导出循环ExportService 把两端接起来public class ExportService { public void runExport(String taskId, File tmpFile, File targetFile) { try (SqlSession session sqlSessionFactory.openSession(ExecutorType.SIMPLE, false)) { OrderMapper mapper session.getMapper(OrderMapper.class); CsvWriter writer new CsvWriter(tmpFile); try (CursorOrder cursor mapper.streamAll()) { for (Order row : cursor) { writer.writeRow(Arrays.asList( String.valueOf(row.getId()), row.getOrderNo(), row.getUserName(), row.getAmount().toPlainString(), row.getCreateTime().toString() )); if (writer.rowCount() % 10_000 0) { progress.put(taskId, writer.rowCount()); LOGGER.info(taskId{} 已导出 {} 行, 堆内存约 {}MB, taskId, writer.rowCount(), usedMb()); } } } finally { writer.close(); // 把不足 1000 行的残留缓冲也刷掉 } Files.move(tmpFile, targetFile, StandardCopyOption.REPLACE_EXISTING); progress.success(taskId, writer.rowCount()); } catch (Exception e) { progress.fail(taskId, e); } } }这段代码有三个关键选择。第一手动打开 SqlSession 而不是用 Spring 管理的 session是为了让游标生命周期完全可控避免Transactional在方法出口把连接关掉时游标还没读完。第二for (Order row : cursor)迭代过程中驱动按 fetchSize 每次拉 500 行进堆消费完再拉下一批所以老年代不会涨。第三每 1 万行打一条日志千万别每行打一条千万行能把磁盘日志刷爆。3.4 异步调度controller 只收请求导出交给线程池RestController RequestMapping(/api/export) public class ExportController { private final ThreadPoolExecutor executor new ThreadPoolExecutor( 1, 2, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(10)); private final Semaphore semaphore new Semaphore(2); PostMapping(/create) public MapString, Object create(RequestBody ExportRequest req) { String taskId task- ID_GEN.incrementAndGet(); semaphore.acquireUninterruptibly(); executor.submit(() - { try { exportService.runExport(taskId, tmpFile(req), targetFile(req)); } finally { semaphore.release(); } }); return Map.of(code, 0, taskId, taskId, status, QUEUED); } GetMapping(/status/{taskId}) public MapString, Object status(PathVariable String taskId) { return progress.status(taskId); } }线程池核心 1、最大 2、队列 10再加上 Semaphore 双信号量同时最多跑两个导出任务。响应体里 status 从 QUEUED 开始轮询接口能拿到 RUNNING / SUCCESS / FAILED 和已导行数前端就能画进度条。文件先写 .tmp 再改名就算导出到一半挂了目录里也不会留下一个看起来完整实际缺行的 csv 文件。这套异步任务模式在导出场景是标配比同步等待让请求线程干瞪眼靠谱得多。4. 跑通与验证造千万行数据用 256M 堆完成一次导出本地复现这套项目分三步造数据、小堆启动、发起任务对账。全程不需要甩大数据集群一台开发机加 MySQL 就够了。4.1 第一步用一条 SQL 把表翻到千万行CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_name VARCHAR(64) DEFAULT NULL, amount DECIMAL(10,2) DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; -- 先造 1000 行基础数据再反复执行下面这条倍增语句 INSERT INTO t_order (order_no, user_name, amount, create_time) SELECT CONCAT(NO, LPAD(id 1000000, 10, 0)), CONCAT(u_, id), RAND() * 1000, NOW() FROM t_order CROSS JOIN t_order;基础数据 1000 行时CROSS JOIN 一次变 100 万再一次变 100 亿——注意别把库跑爆到千万级就停。数据内容是随机的不影响功能验证。插入完成后用select count(*)确认行数到了 1000 万左右后面对账要用这个数。4.2 第二步最小堆启动小内存跑大导出的参数表参数推荐值说明JVM 堆-Xmx256m故意用小堆验证流式是否真生效CsvWriter.BATCH_SIZE1000攒 1000 行刷一次盘Mapper fetchSize500JDBC 每批从库拉 500 行JDBC 连接串见 3.2 节 urluseCursorFetch、socketTimeout 必配java -Xmx256m -jar export-demo.jar --server.port8080如果你能用 256M 堆跑完 1000 万行导出说明内存占用已经是常量级这就是「不内存溢出」的最直接证明。如果 256M 直接 OOM优先回去查连接串里的useCursorFetchtrue有没有配上八成是它丢了。4.3 第三步发起任务、查进度、与数据库对账curl -X POST http://localhost:8080/api/export/create \ -H Content-Type: application/json \ -d {fileName:orders_20240630.csv} # 响应 {code:0,taskId:task-1,status:QUEUED}curl http://localhost:8080/api/export/status/task-1 {taskId:task-1,status:RUNNING,rows:7300000,elapsedMs:16800,usedMb:118}导出完成后做两个对账动作。第一个用wc -l /data/export/orders_20240630.csv数行数和数据库count(*)对比一致说明没丢行第二个用 Excel 打开文件中文不乱码、金额列是数字、换行字段没有把行拆裂说明转义和 BOM 都生效了。想看内存曲线就用jstat -gcutil pid 1s 5重点盯老年代O 列数值平稳不涨就说明没有对象堆积流式查询确实在工作。5. 避坑指南五个能把导出任务干翻的真实翻车现场5.1 现象导 500 万行就 OOM4G 堆也扛不住原因代码表面用了 Cursor实际查询还是selectList全量装载或者连接串少了useCursorFetchtrue驱动把结果一次性拉到客户端。当时我排了半天最后发现 Mapper 返回类型写的是ListOrderMyBatis 根本不走游标。解决Mapper 返回类型改CursorOrder确认 url 带useCursorFetchtrue。改完再用-Xmx256m启动自检一次能跑完就说明真流式了。5.2 现象fetchSize 配上去了抓包发现驱动一次性拉全量原因MySQL 的游标协议需要驱动主动开启useCursorFetchtrue这个参数放在 JDBC url 里写在application.yml的其他位置不生效。解决完整替换连接串defaultFetchSize500和 Mapper 的Options(fetchSize500)至少保留一个。这个坑属于那种查半天文档、最后发现只是差一个参数的血泪经验而且线上已经导到一半才发现只能重启任务再来。5.3 现象Excel 打开 CSV 中文乱码用记事本看却是好的原因文件以 UTF-8 无 BOM 写入Excel 默认按本机 ANSI中文环境就是 GBK解码于是中文全乱。解决写文件头先写EF BB BF三个字节代码见 3.1 节的 CsvWriter 构造函数。注意这个 BOM 不影响wc -l统计行数但会让某些 Linux 命令看到首行开头有不可见字符排查时心里有数。5.4 现象有的行列数变多金额串到了用户名那一列原因数据里带了逗号、双引号或换行符拼接时不处理CSV 解析器看到逗号就切列、看到换行就切行整段数据就错位了。比如用户名张三, 李四不转义写出来就是三列转义后变成张三, 李四才正确。解决所有字段统一走 escape 方法null 输出空字符串而不是null字样金额用toPlainString()避免科学计数法。凡是客户反馈「导出文件打开是乱的」九成是这里漏了。5.5 现象任务跑到中途报 ResultSet is closed或者连接被断开原因游标依赖背后的数据库连接全程不关。Spring 事务方法一退出连接就被还给连接池游标还没读完就报错另一种是导出耗时太长MySQL 网层把空闲连接断掉。解决导出方法不要加Transactional手动持有 SqlSession 直到迭代完成再关连接串加socketTimeout600000。数据量再大就按 ID 范围分段导出单段控制在几分钟内跑完连接根本来不及超时。6. 二次开发多线程分片、断点续跑与 GZIP 压缩改造6.1 多线程分片2000 万行也能压进几分钟单线程游标方案解决了内存问题但 CPU 和 IO 还是单点在跑。要提速就按主键范围切 N 片每片独立游标、独立写临时文件全部完成后按片序号合并long minId orderMapper.selectMinId(); long maxId orderMapper.selectMaxId(); int threads 4; long step (maxId - minId) / threads; for (int i 0; i threads; i) { long lo minId i * step; long hi (i threads - 1) ? maxId : lo step; executor.submit(new RangeExportTask(lo, hi, tmpFile(i))); }分片查询每片直接走主键范围索引绕开了 LIMIT 深翻页的问题。前提是主键分布均匀分布歪的话先SELECT id FROM t_order ORDER BY id LIMIT 1 OFFSET n采样几个切分点。6.2 断点续跑给任务留一颗后悔药进度表里加一列last_exported_id每 5 万行用一个独立短事务更新一次。任务失败重启后从 last_id 继续跑Select(select id, order_no, user_name, amount, create_time from t_order where id #{lastId} order by id) CursorOrder streamAfter(long lastId);导出这种跑十分钟的任务断点续跑能省掉重头再来的时间。配合 6.1 的分片改造正好失败的分片从记录点续跑成功的分片直接跳过。6.3 顺手压成 .csv.gz文件输出流套一层 GZIPOutputStream导出的 csv.gz 体积能砍掉 70% 左右下载耗时明显下降代价只是压缩时多一点 CPU。压缩完成后再用 4.3 节的对账方式验证一次行数确保压缩没破坏内容。做完这套改造之后我养成一个习惯只要有「导出 XX 万行」这类需求第一件事先做内存预算——行数乘单行字节数超过 100MB 直接默认走流式不给全量装载留任何机会。这套项目帮我省掉的半夜爬起来处理线上事故的次数一只手数不过来。希望帮到你。本文还有配套的精品资源点击获取