Node.js stream backpressure 失效导致文件上传中转 24h 三连 OOM 的 4 天复盘:5 种修法 + 8 条工程纪律

一个 Node.js 文件上传中转服务在 24 小时内被 K8s OOMKilled 三次,根因是 Busboy 的 push 模式 Readable pipe 到 S3 Upload 时,backpressure 路径完全失效——Pause 信号停不住外部 push,内部 buffer 涨到 1.5GB。本文复盘 4 天定位过程,讲清楚 pipe vs pipeline 的真实差异、push/pull 模式的区别、AbortSignal 与并发信号量在大文件中转里的正确用法,并给出 8 条 Node stream 高可用纪律。

2026 年 2 月一个周二下午 14:23,我们的文件上传中转服务 upload-relay 第三次在 24 小时内被 K8s OOMKilled。这次比前两次更糟:重启后只撑了 38 分钟就再次 OOM。监控曲线非常典型——内存在某个时间点开始陡峭上涨,从平稳的 800MB 在 4 分钟内冲到 2GB 触顶,然后 SIGKILL 一刀切下来。当天上午刚有一个 SaaS 大客户在做季度数据归档,批量上传几千个 200MB ~ 1GB 的备份文件,我们的服务作为中转,接收他们的上传后流式写到 S3。问题就出在"流式"两个字上——我们以为自己写的是流式,实际上 Node.js 在底层默默地把所有数据都缓存到了内存里。

接下来 4 天,我们带着前端架构组把 Node.js stream API 翻了个底朝天,定位到的根因是一个所有 Node.js 后端工程师都该背一下的反模式:用 src.pipe(dest) 把上传请求 pipe 到 S3 SDK 时,如果 dest 的消费速度跟不上 src,Node.js 的默认行为不是阻塞 src,而是把超出 highWaterMark 的数据无限缓存到内存里——因为我们没有正确处理 writable.write() 的返回值。这篇是完整复盘,涵盖 Node.js stream 的三种实现范式、backpressure 的真实工作机制、pipe vs pipeline vs async iterator 的取舍、以及落地的《Node stream 高可用纪律》。

服务背景:这个看似简单的上传中转

维度 数值
业务 SaaS 文件上传中转——接收用户 multipart 上传,流式转存到 S3
为什么需要中转 客户要求服务端做病毒扫描、元数据提取、加密等预处理
规模 日均上传 28 万文件,平均 12MB,最大允许 5GB
技术栈 Node.js 20 LTS + Fastify 4 + @aws-sdk/client-s3 3.620 + Busboy 1.6(multipart parser)
部署 K8s,8 Pod,每 Pod 1 vCPU + 2GB
事故前现象 "正常情况"下内存平稳 ~800MB,有客户做批量大文件上传时内存暴涨 OOM
OOM 频率 过去 30 天 14 次,但只在 3 个特定客户上传时发生

这个服务 2024 年中上线,前几个月很稳,直到 2025 年下半年开始有大客户用,OOM 才陆续冒出来。我们之前的"应对"是把 Pod 内存从 1GB 调到 2GB(2024 年 11 月),后来从 2GB 调到没敢再调——再大就会影响 K8s 调度密度。"调内存治不了根因"的教训,我们其实早就该学会。

事故时间线:从第三次 OOM 到根因落地的 4 天

时刻 事件
02-10 14:23 upload-relay 24 小时内第三次 OOM,告警触发
02-10 14:30 临时把客户 X 的上传配额从 100MB/s 降到 30MB/s,缓解但不根治
02-10 下午 抓 OOM 前的 heap snapshot(打开 -inspect + Chrome DevTools),看到 Buffer 实例占用 1.6GB,其中 99% 是匿名 Buffer
02-11 用 clinic.js + autocannon 本地复现:50 并发上传 200MB 文件,内存从 100MB 涨到 1.4GB
02-12 读 Node.js stream 源码 + Busboy + AWS SDK upload 源码,理解整个数据流转路径
02-13 上午 定位:Busboy 解析的文件流 pipe 到 S3 client 的 Upload 实例,S3 上传速度受网络限制,而 Busboy 在 piped writable 拒绝时仍持续写入
02-13 下午 设计修复:把所有 pipe 改成 pipeline(),处理 backpressure;同时把 S3 multipart upload 改成手写控制 + 信号量限并发
02-14 预发跑混沌测试(50 并发上传 + 模拟 S3 限速),内存稳定在 600MB,无 OOM
02-15 分批灰度上线,后两周观察无新 OOM,移除"客户限速"临时方案

