gin-contrib/rate非首选因其限速状态无法跨实例共享且错误响应不可定制;需手写Redis+Token Bucket中间件,用Lua保证原子性,并确保认证中间件先于限速执行。

为什么 gin-contrib/rate 不是首选方案
它依赖 golang.org/x/time/rate 的 Limiter,但默认用内存存储计数器,无法在多实例部署时共享状态;一旦加了负载均衡,限速就形同虚设。更麻烦的是,它的错误响应体固定为 {"error":"rate limit exceeded"},没法自定义 HTTP 状态码或返回结构。
- 别直接 import
gin-contrib/rate就开干,除非你确定永远只跑单进程 - 它的
rate.NewRateLimiter初始化时传的limit和burst是每秒请求数和突发上限,但没暴露存储层替换接口 - 如果你用了 Redis,得自己套一层中间件,而不是靠它内置的 “支持 Redis” —— 那个只是 demo 级的 stub
用 redis + token bucket 手写中间件的关键三步
核心是把桶状态存在 Redis 里,用 Lua 脚本保证原子性。Gin 中间件本身只是个 func(*gin.Context),重点在怎么调用 Redis 并决策放行/拦截。
- Redis key 设计用
rate:ip:<code>ctx.ClientIP():path 或rate:uid:<code>userID:path,避免不同用户/路径互相干扰 - 每次请求执行一段 Lua 脚本:读当前 token 数、判断是否足够、够则减 1 并更新过期时间(用
EXPIRE配合INCR不可靠) - 脚本返回 1 表示放行,0 表示拒绝;中间件据此调用
ctx.AbortWithStatusJSON(429, ...),别用ctx.Next()继续往下走
示例 Lua 脚本片段(实际用 redis.Eval 调用):
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = 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, window)
return 1
gin.Context 里怎么安全取用户标识做限速维度
用 IP 最简单,但容易误伤(比如公司出口 NAT);用登录态 UID 更准,但必须确保中间件执行顺序——认证中间件得在限速之前跑完,否则 ctx.Get("user_id") 是空的。
- 别在限速中间件里自己解析 JWT 或查 DB,那会拖慢所有请求,包括未登录的
- 推荐做法:认证中间件统一把
user_id或account_type存进ctx.Set(),限速中间件只读不写 - 如果要区分免费/付费用户配额,可以在 key 里带上类型,比如
rate:uid:123:pro,再配不同 limit - 注意:
ctx.ClientIP()可能被伪造,务必开启gin.SetTrustedProxies()并校验X-Forwarded-For
压测时发现 QPS 比预期高一倍?检查这三点
限速不准不是代码写错了,往往是环境或配置没对齐。
立即学习“go语言免费学习笔记(深入)”;
- Redis 连接池太小(
redis.Options.PoolSize默认 10),并发上来后命令排队,导致时间窗口错位 - 限速 key 的过期时间(window)设太长,比如 60 秒,但脚本里用
EXPIRE每次都重置,实际变成滑动窗口;而你想要的是固定窗口,就得用key:20240501_14这种带时间片的 key - Gin 的
Use()顺序写反了:把限速中间件注册在了认证之后,但某些路径(如/login)根本没走认证,结果这些路径完全没被限——得用engine.Group("/api").Use(rateMiddleware).Use(authMiddleware)显式控制范围
真正难的不是写出来,是想清楚“按什么维度限、窗口怎么算、失败时给什么反馈”。这几个问题没定清楚,代码越写越像补丁。


















