直接用time.Now()做滑动窗口会出错,因map非并发安全导致panic,且频繁遍历切片过滤时间戳引发CPU毛刺;正确做法是用sync.Map+分桶、rate.Limiter分key管理或ulule/limiter/v3等成熟方案。

为什么直接用 time.Now() 做滑动窗口会出错
很多人一上来就用 map[string][]time.Time 存每个 IP 的请求时间戳,每次请求时过滤掉超时的记录再判断长度。这在低并发下看似可行,但实际会遇到两个硬伤:一是 map 非并发安全,多 goroutine 写入直接 panic;二是频繁遍历切片做时间过滤,QPS 上千时 CPU 毛刺明显。
正确做法是避免实时过滤。用 sync.Map + 固定大小滑动窗口桶(如每秒一个桶),或更推荐——直接复用成熟结构:
- 用
golang.org/x/time/rate的Limiter,但它只支持全局速率,不支持 per-IP - 用
github.com/ulule/limiter/v3,底层基于 Redis 或内存,天然支持 key 维度(即 IP) - 若必须纯内存且无第三方依赖,用
sync.Map+time.Timer做 TTL 清理,但需手动控制内存增长
用 github.com/ulule/limiter/v3 快速实现 IP 限流
这是目前最省心的选择,支持内存、Redis、Memcached 后端,API 干净,且自动处理 key 过期和并发写入。
安装:
go get github.com/ulule/limiter/v3
核心代码片段(HTTP 中间件):
import (
"net/http"
"strings"
"github.com/ulule/limiter/v3"
"github.com/ulule/limiter/v3/drivers/middleware/stdlib"
"github.com/ulule/limiter/v3/drivers/store/memory"
)
func main() {
store := memory.NewStore()
rate, _ := limiter.NewRateFromFormatted("100-S")
middleware := stdlib.NewMiddleware(limiter.New(store, rate))
http.Handle("/api/", middleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := realIP(r)
// 关键:把 IP 注入 context,让 limiter 知道按谁限
ctx := r.Context()
ctx = context.WithValue(ctx, limiter.IPKey, ip)
r = r.WithContext(ctx)
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})))
}
func realIP(r *http.Request) string {
if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); ip != "" {
return ip
}
if ip := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); ip != "" {
return strings.Split(ip, ",")[0]
}
return strings.Split(r.RemoteAddr, ":")[0]
}
注意:limiter.IPKey 是它内置的 context key,必须用这个;X-Forwarded-For 要取第一个,防伪造;生产环境务必校验 header 可信性(如只信任入口 LB 的转发头)。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
不用第三方库:手写内存版限流器要注意什么
如果因合规或极简要求必须零依赖,可用 sync.Map + 时间分片,但有三个关键点不能漏:
- 每个 IP 对应一个
*rate.Limiter实例(来自golang.org/x/time/rate),而不是共用一个 —— 否则所有 IP 共享同一桶 -
sync.Map的 value 类型必须是带锁的 struct,因为rate.Limiter的AllowN方法不是并发安全的(文档明确写了) - 必须定期清理过期的 IP 条目,否则内存无限增长;建议用
time.Ticker每分钟扫描一次,删掉最后访问超 10 分钟的 key
示例结构:
type ipLimiter struct {
mu sync.RWMutex
lim *rate.Limiter
lastSeen time.Time
}
var limiters sync.Map // map[string]*ipLimiter
func getLimiter(ip string) *ipLimiter {
if v, ok := limiters.Load(ip); ok {
l := v.(*ipLimiter)
l.mu.Lock()
l.lastSeen = time.Now()
l.mu.Unlock()
return l
}
l := &ipLimiter{
lim: rate.NewLimiter(rate.Every(time.Second/100), 100),
lastSeen: time.Now(),
}
limiters.Store(ip, l)
return l
}
调用时:if !l.lim.AllowN(time.Now(), 1) { http.Error(w, "too many requests", http.StatusTooManyRequests); return }
真实部署时最容易被忽略的点
本地测试全 OK,上线后限流失效?大概率栽在这几处:
- 反向代理(Nginx / ALB)没透传真实 IP,
r.RemoteAddr是代理地址,导致所有请求被当成同一个 IP 限流或完全绕过 - HTTP/2 或长连接复用,同一个 TCP 连接发多个请求,但中间件只在连接建立时取一次 IP,后续请求丢失上下文
- 容器环境(K8s)中 Pod IP 不稳定,但业务日志里记的是 Pod IP,和限流 key 不一致,排查时对不上号
- 使用 Redis 后端时,没设置
MaxRetries和超时,网络抖动导致限流器静默降级为不限制
验证是否生效,别只看 HTTP 状态码;抓包确认响应头是否有 X-RateLimit-Limit、X-RateLimit-Remaining(ulule/limiter 默认加),或者直接查 Redis key:KEYS "limiter:*"。

