第一反应:"是不是 Busboy 自己有内存泄漏"

事故当天我们方向走了点偏,以为是 Busboy 这个 multipart parser 有问题(它最近 1.x 版本确实修过几个 buffer 累积的 issue)。我们把 Busboy 升到最新,问题没解决。又怀疑 AWS SDK 的 Upload 类(自动处理 multipart upload 的高层 API)有 bug,翻 GitHub issue 也没看到对应描述。这一天就在"换库 + 复现"循环里耗掉了。

真正破局是 02-11 晚上一个细节——我让现场同事在 OOM 前的 heap snapshot 里看 Buffer 实例的"retainer"(谁在引用这些 Buffer),发现引用链是这样的:

(GC roots)
  → Upload (AWS SDK 实例)
    → __body (内部 Readable wrapper)
      → BufferedReadable (Busboy 创建的)
        → _readableState.buffer (链表)
          → Buffer (4 MB) × 384 个

翻译一下:Busboy 解析出文件流,把它包成一个 Readable,然后我们 pipe 到 S3 SDK 的 Upload。Upload 内部接收这个 Readable,把数据交给 S3 上传。问题是 Busboy 的 Readable 内部的 _readableState.buffer 累积了 384 个 4MB 的 chunk(共 1.5GB)。这说明数据被读出来了但没被 S3 消费掉——背压机制完全失效。

真凶 1:pipe() 没有真正实现"反压传播"

大多数 Node.js 开发者(包括 4 天前的我)对 stream.pipe() 的理解是:"它会自动处理 backpressure,reader 慢的时候 writer 会等待"。这个说法只对了一半

看 Node.js stream 文档,pipe() 的真实行为是:

步骤 行为
1 src 触发 'data' 事件,调用 dest.write(chunk)
2 如果 dest.write() 返回 false(表示 dest 内部 buffer 已超过 highWaterMark),pipe 会调用 src.pause()
3 当 dest 触发 'drain'(内部 buffer 排空)时,pipe 调用 src.resume()

关键在第 2 步:pipe 不会阻塞 src,它只是调用 src.pause()——这个 pause 是个"建议",不是"强制"。对于符合规范的 Readable(用 _read 实现的),pause 之后确实会停止内部生产;但是对于用 push 模式(由外部数据驱动)的 Readable,pause 不会阻止外部继续 push 数据进来。Busboy 就是 push 模式——它从 HTTP request 解析出 multipart 数据后,直接 push 到 Readable 的 buffer 里,不管 Readable 是不是已经 pause。

结果就是:S3 上传慢,Upload 的内部 Writable 写满 highWaterMark(默认 16KB),返回 false → pipe 调用 Busboy.Readable.pause() → 但 HTTP 请求继续涌入,Busboy 继续 parse 出新 chunk push 到 Readable.buffer → Readable.buffer 无限增长 → 内存爆炸。

这就是为什么"pipe 自动处理 backpressure"在某些场景失效——它只能处理"reader 主动 _read"模式的 stream,处理不了"被动 push"模式。

下面这张图把整条数据通路画清楚,标出每一段缓冲在哪里、谁负责清空:

关键就在 B 节点:Busboy 的 push 模式 Readable 收到 pipe 的 pause 信号后,它只能停止往下游的 emit,但停不掉上游 HTTP req 继续灌数据。req 解析后的 chunk 没地方去,就只能压在 Busboy 的 _readableState.buffer 链表里——这就是 1.5GB Buffer 累积的物理来源。

真凶 2:pipe() 错误处理是个灾难

除了背压,pipe 的另一个深坑是错误处理。来看一段经典写法:

// 看起来很合理, 实际上有 3 个潜在问题
req.pipe(busboy)
   .pipe(uploadStream);

uploadStream.on('error', (err) => {
  logger.error('S3 upload failed', err);
  res.status(500).send('upload failed');
});

