不能直接用全局rate.Limiter或net/http套rate.NewLimiter(),因所有IP共享同一桶会导致恶意IP额度被稀释、正常用户反被误拦;必须按IP维度用sync.Map+atomic.Int32实现并发安全的独立计数,并通过defer原子减量后清理空闲IP。

为什么不能直接用 net/http 或全局 rate.Limiter
直接套一个 rate.NewLimiter() 在 handler 外层,所有 IP 共享同一个桶 —— 恶意 IP 可能被“稀释”掉,而正常用户反而因别人占满额度被拦。Echo 本身不提供 per-IP 并发计数原语,必须自己维护状态,且要解决两个核心问题:并发安全、内存泄漏。
sync.Map + atomic.Int32 是最轻量的实现路径
每个 IP 对应一个原子计数器,用 sync.Map 存储,避免全局锁;计数增减用 atomic.AddInt32,无锁且快。关键点不是“怎么存”,而是“什么时候删”:
- 每次请求进入时
atomic.AddInt32(&count, 1),超限时立刻回退并返回429 - 必须用
defer在退出时减 1,且检查减完后是否 ≤ 0,是则ips.Delete(ip) - IP 解析务必用
c.RealIP()(非c.Request().RemoteAddr),否则 Nginx 反代下全是127.0.0.1 - 不要用
time.Now().Unix()做 key 或清理触发器 —— 它和并发计数无关,加了反而引入竞态
中间件里启动 goroutine?别碰这个坑
有人想“异步清理过期 IP”,但在中间件里启 goroutine 调用 time.AfterFunc 或定时扫描 sync.Map,这会脱离 c.Request().Context() 生命周期。结果就是:连接已断,goroutine 还在往已失效的 map 里写,或反复调用 Delete 导致 panic。
Echo框架 5.1.0 版本源码包下载,适合关注 RealIP 行为变化、StartConfig.Listener、NewDefaultFS 和观测性中间件入口的开发团队。
正确做法只有两个:
- 只在请求进出时做原子增减,靠
defer+atomic.LoadInt32() == 0触发清理 —— 简单、确定、无额外 goroutine - 如果真需要老化机制(比如防长期空闲 IP 占着 slot),改用带 TTL 的第三方库如
golang.org/x/exp/maps(Go 1.22+)或封装map[string]*entry+ 单个后台 ticker,但 ticker 必须自己管理 stop 信号,不能依赖请求上下文
HTTP/2 下的并发数 ≠ 连接数,别被 netutil.LimitListener 带偏
netutil.LimitListener 限制的是 TCP 连接总数,对 HTTP/2 完全无效 —— 一个连接可复用承载上百请求。你看到的“单 IP 并发 50”需求,本质是「同一时刻该 IP 正在处理中的 handler 数」,只能在 Echo 中间件层级拦截统计,和底层 listener 无关。
另外注意:http.Server 的 MaxConnsPerHost 是客户端侧配置,服务端不可控;ReadTimeout 等超时参数影响连接生命周期,但不参与并发计数逻辑 —— 它们只是帮你更快释放卡住的计数器。
















