必须用用户ID作为限流key且认证中间件须先执行,因IP限流无法识别同一用户多端登录;若认证未前置,c.Get("user_id")为空导致所有请求共用桶;sync.Map需配合定时清理,生产环境应选Redis+Lua原子脚本。

不能只靠 IP 或全局中间件,必须用用户 ID 作为限流 key,并确保认证中间件先执行。
为什么直接用 c.ClientIP() 做限流会失效
同一用户在手机、iPad、笔记本上登录,IP 完全不同,但账号相同。用 $binary_remote_addr(Nginx)或 c.ClientIP()(Gin)做维度,等于放行所有多端行为——这不是“限制并发”,是“鼓励并发”。
常见错误现象:
- 用户 A 登录 PC 后再登录手机,PC 端请求突然 429
- 后台日志显示不同 IP 的请求被合并计数,但实际没关联到用户
- 限流中间件里
c.GetString("user_id")总是空字符串
必须保证认证中间件在限流前执行
限流中间件要读取用户标识(如 user_id),这个值必须由前置的认证中间件写入 c 上下文。否则 c.Get("user_id") 永远为 nil,key 变成空字符串,所有用户共享同一个桶。
实操要点:
- 注册顺序必须是:认证中间件 → 限流中间件 → 业务 handler
- 认证中间件内务必调用
c.Set("user_id", uid)(不是c.Set("uid", ...),保持 key 统一) - 不要在限流中间件里重复解析 token 或 cookie——那是认证层的事
- 如果用了 JWT,确保解析后校验了签名和过期时间,再写
user_id
用 golang.org/x/time/rate + sync.Map 实现 per-user 限流
官方 rate.Limiter 是并发安全的,但每个用户需独立实例。用 sync.Map 缓存可避免锁竞争,也防止内存无限增长。
关键代码逻辑:
var userLimiters sync.Map // map[string]*rate.Limiter
func UserRateLimitMiddleware(limit int, window time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
uid, exists := c.Get("user_id")
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing auth"})
return
}
key := fmt.Sprintf("%s:%s", uid, c.Request.URL.Path) // 区分接口粒度
limiter, _ := userLimiters.LoadOrStore(key, rate.NewLimiter(rate.Every(window/time.Duration(limit)), limit))
if !limiter.(*rate.Limiter).Allow() {
c.Header("X-RateLimit-Remaining", "0")
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "too many concurrent sessions"})
return
}
c.Next()
}
}
注意点:
-
key建议包含user_id和路径,避免 /api/pay 和 /api/user 共享桶 -
sync.Map不自动清理,需配合后台 goroutine 定期扫描并删除超时(如 10 分钟无访问)的条目 - 不要用
time.AfterFunc给每个 limiter 单独设过期——开销太大 - 测试时用两个设备登录同一账号,发相同接口请求,第二个应被拦截
Redis 方案更适合生产环境
单机 sync.Map 在多实例部署下失效。真实项目基本都走 Redis + Lua 原子脚本。
核心差异:
- key 改为
rate:user:{uid}:path,用EXPIRE自动过期,不依赖清理逻辑 - Lua 脚本一次性完成:读当前计数、判断是否超限、+1、设过期,全程原子
- 必须把认证中间件放在限流之前,否则
uid拿不到,Redis key 变成rate:user::/api/xxx,所有用户撞进同一个桶 - 错误响应别用默认
{"error":"rate limit exceeded"},按业务约定返回结构体,比如含retry_after字段
最容易被忽略的是:限流中间件里调用 c.AbortWithStatusJSON 后,必须立刻 return,否则可能触发 panic 或重复写 header。这不是风格问题,是 Gin 的执行模型硬约束。


















