Redis 限流器里的 Lua:为什么 Token Bucket 不能用几条命令拼出来
从一次并发超发出发,解释 ModelGate 如何用 Redis Lua 和统一时间源实现分布式 Token Bucket,以及它与并发控制的边界。
Redis 限流器里的 Lua:为什么 Token Bucket 不能用几条命令拼出来
给网关加限流,看起来像一道很简单的题:
- 从 Redis 读取剩余令牌;
- 判断令牌是否足够;
- 足够就减一,再把结果写回 Redis。
如果只有一个请求,这套流程没有问题。真正麻烦的是,网关往往有多个实例,同一时刻也可能有多个请求操作同一个限流状态。
假设桶里只剩最后一个令牌,两个请求同时到达:
| 时刻 | Gateway A | Gateway B | Redis 中的令牌 |
|---|---|---|---|
| T1 | 读取到 1 | 1 | |
| T2 | 也读取到 1 | 1 | |
| T3 | 判断可以通过,写入 0 | 0 | |
| T4 | 也判断可以通过,写入 0 | 0 |
最终 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 个请求,那么每秒补充速率可以写成:
一次请求到达时,先根据经过的时间补充令牌:
如果 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 而言,这不再是客户端发来的许多零散步骤,而是一次完整的状态转移:
如果两个请求同时到达,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 时,也应该能够准确说明:前者控制请求速率,后者控制在途并发。把问题边界讲清楚,比堆叠更多中间件更重要。