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;
}
这段逻辑做了三件事:
- 统一换行符,避免不同系统生成混乱的行尾
- 判断字段是否需要整体包裹双引号
- 把字段内部双引号变成两个双引号
这是 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,且失败判断要发生在响应开始写出之前。
中文乱码
依次检查:
- 文件实际编码
- 响应字符集
- 是否需要 UTF-8 BOM
- Excel 或下游程序的读取方式
- 是否把字节流重复转码
文件列错位
优先检查字段是否包含逗号、引号和换行,以及 CSV 转义是否完整。
不要只判断逗号,字段中有换行和双引号同样需要包裹。
导出内存暴涨
检查是否一次性执行了:
- 全量查询到 List
- 全量拼接 String
- 多次复制 StringBuilder 内容
- 同时保留查询结果和导出缓冲
改成分页、游标或异步文件任务。
小结
SpringBoot 导出 CSV 的关键,不是把数据用逗号连接起来,而是处理好格式、编码、安全和资源边界:
- 字段包含逗号、引号、换行时必须正确转义
- 用户输入要评估 CSV 公式注入风险
- 默认优先 UTF-8,按客户端需要决定是否写 BOM
- 小数据可以同步流式导出,大数据应分页、游标或异步生成
- 响应开始后不能再切换成 JSON 错误
- 导出接口必须做权限、数据范围、行数和敏感字段控制
- 不要用
e.printStackTrace()代替项目日志体系
一句话总结:CSV 导出是数据边界,不是字符串拼接;格式要符合解析规则,数据要符合安全边界,规模要符合服务能力。
把这三件事处理好,用户拿到的文件才不是“能下载但不能用”的半成品。
