SpringBoot导出CSV文件的正确姿势

SpringBoot 导出 CSV 不只是把字段用逗号拼起来。字段里的逗号、引号、换行、中文编码和以等号开头的公式都可能让文件错列、乱码或产生安全风险。这篇从接口响应和 CSV 转义讲起,补上 UTF-8、公式注入、空结果、超量限制与流式导出的完整处理思路。

导出用户、交易或查询结果为 CSV,是后台系统里很常见的功能。

最初写这个功能时,很多人会觉得特别简单:把每列用逗号拼起来,每行加一个换行,再把字符串写到响应流里。

序号,用户名,部门
1,米虫,技术部
​

普通数据确实能导出。

但真实数据很快会把问题暴露出来:

  • 用户名里有逗号,导出的列错位
  • 备注里有换行,Excel 多出几行
  • 内容里有双引号,CSV 解析失败
  • 中文在 Windows Excel 里乱码
  • 单元格以 =、+、-、@ 开头,被当成公式执行
  • 一次把百万行全部放进 List,服务直接占满内存

所以 CSV 导出不是字符串拼接,而是一个小型的数据编码问题。

这篇按 SpringBoot 的接口场景,把它从头整理一遍。

CSV 到底是什么

CSV 可以理解成一种简单的表格文本格式:

  • 一行表示一条记录
  • 字段之间通常使用逗号分隔
  • 字段中如果包含逗号、引号或换行,需要使用引号包裹
  • 字段内部的双引号通常要写成两个双引号
普通字段:米虫
包含逗号:技术部,一组
包含引号:他说 "你好"
包含换行:第一行\n第二行
​

正确的 CSV 表示应该类似这样:

米虫,"技术部,一组","他说 ""你好""","第一行
第二行"
​

也就是说,不能只看到逗号才加引号。

只要字段包含逗号、双引号、回车或换行,就应该整体使用双引号包裹,并把内部双引号翻倍。

CSV 不是 Excel 专属格式

不同软件对 CSV 的编码、换行和分隔符支持可能有差异。

浏览器下载的是文本文件,最终怎么展示取决于 Excel、Numbers、LibreOffice 或其他客户端。

所以导出功能要明确:

  • 文件编码
  • 字段分隔符
  • 换行符
  • 是否需要 UTF-8 BOM
  • 日期、金额和空值的格式

不要把“本机 Excel 能打开”当成格式已经完全正确。

Controller 只负责下载入口

Controller 不需要返回一个大 List,也不应该把文件内容全部组装后再返回对象。

@GetMapping
public void exportUsers(UserQuery query,
                        HttpServletResponse response) {
    userExportService.export(query, response);
}
​

导出接口可以使用 GET,也可以使用 POST。

如果查询条件很多、包含复杂筛选对象,使用 POST JSON 通常更容易维护;如果只是简单条件下载,GET 也可以。

关键是响应应该直接作为文件流返回,而不是先拼一个巨大的字符串。

设置响应头

常见响应配置如下:

response.setContentType("text/csv");
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setHeader(
        HttpHeaders.CONTENT_DISPOSITION,
        ContentDisposition.attachment()
                .filename("users.csv", StandardCharsets.UTF_8)
                .build()
                .toString()
);
​

Content-Disposition 用来告诉浏览器这是下载文件,以及建议使用什么文件名。

文件名包含中文时,使用 Spring 的 ContentDisposition 或其他经过验证的编码方式,不要手动拼接一个未经编码的文件名。

CSV 单元格怎么转义

建议把单元格转义封装成一个独立方法,不要在业务循环里到处判断。

private static String escapeCsv(String value) {
    if (value == null) {
        return "";
    }

    String normalized = value
            .replace("\r\n", "\n")
            .replace("\r", "\n");

    boolean needQuote = normalized.indexOf(',') >= 0
            || normalized.indexOf('\n') >= 0
            || normalized.indexOf('\"') >= 0;

    String escaped = normalized.replace("\"", "\"\"");
    return needQuote ? "\"" + escaped + "\"" : escaped;
}
​

