net/http 默认不控制 IP 并发数,因 ServeMux 和底层 http.Server 无 IP 感知能力;需用中间件 + sync.Map 实现每 IP 计数,并注意真实 IP 提取、端口截断、原子操作及定时清理零值项。

为什么 net/http 默认不控制 IP 并发数
Go 的标准库 http.ServeMux 和大多数轻量框架(如 gin、echo)本身不内置 IP 级别并发限制——它们只管路由分发和 handler 执行,连接管理由底层 net.Listener 和 http.Server 负责,而后者对“IP 来源”无感知。直接靠 http.Server.MaxConns 或 MaxIdleConns 只能控全局连接数,无法按 IP 区分。
用中间件 + sync.Map 实现每 IP 并发计数
最常用且可控的方式是在 handler 前加一层中间件,用 sync.Map 记录每个 RemoteAddr 当前活跃请求数。注意:真实 IP 需从 X-Forwarded-For 提取(若服务在反向代理后),否则拿到的是代理地址。
- 提取 IP 时优先检查
X-Forwarded-For头,但必须校验可信代理列表,避免伪造;否则 fallback 到r.RemoteAddr - 使用
sync.Map而非普通map,避免并发读写 panic;但注意sync.Map不支持原子增减,需用LoadOrStore+ 循环 CAS 模拟 - 计数必须配对:进入 handler 时 +1,退出时 -1(用
defer确保执行) - 超限时返回
http.StatusTooManyRequests,并设Retry-After头
// 示例:Gin 中间件
func IPConcurrencyLimit(max int) gin.HandlerFunc {
ips := sync.Map{} // key: ip string, value: *int32
return func(c *gin.Context) {
ip := realIP(c.Request)
countPtr, _ := ips.LoadOrStore(ip, new(int32))
count := atomic.AddInt32(countPtr.(*int32), 1)
if count > max {
atomic.AddInt32(countPtr.(*int32), -1)
c.Header("Retry-After", "60")
c.AbortWithStatus(http.StatusTooManyRequests)
return
}
defer func() {
atomic.AddInt32(countPtr.(*int32), -1)
if atomic.LoadInt32(countPtr.(*int32)) <= 0 {
ips.Delete(ip)
}
}()
}
}
golang.org/x/net/netutil.LimitListener 只能限总连接数
这个包提供的 LimitListener 是对底层 net.Listener 做并发连接数限制,比如 http.Server{Listener: netutil.LimitListener(lis, 100)},但它统计的是所有来源的 TCP 连接总数,不是每个 IP 的并发请求数。HTTP/2 多路复用下,一个连接可承载多个请求,此时它更不匹配“每 IP 并发请求数”的语义。
- 适用场景:防止服务器被大量 TCP 握手打爆,而非防单 IP 高频请求
- 无法区分 IP,也无法感知 HTTP 请求生命周期(只管连接建立)
- 与中间件方案不冲突,可叠加使用:前者控连接层,后者控应用层
生产环境要注意 realIP 的可信边界和内存泄漏
高频访问下,sync.Map 中未归零的 IP 计数器可能长期驻留,尤其当客户端断连不规范(如直接 kill 连接),导致 count 卡在 >0 状态无法清理。这不是理论问题,是真实发生过的内存缓慢增长现象。
立即学习“go语言免费学习笔记(深入)”;
- 必须设置定时清理逻辑:比如每分钟扫描
sync.Map,删除值为 0 的项(Load后判断再Delete) -
realIP函数不能盲目信任X-Forwarded-For—— 若没配 Nginx 的set_real_ip_from或 Cloudflare 的真实 IP header,就可能被伪造 - IPv6 地址带端口(如
[2001:db8::1]:12345)需截掉端口部分,只保留 IP 段用于计数
真正难的不是写几行计数代码,而是让这套逻辑在长周期、混合代理、偶发网络异常的环境下不漏判、不误杀、不涨内存。



















