因为INCR与EXPIRE非原子操作,中间中断会导致key永久残留或超发;必须用Lua脚本将令牌计算、校验、扣减、更新时间戳等步骤原子执行。

为什么直接用 INCR + EXPIRE 不能可靠实现令牌桶?
因为 Redis 的原子性只保证单命令,INCR 和 EXPIRE 分开执行时可能在中间被中断(比如服务重启、网络断开),导致令牌数已增加但过期时间未设置,桶“永远不漏”。更糟的是,令牌桶需要动态计算「当前可用令牌数 = 上次填充量 + 新增令牌 - 消耗量」,涉及读-改-写,必须用 Lua 脚本保证整个逻辑原子执行。
EVAL 脚本里怎么算「当前令牌数」和「是否放行」?
核心是记录两个关键值:上次更新时间戳(last_time)和当前令牌数(tokens)。每次请求进来,先用当前时间减去 last_time,按速率算出应新增的令牌(向下取整),再与最大容量取最小值,最后扣减本次请求所需令牌。脚本返回 1 表示放行,0 表示拒绝。
示例脚本(限流键为 rate:uid:123,最大容量 10,每秒补充 2 个):
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local fill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local tokens_needed = tonumber(ARGV[4])
<p>local bucket = redis.call("HMGET", key, "tokens", "last_time")
local tokens = tonumber(bucket[1]) or capacity
local last_time = tonumber(bucket[2]) or now</p><p>-- 计算应补充的令牌数(注意:单位是秒,需转为毫秒做差)
local delta = math.floor((now - last_time) * fill_rate)
tokens = math.min(capacity, tokens + delta)
local allowed = tokens >= tokens_needed
if allowed then
tokens = tokens - tokens_needed
end</p><p>-- 总是更新 last_time 为当前时间(不是 now + delta)
redis.call("HMSET", key, "tokens", tokens, "last_time", now)
redis.call("EXPIRE", key, 60) -- 防误存,设个兜底过期时间
return allowed and 1 or 0调用时传参顺序和时间戳单位容易错在哪?
Redis EVAL 的 ARGV 是字符串数组,Lua 里必须显式 tonumber();更重要的是,now 必须用毫秒时间戳(如 Node.js 的 Date.now()),而 fill_rate 单位是「令牌/秒」,所以计算 delta 时要除以 1000 —— 很多人漏掉这个换算,导致补得太快或太慢。
- 错误写法:
delta = (now - last_time) * fill_rate(若 now 是毫秒,结果会大 1000 倍) - 正确写法:
delta = math.floor((now - last_time) / 1000 * fill_rate) -
EXPIRE时间建议设为远大于业务周期的值(比如 60 秒),避免桶提前消失,但又不能设成永不过期
为什么不用 CL.THROTTLE?它和 Lua 实现有啥实质区别?
CL.THROTTLE 是 Redis 6.2+ 内置命令,本质也是 Lua 实现,但它返回的是结构化信息(剩余令牌、重试等待秒数等),且默认使用「滑动窗口」逻辑而非严格令牌桶。如果你需要精确控制填充节奏(比如每 500ms 补 1 个)、或依赖自定义字段(如绑定用户角色动态调整容量),还是得自己写 Lua;另外,CL.THROTTLE 不支持自定义过期策略,对长期空闲的 key 无法自动清理。
真正难的不是写对脚本,而是压测时发现「高并发下令牌数偶尔突降为负」——这通常是因为客户端传入的 now 时间不同步,或脚本里没处理 tokens 小于 0 的边界(应强制设为 0)。别省那句 tokens = math.max(0, tokens)。

















