返回文章列表

Redis 限流器里的 Lua:为什么 Token Bucket 不能用几条命令拼出来

从一次并发超发出发,解释 ModelGate 如何用 Redis Lua 和统一时间源实现分布式 Token Bucket,以及它与并发控制的边界。

Redis 限流器里的 Lua:为什么 Token Bucket 不能用几条命令拼出来

给网关加限流,看起来像一道很简单的题:

  1. 从 Redis 读取剩余令牌;
  2. 判断令牌是否足够;
  3. 足够就减一,再把结果写回 Redis。

如果只有一个请求,这套流程没有问题。真正麻烦的是,网关往往有多个实例,同一时刻也可能有多个请求操作同一个限流状态。

假设桶里只剩最后一个令牌,两个请求同时到达:

时刻Gateway AGateway BRedis 中的令牌
T1读取到 11
T2也读取到 11
T3判断可以通过,写入 00
T4也判断可以通过,写入 00

最终 Redis 里的值仍然是 0,表面上没有出现负数,但两个请求都被放行了。

这类错误很隐蔽:数据的最终值看起来合理,系统却已经突破了限额。问题不在 Token Bucket 的公式,而在“读取、计算、扣减、写回”没有构成一个不可分割的状态变化。

本文结合我在 ModelGate 中实现的 Redis Token Bucket,解释为什么限流器需要 Lua、为什么时间也应该进入同一次原子操作,以及 Token Bucket 和 Semaphore 分别解决什么问题。

一、LLM Gateway 为什么需要限流

普通接口被多调用几次,代价可能只是多消耗一点 CPU。LLM 请求通常还意味着外部 Provider 配额、Token 成本以及长时间占用的流式连接。

网关限流至少可以保护三类对象:

  • 客户端配额:限制某个 API Key 或租户在一段时间内的请求量;
  • 网关自身:避免突发流量把连接、内存和下游调用链压垮;
  • 上游 Provider:让请求速率不超过供应商允许的范围。

ModelGate 使用 Token Bucket 处理“单位时间内允许多少请求”这个问题。它允许一定程度的突发流量,同时又能把长期平均速率限制在配置范围内。

二、Token Bucket 到底保存了什么

可以把 Token Bucket 想成一个会自动补充令牌的桶:

  • capacity:桶最多能容纳多少令牌,也就是允许的突发上限;
  • refill_rate:每秒补充多少令牌;
  • tokens:上次更新后剩余多少令牌;
  • updated_at:上次计算令牌数量的时间。

如果限额配置为每分钟 rpm 个请求,那么每秒补充速率可以写成:

refill_rate=rpm60refill\_rate = \frac{rpm}{60}

一次请求到达时,先根据经过的时间补充令牌:

refilled=min(capacity, tokens+elapsed×refill_rate)refilled = \min(capacity,\ tokens + elapsed \times refill\_rate)

如果 refilled >= cost,请求可以通过,并扣除本次请求消耗的令牌;否则拒绝请求,等待令牌继续补充。

这里最重要的不是公式,而是下面这组操作必须被视为一个整体:

读取旧状态
→ 获取当前时间
→ 计算补充量
→ 判断是否放行
→ 扣除令牌
→ 写入新状态

任何两个请求只要能在这条链路中间交错执行,就可能发生超发。

三、为什么不能用 GET 和 SET 直接拼

最直观的 Go 代码可能是这样:

state := redis.Get(ctx, key)
tokens := refill(state, time.Now())

if tokens < 1 {
	return ErrRateLimited
}

tokens--
redis.Set(ctx, key, tokens, ttl)
return nil

它在单线程测试里大概率能通过,但在多实例并发下,读取和写入之间没有任何互斥关系。

sequenceDiagram
    participant A as Gateway A
    participant R as Redis
    participant B as Gateway B

    A->>R: GET,得到 1
    B->>R: GET,也得到 1
    A->>R: SET 0,并放行
    B->>R: SET 0,也放行

把几条 Redis 命令放进 Pipeline 也不能解决这个问题。Pipeline 的主要价值是减少网络往返,并不会自动把客户端本地的“读取—计算—写回”变成带隔离性的事务。

MULTI/EXEC 可以让一组已经排好的命令连续执行,但这里的新状态依赖旧状态和当前时间。使用 WATCH 可以做乐观并发控制:监听 Key、读取旧值、在客户端计算、提交时检查 Key 是否被修改,冲突后再重试。

