rate.Limiter仅适用于单实例场景,需复用实例、配合context.WithTimeout、避免循环新建,并注意rate.Every单位非QPS;多实例必须改用Redis/Lua、Sentinel或Istio等分布式方案。

rate.Limiter 在单实例场景下怎么用才不踩坑
直接 new 一个 rate.Limiter 然后塞进 HTTP handler 里,看似简单,但很容易忽略它的作用域边界——它只对当前 goroutine 生效,且完全不感知其他实例、不共享状态。如果你的网关是多进程部署(比如 k8s 多副本),那每个实例都独立计数,限流阈值实际被放大了 N 倍。
正确做法是:只在明确单实例、无横向扩展需求的内部服务中使用它,比如本地 CLI 工具调用 API 的客户端限流,或开发环境 mock server 的自我保护。
- 必须配合
context.WithTimeout使用,防止limiter.Wait阻塞过久 - 不要在循环里反复 new
rate.Limiter,复用同一个实例;否则令牌桶初始状态丢失 - 注意
rate.Every的单位是时间间隔(如rate.Every(100 * time.Millisecond)),不是 QPS 数值,别写成rate.Every(10)
分布式限流必须绕开 rate.Limiter
一旦服务部署超过一台机器,rate.Limiter 就失效了。你看到的“每秒 100 请求”限制,其实是每台机器各自放行 100 请求,总流量可能飙到 100 × 实例数。
真正可用的方案得依赖外部共享状态:
立即学习“go语言免费学习笔记(深入)”;
- 用 Redis + Lua 脚本实现原子性令牌桶或滑动窗口(推荐
INCR+EXPIRE组合,避免GETSET时间窗错位) - 接入 Sentinel 或 Nacos 的流控模块,走标准协议而非自研
- 如果已有 gRPC 服务网格,优先用 Istio 的
EnvoyFilter在 sidecar 层做统一限流,不侵入业务代码
别试图用 etcd watch + 内存计数同步——网络分区时极易超发,且延迟不可控。
中间件层嵌入限流器的典型姿势
在你那个基于 gnet 的多协议网关里,限流逻辑必须放在中间件链的固定位置:IP 过滤之后、JWT 认证之后、负载均衡之前。顺序错了会导致认证失败请求也被计数,或绕过鉴权直接打穿后端。
示例结构(伪代码):
func RateLimitMiddleware() Middleware {
return func(next Handler) Handler {
return func(ctx Context) {
key := buildKey(ctx) // 比如 clientIP + routeID + userID(若已认证)
if !redisBucket.Allow(key) {
ctx.AbortWithStatus(http.StatusTooManyRequests)
return
}
next(ctx)
}
}
}-
buildKey必须稳定:同一用户/客户端在不同协议(HTTP/gRPC/WebSocket)下生成相同 key - 避免用完整 URL 做 key,容易被恶意构造导致 Redis 内存爆炸;路径前缀 + query 参数白名单更安全
- gRPC 场景下注意拦截的是
UnaryServerInterceptor,不是 HTTP handler
熔断和限流别混在同一层做判断
限流是防过载,熔断是防雪崩,目标不同,触发条件和恢复机制也不同。把它们揉进同一个中间件,会导致逻辑耦合、指标混乱、调试困难。
常见错误:
- 用同一个 Redis key 同时存限流计数和错误率,结果熔断状态被限流重置覆盖
- 限流返回 429 后,下游服务仍继续处理并抛错,让熔断器误判为“下游不稳定”
- 滑动窗口统计周期设成 60 秒,但限流窗口是 1 秒,两者节奏不一致,熔断阈值永远达不到
真实生产中,限流器应尽可能前置(靠近接入层),熔断器紧贴代理层,数据来源也要隔离:限流看请求到达量,熔断看后端响应成功率与延迟 P99。


















