我不会起名字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%";边界测试用固定窗口故意在窗口边界打双倍流量,验证滑动窗口/令牌桶确实无突刺。
八、总结
- 固定窗口最简单但有边界突刺;滑动窗口精确但写放大;令牌桶平滑且容忍突发;
- 生产组合:外层固定窗口挡风暴 + 内层令牌桶平滑;
- 计数器 key 必带 TTL,否则限流器自己成为内存泄漏源;
- 事务冲突时宁可错杀(拒绝),不可错放。
下一篇预告:流量管住了,下一篇做一个高频业务——下一篇《千万玩家积分榜——ZSet 排行榜的 Go 实现 5 细节》,我们处理同分排序、分页、我的排名这些最容易踩坑的细节。