问题:

  1. req 上的 error 不会自动传播:如果客户端中途断开,req 触发 'error',busboy 不知道,继续 parse,最终也 error,但是uploadStream 在等 busboy 的 'end',永远等不到
  2. busboy 上的 error 也不会传播到 uploadStream:同样的问题,busboy 出错后 uploadStream 处于"半挂"状态
  3. uploadStream 出错时,busboy 不会 destroy:busboy 内部的 buffer 一直占用,直到下一次 GC

这就是为什么 Node.js 14 引入了 stream.pipeline()——它的存在就是为了正确处理这个错误传播链。但很多老项目(包括我们的)仍然用 pipe,没把 pipeline 用起来。

真凶 3:S3 SDK Upload 类的 partSize 默认值陷阱

AWS SDK v3 的 @aws-sdk/lib-storage 提供了一个高层 Upload 类,自动处理 multipart upload。它有几个关键参数:

参数 默认值 含义
partSize 5 MB 每个 multipart part 的大小
queueSize 4 并发上传的 part 数量
leavePartsOnError false 失败时是否保留已上传的 parts

看起来很合理——5MB part × 4 并发 = 20MB 缓冲。但我们实际监控发现,Upload 内部实现会预先读取数据填充自己的内部队列,加上 Node.js stream 默认 highWaterMark,以及 Busboy 的内部 buffer 累积,实际内存占用是 partSize × queueSize × 1.5(经验值)+ 上游累积。

对一个 1GB 的文件,如果 S3 上传速度是 20MB/s 而客户端上传速度是 80MB/s,差值 60MB/s 在 1GB 上传过程中会累积 50 秒 × 60MB/s = 3GB 的"未消费数据"——而我们 Pod 内存只有 2GB。这就是 OOM 的数学原因。

本地复现:50 并发 + 200MB 文件 = 90 秒 OOM

我们用 autocannon 模拟客户端 + 一个 mock S3 server(故意限速 5MB/s)在本地稳定复现了问题:

# 模拟客户端: 50 并发, 每个上传 200MB 文件
autocannon -c 50 -d 90 -m POST \
  --headers "Content-Type=multipart/form-data; boundary=----X" \
  --input ./fixtures/200mb-multipart-body.bin \
  http://localhost:3000/upload

# 同时用 clinic doctor 监控
clinic doctor --on-port 'autocannon ...' -- node server.js

结果非常一致:

时刻 内存(RSS) 说明
启动 120 MB baseline
10 秒后 340 MB 开始上传,缓冲积累
30 秒后 820 MB 继续增长
60 秒后 1.5 GB 逼近 OOM
~90 秒 OOM 进程 SIGKILL

有了稳定复现后,修法的 A/B 验证就方便了。

修法:pipeline + 显式 backpressure + 并发限流

有了根因和复现,接下来要做的是把修法拆成"低风险的局部改造 + 高收益的纪律建设",分批上线,而不是一把梭。我们把修法按风险与收益排序,优先做"代码改动小、风险低、收益明显"的:pipeline 替换 pipe(改 14 处)、AbortSignal 接入(改 6 处)、信号量并发限制(新增 1 处中间件)——这三块加起来 1 天写完,占总修复成本 30%,但解决了 80% 的 OOM 风险。剩下的 highWaterMark 调整、Transform 改写、混沌测试方案,放到第 3、4 天慢慢做。后面回头看,正是因为前 30% 投入解决了 80% 的问题,我们才有底气一边继续修剩下的、一边把"客户限速"的临时方案撤掉,业务影响降到最小。这种 "80/20 切分 + 灰度落地" 在事故修复里几乎永远适用,值得作为肌肉记忆。

修法 1:全部 pipe 改成 pipeline()

const { pipeline } = require('node:stream/promises');

// 之前
req.pipe(busboy).pipe(uploadStream);

// 之后
await pipeline(req, busboy, uploadStream);
// pipeline 自动:
// 1. 正确传播错误(任一节点 error, 整条链路 cleanup)
// 2. 任一节点 destroy 时, 所有上游 destroy
// 3. 返回 Promise, 完成或失败有明确信号

这个改动是基础,但只解决了错误传播,没解决背压的根本问题(push 模式的 stream 仍然不响应 pause)。

