Gin 默认限流中间件不支持行为级限流,因其仅支持IP/Header等静态key,无法在鉴权后动态构造“用户+行为+资源”组合key;须自研中间件,在auth后提取userID、action和resourceID,用Redis+Lua原子实现,并按路由手动挂载。

为什么 Gin 默认的 gin-contrib/limiter 不适合行为级限流
它只支持按 IP、Header 或固定 key 限流,没法把「用户登录态 + 行为类型 + 资源 ID」组合成动态 key。比如 /api/v1/order/{id} 接口,你得区分是用户 A 查自己的订单、还是用户 B 恶意刷别人订单,而默认中间件连 userID 都拿不到——它在鉴权之前就触发了。
真正能落地的做法是:自己写中间件,在鉴权后、业务逻辑前介入,用 context.Value 或中间件链传递用户身份,再拼出带语义的限流 key。
- 必须确保限流逻辑在
auth.Middleware之后执行,否则userID是空的 - 避免用
req.URL.Path直接当 key——要解析路由参数,比如用c.Param("id")替代原始路径 - Redis 的
INCR + EXPIRE原子操作不够安全,推荐用 Lua 脚本或redis-go/radix的Eval封装
如何构造带用户行为语义的限流 key
key 格式决定限流粒度。别用 "user:" + userID 这种粗粒度设计,它会让「查订单」和「删地址」共用一个桶,不合理。
推荐格式:"rate:u:" + userID + ":a:" + action + ":r:" + resourceID,其中 action 来自路由注解或 handler 名,resourceID 来自 c.Param 或 query 参数(需白名单校验)。
立即学习“go语言免费学习笔记(深入)”;
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
-
action可从c.HandlerName()提取,如"(*OrderHandler).Get"→ 截取为"order_get" - 敏感资源 ID(如订单号)必须做长度和字符校验,防止 key 膨胀或注入,例如拒绝
c.Param("id")含/或超 32 位 - 对 GET 类接口,可忽略
resourceID,用"rate:u:" + userID + ":a:dashboard_refresh"单独控频
用 Redis + Lua 实现原子判断与计数
单纯 GET + INCR + EXPIRE 有竞态:两个并发请求同时读到 0,都写入并设过期,导致超限。必须用 Lua 保证原子性。
以下 Lua 脚本实现「若当前计数
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local expire = tonumber(ARGV[2])
local current = tonumber(redis.call("GET", key) or "0")
if current >= limit then
return 0
end
redis.call("INCR", key)
redis.call("EXPIRE", key, expire)
return 1
Go 中调用示例:
script := redis.NewScript(luaScript)
result, err := script.Run(ctx, rdb, []string{key}, limit, expireSec).Result()
if err != nil {
// 记录 warn,但不阻断请求(降级策略)
}
allowed := result == int64(1)
- 脚本中用
redis.call("EXPIRE", ...)而非PEXPIRE,避免毫秒级精度引发的过期不一致 - 如果 Redis 是集群模式,key 必须包含
{...}槽定位标记,例如"rate:u:{123}:a:order_get" - 不要在 Lua 里做复杂逻辑(如时间窗口滑动),Gin 中间件应轻量,窗口计算交给客户端或异步任务
在 Gin 中间件里安全注入限流逻辑
限流不是越早越好,必须等 userID 和行为上下文就绪。典型错误是在 Use() 全局注册,结果所有路由都无差别拦截。
正确做法:定义函数工厂,按路由手动挂载:
func RateLimitByUserAction(store *redis.Client, limit int, expireSec int) gin.HandlerFunc {
return func(c *gin.Context) {
userID, exists := c.Get("userID") // 来自上一中间件
if !exists {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "no auth"})
return
}
action := parseAction(c)
resourceID := parseResourceID(c)
key := buildRateKey(userID.(string), action, resourceID)
allowed := checkRateLimit(c.Request.Context(), store, key, limit, expireSec)
if !allowed {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limited"})
return
}
c.Next()
}
}
- 必须用
c.AbortWithStatusJSON而非c.JSON+c.Abort(),避免后续中间件继续执行 - 不要在中间件里 panic 或 log.Fatal,限流失败应静默降级,不影响主流程可用性
- 对 /health、/metrics 等运维接口,务必跳过限流,否则监控探针会把自己干掉
行为级限流真正的复杂点不在代码,而在 key 设计是否覆盖业务场景、以及降级开关是否可动态配置——这两项漏掉任何一个,上线后都会变成救火现场。

