这段逻辑做了三件事:

  1. 统一换行符,避免不同系统生成混乱的行尾
  2. 判断字段是否需要整体包裹双引号
  3. 把字段内部双引号变成两个双引号

这是 CSV 最基本的格式要求。

公式注入怎么处理

CSV 被 Excel 打开时,如果单元格以这些字符开头,可能被识别为公式:

=
+
-
@
​

例如用户昵称是:

=HYPERLINK(...)
​

如果直接导出并用 Excel 打开,就可能触发公式行为。

当导出内容来自用户输入、备注、名称或外部系统时,应根据业务需求做公式注入防护。

一种常见策略是给危险前缀加一个单引号:

private static String preventFormulaInjection(String value) {
    if (value == null || value.isEmpty()) {
        return value;
    }

    char first = value.charAt(0);
    if (first == '=' || first == '+' || first == '-' || first == '@') {
        return '\'' + value;
    }
    return value;
}
​

然后再调用 escapeCsv。

是否对所有字段处理,要结合业务确认。金额、负数和表达式文本可能存在正常的减号,因此不要把业务含义完全不理解的字符串粗暴改写。

如果导出的 CSV 只是给程序读取,不会用表格软件打开,也可以采用更严格的文件格式或转义策略。

安全防护应该和使用场景一起设计。

UTF-8 和中文乱码

现在更推荐使用 UTF-8。

但部分 Windows Excel 版本直接打开 UTF-8 CSV 时,可能无法正确识别中文。

常见兼容做法是在 UTF-8 文件开头写入 BOM:

writer.write('\uFEFF');
​

这样部分 Excel 会更容易判断文件是 UTF-8。

BOM 并不是所有系统都需要。

如果下游程序严格按 UTF-8 读取,是否写 BOM 要根据对方协议决定。

不要把 GBK 固定写死成所有场景的默认值。

旧文章里使用 GBK 解决中文乱码,但这会限制跨平台和下游系统的兼容性。

更稳妥的策略是:

  • 默认 UTF-8
  • 明确是否需要 BOM
  • 如果对方强制要求 GBK,再由接口协议明确指定
  • 在测试环境用真实 Excel 和程序读取各验证一次

用 Writer 流式导出

小数据量可以使用内存字符串,但数据量一大就会有风险。

错误思路是:

查询全部数据 -> 放进 List -> 拼成一个超大 String -> 一次性写出
​

这会同时占用数据库连接、Java 堆内存和字符串缓冲区。

更稳的方式是分页查询或游标读取,每拿到一批就写出一批。

try (BufferedWriter writer = new BufferedWriter(
        new OutputStreamWriter(
                response.getOutputStream(),
                StandardCharsets.UTF_8))) {

    writer.write('\uFEFF');
    writeRow(writer, List.of("序号", "用户名", "部门"));

    long page = 0;
    while (true) {
        List<UserExportRow> rows =
                userMapper.selectExportPage(query, page, 1000);

        if (rows.isEmpty()) {
            break;
        }

        for (UserExportRow row : rows) {
            writeRow(writer, List.of(
                    String.valueOf(row.id()),
                    row.username(),
                    row.department()
            ));
        }

        writer.flush();
        page++;
    }
}
​

这里只展示结构,分页字段要结合实际数据库设计。

大数据量导出还要考虑:

  • 排序字段必须稳定,避免翻页时重复或漏数据
  • 不要使用过大的 page size
  • 数据库查询和导出耗时要设置合理上限
  • 导出任务太重时改成异步任务,完成后提供下载地址
  • 临时文件要设置权限、过期时间和清理策略

写行方法

