Redis令牌桶实现分布式限流
我在给多个 API 实例加限流时,最先踩到的坑不是“令牌桶怎么画”,而是把读取令牌、补充令牌和扣减令牌拆成了三次 Redis 请求。单实例看不出问题,一旦两个实例同时处理同一个用户,就可能把同一枚令牌放行两次。
更稳妥的做法是:用 Redis 保存每个限流对象的桶状态,再用一段 Lua 脚本在 Redis 内部一次完成“按时间补充、判断余额、扣减成本、返回剩余量”。Redis 官方文档也把 Lua 原子执行作为实现令牌桶限流的基础能力。本文使用固定点整数令牌,示例中每个用户容量为 10 枚、每秒补充 2 枚,每次请求消耗 1 枚。
官方地址:https://redis.io/
分布式限流的关键不是把计数器放到 Redis,而是让“时间推进后的余额”和“本次是否扣减”处在同一个原子边界里。令牌桶负责允许短时突发,Lua 负责避免并发下的双花,过期时间负责清理长期不活跃的限流对象。
先把令牌桶状态说清楚
令牌桶可以看成一个有上限的容器:
capacity:桶最多能放多少枚令牌,决定突发上限。refill_rate:每秒补充多少枚令牌,决定长期平均速率。cost:一次请求消耗几枚令牌;普通接口通常是 1。tokens:当前剩余令牌数。last_ms:上一次计算补充位置的毫秒时间。
例如容量为 10、速率为 2 时,空闲 3 秒最多补回 6 枚,但不会超过 10。请求到达后先补充,再判断余额是否足够,允许时才扣减。这个顺序很重要:如果先判断、后补充,就会把已经等待产生的额度浪费掉。

