单机限流到分布式限流的过渡
Go服务做限流,单机场景用golang.org/x/time/rate包的令牌桶算法即可。但当服务部署为多实例后,每个实例各自独立限流,总QPS等于单实例阈值乘以实例数,无法控制全局QPS上限。把限流状态集中到Redis上,所有实例共享同一份计数器,就能实现精确的全局限流。
滑动窗口计数器实现
滑动窗口限流的核心思路:把时间窗口划分为多个子窗口,每个子窗口维护一个计数器,总窗口的请求量等于所有子窗口计数器之和。用Redis的Sorted Set存储:
func SlidingWindowLimit(ctx context.Context, rdb *redis.Client, key string, limit int64, window time.Duration) (bool, error) {
now := time.Now().UnixMilli()
windowStart := now - window.Milliseconds()
pipe := rdb.Pipeline()
pipe.ZRemRangeByScore(ctx, key, "0", strconv.FormatInt(windowStart, 10))
countCmd := pipe.ZCard(ctx, key)
member := uuid.New().String()
pipe.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: member})
pipe.Expire(ctx, key, window+time.Second)
_, err := pipe.Exec(ctx)
if err != nil { return false, err }
if countCmd.Val() >= limit {
rdb.ZRem(ctx, key, member)
return false, nil
}
return true, nil
}
这段代码用Pipeline把多个Redis命令打包一次发送,减少网络RTT。但存在一个竞态条件:ZCard和ZAdd之间可能有其他实例也在并发写入,导致实际请求数超过阈值。解决这个问题需要用Redis Lua脚本保证原子性。
Lua脚本实现原子滑动窗口
local window_start = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local member = ARGV[4]
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', window_start)
local count = redis.call('ZCARD', KEYS[1])
if count >= limit then
return 0
end
redis.call('ZADD', KEYS[1], now, member)
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[5]))
return 1
Lua脚本在Redis中单线程执行,保证了ZCard读到的计数值和ZAdd写入之间不会被其他请求插入。
令牌桶补充突发流量场景
滑动窗口的问题是它对突发流量不友好——窗口内请求数均匀分布时限流效果好,但如果客户端在窗口末尾集中发送请求,下一个窗口开头又集中发送,两个窗口各自的计数都在阈值内,但跨窗口边界的时间段内QPS翻倍。
令牌桶解决了这个问题。令牌桶允许短时间内的突发请求消耗桶中积累的令牌,只要平均速率不超过填充速率即可。Redis令牌桶的Lua脚本:
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local info = redis.call('HMGET', KEYS[1], 'tokens', 'last_time')
local tokens = tonumber(info[1]) or capacity
local last_time = tonumber(info[2]) or now
local elapsed = now - last_time
tokens = math.min(capacity, tokens + elapsed * rate / 1000)
if tokens < requested then
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_time', now)
return math.floor(tokens)
end
tokens = tokens - requested
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_time', now)
return math.floor(tokens)
组合方案:分层限流
实际生产环境中,建议两层限流并用:令牌桶控制全局限流(允许合理突发),滑动窗口控制单用户/单IP限流(严格限制)。Go中间件中先检查用户级滑动窗口,再检查全局限令牌桶。两层都通过才放行请求。
Redis部署建议用Cluster模式,限流key按用户ID做hash slot分片,避免单key成为热点。如果QPS极高(百万级),考虑用本地令牌桶+Redis周期同步的混合方案,本地做粗粒度限流,Redis做精确定期校准。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/go-yu-yan-shi-xian-fen-bu-shi-xian-liu-ji-yu-redis-de-hua/