修法 2:Busboy 的 file 事件改用 async iterator

Busboy 提供了两种消费方式:事件(file 事件回调)和 stream(file 是个 Readable)。后者支持 async iterator,我们可以用 for await 循环手动消费,完全控制读取节奏。

const busboy = Busboy({ headers: req.headers });

busboy.on('file', async (fieldname, fileStream, info) => {
    // 不要 pipe! 用 async iterator 手动控制
    const upload = new Upload({
        client: s3Client,
        params: { Bucket, Key: info.filename, Body: fileStream },
        queueSize: 2,        // 降低 S3 并发
        partSize: 8 * 1024 * 1024,  // 8MB part
    });

    // 等 S3 上传完成
    await upload.done();
    // upload.done() 会自动消费 fileStream, 并且尊重 backpressure
});

// pipeline 把 req 喂给 busboy
await pipeline(req, busboy);

关键改动:把"S3 拉数据"变成主动 await,而不是被动等 stream 推。AWS SDK 的 Upload.done() 内部实现了正确的 backpressure——它会自己控制读取节奏,Busboy 的 fileStream 在 Upload 没读时就不会继续接收数据。

修法 3:并发上传的信号量控制

除了单个文件的 backpressure,我们还要控制"同时正在处理的文件数"——否则 50 个并发请求每个都开 S3 multipart upload,光每个 8MB × 2 queueSize 的缓冲就是 50 × 16MB = 800MB,加上其他开销很容易 OOM。

用一个简单的信号量限制 Pod 内同时活跃的上传数:

// 用 p-limit 或者手写信号量
const pLimit = require('p-limit');
const uploadLimit = pLimit(4);   // 每 Pod 同时最多 4 个上传

app.post('/upload', async (req, reply) => {
    return uploadLimit(async () => {
        const busboy = Busboy({ headers: req.headers });
        // ... 上面的 file 处理逻辑
        await pipeline(req, busboy);
        reply.send({ ok: true });
    });
});

4 是经验值:每个上传峰值内存 ~ 80MB,4 个并发 = 320MB,加上 base 200MB,峰值 < 600MB,远低于 2GB 限制。超过 4 的请求会在信号量上排队等待——这会让客户端感觉变慢,但不会让 Pod 死掉。

修法 4:highWaterMark 显式调小

Node.js stream 默认 highWaterMark 是 16KB(byte stream)或 16 个对象(object stream)。对于"很多人同时大文件上传"这种场景,大 highWaterMark 容易累积太多 buffer。我们把上游 stream 的 highWaterMark 调到 64KB,虽然小读取次数变多,但内存峰值明显下降。

const fileStream = createReadStreamSomehow({ highWaterMark: 64 * 1024 });

// Busboy 自身也可以调
const busboy = Busboy({
    headers: req.headers,
    fileHwm: 64 * 1024,   // 文件流的 highWaterMark
});

修法 5:用 AbortController 把"客户端断开"信号传到下游

这是 4 天复盘里最后才补上的一块,但是最容易被人忽略的一块。客户端在大文件上传中途断开是高频事件:点了"取消"、关掉浏览器 tab、移动网络切换、CDN 中转超时,任何一个都会让上游 HTTP req 突然 'aborted'。如果下游 S3 multipart upload 不知道这件事,它会继续把已经收到的部分往 S3 推完,然后留下一个"残缺文件"。更糟的是,Upload 类如果配了 leavePartsOnError=false,它会发起 abortMultipartUpload 请求,清理已上传的 parts——可这一切都是"对方早就走了"之后的无用功。

const ac = new AbortController();
req.on('aborted', () => ac.abort(new Error('client aborted upload')));
req.on('close', () => {
  // close 在 aborted 之后也会触发, 但如果没 aborted 是正常结束, 不要 abort
  if (!req.complete) ac.abort(new Error('client closed before complete'));
});

const upload = new Upload({
  client: s3Client,
  params: { Bucket, Key, Body: fileStream },
  queueSize: 2,
  partSize: 8 * 1024 * 1024,
  abortController: ac,      // 关键:把 signal 传给 SDK
});

