必须用Lua脚本实现原子操作,因INCR+EXPIRE多命令存在竞态导致超发或key永久残留;Lua在Redis单线程内执行,确保令牌生成、校验、扣减三步一气呵成,彻底消除并发安全问题。

为什么直接用 INCR + EXPIRE 无法可靠实现令牌桶
因为 INCR 和 EXPIRE 是两个独立命令,中间存在竞态窗口:如果客户端执行 INCR 后崩溃或网络中断,EXPIRE 就不会被执行,导致 key 永久存在,后续所有请求都被拒绝。更糟的是,多个并发请求可能同时读到旧的令牌数、各自计算后写回,造成“超发”。Lua 脚本在 Redis 中原子执行,能彻底规避这个问题。
Lua 脚本必须包含的三个核心逻辑
一个可用的令牌桶 Lua 脚本不能只做“减令牌”,它得完整模拟桶行为:初始化桶、按时间补发令牌、判断是否允许请求、更新剩余令牌。以下逻辑缺一不可:
- 用
redis.call("GET", KEYS[1])读当前令牌数,若为空则用redis.call("SET", KEYS[1], ARGV[1], "EX", ARGV[2])初始化为容量,并记录过期时间(注意:不能用EXPIRE单独设,否则非原子) - 用
redis.call("TIME")获取服务端时间戳,结合上次更新时间(存于 value 中,格式如"{tokens}|{last_time}")计算应新增令牌数:min(capacity, current_tokens + rate * (now - last_time)) - 判断
current_tokens >= 1:是则减 1 并写回新状态;否则返回 0 —— 这个返回值必须是整数(0或1),方便客户端直接判断
调用时 KEY 和 ARGV 的实际含义与陷阱
脚本本身不关心业务维度,全靠传入的 KEYS 和 ARGV 绑定上下文。常见错误是把用户 ID 当 KEY 直接拼进去,结果出现哈希槽不一致报错(CROSSSLOT Keys in request don't hash to the same slot)。正确做法:
-
KEYS[1]必须是单个字符串,例如"rate_limit:uid_123"—— 如果用集群,确保该 key 哈希后落在同一 slot(可加{uid_123}大括号强制哈希标签) -
ARGV[1]是桶容量(如"100"),ARGV[2]是 TTL 秒数(如"60"),ARGV[3]是每秒填充速率(如"2")—— 注意:速率单位必须和 TTL 单位匹配,若 TTL 是 60 秒,速率就该是“60 秒内总补充量”,而非每秒数(否则浮点计算易出错) - 不要在 Lua 里做
os.time()或math.random():Redis 限制部分 Lua 函数,且os.time()返回的是 Redis 进程所在机器时间,未必和客户端一致;一律用redis.call("TIME")
实际部署前必须验证的两个边界场景
脚本上线后最常出问题的不是主流程,而是边界情况。务必手动测试:
- 桶已空时连续请求:预期返回 0,且
GET查看 value 应保持"0|{timestamp}"格式,时间戳持续更新(证明补令牌逻辑仍在跑) - 首次请求时 key 不存在:脚本应自动初始化为满桶(如
"100|{time}"),而不是报错或返回 nil —— 若返回nil,说明GET后没做空值判断就直接string.match,会触发 Lua 错误 - 注意 Redis 版本兼容性:低于 2.6 的 Redis 不支持 Lua;4.0+ 才支持
redis.call("TIME")返回数组,旧版需用redis.call("TIME")[1]取秒数
真正难的不是写对脚本,而是让每个业务方传进来的 KEYS 和 ARGV 都符合约定,且集群环境下不跨 slot —— 这往往需要在客户端 SDK 层统一做 key 规范化处理,而不是靠运维事后补救。


