这条路线不是错误的,但限流是高频路径。冲突时的重试、额外网络往返和更复杂的客户端控制流,都使它不如一段短小的 Lua 脚本直接。

四、Lua 把一次限流判断变成原子状态转移

Redis 会把一段 Lua 脚本作为一次原子操作执行。脚本运行期间,其他客户端命令不会插入脚本中间。因此可以把读取、补充、判断、扣减和写回全部放在 Redis 服务器端完成。

下面是一段为了说明思路而简化的伪代码,并非 ModelGate 源码的逐行复制:

local now = redis.call("TIME")
local state = redis.call("HMGET", KEYS[1], "tokens", "updated_at")

local tokens = load_tokens_or_capacity(state)
local updated_at = load_time_or_now(state)
local elapsed = current_seconds(now) - updated_at

tokens = math.min(capacity, tokens + elapsed * refill_rate)

local allowed = 0
if tokens >= cost then
    tokens = tokens - cost
    allowed = 1
end

redis.call("HSET", KEYS[1],
    "tokens", tokens,
    "updated_at", current_seconds(now))
redis.call("EXPIRE", KEYS[1], ttl)

return {allowed, tokens}

对 Redis 而言,这不再是客户端发来的许多零散步骤,而是一次完整的状态转移:

(tokens,updated_at)request(new_tokens,now)(tokens, updated\_at) \xrightarrow{request} (new\_tokens, now)

如果两个请求同时到达,Redis 会先完整执行其中一个脚本,再执行另一个。第二个请求读取到的是第一个请求已经更新后的状态,因此不会重复消费同一个令牌。

Lua 还有一个附带收益:计算发生在数据旁边。网关只需要发送脚本参数并接收最终判断,不必在一次限流中来回读取和写入多次。

但“原子”不等于“脚本想写多长都可以”。Redis 执行脚本时会阻塞其他服务器活动,因此脚本应该只完成短小、确定的状态计算,不能把慢查询、循环扫描或外部调用塞进去。

五、为什么脚本里还要使用 Redis TIME

即使扣减过程已经原子化,如果时间由每个 Gateway 实例各自提供,仍然会引入新的不一致。

例如:

  • Gateway A 的系统时间比真实时间快 500 毫秒;
  • Gateway B 的系统时间比真实时间慢 300 毫秒;
  • 两个实例交替更新同一个桶。

那么 elapsed 可能忽大忽小,甚至出现负值。扩容后的新实例、虚拟机时钟校准和运维调整,也可能让这个问题更加明显。

ModelGate 在 Lua 脚本内调用 Redis TIME,让同一个限流桶的时间判断来自同一个 Redis 节点。这样,“现在几点”和“状态如何更新”也处在同一次原子操作里。

需要明确的是,Redis TIME 并不是绝对准确的全球时钟。它的价值是为这个限流状态提供统一时间源,避免多个网关实例用彼此不一致的本地时钟参与计算。

六、限流和并发控制不是一回事

ModelGate 同时存在 Token Bucket 和 per-provider Semaphore,因为它们保护的是不同维度。

机制回答的问题典型状态主要保护对象
Token Bucket一段时间内能进入多少请求?令牌数量、更新时间配额与长期请求速率
Semaphore同一时刻能有多少请求正在执行?当前占用的并发槽位连接、内存和 Provider 并发能力

假设限额是每分钟 60 次,但每次 LLM 调用都需要 30 秒。如果只有 Token Bucket,短时间内仍然可能积累大量正在执行的请求。

反过来,如果只有 10 个并发槽位,但请求都能在 10 毫秒内结束,那么系统一分钟仍可能放行非常多的请求,无法表达“每分钟最多 60 次”的配额。

因此两者不是替代关系:Token Bucket 约束时间窗口内的流量,Semaphore 约束某个瞬间的在途请求。

七、限流放在调用链的什么位置

限流器不仅有实现问题,还有策略问题。

ModelGate 当前把限流放在缓存命中和幂等结果重放之前。这意味着,只要请求进入网关,就会占用客户端的请求配额,即使它最后没有真正调用上游 Provider。

