☰
Redis+Lua分布式限流器实战:从算法选型到Go封装
2026/10/20 4:48:02 网站建设 项目流程

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 的ZSET,score 存时间戳,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,结果放过了两个请求,但实际只该放一个。这就是典型的竞态条件。

有人会说用WATCH+MULTI事务。可以,但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/v9:

package 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-open:Redis 异常时放行,并记录日志告警 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 排查限流问题的通用链路

把上面的经验抽象一下,遇到「限流不符合预期」时,我通常按这个顺序排查:

  1. 确认 key 是否正确:用redis-cli直接HGETALL那个 key,看tokens和last_refill是否符合预期。key 拼错、前缀不一致是最常见的原因。
  2. 确认脚本版本:SCRIPT EXISTS <sha1>看脚本是否还在缓存里,不在的话是不是被SCRIPT FLUSH清过。
  3. 确认时间源:如果用的是客户端时间,检查各实例时钟是否同步;用服务端时间则跳过这步。
  4. 确认并发量:用MONITOR或慢查询日志看脚本执行频率,判断是不是 QPS 太高导致 Redis 成为瓶颈。
  5. 确认失败策略:看 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 先扛不住。

这套方案不复杂,但细节不少。把算法选对、脚本写对、参数调对、监控加上,它就能在分布式场景下稳稳地帮你挡住异常流量。剩下的就是根据自己业务的流量特征慢慢磨参数了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询