图文解释Token 级Redis · Lua

Token 级限流:滑动窗口 + 令牌桶

用交互模拟理解两种经典算法如何控制配额,以及为什么生产环境必须把判定放进 Redis Lua 脚本 以保证原子性与多实例一致。

01 · 问题

什么是 Token 级限流?

不仅限制「请求次数」,还按消耗的 token / 算力权重计量。同一用户发 1 次轻量 Chat 与 1 次大上下文 Embedding,应消耗不同配额。

保护下游

避免模型推理、DB、第三方 API 被突发打满,保证整体 SLA。

多维度配额

user / api_key / model / IP 组合 key,精细到租户与路由。

分布式一致

网关多副本并行时,必须以 Redis 为唯一真相源做原子扣减。

Token 级限流链路

请求进入后,在分布式层用 Redis 完成原子判定

01API Gateway解析 user / api_key / model
02限流 Keyrl:{scope}:{id}:{window}
03Redis + Lua单脚本原子读改写
04ALLOW / DENY429 + Retry-After

维度

user · key · model · IP

算法

滑动窗口 + 令牌桶

一致性

Lua 单线程原子

02 · 算法

滑动窗口(Sliding Window Log)

维护窗口内每个请求的时间戳。判定时丢弃过期点,再数当前窗口内数量。比固定窗口更平滑,能消除整点边界的「双倍突发」。

核心思想
  • · 结构:有序集合 score=时间戳
  • · 准入:count(now−W, now] < limit
  • · 适合:严格 QPS / 精确窗口计数
与固定窗口对比
  • · 固定窗口:实现简单,边界可 2× 突发
  • · 滑动窗口:更公平,内存随请求量增长
  • · 生产常折中:滑动计数器近似

滑动窗口模拟器

窗口 5s · 上限 5 次 · 只统计「当前时刻回看窗口」内请求

待命
t − 5snow · 0.0s
窗口内 0/5

每次请求把时间戳写入有序集合;判定时过滤掉窗口外的点。比固定窗口更平滑,避免整点边界突发双倍配额。

请求日志

  • 点击「发送请求」观察 ALLOW / DENY

03 · 算法

令牌桶(Token Bucket)

桶中持续按速率补充令牌;请求按权重扣减。允许短时突发(桶满时),长期平均速率受 refill 约束——天然适配「Token 加权」限流。

capacity

突发上限(桶深)

一次能透支的最大 token 数

refill rate

平均供给速率

长期可持续的 tokens/s

cost

请求权重

Chat=1,Embed=3…

令牌桶模拟器

容量 10 · 补充速率 2 token/s · 不同请求消耗不同令牌

10.00 tokens
bucket
水位10.00 / 10

静置时自动补充;突发可瞬时用光,之后按速率恢复。适合「平均限速 + 允许短时高峰」。

sim t = 0.0s · refill +2/s

扣费日志

  • 发起 Chat / Embed 观察扣费与拒绝

04 · 原子性

Redis + Lua:分布式原子保障

限流是典型的 check-then-act。若拆成多条 Redis 命令,并发下会出现超卖(多放行)。把整段逻辑写入 Lua,由 Redis 单线程执行,读改写一体、原子完成。

危险非原子多命令
race.ts
// 非原子:GET → 判断 → SET(多实例会超卖)
const n = await redis.get(key)
if (Number(n) < limit) {
  await redis.incr(key)   // 中间已被别的请求修改
}

两个网关实例同时读到 n=limit−1,都会 incr → 实际放行 2 次,突破配额。

推荐Lua 单脚本
  • · 脚本执行期间无其他命令插入
  • · 返回 allow、剩余量、retry-after
  • · 可配合 EVALSHA 降低带宽
  • · key 设计带 TTL,避免热 key 永久占用
EVALSHA sha 1 rl:tb:u42 now cap rate cost
→ {1, 7.42} // allow, tokens left
sliding_window.lua
-- 滑动窗口(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
token_bucket.lua
-- 令牌桶(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 + ZADDLua HMGET + 计算 + HMSET

落地清单

  1. 明确计量单位:请求次数 vs 输入/输出 token 加权。
  2. 选定 key 维度与过期策略,防止 Redis 内存泄漏。
  3. 将判定逻辑收敛到一条 Lua(或 Redis Function),禁止 GET+INCR 拆分。
  4. 网关多副本共享同一 Redis;热 key 可按用户分片。
  5. 对外暴露剩余配额与重试时间,便于客户端退避。
  6. 压测边界:时钟回拨、Lua 超时、Redis 主从切换期间的降级策略。