用一个 Hash 保存每个限流对象
可以把用户、租户或 API 路径编码进 key,例如 rate:user:42。Hash 里只保存两个核心字段:
HSET rate:user:42 tokens 7 last_ms 1794712910000
PEXPIRE rate:user:42 5000
这里的过期时间不是限流窗口。它的作用是:用户长期没有请求时,清理已经没有意义的桶状态。示例里可以按“桶补满所需时间”计算一个 TTL:capacity * 1000 / refill_rate,再留一点余量。每次访问都刷新 TTL,避免活跃用户的状态中途消失。
不要把所有用户写进一个大 Hash 再用字段名模拟桶,除非你已经设计了字段级过期和并发策略。按限流维度拆 key 更容易设置 TTL,也更适合把同一个 key 放进 Redis Cluster 的同一个 hash slot。
把补充和扣减放进 Lua 原子边界
下面这段脚本只访问一个 key。参数依次是容量、每秒补充量、请求成本和当前毫秒时间,返回值依次表示是否放行、剩余令牌和建议等待毫秒数。
-- capacity 控制突发上限,rate 控制长期平均速率。
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local now_ms = tonumber(ARGV[4])
-- 读取桶状态;第一次访问时让桶从满额开始。
local state = redis.call('HMGET', key, 'tokens', 'last_ms')
local tokens = tonumber(state[1])
local last_ms = tonumber(state[2])
if tokens == nil or last_ms == nil then
tokens = capacity
last_ms = now_ms
end
-- 只按完整令牌补充,避免浮点小数在多实例间累积差异。
local elapsed = math.max(0, now_ms - last_ms)
local added = math.floor(elapsed * rate / 1000)
if added > 0 then
tokens = math.min(capacity, tokens + added)
-- 只推进已折算的时间,保留不足一枚令牌的余数。
last_ms = last_ms + math.floor(added * 1000 / rate)
end
local allowed = 0
local retry_ms = 0
if tokens >= cost then
-- 余额足够才扣减,拒绝分支不会消耗令牌。
tokens = tokens - cost
allowed = 1
else
-- 告诉调用方大约还要等多久才能补出本次成本。
retry_ms = math.ceil((cost - tokens) * 1000 / rate)
end
-- 给不活跃对象设置清理时间,并在访问时刷新 TTL。
redis.call('HSET', key, 'tokens', tokens, 'last_ms', last_ms)
local ttl_ms = math.max(1000, math.ceil(capacity * 1000 / rate) + 1000)
redis.call('PEXPIRE', key, ttl_ms)
return {allowed, tokens, retry_ms}
脚本的原子性解决的是并发竞态,不是业务额度设计。Redis 执行 Lua 时会把脚本作为一个整体处理,所以另一个请求不会插入到“读余额”和“写余额”之间。但脚本也会占用 Redis 事件循环,逻辑要保持短小,不能在里面做大范围遍历。
在 Go 中把返回值变成限流结果
客户端可以先用 EVAL 发送脚本,生产环境通常把脚本内容固定下来并复用 SHA。下面用 go-redis 展示一个最小封装;now_ms 来自调用实例的 Unix 毫秒时间,部署时要让各节点的时钟保持同步。
package limiter
import (
"context"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
var tokenBucketScript = redis.NewScript(`
-- 参数顺序与文章中的 Redis Lua 脚本保持一致。
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local now_ms = tonumber(ARGV[4])
local state = redis.call('HMGET', key, 'tokens', 'last_ms')
local tokens = tonumber(state[1])
local last_ms = tonumber(state[2])
if tokens == nil or last_ms == nil then
tokens = capacity
last_ms = now_ms
end
local elapsed = math.max(0, now_ms - last_ms)
local added = math.floor(elapsed * rate / 1000)
if added > 0 then
tokens = math.min(capacity, tokens + added)
last_ms = last_ms + math.floor(added * 1000 / rate)
end
local allowed = 0
local retry_ms = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
else
retry_ms = math.ceil((cost - tokens) * 1000 / rate)
end
redis.call('HSET', key, 'tokens', tokens, 'last_ms', last_ms)
local ttl_ms = math.max(1000, math.ceil(capacity * 1000 / rate) + 1000)
redis.call('PEXPIRE', key, ttl_ms)
return {allowed, tokens, retry_ms}
`)
type Decision struct {
Allowed bool
Remaining int64
RetryMS int64
}
func Allow(ctx context.Context, rdb redis.UniversalClient, subject string) (Decision, error) {
// 把限流维度编码进 key,所有应用实例才能共享同一个桶。
key := "rate:user:" + subject
values, err := tokenBucketScript.Run(ctx, rdb, []string{key},
10, 2, 1, time.Now().UnixMilli()).Result()
if err != nil {
return Decision{}, err
}
// Redis 数组返回值是 interface{},先转成字符串再解析整数。
raw := values.([]interface{})
allowed, err := strconv.ParseInt(raw[0].(string), 10, 64)
if err != nil {
return Decision{}, err
}
remaining, _ := strconv.ParseInt(raw[1].(string), 10, 64)
retryMS, _ := strconv.ParseInt(raw[2].(string), 10, 64)
return Decision{Allowed: allowed == 1, Remaining: remaining, RetryMS: retryMS}, nil
}
不同版本的 Redis 客户端可能把整数结果解码成 int64 而不是字符串,因此业务代码里应使用项目当前客户端的返回类型约定封装转换。限流中间件可以把 Allowed=false 映射为 HTTP 429,并用 RetryMS 生成合理的 Retry-After,但不要把内部 key、脚本内容或 Redis 错误直接返回给调用方。

脚本缓存丢失时不要把 NOSCRIPT 当成业务拒绝
如果调用方走 EVALSHA,Redis 重启、主从切换或执行 SCRIPT FLUSH 后可能返回 NOSCRIPT。这说明脚本不在当前节点的缓存里,不代表用户额度不足。
处理方式是捕获这一个错误,重新 SCRIPT LOAD 后再执行一次,或者回退到 EVAL 发送固定脚本。重试时必须复用同一次限流请求的参数,并确保“脚本加载失败”不会被计为一次拒绝。Redis 7 以后也可以考虑 Redis Functions,但要结合部署、发布和回滚方式统一管理,不能在客户端和服务端各维护一套不同逻辑。
分布式限流还要留意四个边界
| 边界 | 容易出现的误解 | 处理建议 |
|---|---|---|
| 时钟 | 每台应用机器的时间完全一致 | 统一 NTP;或把时间采样策略集中到 Redis 侧,接受额外复杂度 |
| 集群 | 脚本可以随意访问多个分片 key | 一次脚本尽量只使用一个 key,让限流维度落在同一 hash slot |
| 请求成本 | 所有接口都固定消耗 1 枚 | 大请求、批量接口可以使用更高 cost,但容量也要重新估算 |
| 失败策略 | Redis 不可用时一定拒绝或一定放行 | 按接口风险分级:关键写操作倾向 fail-closed,低风险读操作可有限 fail-open |
还有一个很容易忽略的选择:令牌桶允许短时突发,所以它适合登录、查询、消息发送等“平均速率有限、瞬时可以放宽”的场景。如果业务只允许严格的固定窗口,或者需要精确统计滑动窗口内的每次请求时间,就应该选对应算法,不要只因为 Redis 能存状态就强行套令牌桶。
最后记住这条实现顺序
落地时可以按下面的顺序检查:先确定限流维度,再确定容量、速率和请求成本;然后把 tokens 与 last_ms 存成单 key Hash;接着在一个 Lua 脚本里完成补充、判断、扣减和 TTL 刷新;最后在客户端区分“额度不足”和“脚本缓存丢失、网络异常、Redis 不可用”。
这样做的价值不只是把请求挡住,而是让每个应用实例看到同一份额度,并让一次决策具有明确的原子边界。真正上线前仍需要根据接口重要性选择 Redis 故障策略、监控拒绝率和桶 key 数量,但这些属于容量与运维设计,不应混进令牌扣减脚本本身。
相关问题
Redis 令牌桶和固定窗口有什么区别?
固定窗口按时间段计数,边界处可能出现突发叠加;令牌桶允许容量范围内的短时突发,同时用补充速率约束长期平均请求量。
为什么不能用 GET 后再 DECR?
多个实例可能同时读到相同余额,随后都执行扣减,导致放行数超过额度。需要把读、判断、写放进 Lua 或其他等价的原子边界。
令牌桶 key 应该设置过期吗?
应该。按桶补满时间加余量设置 TTL,并在每次访问时刷新,可以清理长期不活跃的用户或租户状态。
Redis Sorted Set构建实时排行榜
- 上一篇
- Redis Sorted Set构建实时排行榜
- 下一篇
- Redis缓存旁路控制数据陈旧时间
-
- 数据库 · Redis | 25分钟前 |
- Redis队列处理失败任务可见性超时
- 379浏览 收藏
-
- 数据库 · Redis | 30分钟前 |
- Redis Sorted Set构建实时排行榜
- 239浏览 收藏
-
- 数据库 · Redis | 31分钟前 | redis function FUNCTION LOAD FCALL
- Redis Function避免重复加载逻辑
- 331浏览 收藏
-
- 数据库 · Redis | 34分钟前 | Redis · lua ·
- Redis Lua脚本与Functions如何选择
- 421浏览 收藏
-
- 数据库 · Redis | 37分钟前 |
- Redis事务中WATCH实现乐观锁
- 184浏览 收藏
-
- 数据库 · Redis | 40分钟前 |
- Redis HSTRLEN返回0时怎样区分空值与字段不存在
- 209浏览 收藏
-
- 数据库 · Redis | 57分钟前 | Redis · redis 有序集合 Sorted Set ZPOPMIN 最低分成员
- Redis ZPOPMIN 怎样取出最低分成员并完成删除
- 401浏览 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 485次学习
-
- PubMedQA
- 深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
- 1036次使用
-
- H2O EvalGPT
- H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
- 1034次使用
-
- LMArena
- LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
- 1178次使用
-
- HELM
- 深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
- 1139次使用
-
- MMBench
- MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
- 981次使用
-
- Redis Stream XTRIM 如何避免消费组积压无限增长
- 2026-09-12 501浏览
-
- Redis AOF rewrite 期间如何判断磁盘与内存压力
- 2026-09-12 501浏览
-
- Redis RDB 和 AOF 怎么按可接受数据丢失量选择
- 2026-09-08 501浏览
-
- Redis XAUTOCLAIM 之后为什么仍有 pending:JUSTID、PEL 与消息删除边界
- 2026-08-29 501浏览
-
- Redis SET 的 GET 与 KEEPTTL 怎么一起验收:旧值返回、续期与回滚边界
- 2026-08-20 501浏览
