zremrangebyscore单独使用会出错,因其不保证原子性,需用lua脚本封装zremrangebyscore+zcard+zadd实现滑动窗口限流,否则并发下必然出现超限放行、统计失真等问题。

为什么ZREMRANGEBYSCORE单独用会出错
ZREMRANGEBYSCORE本身不保证限流逻辑的原子性。你先删旧数据、再ZCARD统计、最后ZADD新请求,这三步之间存在时间窗口:并发请求可能在清理后、统计前插入,导致ZCARD漏数;也可能在统计后、ZADD前被其他客户端抢先写入,造成超限放行。生产环境里这类竞态不是“可能”,而是“必然发生”。
常见错误现象包括:监控看到QPS偶尔冲高超标、同一IP短时内被反复放过、压测时限流阈值形同虚设。
- 别把ZREMRANGEBYSCORE当限流主干逻辑,它只是辅助清理动作
- 清理范围必须严格匹配窗口定义:比如60秒窗口,
ZREMRANGEBYSCORE key 0 (now-60000)中的(now-60000)要加括号表示开区间,否则边界请求会被误删 - score 单位统一用毫秒(
time.Now().UnixMilli()),避免秒级精度下多个请求撞同一score,导致ZCARD统计失真
必须用Lua脚本封装ZREMRANGEBYSCORE+ZCARD+ZADD
Redis执行Lua脚本是原子的,整个脚本内所有命令按顺序串行执行,中间不会被其他客户端打断。这才是滑动窗口能落地的前提。
典型脚本结构(保存为sliding_window.lua):
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window_ms = tonumber(ARGV[2])
local max_count = tonumber(ARGV[3])
local req_id = ARGV[4]
<p>redis.call('ZREMRANGEBYSCORE', key, 0, now - window_ms)
local count = redis.call('ZCARD', key)</p><p>if count </p>
-
KEYS[1]是限流key(如"rate:login:192.168.1.100"),必须由调用方传入,不能硬编码 -
ARGV[4]是请求唯一标识,不能为空;若漏传,ZADD不报错但写入失败,脚本仍返回1,等于直接绕过限流 -
EXPIRE时间设为窗口时长+30秒,是兜底策略——防止因网络抖动导致ZREMRANGEBYSCORE没执行完就断连,残留key长期占用内存
ZSet内存暴涨的两个隐藏原因
高频服务下,ZSet成员数增长远超预期,不是因为脚本写得不对,而是两个常被忽略的设计点:
- ZSet不会自动压缩:哪怕窗口只保留最近1秒数据,每毫秒一个请求,1秒就是1000个member;ZSet底层是跳表+哈希表双结构,每个member至少占几十字节,万级/秒请求一天就能吃掉GB级内存
- 没设TTL或TTL太长:仅靠
ZREMRANGEBYSCORE清理不够,网络分区或客户端崩溃会导致部分key永远不被访问,EXPIRE必须写进Lua,且不能依赖外部定时任务补救 - score用毫秒但业务允许秒级精度?改用秒级时间戳(
time.Now().Unix())+HINCRBY分桶,内存可降两个数量级——滑动窗口越“滑”,代价越高
Go/Python调用时参数类型最容易踩的坑
go-redis和redis-py对Lua参数的类型映射有隐式规则,传错类型会导致脚本静默失败:
- Go中
time.Now().UnixMilli()返回int64,传给Lua的ARGV[1]会被自动转为number,没问题;但若传string("1716734280000"),tonumber()会返回nil,后续所有redis.call都失败 - Python里用
redis.eval(script, 1, key, now, window_ms, max_count, req_id),注意now必须是int,不能是float或str - 脚本返回值是
0或1,但go-redis默认解析为interface{},需显式断言:result.(int64) == 1,否则类型错误panic
真正难的不是写对ZREMRANGEBYSCORE,而是让每一次调用都在正确的时间、以正确的类型、触发正确的原子路径——漏掉任意一环,滑动窗口就从闸门变成筛子。










