漏桶限流必须用Redis Hash存储,因其可原子性维护water和last_leak_time两个字段,避免状态撕裂;Lua脚本依据客户端传入时间戳计算匀速漏水、严格按capacity+1判断拒绝,确保输出恒定。

Redis + Lua 实现漏桶限流,核心在于用 HSET 存储“当前水量”和“上次漏水时间”,再靠 Lua 脚本原子性完成“按速率漏水 → 判断是否溢出 → 决定是否放行”三步。它不依赖客户端时钟,也不怕并发写覆盖,是保护数据库、短信通道这类弱弹性下游的首选方案。
漏桶状态为什么必须用 Hash 而不是 String
漏桶需要同时维护两个关键字段:water(当前桶中水量)和 last_leak_time(上一次漏水的时间戳)。如果只用 SET 存一个数字,每次读取后还得额外查时间,中间就可能被其他请求插队修改;而 HSET 可以把这两个字段塞进同一个 key,配合 HGETALL 一次性读全,再用 Lua 做完整计算——避免状态撕裂。
-
HSET的内存开销比拼接字符串或 JSON 小得多,尤其在亿级 key 场景下更省 Redis 内存 - 字段名固定(如
water、last_leak_time),后续加字段(比如capacity)也无需改结构 - 不推荐用
SET存 JSON 字符串:反序列化在 Lua 里难做,且 Redis 不校验格式,容易因字段缺失导致逻辑错乱
Lua 脚本里怎么算“漏了多少水”
漏桶的核心是“匀速漏”,不是“定时漏”。脚本里不能写 redis.call('HINCRBY', key, 'water', -1) 这种固定减 1 的逻辑——那只是模拟漏一滴,没体现速率。正确做法是根据当前时间和 last_leak_time 推算应漏水量:
- 先用
HGETALL拿到water和last_leak_time - 用
tonumber(ARGV[3])得到当前毫秒时间戳(由客户端传入,避免 Redis 服务器时钟漂移) - 计算已过去毫秒数:
elapsed = now - last_leak_time - 换算成应漏水量:
leaked = math.floor(elapsed * rate / 1000)(rate是每秒漏多少单位,比如 5 QPS) - 更新水量:
new_water = math.max(0, water - leaked),并更新last_leak_time = now
注意:这里用 math.floor 而不是四舍五入,防止因浮点误差多漏半滴导致负值。
为什么传入当前时间戳而不是让 Redis 自己取 time()
Redis 的 TIME 命令返回的是服务端时间,但客户端和服务端时钟可能不同步(NTP 漂移、虚拟机暂停等)。一旦服务端时间回拨,elapsed 算出来是负数,new_water 就会比原来还大——相当于桶自己偷偷蓄水,彻底破坏限流效果。
- 所有生产环境脚本都应由客户端传入
now(毫秒时间戳),且调用方需确保该时间来自稳定授时源(如System.currentTimeMillis()或 NTP 校准后的时钟) - 不要信任
redis.call('TIME')做漏量计算,它只适合做日志打点或 debug - 如果客户端无法传时间戳(比如前端直连 Redis),宁可加一层网关代理来注入,也不要妥协
桶满时的拒绝逻辑怎么写才不误伤
漏桶拒绝条件很明确:新请求进来时,如果 new_water + 1 > capacity,就拒绝。但这里有两个易错点:
- 不能写成
if new_water >= capacity then return 0——这等于说“桶满了就不让漏了”,违背漏桶持续排水的本质 - 要预留至少 1 单位空间给新请求,所以判断条件必须是
new_water + 1 > capacity - 如果拒绝,脚本应直接
return 0,不要更新任何字段(保持状态干净,下次还能继续算) - 如果放行,则执行
HSET key water (new_water + 1) last_leak_time now,并return 1
这个细节决定了漏桶是“严格平滑”还是“偶尔抖动”。线上压测时,用 redis-benchmark 批量发请求,观察实际输出 QPS 是否紧贴设定速率,就能验证逻辑是否真正在匀速漏。


