try {
  await upload.done();
} catch (e) {
  if (e.name === 'AbortError') {
    logger.warn({ Key }, 'upload aborted by client, S3 cleanup ok');
    return;          // 不要往上抛, 这是正常路径
  }
  throw e;
}

上线后我们专门观察了 "abort 后是否还有 'orphan part' 残留在 S3" 这个指标,从过去每周 200~300 个 orphan 直接降到 0。S3 列 bucket 的成本顺带降了一点。

决策树:三种 stream 消费范式怎么选

Node.js stream 在过去 10 年累积了三种主流的消费写法。它们的可用性、错误处理难度、backpressure 严谨度差很多。下面这张图是我们 4 天复盘后给团队画的内部规范:

这张图我们贴在团队 Wiki 入口,review 代码时只要看到有人用 .on('data', ...) 处理大流,直接打回——data 事件本质上是 push 模式,没法做 backpressure,只适合体积已知很小的场景。

顺手挖出来的另一个雷:Transform stream 同步 push

复盘 stream 的时候,我们顺手 review 了项目里所有自定义 Transform。发现一个埋了一年没炸的雷——一个"日志清洗"用的 Transform,把上游 JSON Lines 流转成结构化对象:

// 反模式: _transform 里同步循环 push 多次
class JsonLineParser extends Transform {
  constructor() { super({ objectMode: true }); }
  _transform(chunk, _enc, cb) {
    const lines = chunk.toString().split('\n');
    for (const line of lines) {
      if (!line) continue;
      this.push(JSON.parse(line));    // 不管 push 返回值, 继续 push
    }
    cb();
  }
}

这段代码在小流量下完全没问题,上游 5MB/s 日志输入,下游 Elasticsearch sink 也能 5MB/s 消费,内部 buffer 永远在 highWaterMark 以下。但有一天 Elasticsearch 集群 rolling restart,下游 sink 临时变慢到 200KB/s,这个 Transform 内部 buffer 在 30 秒内涨到 1.8GB——同样是没尊重 backpressure。

这个 bug 埋了一年多没被发现,原因有两个:第一,日志服务平时 sink 速度永远高于上游(因为日志总量本来就小),backpressure 路径从来没被触发过;第二,我们的内存监控阈值是 1.5GB 触发告警,而这个 Transform 平时只用 30MB,正常情况告警永远不响。这种"低概率 + 没监控"的组合最危险——平时风平浪静,真正暴露时往往伴随其他故障一起来,放大成事故级别,定位也比孤立故障难数倍。我们的对策是给所有自定义 Transform 加一个小巧的回归测试:制造一个 1 MB/s 输入 + 10 KB/s 下游消费的场景,跑 30 秒,断言 RSS 内存涨幅不超过 100MB。一行 mocha 测试,挡住一类不容易复现的雷。

正解是检查 push 返回值,如果返回 false,把剩余 chunk 缓存,等下次 _read 调用再继续:

class JsonLineParser extends Transform {
  constructor() {
    super({ objectMode: true, highWaterMark: 100 });
    this._pending = [];
  }
  _transform(chunk, _enc, cb) {
    const lines = chunk.toString().split('\n').filter(Boolean);
    this._pending.push(...lines);
    this._flushPending();
    cb();
  }
  _read(size) {
    super._read(size);
    this._flushPending();
  }
  _flushPending() {
    while (this._pending.length) {
      const line = this._pending.shift();
      if (!this.push(JSON.parse(line))) break;   // 下游说"够了", 停手
    }
  }
}

更省力的方式是直接用 stream/promises 里的 compose 或者 Readable.from(asyncGenerator),把自定义 Transform 改写成 async generator,backpressure 由 async iterator 协议天然保证,代码量减半,错都不容易写。

验证:混沌测试 + 14 天观察

测试场景 修复前(峰值内存) 修复后(峰值内存)
50 并发 × 200MB 文件 × 90s OOM(2GB) 620 MB
10 并发 × 1GB 文件 × 5min OOM(2GB) 540 MB
100 并发 × 10MB 文件 1.4 GB 380 MB
客户端随机断开 30% 缓慢内存泄漏 稳定 ~ 200 MB
14 天生产观察 14 次 OOM 0 次 OOM

