登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  数据库 >  Redis

Redis令牌桶实现分布式限流

来源:17golang原创

时间:2026-11-15 11:28:55 464浏览 收藏

我在给多个 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。请求到达后先补充,再判断余额是否足够,允许时才扣减。这个顺序很重要:如果先判断、后补充,就会把已经等待产生的额度浪费掉。

Redis令牌桶中容量、补充速率、当前令牌和请求成本的静态关系说明图
图1:结构说明图,展示限流 key、令牌桶 Hash 与容量、补充速率、当前令牌和请求成本之间的关系,不是运行截图。

用一个 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 错误直接返回给调用方。

Redis Lua原子边界连接应用请求、令牌桶Hash和放行结果的静态结构图
图2:边界说明图,展示应用请求如何把同一个限流 key、脚本参数和结果放在 Redis Lua 原子边界内,不是运行截图。

脚本缓存丢失时不要把 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,并在每次访问时刷新,可以清理长期不活跃的用户或租户状态。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>