Redis+Lua分布式限流器实战:从算法选型到Go封装
1. 为什么单机限流在分布式场景下会集体失效很多团队第一次做限流都是在业务代码里塞一个令牌桶或者用golang.org/x/time/rate的Limiter。单机跑起来没问题压测也能看到 QPS 被压在阈值附近。可一旦服务扩到多个实例问题就来了每个实例各自维护一份令牌实际放过的总流量等于「单机阈值 × 实例数」。你设了 100 QPS部署 8 个 Pod真实入口就是 800 QPS下游数据库照样被打穿。这个坑的本质是限流状态没有共享。限流器要判断「当前还能不能放行」靠的是一个全局计数器或令牌余量。单机方案把这个状态放在进程内存里进程之间互相看不见自然各放各的。要解决它就得把这份状态挪到一个所有实例都能访问的地方并且保证「读-判断-写」这一串动作是原子的。Redis 天然适合干这件事它本身是集中式的、单线程执行命令、支持原子操作还内置了 Lua 脚本能力。把限流逻辑写进 Lua让 Redis 一次性执行完就同时拿到了状态共享和原子性两个关键属性。这也是「Redis Lua 分布式限流器」这套组合在工程里长期流行的根本原因。不过我得先把预期说清楚这套方案不是银弹。它引入了对 Redis 的网络依赖每次限流判断都是一次 RTT往返时延Redis 挂了限流就失效。所以它适合「限流精度要求中等、能接受毫秒级额外延迟、Redis 本身有高可用」的场景。如果你的场景要求微秒级判断、或者完全不能容忍外部依赖那得换思路比如本地令牌桶做粗限流、Redis 做全局兜底的两级方案。下面我会从算法选型一路讲到 Lua 脚本怎么写、Go 侧怎么封装、线上踩过哪些坑尽量把每个决策背后的「为什么」讲透让你看完能直接抄作业也能明白抄的每一步在干嘛。2. 固定窗口、滑动窗口、令牌桶到底该选哪个在动手写代码之前选算法是最容易被忽略、却最影响最终效果的一步。很多人上来就写令牌桶结果发现突发流量控制不住或者计数对不齐。我先把三种常见算法在 Redis 场景下的表现摊开讲。2.1 固定窗口计数实现最简单但有临界突刺固定窗口的思路是把时间切成一个个等长窗口比如每秒一个每个窗口内维护一个计数器请求来了就INCR超过阈值就拒绝窗口到期计数器归零。用 Redis 实现非常直接-- 固定窗口限流 local key KEYS[1] local limit tonumber(ARGV[1]) local window tonumber(ARGV[2]) local current redis.call(INCR, key) if current 1 then redis.call(EXPIRE, key, window) end if current limit then return 0 end return 1这段脚本逻辑清晰INCR本身是原子的配合EXPIRE设置窗口过期基本能用。但它有个经典缺陷临界突刺。假设限流是 100 次/秒窗口按自然秒切分。在 0.9 秒到 1.1 秒这 200 毫秒里前一个窗口的尾部放 100 次后一个窗口的头部又放 100 次实际 200 毫秒内过了 200 次瞬时压力是阈值的两倍。对下游敏感的服务来说这个突刺足以引发雪崩。所以固定窗口只适合「对精度不敏感、只想粗粗挡一下」的场景比如给某个非核心接口做个兜底。2.2 滑动窗口精度高但内存和计算成本上来了滑动窗口要解决的就是临界突刺。它的思路是记录每个请求的时间戳判断「过去 N 秒内」的请求数是否超限。最朴素的实现是用 Redis 的ZSETscore 存时间戳member 存请求唯一标识每次请求先ZREMRANGEBYSCORE清掉过期成员再ZCARD统计最后ZADD加入当前请求。-- 滑动窗口限流ZSET 实现 local key KEYS[1] local limit tonumber(ARGV[1]) local window tonumber(ARGV[2]) local now tonumber(ARGV[3]) -- 清除窗口外的请求 redis.call(ZREMRANGEBYSCORE, key, 0, now - window * 1000) -- 统计窗口内请求数 local count redis.call(ZCARD, key) if count limit then return 0 end -- 加入当前请求 redis.call(ZADD, key, now, now .. - .. math.random(1000000)) redis.call(PEXPIRE, key, window * 1000) return 1精度确实高任意时刻都严格保证「过去 N 秒内不超过 limit」。但代价也明显每个请求都要在 ZSET 里存一条记录高 QPS 下这个 ZSET 会非常大内存占用和ZREMRANGEBYSCORE的清理开销都上去了。如果限流阈值是每秒几万次这个方案基本不可行。工程上更常用的是滑动窗口计数的近似版把窗口再细分成若干小格每格一个计数器统计时把当前窗口覆盖的所有小格加起来。这样内存可控精度也够用是精度和成本之间比较好的平衡点。2.3 令牌桶能控平均速率也能容忍一定突发令牌桶是我个人在大多数业务限流场景里最推荐的。它的模型是桶里最多放 N 个令牌系统以固定速率往桶里加令牌请求来了要先拿一个令牌拿不到就拒绝。它的好处是既能限制平均速率又能允许一定程度的突发——只要桶里有存货瞬间来一波请求也能扛住。在 Redis 里实现令牌桶关键是把「上次补充令牌的时间」和「当前令牌数」存下来每次请求时按时间差算出应该补多少令牌。这套逻辑用 Lua 写最合适因为涉及读、算、写三步必须原子。-- 令牌桶限流 local key KEYS[1] local capacity tonumber(ARGV[1]) -- 桶容量 local rate tonumber(ARGV[2]) -- 每秒补充令牌数 local now tonumber(ARGV[3]) -- 当前时间毫秒 local requested tonumber(ARGV[4]) -- 本次请求令牌数 local bucket redis.call(HMGET, key, tokens, last_refill) local tokens tonumber(bucket[1]) local last_refill tonumber(bucket[2]) if tokens nil then tokens capacity last_refill now end -- 按时间差补充令牌 local delta math.max(0, now - last_refill) local refill delta * rate / 1000 tokens math.min(capacity, tokens refill) local allowed 0 if tokens requested then tokens tokens - requested allowed 1 end redis.call(HMSET, key, tokens, tokens, last_refill, now) -- 设置过期时间避免冷 key 常驻内存 redis.call(PEXPIRE, key, math.ceil(capacity / rate * 1000) 1000) return allowed这段脚本有几个细节值得说。第一last_refill只在请求到来时更新也就是说令牌是「惰性补充」的没有后台定时任务这对 Redis 很友好。第二过期时间设成「桶从空到满所需时间 1 秒」保证冷 key 能自动回收。第三requested参数让这个限流器支持「一次拿多个令牌」方便做加权限流比如某个重接口一次消耗 5 个令牌。三种算法的取舍我整理成一张表方便你按场景对号入座算法精度突发容忍内存开销实现复杂度适用场景固定窗口低无极低低非核心接口兜底滑动窗口高无高中精度要求高的核心接口令牌桶中高有低中大多数业务限流选型时我的经验是先问清楚「超限的后果是什么」。如果超一点只是慢令牌桶足够如果超一点就资损或数据错乱那得上滑动窗口甚至更严格的方案。3. 把限流逻辑塞进 Lua 脚本的原子性考量选好算法只是第一步真正决定这套方案靠不靠谱的是 Lua 脚本怎么写、怎么被调用。这一节我重点讲原子性、KEYS/ARGV 规范、以及几个容易写错的边界。3.1 为什么必须用 Lua而不是多条 Redis 命令假设不用 Lua在 Go 里这样写令牌桶tokens, _ : redis.Get(ctx, key).Int() // 中间做一堆计算 redis.Set(ctx, key, newTokens, 0)问题出在「读」和「写」之间。两个并发请求可能同时读到tokens 1都判断「够用」然后都写回tokens 0结果放过了两个请求但实际只该放一个。这就是典型的竞态条件。有人会说用WATCHMULTI事务。可以但WATCH在冲突时需要重试高并发下重试率会很高性能反而更差。Lua 脚本则是 Redis 单线程内一次性执行完中间不会被其他命令打断天然原子没有重试开销。所以只要涉及「读-判断-写」就应该用 Lua。3.2 KEYS 和 ARGV 的规范用法Redis 对 Lua 脚本有个硬性要求所有操作的 key 必须通过KEYS数组传入不能把 key 名拼在ARGV里。这不是洁癖而是为了在 Redis 集群模式下让代理层能根据KEYS判断这个脚本该路由到哪个分片。如果你把 key 藏在ARGV里集群模式下会直接报错。// 正确key 走 KEYS参数走 ARGV script : redis.NewScript(luaScript) result, err : script.Run(ctx, rdb, []string{limitKey}, capacity, rate, now, requested).Int() // 错误key 拼进 ARGV集群下会失败 result, err : script.Run(ctx, rdb, []string{}, limitKey, capacity, rate, now, requested).Int()另外Lua 脚本里用到的所有 key 最好都显式声明在KEYS里哪怕只是用来做前缀拼接。这样脚本的可读性和可维护性都更好也方便后续做 key 的监控。3.3 时间从哪来客户端传还是 Redis 取令牌桶脚本里我用的是客户端传入的now毫秒时间戳。这里有个权衡用客户端时间多实例之间时钟可能有偏差用 Redis 的TIME命令则完全以服务端为准更一致。-- 用 Redis 服务端时间避免客户端时钟偏差 local time redis.call(TIME) local now tonumber(time[1]) * 1000 math.floor(tonumber(time[2]) / 1000)我建议优先用 Redis 的TIME。原因很简单分布式环境下各机器时钟不可能完全同步哪怕差几十毫秒在高频限流下也会导致令牌补充量算错。用服务端时间所有实例看到的是同一个时钟逻辑最干净。代价是每次多一次TIME调用但这个开销在脚本内部可以忽略。3.4 脚本缓存与 EVALSHA每次限流都传一遍完整 Lua 脚本网络开销不小。Redis 提供了EVALSHA先用SCRIPT LOAD把脚本加载进去拿到 SHA1之后用 SHA1 调用。Go 的go-redis客户端里redis.NewScript已经帮你做了这层封装——第一次用EVAL之后自动切EVALSHA如果遇到NOSCRIPT错误还会自动回退重载。所以你不用手动管但要知道这层机制存在排查问题时能对上号。注意Redis 重启或主从切换后脚本缓存会丢失。go-redis的自动回退能兜住这个情况但如果你用的是更底层的客户端记得处理NOSCRIPT异常。4. Go 侧封装从裸脚本到可复用的限流器Lua 脚本写好了接下来是 Go 侧怎么把它包装成一个好用、线程安全、可配置的限流器。这一节我给出一个可以直接落地的封装并解释每个设计决策。4.1 定义限流器接口与配置先定接口方便后续替换实现或做 mock 测试package ratelimit import context // Limiter 限流器接口 type Limiter interface { // Allow 判断是否放行返回 true 表示放行 Allow(ctx context.Context, key string) (bool, error) // AllowN 一次申请 n 个令牌 AllowN(ctx context.Context, key string, n int) (bool, error) }配置结构体把桶容量、补充速率、key 前缀都收进来type Config struct { Capacity int // 桶容量决定突发上限 Rate float64 // 每秒补充令牌数决定平均速率 KeyPrefix string // Redis key 前缀便于隔离不同业务 Expire time.Duration // key 过期时间防止冷 key 堆积 }Capacity和Rate的关系值得单独说。如果Capacity Rate那这个桶基本没有突发能力退化成匀速放行如果Capacity远大于Rate就能容忍大突发但突发过后要等很久才能恢复。我的经验值是Capacity取Rate的 1 到 3 倍既能吸收正常抖动又不会让突发失控。4.2 基于 go-redis 的令牌桶实现下面是完整实现用的是github.com/redis/go-redis/v9package ratelimit import ( context fmt time github.com/redis/go-redis/v9 ) const tokenBucketScript local key KEYS[1] local capacity tonumber(ARGV[1]) local rate tonumber(ARGV[2]) local requested tonumber(ARGV[3]) local expire_ms tonumber(ARGV[4]) -- 使用 Redis 服务端时间 local t redis.call(TIME) local now tonumber(t[1]) * 1000 math.floor(tonumber(t[2]) / 1000) local bucket redis.call(HMGET, key, tokens, last_refill) local tokens tonumber(bucket[1]) local last_refill tonumber(bucket[2]) if tokens nil then tokens capacity last_refill now end local delta math.max(0, now - last_refill) local refill delta * rate / 1000 tokens math.min(capacity, tokens refill) local allowed 0 if tokens requested then tokens tokens - requested allowed 1 end redis.call(HMSET, key, tokens, tokens, last_refill, now) redis.call(PEXPIRE, key, expire_ms) return allowed type redisLimiter struct { rdb redis.UniversalClient script *redis.Script cfg Config } func NewRedisLimiter(rdb redis.UniversalClient, cfg Config) Limiter { if cfg.Capacity 0 { cfg.Capacity 100 } if cfg.Rate 0 { cfg.Rate 100 } if cfg.Expire 0 { // 桶从空到满所需时间 1 秒 cfg.Expire time.Duration(float64(cfg.Capacity)/cfg.Rate*1000)*time.Millisecond time.Second } return redisLimiter{ rdb: rdb, script: redis.NewScript(tokenBucketScript), cfg: cfg, } } func (l *redisLimiter) Allow(ctx context.Context, key string) (bool, error) { return l.AllowN(ctx, key, 1) } func (l *redisLimiter) AllowN(ctx context.Context, key string, n int) (bool, error) { if n 0 { return true, nil } fullKey : fmt.Sprintf(%s:%s, l.cfg.KeyPrefix, key) res, err : l.script.Run(ctx, l.rdb, []string{fullKey}, l.cfg.Capacity, l.cfg.Rate, n, l.cfg.Expire.Milliseconds(), ).Int() if err ! nil { return false, err } return res 1, nil }几个实现细节解释一下。redis.UniversalClient是go-redis提供的通用接口单机、哨兵、集群都能用这样限流器不绑定具体部署形态。redis.NewScript内部维护了 SHA1 缓存第一次Run会走EVAL之后走EVALSHA遇到脚本丢失会自动重载省心。4.3 失败时放行还是拒绝一个必须想清楚的问题上面的实现里Redis 报错时直接返回false, err也就是拒绝请求。这在限流器里叫「fail-closed」。但这不是唯一选择很多场景反而应该「fail-open」——Redis 挂了就放行别让限流器成为单点故障。func (l *redisLimiter) AllowN(ctx context.Context, key string, n int) (bool, error) { // ... 前面的逻辑 res, err : l.script.Run(ctx, l.rdb, []string{fullKey}, ...).Int() if err ! nil { // fail-openRedis 异常时放行并记录日志告警 return true, err } return res 1, nil }到底选哪个取决于「限流失效」和「误杀请求」哪个后果更严重。如果是保护数据库的限流Redis 挂了数据库本来也快挂了fail-open 可能加速雪崩那就 fail-closed如果是给第三方接口做配额控制误杀用户请求的代价更大那就 fail-open。我的做法是把策略做成配置项让业务方自己选而不是替他们拍板。4.4 本地缓存兜底两级限流的思路纯 Redis 限流有个绕不开的问题每次判断都要一次网络往返。QPS 上万时Redis 的带宽和连接数都会吃紧。一个常见的优化是加一层本地令牌桶做粗筛本地桶先挡掉明显超限的请求只有本地桶放行的才去问 Redis。type twoLevelLimiter struct { local *rate.Limiter // golang.org/x/time/rate remote Limiter } func (l *twoLevelLimiter) Allow(ctx context.Context, key string) (bool, error) { // 本地粗筛本地桶容量设成全局阈值的 1/N if !l.local.Allow() { return false, nil } // 本地放行的再走 Redis 精确判断 return l.remote.Allow(ctx, key) }本地桶的容量要设得比全局阈值小比如全局 1000 QPS、8 个实例本地桶设 150 QPS 左右留点余量。这样大部分超限请求在本地就被挡了Redis 压力大幅下降。代价是限流精度略有损失但换来的是吞吐和稳定性多数场景划算。5. 线上跑起来才会遇到的坑与排查链路代码写完、单测通过不代表线上就稳。这套方案我在实际项目里踩过几个印象深刻的坑这里把排查过程完整还原你遇到类似现象时能少走弯路。5.1 令牌数出现负数浮点精度与并发写有次监控发现某个 key 的tokens字段是负数限流形同虚设。排查时先怀疑脚本逻辑把脚本单独拿出来在 Redis 里手动跑发现单次执行没问题。后来用MONITOR抓了一段时间的命令流才定位到原因HMSET写入的tokens是浮点数而HMGET读出来再tonumber时浮点精度导致极小概率下tokens被算成略小于 0。修复方式是在扣减后加一个下限保护if tokens 0 then tokens 0 end同时在补充令牌时用math.floor或保留足够精度。这个坑的教训是Lua 里的数字都是 double涉及金额、计数这类需要精确的场景要么用整数运算要么显式做边界保护。别指望浮点运算永远精确。5.2 冷 key 堆积把内存吃满上线一段时间后Redis 内存持续上涨INFO memory显示大量 key 长期不释放。原因是限流 key 是按用户 ID 或 IP 维度生成的长尾用户访问一次就留下一个 key虽然设了PEXPIRE但如果Expire设得太长比如按「桶满时间」算出来是几小时这些冷 key 就会堆积。解决办法有两个。一是缩短过期时间令牌桶的 key 其实只需要保留「桶从空到满」的时间就够了超过这个时间桶早就满了状态没有意义。二是对 key 做维度收敛比如按用户分群限流而不是按单个用户或者对超长尾的 key 用统一的兜底桶。我后来把Expire统一压到「桶满时间 1 秒」内存立刻降下来了。5.3 主从切换瞬间的限流失效Redis 主从切换时如果限流 key 还没同步到新主新主上读到的tokens是nil脚本会当成新桶处理直接给满额令牌。切换瞬间可能放过一波超量请求。这个问题的根因是限流状态是易失的不能指望它像数据库一样强一致。缓解思路一是接受这个现实限流本来就是「尽力而为」切换瞬间的抖动可以容忍二是如果实在不能容忍可以在切换后的一小段时间内把阈值临时调低等状态重建后再恢复。我一般选第一种因为为了一次切换去做复杂的状态同步性价比太低。5.4 排查限流问题的通用链路把上面的经验抽象一下遇到「限流不符合预期」时我通常按这个顺序排查确认 key 是否正确用redis-cli直接HGETALL那个 key看tokens和last_refill是否符合预期。key 拼错、前缀不一致是最常见的原因。确认脚本版本SCRIPT EXISTS sha1看脚本是否还在缓存里不在的话是不是被SCRIPT FLUSH清过。确认时间源如果用的是客户端时间检查各实例时钟是否同步用服务端时间则跳过这步。确认并发量用MONITOR或慢查询日志看脚本执行频率判断是不是 QPS 太高导致 Redis 成为瓶颈。确认失败策略看 Redis 报错时是 fail-open 还是 fail-closed很多「限流失效」其实是 fail-open 把错误吞了。提示排查时优先用redis-cli --eval手动跑脚本把线上参数原样传进去能快速区分是脚本逻辑问题还是调用方问题。6. 参数怎么调容量、速率与业务阈值的换算限流器能不能真正保护系统很大程度上取决于参数调得对不对。这一节讲怎么从业务指标反推出Capacity和Rate以及几个常见的调参误区。6.1 从下游承载能力倒推阈值限流阈值不该拍脑袋定而应该从下游能承受的最大压力倒推。假设你的服务要保护一个数据库数据库压测显示稳定支撑 2000 QPS那所有打到这个库的入口加起来限流阈值之和就不能超过 2000还要留 20% 余量也就是 1600 左右。如果服务有多个入口、多个实例还要考虑限流 key 的维度。按接口维度限流每个接口一个桶按用户维度限流每个用户一个桶。维度越细保护越精准但 key 数量越多Redis 内存压力越大。我的经验是核心接口按接口维度限流非核心接口按服务维度限流兼顾精度和成本。6.2 容量与速率的配比实验Capacity和Rate的配比直接决定限流器的「性格」。我做过一组对比实验同样是平均 100 QPS 的限流不同配比下的表现差异很大CapacityRate突发容忍恢复速度适用场景100100无快严格匀速如计费接口200100中等中常规业务接口500100高慢允许大突发的批处理入口配比没有标准答案得结合业务的流量特征。如果流量本身很平稳Capacity Rate就够如果流量有明显波峰波谷Capacity要放大到能吸收波峰。我一般会先按Capacity 2 × Rate上线观察一周的拒绝率和 Redis 内存再微调。6.3 别把限流阈值设成「刚好等于容量」一个常见误区是把限流阈值设成下游容量的 100%。这样一旦有限流器本身的误差、或者流量统计的延迟下游立刻过载。正确做法是留出安全余量通常 70% 到 80% 比较稳妥。限流器的职责是「在异常流量下保护系统」而不是「把系统压到极限」留余量是给自己留退路。另外限流阈值应该可动态调整。业务流量会变硬编码的阈值迟早过时。可以把阈值放到配置中心配合监控做动态下发这样大促前调高、平时调低不用改代码重新发版。7. 写在最后几个我反复验证过的经验这套 Redis Lua 限流器我在几个项目里都用过从最初的裸脚本到后来的两级限流中间踩的坑基本都在上面了。最后再补几个零散但很实用的点。第一限流器一定要有监控。至少暴露三个指标放行数、拒绝数、Redis 调用耗时。拒绝数突然飙升可能是阈值设低了也可能是真有人在刷耗时飙升说明 Redis 有压力该考虑加本地兜底了。没有监控的限流器等于没有。第二单测要覆盖边界。令牌刚好用完、请求数刚好等于剩余令牌、requested大于Capacity、Redis 返回错误这些边界都得测。我见过因为requested Capacity导致永远拒绝的 bug就是没测边界。第三别用限流器做业务逻辑判断。限流是保护手段不是业务规则。有些团队拿限流器当「用户每天只能调用 10 次」的配额系统用结果发现限流器的精度和持久性都不满足配额要求。配额该用数据库或专门的计数服务别混用。第四压测时把 Redis 也压上。很多人只压业务服务忘了限流器会给 Redis 带来等量的 QPS。限流阈值 1 万Redis 就要扛 1 万次脚本调用这个量级得提前验证别等上线才发现 Redis 先扛不住。这套方案不复杂但细节不少。把算法选对、脚本写对、参数调对、监控加上它就能在分布式场景下稳稳地帮你挡住异常流量。剩下的就是根据自己业务的流量特征慢慢磨参数了。