在日常的后端开发中,“数据导出”是一个极其常见的需求。当数据量较小(例如几百条)时,我们可以轻松地将所有数据查出、在内存中拼接成 CSV 或 Excel,然后一次性返回给前端。
然而,当面对十万、百万级的数据导出时,传统的做法会面临两个致命问题:
- 数据库超时:一次性使用
IN查询巨量 ID,或单次拉取巨量数据,会导致数据库卡死。 - 内存溢出(OOM) :Node.js 默认内存有限,将海量数据全部加载进内存转换,极易导致服务器直接崩溃。
为了解决这些问题,我们可以结合 NestJS、RxJS (Observable) 和 Node.js Streams,构建一条“边查、边转、边下载”的高性能流水线。本文将深度拆解这一优雅的实现方案。
1. 核心思想:流水线作业
要解决内存和性能问题,核心思维是从“批处理”转向“流处理”。
- 不要等所有数据都准备好才上菜。
- 应该像流水线一样:数据库查出第一批,立刻转成 CSV 文本,立刻发给用户的浏览器;同时数据库去查第二批……如此循环,直到结束。
这需要三大组件的紧密配合:
- 数据源(Producer) :基于
RxJS Observable的分批数据库查询。 - 转换器(Pipeline) :Node.js 原生
Readable可读流。 - 响应器(Consumer) :NestJS内置的
StreamableFile。
2. 第一步:打造按批次拉取的数据源 (RxJS Observable)
首先,我们需要保护数据库和内存。通过自定义一个 Observable,我们将庞大的 ID 列表切片(例如每批 500 个),分批进行查询。
// 伪代码示例
getMembersByIds(accountIds: number[], batchSize = 500): Observable<Member[]> {
return new Observable((subscriber) => {
(async () => {
// 1. 将海量 ID 数组进行循环切片
for (let i = 0; i < accountIds.length; i += batchSize) {
const batchIds = accountIds.slice(i, i + batchSize);
// 2. 仅查询当前批次的 500 条数据
const rows = await prisma.member.findMany({ where: { id: { in: batchIds } } });
// 3. 查完立刻通过 next() 推送给下游,绝不囤积
subscriber.next(rows);
}
// 4. 所有批次处理完毕,通知下游结束
subscriber.complete();
})().catch((err) => subscriber.error(err));
});
}
亮点:在这个环节,无论用户要导出多少数据,服务器内存中始终只保留当前这 batchSize(如 500 条)的数据量,彻底告别 OOM。
3. 第二步:将数据流接入下载流 (Node.js Readable)
NestJS 需要一个 Node.js 的原生流来触发下载。我们需要一座桥梁,将刚才 RxJS 吐出的数据对接过去,并在这个过程中完成 CSV 的格式化。
// 1. 创建一个空白的可读流
const readable = new Readable({ read() {} });
// 2. 写入 CSV 表头
readable.push('會員ID,手機號,郵箱,會員暱稱...\n');
// 3. 订阅我们在第一步写好的 Observable
const subscription = observable.subscribe({
next: (rows) => {
// 收到 500 条数据,立刻格式化为 CSV 字符串 (注意处理逗号转义)
const lines = rows.map(row => formatToCSVLine(row)).join('\n');
// 推入 Node.js 流中,发送给客户端
readable.push(lines + '\n');
},
error: (error) => readable.destroy(error),
complete: () => readable.push(null), // 传入 null 告诉 Node.js 文件读取结束
});
在这里,业务逻辑(转义处理、日期格式化、状态字典映射)被完美地安插在了 next 的回调中,实现了“边查边洗边发”。
4. 第三步:优雅地返回响应 (StreamableFile 与资源回收)
在 NestJS 中,我们不再需要手动操作底层的 res 对象。只需将组装好的 readable 包装进 StreamableFile 即可。
// 极其重要的防内存泄漏设计
readable.on('close', () => {
subscription.unsubscribe();
});
const filename = `export-${Date.now()}.csv`;
// 交由 NestJS 接管 HTTP 响应
return new StreamableFile(readable, {
type: 'text/csv; charset=utf-8',
disposition: `attachment; filename="${filename}"`,
});
工程化高光时刻:代码中的 readable.on('close') 监听是一个极其优秀的设计。如果用户在浏览器下载中途点击了“取消”,或者网络异常断开,底层的 HTTP 请求会中断,触发流的 close 事件。此时,代码通过 unsubscribe() 立刻取消了对 RxJS 的订阅。这意味着后端的 for 循环和数据库查询会立刻停止,避免了无效的资源浪费。
总结
通过将 RxJS Observable 的惰性/分批特质,与 Node.js 底层的 Stream,以及 NestJS 顶层的 StreamableFile 结合,我们构建了一个无懈可击的导出接口。
它的优势在于:
- 极速响应:用户点击下载后,首字节时间(TTFB)极短,几乎瞬间就能看到浏览器开始下载(因为第一批数据查出来就直接发回去了)。
- 极低消耗:内存占用呈一条平缓的直线,哪怕导出数百万行数据,也不会给服务器带来压力。
- 安全可控:完善的中断机制确保了异常情况下不会拖垮数据库。
这套模式不仅适用于 CSV 导出,任何需要向客户端传输巨量数据的场景,都可以复用这一架构。