NestJS 高性能实践:利用 RxJS 与 Stream 实现海量数据 CSV 导出

16 阅读4分钟

在日常的后端开发中,“数据导出”是一个极其常见的需求。当数据量较小(例如几百条)时,我们可以轻松地将所有数据查出、在内存中拼接成 CSV 或 Excel,然后一次性返回给前端。

然而,当面对十万、百万级的数据导出时,传统的做法会面临两个致命问题:

  1. 数据库超时:一次性使用 IN 查询巨量 ID,或单次拉取巨量数据,会导致数据库卡死。
  2. 内存溢出(OOM) :Node.js 默认内存有限,将海量数据全部加载进内存转换,极易导致服务器直接崩溃。

为了解决这些问题,我们可以结合 NestJSRxJS (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 结合,我们构建了一个无懈可击的导出接口。

它的优势在于:

  1. 极速响应:用户点击下载后,首字节时间(TTFB)极短,几乎瞬间就能看到浏览器开始下载(因为第一批数据查出来就直接发回去了)。
  2. 极低消耗:内存占用呈一条平缓的直线,哪怕导出数百万行数据,也不会给服务器带来压力。
  3. 安全可控:完善的中断机制确保了异常情况下不会拖垮数据库。

这套模式不仅适用于 CSV 导出,任何需要向客户端传输巨量数据的场景,都可以复用这一架构。