限流算法选型:令牌桶 vs 滑动窗口 vs 固定窗口
高并发后端服务的限流需要根据业务特征选择算法:
- 固定窗口:实现简单,但窗口边界有突发流量翻倍问题
- 滑动窗口:精度高,内存开销大(需存储每个请求时间戳)
- 令牌桶:允许突发流量,匀速补充令牌,适合API限流
- 漏桶:强制匀速输出,不适合有突发特征的HTTP请求
实际生产中,令牌桶是最通用的方案,本文给出完整实现。
单机令牌桶限流器实现
package ratelimit
import (
"sync"
"time"
)
type TokenBucket struct {
mu sync.Mutex
rate float64 // 每秒补充令牌数
burst int // 桶容量(最大突发量)
tokens float64 // 当前令牌数
lastRefill time.Time // 上次补充时间
}
func NewTokenBucket(rate float64, burst int) *TokenBucket {
return &TokenBucket{
rate: rate,
burst: burst,
tokens: float64(burst),
lastRefill: time.Now(),
}
}
func (tb *TokenBucket) Allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()
now := time.Now()
elapsed := now.Sub(tb.lastRefill).Seconds()
tb.tokens += elapsed * tb.rate
if tb.tokens > float64(tb.burst) {
tb.tokens = float64(tb.burst)
}
tb.lastRefill = now
if tb.tokens >= 1 {
tb.tokens--
return true
}
return false
}
rate控制稳态吞吐,burst允许瞬间通过的最大请求数。例如rate=100、burst=20:稳态100 QPS,瞬时最多20个请求并发通过。
分布式限流:Redis + Lua原子操作
多实例部署时,单机限流无法保证全局限流精度。用Redis实现分布式令牌桶:
-- Lua脚本:原子化令牌桶
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local burst = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or burst
local last_refill = tonumber(bucket[2]) or now
-- 补充令牌
local elapsed = now - last_refill
tokens = math.min(burst, tokens + elapsed * rate)
last_refill = now
-- 尝试消费
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)
redis.call('EXPIRE', key, math.ceil(burst / rate) + 10)
return allowed
Go端调用:
func (r *RedisLimiter) Allow(ctx context.Context, key string) (bool, error) {
now := time.Now().UnixMilli() / 1000.0
result, err := r.luaScript.Run(
ctx, r.client,
[]string{fmt.Sprintf("ratelimit:%s", key)},
r.rate, r.burst, now,
).Int64()
return result == 1, err
}
Lua脚本在Redis单线程内原子执行,避免并发竞态。key设TTL自动清理过期限流器。
滑动窗口限流:精确控制每分钟请求量
对需要严格限制请求总量的场景(如第三方API调用配额),滑动窗口比令牌桶更直观:
-- Redis滑动窗口Lua脚本
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2]) -- 窗口秒数
local now = tonumber(ARGV[3])
redis.call('ZADD', key, now, now) -- member和score都用时间戳
redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000)
local count = redis.call('ZCARD', key)
redis.call('EXPIRE', key, window + 1)
if count <= limit then
return 1
else
redis.call('ZREM', key, now) -- 回滚
return 0
end
用Sorted Set存储请求时间戳,ZREMRANGEBYSCORE清除窗口外的记录。内存开销:每个请求占8字节(时间戳),1万QPS的分钟窗口约480KB,可控。
限流中间件集成与降级策略
限流器需集成到HTTP中间件层,对被拒绝的请求返回合理的响应:
func RateLimitMiddleware(limiter *TokenBucket) gin.HandlerFunc {
return func(c *gin.Context) {
if !limiter.Allow() {
c.JSON(http.StatusTooManyRequests, gin.H{
"code": 429,
"message": "请求过于频繁,请稍后重试",
"retry_after": 1, // 秒
})
c.Abort()
return
}
c.Next()
}
}
返回429状态码和Retry-After头,客户端可据此实现退避重试。降级策略:限流触发时不直接丢弃请求,而是排入延迟队列异步处理,适用于写操作。读操作则走缓存降级,返回过期数据而非空结果。
监控维度:限流触发率、被限流请求的P99延迟、各key维度的QPS分布。接入Prometheus后用rate(limiter_rejected_total[5m])告警,超过5%触发扩容或降级。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/go-yu-yan-gao-bing-fa-xian-liu-qi-shi-xian-ling-pai-tong-yu/