java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Java处理千万级CSV导出

Java处理千万级CSV导出并避免内存溢出问题解决

作者:菩提风

本文主要针对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 常规写法为什么必炸

先看一段最常见的代码,我相信很多人第一版就是这么写的:

// 错误示范:全量加载 + 全量拼接
List<User> 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到底用不用现成库

很多人一上来就问用什么库,OpenCSV?EasyExcel?还是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连接串上加 useCursorFetch=true&defaultFetchSize=5000 ,配合 PreparedStatement 的游标机制,数据库会分批输送结果集,服务端内存占用非常平稳。但注意这个参数只能配MySQL,Oracle有自己的一套游标方案。

2.3 编码、分隔符和特殊字符处理

CSV导出的坑有一大半是藏在编码和特殊字符里的。

第一,编码一定要用带BOM的UTF-8。Excel在打开UTF-8编码的CSV文件时,如果没有BOM头,会默认按ANSI解码,中文直接乱码成一堆问号。解决方案是在文件开头写入三个字节的BOM: writer.write('\uFEFF') ,这样Excel就能正确识别编码。

第二,行分隔符统一用 \r\n (CRLF),这是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 条
    List<User> selectUsersAfterId(@Param("lastId") Long lastId,
                                  @Param("limit") int limit);
}

对应XML:

<select id="selectUsersAfterId" resultType="com.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) {
                List<User> 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;charset=UTF-8");
    response.setHeader("Content-Disposition",
        "attachment; filename=users_" + 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连接必须开启流式读取,也就是 useCursorFetch=true&defaultFetchSize=5000 。如果不加这两个参数,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.23E+10 。解决方式是在数字前面加一个制表符 \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和字段映射就能直接上线。希望这套思路对你也有用。

到此这篇关于Java处理千万级CSV导出并避免内存溢出问题解决的文章就介绍到这了,更多相关Java处理千万级CSV导出内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文