新闻详情 资讯动态

全面了解最新资讯与建站知识,洞察行业趋势。

行业资讯

Go+Redis+Lua分布式限流器:令牌桶算法与原子性实践

发布时间:2026/10/20 4:48:28
Go+Redis+Lua分布式限流器:令牌桶算法与原子性实践 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 里数字都是 doublemath.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 1000capacity 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而不是niltonumber(false)会得到nil。我一开始判断tokens nil没覆盖false的情况导致首次访问时tokens是nil后面tokens newTokens直接报错。正确写法是if not tokens thenLua 里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 nil6.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 的压力能降一个数量级代价是精度略微下降。这个方案适合对精度要求不那么极致的场景具体要不要用得看业务能接受多大的误差。

想做一个「会获客」的企业网站?

留下需求,1 小时内获取专属建站方案与透明报价。

免费咨询方案
↑