1. 为什么分布式限流绕不开 Redis + Lua 这套组合
做后端服务的兄弟大概率都遇到过这种场景:某个接口突然被刷爆,数据库连接池瞬间打满,整条链路雪崩。限流这件事,单机时代用 Guava 的RateLimiter就能搞定,但服务一上多实例,单机限流就彻底失效了——每个实例各限各的,总量根本控不住。这时候就得把限流状态外置到一个所有实例都能访问的地方,Redis 天然就是这个角色的最佳人选。
但光有 Redis 还不够。限流的本质是“读当前计数 → 判断是否超限 → 更新计数”这一串操作,如果分三步走,高并发下必然出现竞态:两个请求同时读到计数是 99,都判断没超限,结果双双放行,实际放行了 101 个。要解决这个问题,要么用 Redis 的原子命令拼凑,要么把整段逻辑塞进 Lua 脚本让 Redis 单线程原子执行。前者在逻辑复杂时几乎没法维护,后者才是工程上的正解。
所以“Go + Redis + Lua 分布式限流器”这个组合,核心价值就三点:跨实例共享限流状态、Lua 保证判断与更新的原子性、Go 侧只负责传参和兜底。它适合所有需要多实例部署、又不想引入重型网关或独立限流中间件的团队。下面我按自己实际落地的思路,把整套东西拆开讲透,包括为什么这么选、参数怎么算、坑在哪。
2. 限流方案选型:固定窗口、滑动窗口还是令牌桶
2.1 三种主流算法的取舍逻辑
在动手写代码前,先得想清楚用哪种限流算法,因为这直接决定了 Lua 脚本怎么写。我先把三种常见方案摆出来对比一下。
| 算法 | 核心思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 固定窗口 | 每个时间窗口一个计数器,窗口切换清零 | 实现最简单,内存占用小 | 窗口边界有双倍突刺问题 | 对精度要求不高的粗粒度限流 |
| 滑动窗口 | 用有序集合记录每次请求时间戳,统计窗口内数量 | 精度高,无边界突刺 | 内存随请求量增长,ZSET 操作偏重 | 精度要求高的核心接口 |
| 令牌桶 | 按固定速率往桶里放令牌,请求取令牌 | 允许一定突发,平滑限流 | 需要维护令牌数和上次填充时间 | 需要容忍突发的 API 网关 |
固定窗口的“双倍突刺”是经典问题:假设限制每分钟 100 次,如果前 59 秒来了 100 个请求,第 60 秒窗口切换后又来 100 个,那这 2 秒内实际通过了 200 个请求,瞬间压力翻倍。滑动窗口能解决这个问题,但代价是每个请求都要往 ZSET 里写一条记录,QPS 上万时 Redis 内存和 CPU 都吃不消。
令牌桶是我个人最推荐的方案,因为它兼顾了“平均速率可控”和“允许突发”两个需求。它的数学模型很清晰:桶有容量capacity,按速率rate补充令牌,请求来了先看桶里有没有令牌,有就取走一个放行,没有就拒绝。关键参数就两个,而且都能通过业务指标反推出来。
2.2 为什么最终锁定令牌桶
我选令牌桶还有一个很实际的原因:它天然适合用 Lua 脚本实现,而且状态极简。整个限流状态只需要存两个值——当前令牌数tokens和上次补充时间lastRefillTime,用 Redis 的 Hash 结构一个 key 就装下了。相比之下滑动窗口要维护一个 ZSET,每次请求都要ZREMRANGEBYSCORE清理过期成员再ZCARD统计,脚本复杂度和执行耗时都高一个量级。
令牌桶的补充逻辑也不复杂,核心公式就一行:
新增令牌 = (当前时间 - 上次补充时间) * 速率 当前令牌 = min(桶容量, 旧令牌 + 新增令牌)这里有个细节要注意:令牌数不能超过桶容量,否则桶就无限大了,突发控制就失效了。另外补充令牌时要用“惰性补充”,也就是请求来了才算,而不是起一个定时任务不停往桶里加。惰性补充的好处是零额外开销,没有请求时 Redis 里啥都不干,这对成本敏感的场景很友好。
3. Lua 脚本设计:把原子性交给 Redis 单线程
3.1 脚本入参与返回值约定
Lua 脚本是整个限流器的灵魂,它跑在 Redis 单线程里,天然避免了并发竞态。我设计的脚本接收四个参数:限流 key、桶容量、补充速率、当前时间戳(毫秒)。返回两个值:是否放行(1 或 0)、当前剩余令牌数。
为什么时间戳要从 Go 侧传进去,而不是在 Lua 里用redis.call('TIME')取?这是个容易踩的坑。TIME命令在 Redis 集群模式下,不同节点返回的时间可能不一致,而且脚本里调用TIME会引入额外的命令开销。更稳妥的做法是 Go 侧统一取时间,所有实例用同一个时间源(比如 NTP 同步过的机器时间),传进脚本用。这样逻辑更可控,也方便测试时 mock 时间。
入参用KEYS和ARGV分开传,这是 Redis 的规范:KEYS放 key 名,ARGV放其他参数。虽然单 key 场景下放哪都行,但养成规范习惯,将来要扩展成多 key 或者上集群时不会翻车。
3.2 完整 Lua 脚本逐行拆解
下面是我实际在用的脚本,我把它拆开讲每一行的意图。
-- KEYS[1]: 限流 key -- ARGV[1]: 桶容量 capacity -- ARGV[2]: 补充速率 rate(每秒补充多少令牌) -- ARGV[3]: 当前时间戳(毫秒) -- 返回: {是否放行, 剩余令牌数} local key = KEYS[1] local capacity = tonumber(ARGV[1]) local rate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) -- 读取当前状态 local bucket = redis.call('HMGET', key, 'tokens', 'lastRefillTime') local tokens = tonumber(bucket[1]) local lastRefillTime = tonumber(bucket[2]) -- 首次访问,初始化满桶 if tokens == nil then tokens = capacity lastRefillTime = now end -- 惰性补充令牌 local deltaMs = now - lastRefillTime if deltaMs > 0 then local newTokens = deltaMs * rate / 1000 tokens = math.min(capacity, tokens + newTokens) lastRefillTime = now end -- 判断是否放行 local allowed = 0 if tokens >= 1 then tokens = tokens - 1 allowed = 1 end -- 写回状态,设置过期时间防止冷 key 堆积 redis.call('HMSET', key, 'tokens', tokens, 'lastRefillTime', lastRefillTime) redis.call('PEXPIRE', key, math.ceil(capacity / rate * 1000) + 1000) return {allowed, math.floor(tokens)}逐段说下关键点。第一段HMGET一次取出两个字段,比两次HGET少一次网络往返,虽然脚本内部是本地调用,但减少命令数对 Redis 单线程也是减负。初始化逻辑里,首次访问直接给满桶,这样服务刚启动时不会因为桶是空的而误杀请求。
补充令牌那段,deltaMs * rate / 1000是把毫秒差换算成秒再乘速率,注意 Lua 里数字都是 double,math.min保证不超容量。这里有个精度问题:如果 rate 很小、deltaMs 很大,算出来的 newTokens 可能是小数,没关系,令牌数允许是浮点,判断时用>= 1就行。
最后写回状态时设了PEXPIRE,过期时间按“桶从满到空需要的时间 + 1 秒”来算。这个设计很关键:如果一个 key 长时间没人访问,它会自动过期,不会在 Redis 里堆积一堆僵尸 key。过期时间算错会导致两种问题——太短了状态被提前清掉,限流失效;太长了内存回收不及时。我用的公式是capacity / rate * 1000毫秒,也就是桶自然流空的时间,再加 1 秒缓冲。
3.3 脚本加载与执行方式的选择
Lua 脚本在 Go 侧有两种执行方式:EVAL直接传脚本内容,或者SCRIPT LOAD先加载拿到 SHA1,之后用EVALSHA执行。生产环境必须用后者,原因很简单:EVAL每次都要把整个脚本内容传到 Redis,脚本大了网络开销很可观,而且 Redis 每次都要重新解析脚本。EVALSHA只传 40 字节的 SHA1,效率高得多。
但EVALSHA有个坑:如果 Redis 重启或者脚本被SCRIPT FLUSH清了,EVALSHA会报NOSCRIPT错误。所以 Go 侧要封装一层兜底逻辑——捕获NOSCRIPT错误后自动回退到EVAL重新加载。这个兜底不做的话,Redis 一重启你的限流就全挂了。
4. Go 侧工程实现:封装、降级与并发安全
4.1 限流器的结构体设计
Go 侧我习惯把限流器封装成一个结构体,对外只暴露一个Allow方法。核心字段包括 Redis 客户端、脚本 SHA1、桶容量、速率,以及一个降级开关。
type TokenBucketLimiter struct { client *redis.Client scriptSHA string capacity int64 rate float64 fallback *rate.Limiter // 本地降级用 }这里我特意加了一个本地rate.Limiter作为降级方案。为什么?因为 Redis 是外部依赖,网络抖动、主从切换、集群扩容都可能让它短暂不可用。如果 Redis 挂了限流器直接报错,那要么全放行(雪崩风险)要么全拒绝(服务不可用),两种都不可接受。合理的做法是 Redis 异常时降级到单机限流,虽然多实例下总量可能略微超标,但至少保住了服务可用性。这是典型的“可用性优先于精确性”的取舍。
4.2 核心 Allow 方法的实现细节
Allow方法的逻辑分三步:取时间戳、执行脚本、处理结果和异常。
func (l *TokenBucketLimiter) Allow(ctx context.Context, key string) (bool, error) { now := time.Now().UnixMilli() result, err := l.client.EvalSha(ctx, l.scriptSHA, []string{key}, l.capacity, l.rate, now, ).Result() if err != nil { if strings.Contains(err.Error(), "NOSCRIPT") { // 脚本丢失,重新加载 if loadErr := l.reloadScript(ctx); loadErr != nil { return l.fallback.Allow(), nil } return l.Allow(ctx, key) } // 其他错误走降级 return l.fallback.Allow(), nil } values := result.([]interface{}) allowed := values[0].(int64) == 1 return allowed, nil }几个细节值得说。时间戳用UnixMilli()拿毫秒,和脚本里的毫秒计算对齐。EvalSha的返回值是interface{},实际是[]interface{},里面两个元素都是int64,类型断言要小心,断言失败会 panic,生产代码里最好加ok判断。
NOSCRIPT的处理我用了递归重试,但要注意加个重试次数上限,否则脚本一直加载失败会无限递归。实际写的时候我会传一个 depth 参数控制。降级那块,fallback.Allow()返回的是本地限流结果,直接返回nil错误,对上层透明。
4.3 并发安全与连接池配置
Go 的 Redis 客户端(比如 go-redis)本身是并发安全的,多个 goroutine 同时调EvalSha没问题。但连接池配置要调好,否则高并发下限流器自己成了瓶颈。关键参数是PoolSize,经验值是“预期 QPS / 单连接吞吐”,单连接吞吐大概在 5000 到 10000 之间。如果限流 QPS 是 5 万,PoolSize至少给 10。
还有个容易忽略的点:限流脚本的执行时间要监控。Lua 脚本在 Redis 里是阻塞执行的,如果脚本写得太重(比如循环几千次),会拖慢整个 Redis。我一般会在 Go 侧记录EvalSha的耗时,P99 超过 5ms 就要警惕了。正常情况下这个脚本执行时间应该在 1ms 以内。
5. 参数计算与压测验证:让限流真正可控
5.1 桶容量和速率的推导方法
参数不能拍脑袋定,得从业务指标反推。假设某个接口的产品要求是“平均每秒处理 1000 个请求,允许瞬时突发到 2000”,那rate = 1000,capacity = 2000。突发能力就是桶容量决定的,桶越大能扛的瞬时峰值越高,但恢复时间也越长。
再举个实际例子。某查询接口,后端数据库单实例能扛 800 QPS,线上部署了 4 个服务实例。如果每个实例单独限流 200 QPS,那单实例故障时其他实例不会自动分担,总量还是上不去。用分布式限流,全局设rate = 700(留 100 余量),capacity = 1400(允许 2 倍突发),这样无论几个实例,总量都被控制在数据库承受范围内。
这里有个计算细节:capacity / rate就是桶从满到空的时间。上面例子是1400 / 700 = 2秒,意味着持续满速请求下,2 秒后桶就空了,之后只能按 700 QPS 放行。这个“突发持续时间”是否符合业务预期,要提前想清楚。
5.2 压测方案与关键指标
参数定完必须压测验证。我的压测方案分两轮:第一轮单实例压,验证脚本逻辑和单机吞吐;第二轮多实例压,验证分布式限流的准确性。
单实例压测用wrk或hey,命令大概是这样:
hey -n 100000 -c 200 -q 500 http://localhost:8080/api/query重点看三个指标:实际放行 QPS 是否接近设定的 rate、被拒绝的请求比例、Redis 的 CPU 和内存占用。如果放行 QPS 明显低于 rate,可能是脚本执行太慢或者连接池不够。
多实例压测要起至少 3 个服务实例,用同一个 Redis,然后从压测机同时打流量。验证点是:所有实例的放行总量是否等于全局 rate。这里最容易出问题的是时间戳不同步——如果各实例机器时间差了几百毫秒,令牌补充就会算错。所以生产环境必须保证 NTP 同步,时间偏差控制在 50ms 以内。
5.3 压测中发现的典型现象
我第一次压测时遇到一个反直觉的现象:QPS 越高,实际放行量反而略低于设定值。排查后发现是浮点精度问题——每次补充令牌时deltaMs * rate / 1000会产生小数,多次累加后误差累积,导致令牌数比理论值少一点点。解决办法是在脚本里对补充量做处理,或者干脆把时间单位改成秒,减少除法次数。这个误差在 rate 很大时(比如 10 万)才明显,小 rate 下可以忽略。
还有个现象是 Redis 内存增长比预期快。原因是限流 key 的过期时间设得太长,大量冷 key 没及时回收。后来我把过期时间从“桶流空时间 + 1 秒”改成了更激进的策略,并且对不同的限流维度(用户级、接口级、IP 级)用不同的 key 前缀,方便批量清理。
6. 生产环境踩坑记录与排查速查表
6.1 那些文档里不会写的坑
坑一:Redis 主从切换导致限流状态丢失。如果 Redis 是主从架构且没开持久化,主节点挂了切到从节点,限流 key 可能全没了,所有桶重置为满桶,瞬间放行量暴增。解决办法是限流场景用 AOF 持久化,或者接受这个风险但在切换后有个“预热期”,把 rate 临时调低。
坑二:Lua 脚本里的数字类型陷阱。Redis 返回的HMGET结果里,如果字段不存在返回的是false而不是nil,tonumber(false)会得到nil。我一开始判断tokens == nil没覆盖false的情况,导致首次访问时tokens是nil,后面tokens + newTokens直接报错。正确写法是if not tokens then,Lua 里nil和false都是 falsy。
坑三:大 key 问题。如果限流维度是用户 ID,而用户量上千万,那 Redis 里会有上千万个限流 key。虽然每个 key 很小,但数量多了对 Redis 的内存管理和过期扫描都是压力。我的做法是对用户级限流做采样,或者用“用户 ID 哈希取模”把 key 数量控制在一个量级。
坑四:时钟回拨。如果服务器时间被 NTP 往回拨了,now - lastRefillTime会变成负数,deltaMs > 0判断为假,令牌不补充,限流会变得异常严格。脚本里要加保护:if deltaMs < 0 then deltaMs = 0 end,宁可少补也不能算错。
6.2 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决办法 |
|---|---|---|---|
| 放行量远低于设定 rate | 脚本执行慢 / 连接池不足 | 看 EvalSha 耗时和 Redis 慢日志 | 优化脚本、加大 PoolSize |
| 放行量高于设定 rate | 时间戳不同步 / 状态被重置 | 检查各实例时间偏差 | 强制 NTP 同步、开持久化 |
| 报 NOSCRIPT 错误 | Redis 重启或脚本被清 | 看 Redis 是否重启过 | 加 EVAL 兜底重载 |
| Redis 内存持续增长 | key 过期时间太长 | 统计限流 key 数量 | 缩短过期时间、加 key 前缀 |
| 限流突然失效 | 主从切换状态丢失 | 检查 Redis 主从状态 | 开 AOF、加预热逻辑 |
| 脚本报类型错误 | nil/false 判断遗漏 | 看 Lua 报错行号 | 用not x替代x == nil |
6.3 监控与告警该盯哪些指标
限流器上线后,光看业务日志不够,得有几个核心监控指标。我一般会埋这几个点:放行率(放行数 / 总请求数)、脚本执行耗时 P99、降级触发次数、Redis 连接池等待时间。放行率突然下降可能是限流参数配错了,降级次数上涨说明 Redis 不稳定,连接池等待时间长说明 PoolSize 不够。
告警阈值我设的是:脚本 P99 超过 10ms 告警、降级触发 1 分钟内超过 10 次告警、放行率低于设定值 80% 告警。这几个指标能覆盖绝大多数异常场景。
7. 限流维度的扩展与组合玩法
7.1 多维度限流的 key 设计
实际业务里限流往往不是单一维度。比如一个登录接口,既要限制单 IP 的请求频率,又要限制单账号的尝试次数,还要有个全局兜底。这时候 key 的设计就很重要。
我的做法是用前缀区分维度:rl:ip:{ip}、rl:user:{uid}、rl:global:login。每个维度独立一个限流器实例,参数可以不同。比如 IP 维度 rate 设 10/s,账号维度设 5/s,全局设 1000/s。请求进来依次检查三个维度,任何一个超限就拒绝。这种组合限流的顺序有讲究——先检查最严格的(账号级),再检查 IP 级,最后全局,这样能尽早拒绝,减少无效的 Redis 调用。
7.2 动态调整参数而不重启服务
限流参数经常需要根据线上情况调整,如果每次都要改配置重启服务,那太笨了。我的方案是把参数存在 Redis 的一个配置 key 里,Go 侧定时(比如每 10 秒)拉取一次,更新到限流器结构体里。这样运维改个 Redis 值就能实时生效。
但要注意并发安全:参数更新时可能有请求正在读,得用atomic或者读写锁保护。我一般把 capacity 和 rate 包成一个结构体指针,更新时整体替换指针,读的时候先 load 指针再取值,这样无锁且安全。
7.3 和熔断、重试的配合
限流只是流量治理的一环,它和熔断、重试是配合关系。限流挡在前面,把超过系统能力的流量拒掉;熔断在后面,当依赖服务真的挂了时快速失败;重试要谨慎,因为重试会放大流量,限流场景下重试很容易把刚放行的请求又打回来。我的经验是:被限流拒绝的请求不要重试,直接返回 429 让客户端退避,否则重试风暴会让限流形同虚设。
8. 一些个人实操体会
这套 Go + Redis + Lua 的限流器我在几个项目里都用过,最大的感受是:限流的难点从来不在代码,而在参数和边界情况。脚本本身两百行不到,但参数怎么定、Redis 挂了怎么办、时间不同步怎么处理,这些才是真正花时间的地方。
我踩过最深的坑是时钟回拨那次,线上限流突然变得极严,大量正常请求被拒,排查了半天才想到是 NTP 同步导致时间往回跳了。从那以后我在脚本里加了deltaMs < 0的保护,并且把机器时间同步状态也纳入了监控。
另一个体会是,降级方案一定要有,而且要提前测。很多人写完限流器只测正常路径,Redis 一挂就抓瞎。我现在的习惯是专门写个测试用例,把 Redis 连接断掉,验证降级逻辑是否按预期工作。这个测试用例救过我好几次。
最后说个性能上的小技巧:如果限流 QPS 特别高(比如几十万),单靠一个 Redis 实例可能扛不住,可以考虑在 Go 侧加一层本地令牌桶做“预筛”,本地桶按全局 rate / 实例数配置,只有本地桶放行的请求才去查 Redis。这样 Redis 的压力能降一个数量级,代价是精度略微下降。这个方案适合对精度要求不那么极致的场景,具体要不要用,得看业务能接受多大的误差。