rate.Limiter在多节点下失效,因其纯内存实现导致各实例维护独立令牌桶,无法协同计数;压测正常而上线后QPS翻倍,根源在于无共享存储,必须用Redis+Lua原子脚本实现全局一致限流。

为什么本地 rate.Limiter 在多节点下完全失效
Go 标准库的 rate.Limiter 是纯内存实现,每个进程维护独立的令牌桶状态。一旦服务部署多个实例(比如 Kubernetes 多 Pod 或多台服务器),各节点之间不共享计数器,请求会被各自放行,整体流量远超预期阈值——这不是“限得不够严”,而是根本没在限同一个东西。
典型现象:压测时单节点限速正常,上线后 API 突然被刷爆;监控看到各节点 rate.Limiter.Allow() 返回都为 true,但总 QPS 已翻倍甚至十倍。
必须把速率限制逻辑下沉到一个所有节点都能原子读写的共享存储层。
用 Redis + Lua 实现原子计数是最稳妥的选择
Redis 的单线程模型和 EVAL 命令保证 Lua 脚本执行的原子性,适合实现带时间窗口、滑动窗口或漏桶语义的分布式限流。Go 侧只需调用一次 Eval,避免网络往返中的竞态。
关键点:
- 不要用
INCR+EXPIRE两步操作:存在竞态窗口(如 INCR 成功但 EXPIRE 失败,key 永久残留) - Lua 脚本里必须同时处理 key 初始化、计数递增、过期设置、返回是否允许,且用
redis.call("expire", KEYS[1], ARGV[2])而非expire命令字面量 - 推荐使用
github.com/redis/go-redis/v9,它对EVAL的参数类型检查更严格,避免因int/string混用导致脚本静默失败
示例脚本(固定窗口):
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call("incr", key)
if current == 1 then
redis.call("expire", key, window)
end
if current > limit then
return 0
else
return 1
end
Key 设计决定限流粒度,别硬编码字符串
限流效果取决于 key 的区分度。常见错误是把所有请求塞进同一个 key(如 "api:rate:global"),或按路径粗暴拼接("api:/user/" + userID 却忘了 URL 解码,导致 %40 和 @ 被视为不同 key)。
建议按场景分层设计:
- 用户级:用
userID(确保是可信后端 ID,不是前端传的任意字符串) - IP 级:用
realIP(从X-Forwarded-For提取并校验,防伪造) - API 级:用
method:path拼接,如"POST:/v1/order" - 组合级:如
"user:" + userID + ":post_order",注意长度控制,避免 Redis key 过长影响性能
Key 中禁止包含高基数字段(如毫秒级时间戳、随机 UUID),否则无法复用计数器,也易触发 Redis 内存爆炸。
客户端需容忍 Redis 不可用,但不能静默降级为不限流
当 Redis 连接超时或返回 redis.Nil(key 不存在)、redis.Timeout、redis.NetworkError 时,常见错误做法是直接返回 true(放行所有请求)或 false(全部拒绝)。
更合理策略:
- 连接失败时,启用本地
rate.Limiter作为保底(rate.NewLimiter(10, 20)),防止雪崩,但通过日志 / metric 明确标记 “fallback_to_local” - 绝不忽略错误:对
redis.Nil要重试(可能是刚初始化),对超时要熔断短时间(如 30 秒内不再请求 Redis),而非每次重试 - 在 HTTP middleware 中,若限流判定失败,应返回
503 Service Unavailable并带上Retry-After,而不是429(后者暗示“你超限了”,实际是系统故障)
真正难的不是写通 Redis 脚本,而是定义清楚:这个限流到底保护谁?是下游数据库连接数,还是第三方 API 配额,或是自身 CPU?粒度、窗口、响应行为都得跟着保护目标走。漏掉其中一环,再“正确”的代码也只是假安全感。


















