不能只用 incr + expire 做限流,因为二者非原子:可能 incr 成功但 expire 失败导致 key 永不过期;并发时双倍放行;集群下跨 slot 触发 crossslot 错误;必须用 lua 脚本(eval/evalsha)保证原子性。

为什么不能只用 INCR + EXPIRE 做限流
因为这两条命令不是原子的,中间可能被中断或并发覆盖。比如 INCR 成功了但 EXPIRE 失败,key 就变成永不过期;或者两个请求同时判断 INCR 后值为 1,都设了过期时间,实际窗口内放行了双倍请求。
更糟的是,在 Redis 集群模式下,INCR 和 EXPIRE 如果落在不同 slot(比如 key 被 rehash),会直接报 CROSSSLOT Keys in request don't hash to the same slot 错误。
- 必须用
EVAL或EVALSHA执行 Lua 脚本,Redis 保证脚本内所有操作原子执行 - Go 客户端(如
github.com/go-redis/redis/v9)调用Eval时,脚本里不能依赖外部状态(比如时间戳得用redis.call("TIME")) - Lua 脚本长度建议控制在 1KB 内,否则影响 Redis 主线程响应
EVAL 脚本怎么写才安全可靠
核心是:单个 key 存储计数 + 过期时间,用 redis.call("INCR") 初始化计数,再用 redis.call("EXPIRE") 统一设过期——这两步在 Lua 里天然原子。
常见错误是把时间逻辑放在 Go 侧计算后传入,比如先算好 expireAt 再 SETEX,这会导致时钟漂移或客户端时间不准问题。
立即学习“go语言免费学习笔记(深入)”;
- 正确做法:脚本内用
redis.call("TIME")拿当前秒级时间,结合窗口大小算过期,再用PEXPIREAT(毫秒级精度)设过期点 - key 命名必须带业务标识和用户维度,例如
"rate:login:ip:192.168.1.1",避免不同接口共用 key 导致误限 - 返回值统一设计为整数:0 表示被限流,1 表示放行,不要返回字符串或复杂结构,Go 侧
int64直接接收即可
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call("INCR", key)
if current == 1 then
redis.call("PEXPIREAT", key, tonumber(ARGV[3]))
end
if current > limit then
return 0
else
return 1
end
Go 里怎么调用并处理 Lua 脚本结果
别手写 client.Eval(ctx, script, keys, args...) 就完事。重点在错误分类和重试边界——Redis 执行 Lua 报错(比如 NOSCRIPT、BUSY)和业务限流(返回 0)必须严格区分。
- 用
redis.NewScript(...)预加载脚本,避免每次传输重复内容;首次运行自动SCRIPT LOAD,后续用EVALSHA - 检查
err是否为redis.Nil(key 不存在)、*redis.RedisError(连接/语法错)、还是nil(正常执行) - 返回值用
result.Val().(int64)断言,别用int(result.Val()),防止类型 panic - 不要对限流失败做重试——它本身就是“拒绝”,重试只会让下游更堵
集群环境下 key 分布和哈希标签要注意什么
如果 key 是 "rate:api:user:123",Redis Cluster 会按整个字符串哈希到某个 slot;但如果你用 {user:123} 包裹,比如 "rate:api:{user:123}",就强制把所有相关 key 路由到同一 slot,避免 CROSSSLOT 错误。
- 哈希标签
{}只取第一个出现的花括号内内容做哈希,多余部分忽略 - 别在脚本里拼接多个 key 并跨 slot 操作,Cluster 下直接失败;单节点 Redis 不校验,上线集群才暴露问题
- 本地开发用单节点 Redis 测试没问题,不代表生产集群能跑通——务必在类生产集群环境压测
真正麻烦的不是写对脚本,而是 key 设计没预留哈希标签,等上了集群才发现所有限流 key 都散落各处,改起来要动全链路。










