ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis 从 0 到 1(十四):接口被刷爆?3 种限流算法的 Go+Redis 实现

Redis 从 0 到 1(十四):接口被刷爆?3 种限流算法的 Go+Redis 实现

我不会起名字322· 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

  • Redis 从 0 到 1(十四):接口被刷爆?3 种限流算法的 Go+Redis 实现
    • 一、现象:秒杀开始 1 秒,接口被打爆
    • 二、算法 1:固定窗口(最简单,有突刺)
    • 三、算法 2:滑动窗口(用 ZSet 精确计数)
    • 四、算法 3:令牌桶(平滑限流,预支能力)
    • 五、3 种算法对比与选型(收藏点 1)
    • 六、中间件封装(收藏点 2)
    • 七、命令清单与压测建议
    • 八、总结

Redis 从 0 到 1(十四):接口被刷爆?3 种限流算法的 Go+Redis 实现

上一篇《Redis 从 0 到 1(十三):分布式锁被误删、锁不住?Go 无 Lua 实现 3 级防御》我们做出了 redisx.Lock,链接:https://blog.csdn.net/2501_92769340/article/details/167399708 。
本篇把粒度从"同一时刻谁能做"放宽到"单位时间能做多少":限流。

一、现象:秒杀开始 1 秒,接口被打爆

活动开始瞬间,网关日志出现大量 502/超时。Redis 没挂、数据库没挂——是接口的 QPS 超过了它能承受的上限(假设单实例只能扛 2000 QPS),请求堆积把线程池耗光,雪崩开始。

限流的目标不是"拒绝用户",而是在入口就把超出的流量优雅地丢掉(返回 429 或降级数据),保护系统活到流量峰值过去。3 种经典算法各有坑,本篇用 Go+Redis 全部实现一遍。

二、算法 1:固定窗口(最简单,有突刺)

// AllowFixedWindow 每 window 秒允许 limit 次funcAllowFixedWindow(ctx context.Context,rdb*redis.Client,keystring,limitint64,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{returnfalse,err}returnincr.Val()<=limit,nil}

坑在哪:窗口边界突刺。限制 100 QPS,第 1 秒末尾来了 100 个请求,第 2 秒开头又来 100 个——窗口各自都没超限,但 2 秒内实际打了 200 QPS,服务照样被冲垮。

三、算法 2:滑动窗口(用 ZSet 精确计数)

思路:把每个请求的时间戳存进 ZSet(member=唯一 id,score=毫秒时间戳),每次检查"当前时间往前滑一个窗口"内的数量。

// AllowSlidingWindow 滑动窗口:窗口内请求精确计数funcAllowSlidingWindow(ctx context.Context,rdb*redis.Client,keystring,limitint64,window time.Duration)(bool,error){now:=time.Now().UnixMilli()winStart:=now-window.Milliseconds()zkey:="rl:sw:"+key// 用 WATCH 保证"清理旧记录 + 计数 + 写入新记录"原子(第 13 篇的套路)varallowedbool_,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 会在函数返回后统一 EXECreturnerr}allowed=cnt.Val()<limitifallowed{pipe.ZAdd(ctx,zkey,redis.Z{Score:float64(now),Member:fmt.Sprintf("%d",now)})pipe.Expire(ctx,zkey,window+time.Second)}returnnil})iferr!=nil{// 并发冲突时 WATCH 事务失败,直接按拒绝处理(限流宁可错杀)returnfalse,nil}returnallowed,nil}

代价:每个请求一条 ZAdd + 一条 ZRemRangeByScore + 一次事务,写放大明显。QPS 高(万级)时不划算——这时用算法 3。

四、算法 3:令牌桶(平滑限流,预支能力)

令牌桶:桶容量burst,每秒补充rate个令牌;请求拿走一个令牌才放行,桶空则拒绝。平滑(无突刺)且能容忍短时突发。

用 Redis Hash 存{tokens, last_refill_time},扣减逻辑用 WATCH/MULTI 保证并发下不多发:

typeTokenBucketstruct{rdb*redis.Client burstint64ratefloat64// 每秒补充的令牌数}func(b*TokenBucket)Allow(ctx context.Context,keystring)(bool,error){hkey:="rl:tb:"+keyvarallowedbool_,err:=b.rdb.TxPipelined(ctx,func(pipe redis.Pipeliner)error{hm:=pipe.HGetAll(ctx,hkey)if_,err:=pipe.Exec(ctx);err!=nil{returnerr}vals:=hm.Val()tokens:=b.burst last:=time.Now().UnixMilli()ift,ok:=vals["tokens"];ok{fmt.Sscanf(t,"%d",&tokens)fmt.Sscanf(vals["ts"],"%d",&last)}// 按流逝时间补充令牌,上限 burstrefill:=float64(time.Now().UnixMilli()-last)/1000*b.rate tokens=int64(math.Min(float64(tokens)+refill,float64(b.burst)))iftokens>0{allowed=truetokens--}pipe.HSet(ctx,hkey,"tokens",tokens,"ts",time.Now().UnixMilli())pipe.Expire(ctx,hkey,time.Hour)returnnil})iferr!=nil{returnfalse,nil}returnallowed,nil}

五、3 种算法对比与选型(收藏点 1)

维度固定窗口滑动窗口令牌桶
实现复杂度2 条命令ZSet + 事务Hash + 事务
突刺问题窗口边界双倍流量无无(突发被 burst 平滑限制)
写放大低高(每请求 3+ 写)中
适合场景粗粒度保护(如 1 分钟 1000 次)精度要求高的计数平滑 QPS + 容忍突发(API 网关)

组合用法是生产常态:外层固定窗口挡风暴(便宜),内层令牌桶平滑(精确)。

六、中间件封装(收藏点 2)

funcRateLimitMiddleware(rdb*redis.Client,tb*TokenBucket)func(http.Handler)http.Handler{returnfunc(next http.Handler)http.Handler{returnhttp.HandlerFunc(func(w http.ResponseWriter,r*http.Request){// 按用户/IP 维度限流:key 取登录用户 id 或 X-Forwarded-Forkey:=r.Header.Get("X-Forwarded-For")ok,err:=tb.Allow(r.Context(),key)iferr!=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%";边界测试用固定窗口故意在窗口边界打双倍流量,验证滑动窗口/令牌桶确实无突刺。

八、总结

  1. 固定窗口最简单但有边界突刺;滑动窗口精确但写放大;令牌桶平滑且容忍突发;
  2. 生产组合:外层固定窗口挡风暴 + 内层令牌桶平滑;
  3. 计数器 key 必带 TTL,否则限流器自己成为内存泄漏源;
  4. 事务冲突时宁可错杀(拒绝),不可错放。

下一篇预告:流量管住了,下一篇做一个高频业务——下一篇《千万玩家积分榜——ZSet 排行榜的 Go 实现 5 细节》,我们处理同分排序、分页、我的排名这些最容易踩坑的细节。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进