先记住这个答案
先按路由定义字节上限,合法 Content-Length 可用于提前拒绝,但读取过程中仍要累计实际 Buffer 字节数,不能只信请求头或字符串长度。每次收到块先检查剩余预算,超过后停止继续累积,释放已保存数据,并按协议状态选择发送 413 和关闭连接。分块传输也必须执行相同累计规则,压缩请求还需要独立限制解压后的体积。请求接收时间、并发数量和解析成本同样需要预算;单个 body 上限并不等于整个进程的内存上限。
- 限制真实接收字节而不是字符数量
- 声明长度可提前拒绝但不能替代流式计数
- 超限分支同时处理内存、响应与连接生命周期
为什么头部和字符串长度都不够
请求可能使用分块传输而没有 Content-Length,代理也可能改变传输方式。长度声明可以减少明显超限请求的消耗,但实际数据仍要受约束,且应让 HTTP 解析器处理报文边界是否合法。
UTF-8 中文字符通常占多个字节,JavaScript 字符串 length 又按 UTF-16 码元计算。若限制的是内存和网络正文大小,应在解码前使用 Buffer 的 byteLength,避免多字节字符跨块时误计或错误拼接。
在保存块之前执行不可逆的超限判断
预算对象在首次超限后保持拒绝状态,避免调用方忽略一次失败又继续追加后面的块。使用剩余预算比较,避免先累加任意大数值;示例要求输入仍是字节块,不在中间调用 setEncoding。
接入 data 处理时,只有 accept 返回 true 才保存块。返回 false 后应进入唯一的拒绝分支并清空已有缓存;结束事件也必须检查拒绝状态,不能把超限输入作为一个较短的合法 JSON 继续解析。
export function createByteBudget(maxBytes: number) {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
throw new RangeError('invalid byte limit');
}
let used = 0;
let rejected = false;
return {
accept(chunk: Uint8Array) {
if (rejected) return false;
if (chunk.byteLength > maxBytes - used) {
rejected = true;
return false;
}
used += chunk.byteLength;
return true;
},
get usedBytes() { return used; }
};
}例如上限 6 字节时,两块各含一个 UTF-8 中文字符可以接受,随后再来一个字节就拒绝。usedBytes 仅统计已接受部分,不宣称统计了超限后对端仍发送的全部流量。
拒绝后还要决定连接与并发成本
如果希望客户端看到 413,就不能先无条件 destroy 请求 socket 再期待响应完整送达。应在可写且尚未发出业务响应时设置拒绝响应和关闭策略,并为未关闭连接提供有界清理;协议已坏时可直接终止。
压缩正文可能很小而解压结果巨大,JSON 解析也可能产生额外对象内存。应分别限制压缩与展开体积、解析结构以及同时处理的请求数量,大文件则考虑受限流式处理或专用上传路径。
容易答错的地方
- 收到 end 后才用 Buffer.concat 检查
- 此时所有块已经保存在内存里,concat 还可能分配另一份连续缓冲。应在每块进入缓存前限制预算,并在超限时清空引用;最终解析只处理确认完整且未超限的数据。
- 超限后无限读完并丢弃就算安全
- 这虽然不再积累正文,却仍可能长期占用连接、带宽和处理机会。拒绝流程还需要时间与连接上限,结合是否保持连接的策略明确停止条件,不能让攻击性慢上传无限延续。
面试官还会怎么问?
大文件上传也要用同一个很小上限吗?
应按路由和业务定义不同策略。小 JSON 接口适合有限内存解析,大文件应使用有界流式处理并限制总量、速率或时间,不能为了支持上传就取消所有接口的正文限制。
请求提前断开还能使用已收到的部分吗?
通常不能把截断的正文当作完整业务输入,应识别异常结束并放弃解析或提交。若业务支持分片上传,需要独立的分片编号、校验与完成协议,而不是依赖一次不完整 HTTP body。
为什么加了 body 限制仍可能内存很高?
并发请求会把单请求上限相乘,解压、字符串解码和对象解析还可能增加副本与对象开销。应观察同时活跃请求、解析阶段和缓存生命周期,结合进程预算限制并发及输入结构。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。