滑动窗口限流必须用ZSET+Lua原子执行,因INCR+EXPIRE非原子且为固定窗口:时间切片硬隔离致漏限,竞态致误限;Lua中须调TIME获取服务端时间戳,ZREMRANGEBYSCORE需在ZADD前执行。

滑动窗口限流不能靠 INCR + EXPIRE 拼凑,否则就是固定窗口,且高并发下必然漏限或误限——必须用 ZSET + Lua 脚本原子执行清理、写入、统计三步,且时间戳必须来自 Redis 服务端。
为什么 INCR + EXPIRE 不是滑动窗口
它本质是「固定窗口」:比如窗口设为 60 秒,key 名按 rate:ip:192.168.1.100:20260512:14(小时粒度)或 ...:14:32(分钟粒度)硬切。第 59 秒进 100 个请求,第 0 秒又进 100 个,实际 2 秒内 200 次,但两个 key 完全隔离,限流完全失效。
更严重的是竞态:INCR 成功后若 EXPIRE 因网络中断、Redis 主从切换失败,key 就永久存在;反之若 key 刚被删,多个客户端同时 INCR,各自从 1 开始计数,QPS 瞬间翻倍。
- 现象:
ERR no such key、TTL返回 -2、监控看到计数持续上涨但实际流量平稳 - 根本原因:
INCR和EXPIRE是两个独立命令,Redis 不保证原子性 - 替代方案:所有逻辑压进一个
EVAL,由 Redis 单线程顺序执行
ZSET + Lua 实现滑动窗口的最小可靠脚本
核心逻辑只有三行:清理过期项 → 插入当前请求 → 统计窗口内总数。必须用 ZREMRANGEBYSCORE 清理,且必须在 ZADD 前执行,否则刚插入的请求可能被自己删掉。
关键陷阱在于时间戳来源:绝不能用 Go 的 time.Now().UnixMilli() 算好再传入 ARGV,多实例 NTP 误差 > 窗口长度(如 1 秒)时,结果直接错乱。必须在 Lua 中调 redis.call("TIME") 获取服务端毫秒级时间戳:
local key = KEYS[1]
local window_ms = tonumber(ARGV[1])
local max_req = tonumber(ARGV[2])
local redis_time = redis.call("TIME") -- 返回 {seconds, microseconds}
local now_ms = redis_time[1] * 1000 + math.floor(redis_time[2] / 1000)
local window_start = now_ms - window_ms
<p>redis.call("ZREMRANGEBYSCORE", key, 0, window_start)
redis.call("ZADD", key, now_ms, ARGV[3])
local count = redis.call("ZCOUNT", key, window_start, "+inf")</p><p>if count > max_req then
return 0
end
return 1</p>
-
ARGV[3]建议传唯一标识,如"req:" .. math.random(1, 999999)或客户端生成的uuid,避免 score 相同时 member 被覆盖 - 不要用
PEXPIREAT给ZSET设过期:它只对 key 生效,不影响内部 member 存活,而我们依赖ZREMRANGEBYSCORE主动清理 - 返回值是
int64,Go 里必须用.Int64()解包,不是.Bool()—— 返回0表示拒绝,非零表示通过
Go 客户端调用时必须注意的细节
用 github.com/go-redis/redis/v9 时,别手写 EVAL 字符串拼接,先 script.Load() 预加载,再 Run() 执行,避免每次解析开销。
调用参数顺序和类型必须严格匹配脚本预期,KEYS 是字符串切片,ARGV 是任意类型切片,传错会导致 Lua error 或静默失败:
script := redis.NewScript(luaScript)
result, err := script.Run(ctx, rdb, []string{"rate:login:192.168.1.100"}, 60000, 100, "req:"+uuid.NewString()).Int64()
if err != nil {
// 处理网络错误或 Lua 运行错误
return false
}
allowed := result == 1
-
window_ms单位是毫秒,别传60期望代表 60 秒——得传60000 - key 命名要带业务维度,如
"rate:api:/user/profile:" + userID,避免全站共用一个 key 导致误限 - 别忽略
redis.Nil错误:如果脚本里用了redis.call("GET", ...)但 key 不存在,会返回nil,Lua 里未判空直接算会报错
生产环境容易被忽略的三个点
一是内存膨胀:每个请求都往 ZSET 塞一条,高频接口一天上亿条,不清理必爆内存。ZREMRANGEBYSCORE 必须在每次调用时执行,不能依赖 TTL 自动过期——ZSET 本身不支持 key 级 TTL 触发 member 清理。
二是 Redis 服务端时间必须稳定:用 chrony 同步,NTP 误差控制在 100ms 内;主从延迟超过窗口长度时,从节点读取会不准,所以脚本必须走 master 执行。
三是调试困难:Lua 脚本出错时,Redis 只返回泛泛的 Lua script failed,建议在脚本开头加 redis.log(redis.LOG_WARNING, "key="..key..", now="..now_ms),配合 redis-cli monitor 抓日志定位。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