除了内存稳定,有几个意外收获:

  • S3 上传速度因为不再"过度并发"反而提升了 12%(S3 对单 connection 的并发请求其实有限流,我们之前把它惹毛了)
  • P95 上传时长从 8 秒降到 6 秒
  • 不再需要"限制大客户上传速度"的临时方案,客户体验改善

沿途扫到的另外几类 Node stream 反模式

反模式 问题 修法
src.pipe(dest) 没监听 src 'error' 客户端断开导致 dest 永远挂起 用 pipeline()
用 stream.on('data', ...) 而非 for await data 事件无 backpressure,容易累积 改用 async iterator
把 Readable 转成 Buffer(.toBuffer / 累积 chunk) 大文件 OOM 真正用流式,或限制大小
Transform stream 同步 push 大量数据 无视 backpressure push 后检查返回值,等 _writev / drain
多 stream fanout 用同一个 Readable 消费慢的 stream 拖死整体 用 PassThrough 解耦或拷贝缓冲
busboy / formidable 等 multipart 解析没限制文件大小 / 数量 恶意上传可耗尽资源 显式 limits 配置
没有 abort 信号传递 客户端断开后上游还在处理 用 AbortController + AbortSignal

横向对比:其它语言怎么处理同类问题

这里值得跳出 Node 视角,看一眼其它语言生态在做"大文件流式中转"时的工程姿态,你会更理解 Node 的痛点出在哪儿。

语言 / 运行时 主流 stream / IO 抽象 backpressure 默认行为 常见陷阱
Node.js stream(4 类) + async iterator + Web Streams 规范有, push 模式实现常常无视 本文 6 个反模式
Go io.Reader / io.Writer + io.Copy 纯 pull 模式, 天然 backpressure goroutine 泄漏(没等到 reader 关闭)
Rust(tokio) AsyncRead / AsyncWrite + futures 纯 pull, 编译期就强制处理 类型噩梦, 入门门槛高
Java(NIO + Reactor) Flux / Mono + Project Reactor 规范严格, request(n) 显式 onBackpressureXxx 选错策略默默丢数据
Python(asyncio) StreamReader / StreamWriter + drain 必须显式 await writer.drain() 忘记 await drain 等同 Node 不等 'drain' 事件

从这张表能看出来:所有语言都得面对 backpressure 这个客观存在的问题,只是各自给开发者的 API 暴露程度不同。Node 在便利性这一头走得最远(pipe 一行搞定 90% 场景),代价是另外 10% 的"非标准模式"会以诡异方式失败。Go / Rust 在严谨性那一头走得最远,代价是新人写一段流式代码可能就要花上半天。没有银弹,选语言时把这件事算到 TCO 里去。

这个对比也解释了为什么我们 4 天事故里被否决了"换 Go" 的方案——并不是 Go 不行,而是如果团队对 Node 的 backpressure 都没想清楚,换 Go 后大概率会写出 goroutine 泄漏的等价 bug。先把工程纪律建起来,语言只是工具。

立的《Node.js stream 高可用纪律》

  • 禁止裸用 stream.pipe(),所有 stream 串联必须用 pipeline()(或 stream/promises 的 await pipeline)。
  • 大文件 / 高吞吐场景必须显式控制 backpressure:用 async iterator + 主动 await,或者明确检查 writable.write() 的返回值并等 'drain'。
  • 所有 multipart / 文件上传接口必须有 limits:fileSize、files 数量、fieldSize 都要配,防止恶意输入。
  • 每 Pod 必须有并发上传信号量,根据内存预算反算最大并发数(单上传峰值内存 × 并发数 ≤ 70% Pod 内存)。
  • highWaterMark 不能用默认值,大对象流必须显式调小到 64KB ~ 256KB。
  • 所有 long-lived stream 必须支持 AbortSignal,客户端断开能传播到下游所有节点。
  • Node 进程必须开启 --max-old-space-size 限制(略小于 Pod 内存上限),让 V8 在 OOM 前先抛 OOMError,留个 heap dump 比直接 SIGKILL 强。
  • 生产环境定期跑 heap snapshot(每天一次,采样),对比趋势,异常增长趋势能提前发现泄漏。

4 天里被否决的"看似合理"方案

修法定下来之前,我们试过或者认真讨论过另外 6 个方向,这里把每个为什么没采用写一下,免得别人重复绕弯。

