readablestream 的 pull 回调必须检查 controller.desiredsize>0 才 enqueue,否则背压失效;highwatermark 需按处理耗时与吞吐反推;for-await-of 隐式丢弃背压,应显式用 reader 控制节奏;pipeto/pipethrough 中各环节策略须对齐,避免中间断裂。

ReadableStream 的 pull 回调必须检查 controller.desiredSize
不检查 desiredSize 是高频率流中背压失效的最常见原因。很多开发者在 pull 里无条件调用 controller.enqueue(),结果就是数据疯狂堆积进内部缓冲区,直到内存告急。
正确做法是只在 controller.desiredSize > 0 时才推送新 chunk。这个值代表下游还“想要多少字节/项”,它由 reader 消费速度和 highWaterMark 共同决定。
-
desiredSize为正:可安全入队;为 0 或负:说明缓冲区已满或即将满,应暂停生产 - 若数据源本身是定时器(如每 10ms 推一个 sensor 值),需用
clearTimeout+ 重新setTimeout控制节奏,不能只靠pull触发 - 搭配
controller.signal.aborted判断流是否已被取消,避免在取消后仍尝试 enqueue
highWaterMark 不是越大越好,要匹配消费延迟
highWaterMark 设为 1MB 对大文件下载合理,但对每秒上千条 JSON 日志的实时流,可能意味着数百条消息滞留在内存里——尤其当单条处理耗时波动较大时,极易触发 OOM。
实操建议按“典型单次处理耗时 × 预期最大吞吐”反推:
- 假设下游
for await...of处理一条平均需 50ms,目标峰值 20 条/秒 → 缓冲区最多存 1 条(highWaterMark: 1) - 若用
pipeThrough(new TransformStream())做中间转换,记得它的writableStrategy和readableStrategy也需同步设小,否则背压在中间断掉 - Node.js 环境下注意:
highWaterMark单位是字节;浏览器环境对对象流是“项数”,别混用
避免在 for-await-of 中隐式丢弃背压信号
直接写 for await (const chunk of stream) { await process(chunk) } 看似简洁,但一旦 process() 变慢,for-await-of 并不会暂停流的内部 pull 调用——它只是卡住迭代,而流仍在往缓冲区塞数据(尤其当 pull 未检查 desiredSize 时)。
更可控的方式是显式控制 reader:
- 用
const reader = stream.getReader()获取 reader - 每次
await reader.read()后,立刻判断done,再决定是否发起下一次read() - 若处理逻辑本身是异步的,确保
await完成后再调用read(),形成自然节拍 - 不要在循环体里做
reader.releaseLock(),那会中断流;锁只应在彻底退出时释放
pipeTo() 和 pipeThrough() 的背压链容易在中间断裂
用 source.pipeTo(dest) 时,背压依赖 dest 的 writableStrategy.highWaterMark 和 write() 方法是否返回 Promise。如果目标流的 write() 是同步完成的(比如写入内存 buffer),它永远不会阻塞,上游就收不到背压反馈。
常见断裂点:
- 自定义
WritableStream的write()没有await实际 I/O,导致desiredSize始终为正 -
TransformStream的transform()函数里没用controller.enqueue()而是直接return,造成输出流饥饿,上游被“拖慢”而非“压停” - 链中任一环节用了
tee()分流,但只消费其中一支,另一支未读取会持续占用缓冲区且不释放
真正难调试的是多层管道嵌套后的延迟叠加——比如 WebSocketStream → TransformStream → IndexedDB 写入,每一环的 highWaterMark 和处理延迟都得对齐,否则某环先撑爆就全链雪崩。











