Token 级限流:
滑动窗口 + 令牌桶
用交互模拟理解两种经典算法如何控制配额,以及为什么生产环境必须把判定放进 Redis Lua 脚本 以保证原子性与多实例一致。
01 · 问题
什么是 Token 级限流?
不仅限制「请求次数」,还按消耗的 token / 算力权重计量。同一用户发 1 次轻量 Chat 与 1 次大上下文 Embedding,应消耗不同配额。
保护下游
避免模型推理、DB、第三方 API 被突发打满,保证整体 SLA。
多维度配额
user / api_key / model / IP 组合 key,精细到租户与路由。
分布式一致
网关多副本并行时,必须以 Redis 为唯一真相源做原子扣减。
Token 级限流链路
请求进入后,在分布式层用 Redis 完成原子判定
维度
user · key · model · IP
算法
滑动窗口 + 令牌桶
一致性
Lua 单线程原子
02 · 算法
滑动窗口(Sliding Window Log)
维护窗口内每个请求的时间戳。判定时丢弃过期点,再数当前窗口内数量。比固定窗口更平滑,能消除整点边界的「双倍突发」。
- · 结构:有序集合 score=时间戳
- · 准入:count(now−W, now] < limit
- · 适合:严格 QPS / 精确窗口计数
- · 固定窗口:实现简单,边界可 2× 突发
- · 滑动窗口:更公平,内存随请求量增长
- · 生产常折中:滑动计数器近似
滑动窗口模拟器
窗口 5s · 上限 5 次 · 只统计「当前时刻回看窗口」内请求
每次请求把时间戳写入有序集合;判定时过滤掉窗口外的点。比固定窗口更平滑,避免整点边界突发双倍配额。
请求日志
- 点击「发送请求」观察 ALLOW / DENY
03 · 算法
令牌桶(Token Bucket)
桶中持续按速率补充令牌;请求按权重扣减。允许短时突发(桶满时),长期平均速率受 refill 约束——天然适配「Token 加权」限流。
capacity
突发上限(桶深)
一次能透支的最大 token 数
refill rate
平均供给速率
长期可持续的 tokens/s
cost
请求权重
Chat=1,Embed=3…
令牌桶模拟器
容量 10 · 补充速率 2 token/s · 不同请求消耗不同令牌
静置时自动补充;突发可瞬时用光,之后按速率恢复。适合「平均限速 + 允许短时高峰」。
sim t = 0.0s · refill +2/s
扣费日志
- 发起 Chat / Embed 观察扣费与拒绝
04 · 原子性
Redis + Lua:分布式原子保障
限流是典型的 check-then-act。若拆成多条 Redis 命令,并发下会出现超卖(多放行)。把整段逻辑写入 Lua,由 Redis 单线程执行,读改写一体、原子完成。
// 非原子:GET → 判断 → SET(多实例会超卖)
const n = await redis.get(key)
if (Number(n) < limit) {
await redis.incr(key) // 中间已被别的请求修改
}
两个网关实例同时读到 n=limit−1,都会 incr → 实际放行 2 次,突破配额。
- · 脚本执行期间无其他命令插入
- · 返回 allow、剩余量、retry-after
- · 可配合 EVALSHA 降低带宽
- · key 设计带 TTL,避免热 key 永久占用
-- 滑动窗口(ZSET + 毫秒时间戳)
-- KEYS[1] = rl:sw:{user}:{route}
-- ARGV[1]=now_ms ARGV[2]=window_ms ARGV[3]=limit ARGV[4]=member_id
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local member = ARGV[4]
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count >= limit then
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retry = window
if oldest[2] then
retry = math.max(0, tonumber(oldest[2]) + window - now)
end
return {0, count, retry} -- deny
end
redis.call('ZADD', key, now, member)
redis.call('PEXPIRE', key, window)
return {1, count + 1, 0} -- allow
-- 令牌桶(HASH: tokens, ts)
-- KEYS[1] = rl:tb:{user}
-- ARGV[1]=now_ms ARGV[2]=capacity ARGV[3]=refill_per_ms ARGV[4]=cost
local key = KEYS[1]
local now = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local refill = tonumber(ARGV[3]) -- tokens per ms
local cost = tonumber(ARGV[4])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])
if tokens == nil then
tokens = capacity
ts = now
end
local delta = math.max(0, now - ts)
tokens = math.min(capacity, tokens + delta * refill)
if tokens < cost then
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, 86400000)
return {0, tokens} -- deny
end
tokens = tokens - cost
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, 86400000)
return {1, tokens} -- allow
Key 设计建议
rl:sw:{tenant}:{route} # 滑动窗口 ZSET
rl:tb:{tenant}:{model} # 令牌桶 HASH
rl:quota:{plan}:daily # 日配额计数响应约定
- · 拒绝时 HTTP 429 + Retry-After
- · 响应头 X-RateLimit-Remaining
- · 脚本内计算剩余等待毫秒,避免客户端猜
05 · 选型
怎么选、怎么组合
工程上很少只绑死一种算法。常见组合:网关入口用令牌桶控突发与加权 token;对敏感接口再叠滑动窗口做硬上限。
| 维度 | 滑动窗口 | 令牌桶 |
|---|---|---|
| 平滑度 | 高,无整点双倍 | 高,由桶深决定突发 |
| Token 加权 | 需把 cost 折成多次或改计数 | 原生支持 cost |
| 内存 | 与窗口内请求数成正比 | 每 key 常数级(tokens+ts) |
| 实现复杂度 | ZSET 清理 + 计数 | 按时间差 refill 再扣减 |
| 典型场景 | 严格 QPS / 审计窗口 | API 配额、LLM token 预算 |
| 原子实现 | Lua ZREMRANGE + ZADD | Lua HMGET + 计算 + HMSET |
落地清单
- 明确计量单位:请求次数 vs 输入/输出 token 加权。
- 选定 key 维度与过期策略,防止 Redis 内存泄漏。
- 将判定逻辑收敛到一条 Lua(或 Redis Function),禁止 GET+INCR 拆分。
- 网关多副本共享同一 Redis;热 key 可按用户分片。
- 对外暴露剩余配额与重试时间,便于客户端退避。
- 压测边界:时钟回拨、Lua 超时、Redis 主从切换期间的降级策略。