方案 提出理由 为什么没用
把 Pod 内存调到 4GB "加内存最快" 治标不治本,大客户上来一样炸;调度密度直接砍半,集群成本翻倍
把上传中转干掉,客户直连 S3 pre-signed URL 架构最干净 预处理(病毒扫描、加密、元数据)是合同硬约束,绕不过去
换 Tus 协议(可断点续传 + 后台分片) 更标准的大文件协议 客户端 SDK 改造工作量 4 周以上,不在这次事故修复范围
把 Node 换成 Go / Rust 服务 "Node 不适合做这种 IO 密集型" 没证据支持这个判断,根因是 backpressure 用错,不是语言问题
每 Pod 挂 PV,先落盘再异步推 S3 用磁盘做缓冲池 磁盘 IO 反而成瓶颈,且 K8s ReadWriteOnce PV 在多 Pod 调度时麻烦
限制单文件最大 100MB "先躲一下" 客户合同要求支持 5GB,产品强烈反对

最后留下的是"修代码 + 加纪律",成本最低、范围最可控、可解释性最好。这种"看似只是一个 bug 修复"的工作如果做得规整,会比"我们要切 Go"那种豪言壮语对业务长期更有用——但前提是把根因讲清楚、把纪律落到代码 review 里。

顺手解决的两个长尾问题

4 天复盘的副产品里有两个意外收获,记一下,都是那种"事故没出之前永远懒得动" 的长尾。

问题 A:S3 multipart upload 残留。前面提过,改 abort signal 之后,S3 bucket 里的 orphan part 从每周几百个降到 0。我们写了个 Lambda 定期 list multipart uploads,把超过 7 天的 abort 掉——这个脚本本来在 backlog 躺了半年,这次借机一起上了。S3 multipart 计费有个反直觉的地方:即使你看不到的"未完成的 part"也是计费的,我们每月省了约 38 美元,不多但是干净。

问题 B:Fastify 的 logger 在大流量下也是 backpressure 重灾区。我们用 pino 输出 JSON 日志,默认 sync: false 是异步的,但底层用的还是 stream。大流量下日志 stream 也会内部累积——单不会 OOM(因为我们日志总量没那么大),但会触发 GC 压力。后来给 pino 加了 dedicated worker(pino/transport),把日志序列化扔到 worker thread,主线程一身轻。

这次复盘对团队 Node.js 工程能力的更新

除了具体修法,这 4 天我们在 dev brown bag 上做了一次 1.5 小时的分享,把整个团队对 Node.js stream 的认知刷新了一遍。事后我做了一个简单的"分享前 / 分享后" 18 人问卷,问 6 个核心问题(stream.pipe 是否阻塞 src、push 模式 vs pull 模式区别、Transform 何时该等 drain、pipeline 比 pipe 多解决什么、async iterator 在 stream 上的语义、Upload 的 partSize × queueSize 含义)。

问题 分享前正确率 分享后正确率
pipe 是否阻塞 src 22% 94%
push 模式与 pull 模式区别 11% 83%
Transform 何时等 drain 17% 78%
pipeline 相比 pipe 的优势 50% 100%
async iterator 在 stream 上的语义 28% 89%
S3 Upload partSize × queueSize 内存影响 6% 67%

这个数据说明一个事:大多数 Node.js 后端工程师对 stream 的实际行为是没有正确心智模型的。stream API 文档写得不算差,但是"行为细节"和"反模式陷阱"散落在各处,需要有人把它们串起来讲清楚。如果你的团队也写 Node 服务,值得花一次 brown bag 把这些事过一遍,不要等出事再补。

给读者的几条自查清单

  1. 项目里 grep ".pipe(",数一下出现次数,> 5 处的项目建议全面审视,改成 pipeline。
  2. 大文件接口跑一次压测:50 并发 + 200MB 文件,看 RSS 内存峰值。超过 1GB 基本就有问题。
  3. 本地用 node --inspect 启动 + Chrome DevTools 抓 heap snapshot,看 Buffer 实例的占用,如果远超你"预期还在传输中"的数据量,有泄漏。
  4. 用 clinic.js(npx clinic doctor -- node server.js)跑一次完整请求路径,看 event loop / GC / memory 曲线。
  5. 检查所有 multipart 解析配置,确保 limits.fileSize 设了合理上限,不要让恶意用户上传 100GB 把服务打挂。
  6. S3 / OSS / 任意对象存储的上传 SDK 调用,确认 partSize 和 queueSize 配置合理,不是用默认。
  7. HTTP 服务的 server.headersTimeout / requestTimeout / keepAliveTimeout 设合理值,避免慢上传占用资源。