这是一项明确的产品选择:

  • 如果配额表示“调用网关接口的次数”,那么缓存命中和幂等重放也应该计数;
  • 如果配额表示“真正调用 Provider 的次数”,那么限流位置可能需要后移,或额外统计上游调用量。

不存在脱离语义的唯一正确顺序。关键是先定义限额究竟约束什么,再让中间件顺序、指标和文档保持一致。

八、Redis 出故障时,该放行还是拒绝

分布式限流器还必须回答一个无法靠 Lua 自动解决的问题:Redis 不可用时怎么办?

常见选择有两种:

  • Fail-open:限流器失败时仍允许请求通过,优先保证可用性,但失去流量保护;
  • Fail-closed:限流器失败时拒绝请求,严格保护配额和下游,但 Redis 故障会扩大成网关不可用。

选择取决于被保护资源的代价和业务要求。付费 Provider、严格租户配额更倾向于保守;内部低风险接口可能更看重可用性。

这不应该藏在错误处理的某个默认分支里,而应该成为显式策略,并通过指标和告警暴露出来。本文只讨论原子限流本身,不把某种故障策略包装成所有系统都适用的答案。

九、应该怎样验证它真的可靠

这类限流器不能只测“第一次请求成功、第二次请求失败”。至少需要覆盖以下层次。

1. 公式边界

  • 空桶是否会按时间正确补充;
  • 补充后是否不会超过 capacity
  • 令牌刚好等于 cost 时是否允许;
  • 时间间隔很短时,小数令牌如何处理;
  • 长时间无人访问后,Key 的 TTL 与初始状态是否合理。

2. 并发原子性

把桶设置为有限容量,再让多个 goroutine 同时请求同一个 Key。最终成功数不能超过可用令牌数。

这里必须连接真实 Redis。用内存 Map 或 Mock 验证 Go 调用代码,只能说明参数与返回值解析正确,无法证明 Redis 对 Lua 的真实执行语义。

3. 多实例语义

测试不同客户端同时操作同一个限流 Key,确认所有实例共享同一桶,而不是每个进程各自拥有一份本地配额。

4. 故障与可观测性

  • Redis 超时或断开时,系统是否执行约定的故障策略;
  • 被限流请求是否返回稳定的状态码和错误结构;
  • Prometheus 是否能区分正常放行、限流拒绝和 Redis 故障;
  • 日志是否包含可定位问题的 Key 类型和错误原因,同时避免泄露完整 API Key。

Go Race Detector 也不能代替这些测试。它能发现 Go 进程内被测试路径的数据竞争,却不能证明 Redis 中的状态转移正确。Lua 的原子性需要真实 Redis 集成测试来验证,客户端并发安全则由 Go 测试和 Race Detector 共同覆盖。

十、这个方案的边界

Redis Lua 解决的是“同一个 Redis 执行上下文内,这次状态更新不可被其他命令插入”,它并不会自动让整个系统获得无限范围的一致性。

还需要注意:

  • 脚本执行会阻塞 Redis,复杂逻辑应移出脚本;
  • Redis Cluster 中涉及多个 Key 时,需要遵守 Key Slot 约束;
  • 如果系统存在多个彼此独立的主节点,同一个限流状态不能随意分散保存;
  • 限流计数适合做流量控制,不应直接替代精确的计费账本;
  • 单机或进程内 Benchmark 不能被宣传为生产环境 QPS;
  • Lua 避免了并发超发,不代表网络超时后的重试语义自动正确,客户端仍要明确处理“请求是否可能已经在 Redis 执行”的问题。

这些限制不会让 Lua 方案失去价值,反而帮助我们准确描述它解决了什么、没有解决什么。

结语

Token Bucket 最容易被低估的地方,是把它当成一个简单的减法。

真正的限流判断是一段带时间的共享状态转移:读取旧状态、计算补充量、决定是否放行、扣除令牌并保存新状态。只要其中任何一步能被另一个请求插入,就可能让最终数据看似正常,却放行了超过配额的请求。

Redis Lua 的作用不是让实现显得更“高级”,而是把这段不可分割的逻辑放到状态所在的位置一次完成;Redis TIME 则让多个网关实例围绕同一个时间源更新状态。

而当系统同时使用 Token Bucket 和 Semaphore 时,也应该能够准确说明:前者控制请求速率,后者控制在途并发。把问题边界讲清楚,比堆叠更多中间件更重要。

延伸阅读