基于Go+Redis实现3种限流算法
一、现象:秒杀开始 1 秒,接口被打爆
活动开始瞬间,网关日志出现大量 502/超时。Redis 没挂、数据库没挂——是接口的 QPS 超过了它能承受的上限(假设单实例只能扛 2000 QPS),请求堆积把线程池耗光,雪崩开始。
限流的目标不是"拒绝用户",而是在入口就把超出的流量优雅地丢掉(返回 429 或降级数据),保护系统活到流量峰值过去。3 种经典算法各有坑,本篇用 Go+Redis 全部实现一遍。
二、算法 1:固定窗口(最简单,有突刺)
// AllowFixedWindow 每 window 秒允许 limit 次
func AllowFixedWindow(ctx context.Context, rdb *redis.Client, key string, limit int64, window time.Duration) (bool, error) {
// INCR + EXPIRE 的经典组合;key 带时间窗口戳天然滚动
winKey := fmt.Sprintf("rl:%s:%d", key, time.Now().Unix()/int64(window.Seconds()))
pipe := rdb.Pipeline()
incr := pipe.Incr(ctx, winKey)
pipe.Expire(ctx, winKey, window+time.Second) // 多留 1 秒防边界误差
if _, err := pipe.Exec(ctx); err != nil {
return false, err
}
return incr.Val() <= limit, nil
}
坑在哪:窗口边界突刺。限制 100 QPS,第 1 秒末尾来了 100 个请求,第 2 秒开头又来 100 个——窗口各自都没超限,但 2 秒内实际打了 200 QPS,服务照样被冲垮。
三、算法 2:滑动窗口(用 ZSet 精确计数)
思路:把每个请求的时间戳存进 ZSet(member=唯一 id,score=毫秒时间戳),每次检查"当前时间往前滑一个窗口"内的数量。
// AllowSlidingWindow 滑动窗口:窗口内请求精确计数
func AllowSlidingWindow(ctx context.Context, rdb *redis.Client, key string, limit int64, window time.Duration) (bool, error) {
now := time.Now().UnixMilli()
winStart := now - window.Milliseconds()
zkey := "rl:sw:" + key
// 用 WATCH 保证"清理旧记录 + 计数 + 写入新记录"原子(第 13 篇的套路)
var allowed bool
_, err := rdb.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.ZRemRangeByScore(ctx, zkey, "0", fmt.Sprintf("%d", winStart)) // 清掉窗口外的
cnt := pipe.ZCard(ctx, zkey)
if _, err := pipe.Exec(ctx); err != nil { // 占位:go-redis 会在函数返回后统一 EXEC
return err
}
allowed = cnt.Val() < limit
if allowed {
pipe.ZAdd(ctx, zkey, redis.Z{Score: float64(now), Member: fmt.Sprintf("%d", now)})
pipe.Expire(ctx, zkey, window+time.Second)
}
return nil
})
if err != nil {
// 并发冲突时 WATCH 事务失败,直接按拒绝处理(限流宁可错杀)
return false, nil
}
return allowed, nil
}
代价:每个请求一条 ZAdd + 一条 ZRemRangeByScore + 一次事务,写放大明显。QPS 高(万级)时不划算——这时用算法 3。
四、算法 3:令牌桶(平滑限流,预支能力)
令牌桶:桶容量 burst,每秒补充 rate 个令牌;请求拿走一个令牌才放行,桶空则拒绝。平滑(无突刺)且能容忍短时突发。
用 Redis Hash 存 {tokens, last_refill_time},扣减逻辑用 WATCH/MULTI 保证并发下不多发:
type TokenBucket struct {
rdb *redis.Client
burst int64
rate float64 // 每秒补充的令牌数
}
func (b *TokenBucket) Allow(ctx context.Context, key string) (bool, error) {
hkey := "rl:tb:" + key
var allowed bool
_, err := b.rdb.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
hm := pipe.HGetAll(ctx, hkey)
if _, err := pipe.Exec(ctx); err != nil {
return err
}
vals := hm.Val()
tokens := b.burst
last := time.Now().UnixMilli()
if t, ok := vals["tokens"]; ok {
fmt.Sscanf(t, "%d", &tokens)
fmt.Sscanf(vals["ts"], "%d", &last)
}
// 按流逝时间补充令牌,上限 burst
refill := float64(time.Now().UnixMilli()-last) / 1000 * b.rate
tokens = int64(math.Min(float64(tokens)+refill, float64(b.burst)))
if tokens > 0 {
allowed = true
tokens--
}
pipe.HSet(ctx, hkey, "tokens", tokens, "ts", time.Now().UnixMilli())
pipe.Expire(ctx, hkey, time.Hour)
return nil
})
if err != nil {
return false, nil
}
return allowed, nil
}
五、3 种算法对比与选型(收藏点 1)
| 维度 | 固定窗口 | 滑动窗口 | 令牌桶 |
|---|---|---|---|
| 实现复杂度 | 2 条命令 | ZSet + 事务 | Hash + 事务 |
| 突刺问题 | 窗口边界双倍流量 | 无 | 无(突发被 burst 平滑限制) |
| 写放大 | 低 | 高(每请求 3+ 写) | 中 |
| 适合场景 | 粗粒度保护(如 1 分钟 1000 次) | 精度要求高的计数 | 平滑 QPS + 容忍突发(API 网关) |
组合用法是生产常态:外层固定窗口挡风暴(便宜),内层令牌桶平滑(精确)。
六、中间件封装(收藏点 2)
func RateLimitMiddleware(rdb *redis.Client, tb *TokenBucket) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 按用户/IP 维度限流:key 取登录用户 id 或 X-Forwarded-For
key := r.Header.Get("X-Forwarded-For")
ok, err := tb.Allow(r.Context(), key)
if err != nil || !ok {
w.Header().Set("Retry-After", "1")
http.Error(w, `{"code":429,"msg":"请求过于频繁"}`, http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
配套约定:返回 429 要带 Retry-After;限流维度选"谁"要想清楚——按 IP 会误伤 NAT 后的公司出口,按用户 id 会被小号绕过。
七、命令清单与压测建议
| 命令 | 用于算法 |
|---|---|
INCR + EXPIRE | 固定窗口 |
ZADD/ZCARD/ZREMRANGEBYSCORE/ZREVRANGE | 滑动窗口 |
HGETALL/HSET + WATCH/MULTI | 令牌桶 |
EXPIRE | 三种都要:计数器 key 必须带 TTL,否则内存泄漏 |
压测建议:用第 5 篇的压测器改造,验证"100 QPS 限制下压 500 QPS,1 秒吞吐 ≈100,拒绝率 ≈80%";边界测试用固定窗口故意在窗口边界打双倍流量,验证滑动窗口/令牌桶确实无突刺。
八、总结
- 固定窗口最简单但有边界突刺;滑动窗口精确但写放大;令牌桶平滑且容忍突发;
- 生产组合:外层固定窗口挡风暴 + 内层令牌桶平滑;
- 计数器 key 必带 TTL,否则限流器自己成为内存泄漏源;
- 事务冲突时宁可错杀(拒绝),不可错放。
到此这篇关于基于Go+Redis实现3种限流算法的文章就介绍到这了,更多相关Go Redis限流算法内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
相关文章
GoFrame框架gset使用对比PHP Java Redis优势
这篇文章主要为大家介绍了GoFrame框架gset对比PHP Java Redis的使用优势详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪2022-06-06
如何使用Go语言获取当天、昨天、明天、某天0点时间戳以及格式化时间
这篇文章主要给大家介绍了关于如何使用Go语言获取当天、昨天、明天、某天0点时间戳以及格式化时间的相关资料,格式化时间戳是将时间戳转换为特定的日期和时间格式,文中通过代码示例介绍的非常详细,需要的朋友可以参考下2023-10-10


最新评论