再说几句关于"事故文化"的延伸。这次复盘从第三次 OOM 触发开始,到第 4 天上线修复,中间没有任何一次"找谁背锅"的对话——这件事比修代码本身更重要。我们团队有个不成文的约定:任何 P0/P1 事故的复盘文档,只能写"哪些信号被我们忽略了""哪些工具我们没用上""哪些纪律下次该立起来",不写"谁的责任"。理由也朴素:写责任的复盘大家会防御性地隐藏细节,写工具与纪律的复盘大家敢把"我也犯过同样错"摊到桌上。Node.js stream 这个坑 6 个月前另一个团队就在内部 IM 里抱怨过类似现象,只是当时我们没人把它当回事——这才是真正值得反省的地方。

这次事故最大的认知更新是:"流式"在 Node.js 里不等于"低内存"。它只是一种 API 形式——如果你没有正确处理 backpressure,流式 API 的内部 buffer 一样可以涨到无限大。真正的"低内存流式"需要数据的生产速度 ≤ 消费速度这条不变量始终成立,要么靠 stream 自身的 pause/resume 机制,要么靠应用层显式控制读取节奏。

另一个感悟是:Node.js 的 stream API 历经 v1/v2/v3 三代演化(callback → event → async iterator),每一代都没废弃前一代,导致代码里经常混用,踩坑不可避免。新代码尽量统一用 stream/promises 的 pipeline + async iterator——这是当前最不容易出错的范式,值得团队达成共识。如果你正在维护一个三种风格混用的老 Node 项目,不必一次全部重写,可以借任何一次相关 PR 顺手把动到的几行老 pipe 改成 pipeline,半年下来项目自然就清爽了——这种小步重构比"专门排期重构 stream"成本低得多,也不容易引入新 bug。

下次再有同事说"我把 req pipe 到 S3 就完事了",别让他这么干——5 行 pipeline() + 信号量 + 显式 backpressure 才是生产代码的样子。

事故后第 30 天我又翻了一遍当初的 OOM 告警邮件,有点感慨:这个 bug 其实在第一次 OOM 那天就给了我们 hint(heap snapshot 里 1.6GB 的匿名 Buffer 是非常明确的"流式没流式"信号),但我们当时图省事把 Pod 内存从 1GB 调到 2GB 就糊弄过去了——直到第三次 24 小时内三连 OOM 才正眼看它。这种"先把告警按掉再说"的反射,是工程师面对模糊问题时最容易陷入的陷阱。后来在事故复盘会上,我们把"调资源 ≠ 修问题"列成了团队 oncall 第一条手册:任何 OOM / 高 CPU / 高延迟告警,如果第一反应是调资源,必须在 24 小时内由 oncall 提交一份"根因分析或暂缓决定"短文档,白纸黑字记下来——目的是给自己留一道刹车,逼自己慢一步去看真问题。

一句话送给同样在 Node.js 一线写流式代码的同行:"流式"是一种契约,不是一种 API。契约的核心是上下游对生产/消费速度达成共识;API 只是这份契约的实现手段。pipeline、async iterator、AbortSignal 都是工具,真正的工程能力,是看到一段流式代码时本能地问:"这里的 backpressure 是怎么传递的?谁是 slow side?slow side 慢下来时,fast side 会堆在哪里?"——把这三个问题都答清楚,OOM 才离你远去。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

FastAPI 每隔 6 小时变僵尸:SQLAlchemy async 连接池静默泄漏 4 天复盘

2026-5-26 11:19:32

技术教程

Go context 用 Background 启动后台 goroutine 拖死 PG 连接池的 3 天复盘:1800 个僵尸 goroutine 实战定位 + WithoutCancel 正解

2026-5-26 11:45:34

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索