不能只用time.Now()做简单计数,因为并发请求下多个goroutine读写共享变量会引发数据竞争导致panic,全局锁又成性能瓶颈;正确做法是按IP分桶+rate.Limiter+sync.Map+主动清理冷IP。

限流中间件为什么不能只用 time.Now() 做简单计数
因为并发请求下,多个 goroutine 同时读写共享变量(比如 map[string]int)会引发数据竞争,Go runtime 会直接 panic;即使加锁,全局锁也会成为性能瓶颈。真实场景中,你看到的 503 Service Unavailable 或突然的请求抖动,往往就源于这种“看似简单”的计数逻辑。
正确做法是:按 IP 分桶 + 原子操作 + 时间窗口滑动。推荐用 golang.org/x/time/rate 的 rate.Limiter,但它不支持 per-IP 动态创建——所以得自己封装一层带清理机制的映射结构。
- 每个 IP 对应一个独立的
rate.Limiter实例,避免互相干扰 - 用
sync.Map存储,避免读写锁开销(Map.LoadOrStore是线程安全的) - 必须设过期策略,否则内存无限增长;不要依赖 GC,要主动驱逐冷 IP
- 注意:
rate.Every(1 * time.Second)表示每秒最多 1 次,不是“1 秒内最多 1 次”,后者需用rate.Limit(1)+time.Second构造
如何用 sync.Map + rate.Limiter 实现线程安全的 IP 限流
核心是把 IP 当 key,*rate.Limiter 当 value,并在每次请求时检查是否允许通过。关键不在“存”,而在“存多久”和“怎么取”。
下面是一个可直接粘贴进 HTTP 中间件的函数片段:
func NewIPRateLimiter(qps int, window time.Duration) func(http.Handler) http.Handler {
limiterMap := sync.Map{} // IP → *rate.Limiter
cleanupTicker := time.NewTicker(5 * time.Minute)
go func() {
for range cleanupTicker.C {
now := time.Now()
limiterMap.Range(func(key, value interface{}) bool {
ip := key.(string)
limiter := value.(*rate.Limiter)
// 判断该 limiter 是否还有近期使用痕迹(粗略判断)
// 实际生产建议用 LRU cache 或带 TTL 的 map(如 github.com/cespare/mph)
if now.Sub(limiter.Last()) > 10*time.Minute {
limiterMap.Delete(ip)
}
return true
})
}
}()
<pre class="brush:php;toolbar:false;">return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := getClientIP(r)
limiter, _ := limiterMap.LoadOrStore(ip, rate.NewLimiter(rate.Limit(qps), qps))
ok := limiter.(*rate.Limiter).Allow()
if !ok {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}}
注意:rate.NewLimiter(rate.Limit(qps), qps) 中第二个参数是 burst(突发容量),设为和 qps 相同,表示不允许瞬时突增;若要允许短时爆发(比如首请求打满),可设为 2 * qps。
getClientIP 怎么写才不会被伪造 IP 绕过
直接读 r.RemoteAddr 是最不可靠的——代理、CDN、负载均衡都会覆盖它。必须按可信顺序逐级提取,且只信任你控制的上游头字段(比如 Nginx 配置了 proxy_set_header X-Real-IP $remote_addr;)。
- 优先检查
r.Header.Get("X-Forwarded-For"),但只取第一个非私有 IP(过滤掉 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、127.0.0.1) - fallback 到
r.Header.Get("X-Real-IP"),这个更可信(通常由你自己的反向代理设置) - 最后才用
r.RemoteAddr,并去掉端口号(strings.Split(r.RemoteAddr, ":")[0]) - 千万别相信
X-Forwarded-For末尾的 IP,那是最可能被客户端伪造的部分
简单实现示例:
func getClientIP(r *http.Request) string {
clientIP := r.Header.Get("X-Real-IP")
if clientIP != "" {
return clientIP
}
ips := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
if len(ips) > 0 {
ip := strings.TrimSpace(ips[0])
if net.ParseIP(ip) != nil && !ipNetContains(privateNetworks, ip) {
return ip
}
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
return host
}
为什么不用 Redis 做分布式限流反而更简单
单机部署时,引入 Redis 不仅增加运维复杂度,还带来网络延迟和失败重试逻辑。而 sync.Map + rate.Limiter 在单实例 QPS ≤ 5k 场景下完全够用,实测 p99 延迟低于 50μs。
真正需要 Redis 的只有两个明确信号:
- 你的服务横向扩到了 3+ 实例,且要求全站统一限流阈值(比如全站每 IP 每分钟最多 60 次)
- 你已经在用 Redis 做 session 或缓存,且能接受 Lua 脚本原子性保障(如
INCR+EXPIRE组合)
否则,先用内存方案跑通逻辑,压测验证后再考虑升级。很多团队卡在“一上来就想分布式”,结果连本地并发 bug 都没调清楚。
IP 字符串拼接、时间窗口对齐、burst 容量误设、冷 key 清理遗漏——这些才是上线前最容易漏掉的点。











