rate.limiter实例必须全局复用,不能每次请求new,否则每个请求新建独立令牌桶,100并发即100个桶各自放行,qps限制完全失效;正确做法是包级变量复用或按path/ip/user等key用sync.map缓存实例。

rate.Limiter 实例必须全局复用,不能每次请求 new
限流失效的头号原因就是把 rate.NewLimiter 写在 handler 或中间件函数体内。每个请求都新建一个实例,等于每人发一个独立桶,完全不共享状态——100 并发就相当于 100 个桶同时放行,QPS 限制形同虚设。
正确做法是按需复用:
- 全局限流:定义为包级变量,如
var globalLimiter = rate.NewLimiter(rate.Every(100*time.Millisecond), 10) - 路径级限流:用
sync.Map缓存,key 为路由路径(如"POST:/api/order"),避免硬编码 if 判断 - IP 或用户级限流:key 构造为
"ip:" + realIP或"user:" + userID,注意反向代理下要解析X-Forwarded-For并校验可信跳数 - burst 值别设为 1:建议设为长期速率的 2–3 倍(如 QPS=10 → burst=20~30),否则正常重试或前端批量请求会直接被拒
HTTP 中间件里该用 Wait 还是 Allow?
Allow() 是非阻塞判断,返回 true 就放行;Wait(ctx) 会阻塞直到拿到令牌或上下文超时。网关层通常应快速失败,但直接用 Allow() 有并发风险——高并发下多个请求几乎同时调用 Allow(),可能瞬间打穿阈值。
更稳妥的选择是 ReserveN(ctx, 1):
- 它返回
*rate.Reservation,可调用OK()判断是否成功获取令牌 - 支持预占多个令牌(如文件上传按字节数计费),失败时记得调用
reserve.Cancel() - 务必检查
ctx.Err():若为context.Canceled或context.DeadlineExceeded,说明请求已无效,不该继续执行 handler - 别用原始
r.Context(),要用context.WithTimeout(r.Context(), 200*time.Millisecond)显式设等待上限,防 goroutine 泄漏
响应头必须返回 X-RateLimit-* 标准字段
只返回 429 Too Many Requests 不够。下游服务、前端 SDK 或网关需要明确知道“还剩多少额度”“何时重置”,否则无法做退避或降级。
关键点在于:X-RateLimit-Remaining 不能靠简单减法算,得用 limiter.ReserveN 预占后再查:
-
X-RateLimit-Limit:固定值,即limiter.Limit() -
X-RateLimit-Remaining:用reserve.Limit()和reserve.Delay()推导,或调用limiter.Burst()减去当前已用数(需配合原子读) -
X-RateLimit-Reset:用time.Now().Add(time.Second / limiter.Limit()).Unix()计算重置时间戳 - 健康探针路径(如
/healthz)必须绕过限流中间件,否则 K8s 会因 429 反复重启 Pod
多实例部署时 rate.Limiter 失效怎么办
rate.Limiter 是纯内存实现,单机有效。3 个 Pod 各自维护一套计数器,总承载能力就是单机阈值 × 副本数——配置 100 QPS,实际可能扛住 300 QPS。
跨节点限流必须引入外部存储:
- Redis + Lua 是最常用方案,关键用原子脚本完成“清理窗口 + 计数 + 设置过期”三步,禁用
INCR + EXPIRE两步操作(存在竞态) - key 设计要带时间精度,例如
rate:ip:192.168.1.1:2026063014(小时级),比固定窗口更平滑 - 别自己实现分布式限流逻辑:Redis 的
eval脚本或成熟库(如gomodule/redigo配合 Lua)更可靠 - 真正难的不是技术选型,而是决定“谁该被限”——是按 IP、User-ID、API Path,还是组合维度(如
user:123:api:/v1/pay)
单机限流能解决 80% 场景,但上线前必须确认部署拓扑:K8s 多副本?Service Mesh?是否已有统一网关?这些决定了你到底要不要立刻上 Redis 方案。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











