Node 流式响应卡在 drain:检查多字节字符串分块
HTTP chunked 响应写过高水位后不再继续,缓冲已空却没有 drain。限定 Node 24.22.x 等路径,保持文本内容并改用 Buffer 分块。
流式 JSON 响应写到约 64 KiB 后停住,客户端最终超时;服务端仍在等 drain,但 res.writableLength 和 socket 缓冲都已为 0。若响应使用 chunked 编码,写入的是含中文、重音字符或 emoji 的字符串,可以检查 Node 的多字节分块与背压计数。
已确认出现该路径的版本包括 Node.js 24.22.0、24.22.1,以及 26.9.0、26.10.0、26.11.1、26.12.0、26.12.1。这里的 64 KiB 不是协议的响应上限,而是相关环境中的高水位附近;小响应、ASCII 文本或不同写入节奏可能不暴露。
HTTP 层和 socket 层没有等待同一个计数
这条实现会在自动 cork 期间合并 chunked 写入。HTTP 消息层按编码后的字节计算长度,socket 的字符串队列却可能按字符串长度记账。多字节字符使两个数值分离:消息层已经返回 false、要求等待 drain,socket 却没有进入对应的 drain 状态。
随后自动 uncork 的同步检查又可能早于真正写完的时刻,消息层于是保留“需要 drain”的标记,却等不到下一次通知。正式 24.22.1、26.12.1 的相应合并与检查路径仍在,不能把同一响应换成 Readable.pipe(res) 就当作已经避开。
先保存实际 Node 版本、分块类型、编码、响应头及缓冲状态。客户端断开、代理超时和真实慢消费者也会让写入停顿;只有结合完整时序,才能区分这里的通知缺失与正常背压。不要把所有超过 64 KiB 的响应都判为受影响。
将完整文本块编码为 Buffer
应用能控制输出分块时,可以在写入边界把原来准备发送的字符串编码成 Buffer,继续使用既有的背压、错误和关闭处理:
// res 是当前 HTTP ServerResponse;piece 是一段完整 UTF-8 文本。
const chunk = Buffer.from(piece, 'utf8');
const canContinue = res.write(chunk);
// canContinue 为 false 时,接回原来的 drain/错误/关闭等待流程。
这是集成片段,不是完整的响应循环。若原数据使用其他编码,应按真实协议编码,不能一律写成 UTF-8。原分块也应保留完整字符边界,避免自行按 UTF-16 下标切断代理对后分别编码。不要重复编码已经是 Buffer 的内容。
Buffer 使相关队列按字节处理,但仍可能产生正常背压;不能删掉 false 分支、不断向内存堆积数据。也不要手动 emit('drain') 强行唤醒,那会把框架的流量控制状态与真实缓冲拆开。替换中文或标点为问号虽然可能改变现象,却已经损坏响应数据。
验收应对同一端点覆盖 ASCII 与多字节文本、超过高水位的完整响应、慢速消费及客户端中断。检查响应正常结束、字节与解码内容完整,错误或断开时生产者及时停止。只收到第一段数据、返回 200 或单看缓冲为 0,都不能证明流式响应已完成。