private static void writeRow(Writer writer, List<String> values)
        throws IOException {
    for (int i = 0; i < values.size(); i++) {
        if (i > 0) {
            writer.write(',');
        }
        writer.write(escapeCsv(values.get(i)));
    }
    writer.write(System.lineSeparator());
}
​

业务查询只负责提供数据,CSV 格式细节集中在 writeRow 和 escapeCsv 中,后续改换 JSON、Excel 或其他导出格式时也更容易拆分。

空结果和超量怎么处理

导出接口不是查不到数据就返回一个空文件了事。

建议提前检查:

数量为 0
    -> 返回结构化业务错误

数量在允许范围内
    -> 开始流式导出

数量超过上限
    -> 提示缩小条件,或转为异步导出
​

如果响应已经开始写文件,就不能再把 JSON 错误对象写到同一个响应里。

因此数量检查最好在写响应头之前完成。

long count = userMapper.countForExport(query);
if (count == 0) {
    throw new BusinessException(
            ErrorCode.NO_DATA,
            ErrorMessage.NO_EXPORT_DATA
    );
}

if (count > EXPORT_LIMIT) {
    throw new BusinessException(
            ErrorCode.TOO_MANY_ROWS,
            ErrorMessage.USE_ASYNC_EXPORT
    );
}

writeCsv(query, response);
​

如果导出可能超过百万行,建议改成异步任务:

提交导出任务 -> 后台生成文件 -> 保存到受控存储
             -> 返回任务 ID -> 查询状态 -> 下载临时链接
​

不要让一次 HTTP 请求长期占用连接和数据库资源。

导出接口的安全边界

导出功能经常被低估,但它实际上是批量数据读取接口。

至少要检查:

  • 当前用户是否有导出权限
  • 查询条件是否受到数据权限约束
  • 是否限制单次导出行数
  • 是否记录操作者、条件和导出范围
  • 文件下载地址是否有有效期
  • 导出内容是否包含身份证号、手机号等敏感字段
  • 是否对 CSV 公式注入做处理
  • 错误响应是否可能泄露 SQL 和服务器路径

不要因为接口返回的是文件,就绕过普通列表接口的权限校验。

导出权限往往比单条查询权限更敏感。

常见错误排查

Invalid mime type

不要把文件流接口设计成先返回一个业务对象,再试图同时写文件。

一个 HTTP 响应只能有一种明确的响应形态。

成功时返回 CSV 文件,失败时返回结构化 JSON,且失败判断要发生在响应开始写出之前。

中文乱码

依次检查:

  1. 文件实际编码
  2. 响应字符集
  3. 是否需要 UTF-8 BOM
  4. Excel 或下游程序的读取方式
  5. 是否把字节流重复转码

文件列错位

优先检查字段是否包含逗号、引号和换行,以及 CSV 转义是否完整。

不要只判断逗号,字段中有换行和双引号同样需要包裹。

导出内存暴涨

检查是否一次性执行了:

  • 全量查询到 List
  • 全量拼接 String
  • 多次复制 StringBuilder 内容
  • 同时保留查询结果和导出缓冲

改成分页、游标或异步文件任务。

小结

SpringBoot 导出 CSV 的关键,不是把数据用逗号连接起来,而是处理好格式、编码、安全和资源边界:

  • 字段包含逗号、引号、换行时必须正确转义
  • 用户输入要评估 CSV 公式注入风险
  • 默认优先 UTF-8,按客户端需要决定是否写 BOM
  • 小数据可以同步流式导出,大数据应分页、游标或异步生成
  • 响应开始后不能再切换成 JSON 错误
  • 导出接口必须做权限、数据范围、行数和敏感字段控制
  • 不要用 e.printStackTrace() 代替项目日志体系

一句话总结:CSV 导出是数据边界,不是字符串拼接;格式要符合解析规则,数据要符合安全边界,规模要符合服务能力。

把这三件事处理好,用户拿到的文件才不是“能下载但不能用”的半成品。

更多推荐

章节目录