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');
});
问题:
- req 上的 error 不会自动传播:如果客户端中途断开,req 触发 'error',busboy 不知道,继续 parse,最终也 error,但是uploadStream 在等 busboy 的 'end',永远等不到
- busboy 上的 error 也不会传播到 uploadStream:同样的问题,busboy 出错后 uploadStream 处于"半挂"状态
- 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 把这些事过一遍,不要等出事再补。
给读者的几条自查清单
- 项目里 grep "
.pipe(",数一下出现次数,> 5 处的项目建议全面审视,改成 pipeline。 - 大文件接口跑一次压测:50 并发 + 200MB 文件,看 RSS 内存峰值。超过 1GB 基本就有问题。
- 本地用
node --inspect启动 + Chrome DevTools 抓 heap snapshot,看 Buffer 实例的占用,如果远超你"预期还在传输中"的数据量,有泄漏。 - 用 clinic.js(
npx clinic doctor -- node server.js)跑一次完整请求路径,看 event loop / GC / memory 曲线。 - 检查所有 multipart 解析配置,确保
limits.fileSize设了合理上限,不要让恶意用户上传 100GB 把服务打挂。 - S3 / OSS / 任意对象存储的上传 SDK 调用,确认 partSize 和 queueSize 配置合理,不是用默认。
- 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