在Deno中处理大型CSV文件生成任务时,很多开发者会习惯先将所有数据拼接成完整字符串再一次性写入文件,这种方式在数据量较小时没有问题,但当数据量达到十万、百万级别时,会占用大量内存,甚至导致程序崩溃。使用流式API可以逐行、分块写入数据,不需要将所有数据同时保存在内存中,能大幅提升生成效率和稳定性。

传统生成方式的性能问题
传统的一次性写入方式需要先收集所有CSV行数据,拼接成完整的字符串,再调用Deno的写入接口。假设我们要生成包含100万行用户数据的CSV文件,每一行包含用户ID、姓名、邮箱三个字段,传统实现方式如下:
// 传统一次性生成CSV的方式
async function generateCsvTraditional(data: Array<{id: number, name: string, email: string}>, filePath: string) {
// 拼接表头
let csvContent = "id,name,emailn";
// 拼接所有数据行
for (const item of data) {
csvContent += `${item.id},${item.name},${item.email}n`;
}
// 一次性写入文件
await Deno.writeTextFile(filePath, csvContent);
}
// 模拟100万条数据
const mockData: Array<{id: number, name: string, email: string}> = [];
for (let i = 0; i < 1000000; i++) {
mockData.push({
id: i + 1,
name: `user_${i}`,
email: `user_${i}@ipipp.com`
});
}
// 执行生成
await generateCsvTraditional(mockData, "./traditional_users.csv");
这种方式的问题在于,csvContent字符串会完整保存在内存中,100万行数据拼接后的字符串大小可能超过100MB,对于内存有限的运行环境来说压力很大,而且拼接字符串的过程也会消耗额外的CPU资源。
使用流式API优化生成逻辑
Deno提供了Deno.create接口可以创建可写流,结合WritableStream的写入能力,我们可以实现逐行写入CSV数据,不需要提前拼接完整内容。核心思路是打开文件的可写流后,先写入表头,再遍历数据逐行写入,最后关闭流即可。
基础流式生成实现
下面是使用流式API生成CSV的基础实现代码:
// 流式生成CSV的基础实现
async function generateCsvStream(data: AsyncGenerator<{id: number, name: string, email: string}>, filePath: string) {
// 创建文件可写流
const file = await Deno.create(filePath);
const writer = file.writable.getWriter();
const encoder = new TextEncoder();
try {
// 写入表头
await writer.write(encoder.encode("id,name,emailn"));
// 逐行写入数据
for await (const item of data) {
const row = `${item.id},${item.name},${item.email}n`;
await writer.write(encoder.encode(row));
}
} finally {
// 关闭写入器
writer.close();
}
}
// 异步生成器模拟数据源,不需要一次性加载所有数据
async function* mockDataGenerator(total: number) {
for (let i = 0; i < total; i++) {
yield {
id: i + 1,
name: `user_${i}`,
email: `user_${i}@ipipp.com`
};
}
}
// 执行生成,生成100万行数据
const dataGen = mockDataGenerator(1000000);
await generateCsvStream(dataGen, "./stream_users.csv");
这个实现中,数据通过异步生成器逐条产出,每产出一条就写入一条到文件流中,内存中始终只保存当前处理的一行数据,不会累积所有数据,内存占用可以控制在极低水平。
处理特殊字符的转义
CSV文件中如果字段包含逗号、换行符或者双引号,需要按照CSV规范进行转义,否则生成的文件会出现格式错误。我们可以在写入每一行之前对字段做转义处理:
// 转义CSV字段
function escapeCsvField(field: string): string {
// 如果字段包含逗号、双引号、换行符,需要用双引号包裹,并且双引号需要转义为两个双引号
if (field.includes(",") || field.includes(""") || field.includes("n")) {
return `"${field.replace(/"/g, """")}"`;
}
return field;
}
// 带转义处理的流式写入
async function generateCsvStreamWithEscape(data: AsyncGenerator<{id: number, name: string, email: string}>, filePath: string) {
const file = await Deno.create(filePath);
const writer = file.writable.getWriter();
const encoder = new TextEncoder();
try {
// 写入表头
const headers = ["id", "name", "email"].map(escapeCsvField).join(",");
await writer.write(encoder.encode(`${headers}n`));
// 逐行写入转义后的数据
for await (const item of data) {
const row = [item.id.toString(), item.name, item.email].map(escapeCsvField).join(",");
await writer.write(encoder.encode(`${row}n`));
}
} finally {
writer.close();
}
}
两种方式的性能对比
我们可以通过简单的测试对比两种方式的差异,测试环境为8GB内存的普通开发机,生成100万行CSV文件:
| 生成方式 | 内存峰值占用 | 总耗时 | 是否支持超大数据量 |
|---|---|---|---|
| 传统一次性写入 | 约120MB | 约2.3秒 | 否,数据量超过内存限制会崩溃 |
| 流式API写入 | 约2MB | 约1.8秒 | 是,仅受磁盘空间限制 |
从对比结果可以看到,流式API不仅内存占用大幅降低,生成速度也略有提升,因为避免了大量字符串拼接的开销。
常见场景适配方案
从数据库读取数据生成CSV
如果数据来自数据库查询,很多数据库驱动支持流式读取结果,可以直接对接CSV生成的流,不需要把查询结果全部加载到内存:
// 模拟数据库流式查询结果生成CSV
async function generateCsvFromDb(queryStream: AsyncGenerator<any>, filePath: string) {
const file = await Deno.create(filePath);
const writer = file.writable.getWriter();
const encoder = new TextEncoder();
let isFirstRow = true;
try {
for await (const row of queryStream) {
// 第一行写入表头
if (isFirstRow) {
const headers = Object.keys(row).map(escapeCsvField).join(",");
await writer.write(encoder.encode(`${headers}n`));
isFirstRow = false;
}
// 写入数据行
const values = Object.values(row).map(v => {
if (typeof v === "string") return escapeCsvField(v);
return v;
}).join(",");
await writer.write(encoder.encode(`${values}n`));
}
} finally {
writer.close();
}
}
分块批量写入优化
如果单条写入的IO次数过多,可以适当分块批量写入,平衡内存和IO效率:
// 分块批量写入的流式生成
async function generateCsvByChunk(data: AsyncGenerator<{id: number, name: string, email: string}>, filePath: string, chunkSize = 1000) {
const file = await Deno.create(filePath);
const writer = file.writable.getWriter();
const encoder = new TextEncoder();
let chunk: string[] = [];
let isFirstRow = true;
try {
// 先写入表头
await writer.write(encoder.encode("id,name,emailn"));
for await (const item of data) {
const row = `${item.id},${item.name},${item.email}`;
chunk.push(row);
// 达到块大小就批量写入
if (chunk.length >= chunkSize) {
await writer.write(encoder.encode(`${chunk.join("n")}n`));
chunk = [];
}
}
// 写入剩余的不足一个块的数据
if (chunk.length > 0) {
await writer.write(encoder.encode(`${chunk.join("n")}n`));
}
} finally {
writer.close();
}
}
这种分块方式内存中最多保存1000行数据,既不会占用太多内存,也减少了IO写入次数,适合大多数大型CSV生成场景。