请求大小限制中间件看似是个简单的功能,实际做起来坑不少。最常见的问题是很多实现把限制逻辑写在业务处理函数里,或者等请求体完整读完才校验,这时候内存早就被吃掉了。本文围绕 API 请求大小限制中间件的设计与性能优化展开,从原理、实现到调优一步步讲清楚。

一、为什么请求大小限制必须在读取请求体之前完成
先理解一个关键点:HTTP 请求体是流式传输的,服务器并不需要等整个 body 到达才开始处理。如果你的校验逻辑写在读取完 body 之后,那么恶意客户端发送一个 10GB 的 body,你的服务进程会老老实实把这 10GB 全部缓冲到内存或磁盘里,校验形同虚设。
正确的做法是在中间件层面做两件事:第一,读取请求头中的 Content-Length 字段做预检,如果超限直接返回 413 状态码,一个字节都不读;第二,对于分块传输(chunked transfer encoding)的情况,请求头里没有 Content-Length,必须在流式读取过程中做增量计数,一旦累计超过阈值就立即中断连接,而不是读完再判断。
这两种手段缺一不可。只依赖 Content-Length 的话,客户端完全可以伪造或不带这个头;只依赖流式计数的话,虽然安全但白白读进了大量数据,浪费带宽和 CPU。预检加流式兜底,才能做到既快又稳。
二、基于 Gin 的 Go 语言实现
Go 语言的标准库 http.MaxBytesReader 是做这件事的利器,它返回一个包装过的 Reader,读取超过限制时会返回错误,配合 Gin 中间件可以写得非常干净。下面是完整实现:
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
// MaxBodySize 限制请求体最大为 10MB
const MaxBodySize = 10 << 20
func RequestSizeLimit() gin.HandlerFunc {
return func(c *gin.Context) {
// 第一步:Content-Length 预检,超限直接短路返回
if c.Request.ContentLength > MaxBodySize {
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{
"error": "请求体超过大小限制",
})
return
}
// 第二步:包装 Body,防止分块传输绕过预检
c.Request.Body = http.MaxBytesReader(
c.Writer, c.Request.Body, MaxBodySize,
)
c.Next()
}
}
注册时挂在全局路由的最前面,确保所有后续 handler 都受保护:
func main() {
r := gin.New()
r.Use(middleware.RequestSizeLimit())
r.Use(gin.Logger(), gin.Recovery())
r.POST("/upload", func(c *gin.Context) {
// 正常读取 body,超限时 Read 会返回错误
data, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"size": len(data)})
})
r.Run(":8080")
}
这套实现的优势在于零业务侵入:业务代码不需要感知限制的存在,中间件统一处理。而且 MaxBytesReader 在超限时会主动关闭连接,客户端不会继续发送剩余数据,带宽也随之省下来了。
三、Node.js Express 中的流式限制方案
Express 自带的 express.json 有 limit 参数,但它只对 JSON 解析生效,且错误处理时机偏晚。更通用的做法是自定义一个基于流事件的中间件:
function requestSizeLimit(maxBytes) {
return function (req, res, next) {
// Content-Length 预检
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
if (contentLength > maxBytes) {
return res.status(413).json({ error: '请求体超过大小限制' });
}
// 分块传输场景:监听 data 事件做累计计数
let received = 0;
req.on('data', chunk => {
received += chunk.length;
if (received > maxBytes) {
req.destroy(); // 立即断开连接,阻止后续数据
res.status(413);
res.end();
}
});
next();
};
}
app.use(requestSizeLimit(10 * 1024 * 1024));
注意这里用了 req.destroy() 而不是仅仅返回 413。如果不断开连接,客户端会继续把剩余数据推过来,Node.js 默认会丢弃这些数据,但 TCP 层的接收和确认开销依然存在。断开连接是对恶意流量的最硬回绝。
另一个细节是计数用的变量要放在闭包里,每 个请求独立一份,千万别挂到模块级变量上做全局累计,那是初级开发者最容易踩的坑。
四、高并发场景下的性能优化策略
1. 提前短路,减少无谓开销
预检不通过时,直接 Abort 并返回,中间件链上后续的日志、鉴权、参数解析统统不执行。对于被攻击的场景,这部分节省的 CPU 相当可观。可以把大小校验放在中间件链的第一位,排在日志中间件之前,代价只是解析一个请求头。
2. 合理设置分级限制
不同接口对请求大小的容忍度差异很大。登录接口可能 4KB 就够了,文件上传接口却需要 50MB。与其全局一个限制,不如支持路由级覆盖:
// 全局 1MB,上传接口单独放宽
r.Use(middleware.RequestSizeLimitWith(1 << 20))
upload := r.Group("/upload", middleware.RequestSizeLimitWith(50 << 20))
分级限制的好处是把安全边界收紧到最小必要范围,攻击者找不到一个宽松的通用接口可以滥用。
3. 结合反向代理层做第一道防线
Nginx 的 client_max_body_size 指令可以在网关层直接拒绝超大请求,连应用服务器的门都摸不到。配置示例:
http {
server {
listen 80;
client_max_body_size 10m;
location /upload {
client_max_body_size 60m;
proxy_pass http://127.0.0.1:8080;
}
}
}
三层防线(Nginx、中间件预检、流式兜底)各司其职:Nginx 扛住最粗暴的攻击流量,中间件处理绕过网关的内部调用,流式计数兜住分块传输的漏网之鱼。这种纵深防御架构下,应用服务的内存安全基本可以得到保障。
五、常见误区与验证方法
第一个误区是只配置了框架的 body parser limit 就以为万事大吉。比如 Express 的 express.json({ limit: '10mb' }) 只在 JSON 解析阶段生效,如果攻击者把 Content-Type 改成 application/octet-stream,这个限制就失效了。自定义中间件按请求维度校验,才不受 Content-Type 影响。
第二个误区是忽略了 gzip 炸弹场景。客户端声明 Content-Encoding 为 gzip 时,解压后的实际大小可能远超压缩后的大小,这时需要在校验时同时监控解压流的输出量,而不是只看网络传输字节数。
验证方法很简单,用 curl 模拟超大请求:
# 生成 20MB 的随机文件并上传,限制为 10MB 时应返回 413 dd if=/dev/urandom of=/tmp/big.bin bs=1M count=20 curl -v -X POST http://127.0.0.1:8080/upload --data-binary @/tmp/big.bin # 模拟分块传输绕过 Content-Length curl -v -X POST http://127.0.0.1:8080/upload -H "Transfer-Encoding: chunked" --data-binary @/tmp/big.bin
两条命令都返回 413 且服务端内存占用稳定,说明预检和流式兜底都正常工作。上线前建议把这个验证步骤纳入 CI 流程,防止后续改动破坏了限制逻辑。