因为INCR和EXPIRE是两个独立命令,集群模式下若key未对齐到同一slot,会触发CROSSSLOT错误;必须用{}包裹公共前缀强制哈希一致,再通过Lua脚本原子执行。

为什么直接用 INCR + EXPIRE 在集群里会失败?
Redis 集群要求同一个 Lua 脚本里所有 key 必须落在同一个 slot,否则执行时会报 CROSSSLOT Keys in request don't hash to the same slot。而用户 ID 和计数器 key(比如 user:123:visit:2024-06-15)如果没做哈希对齐,大概率被分配到不同节点——脚本一运行就崩。
解决思路只有一条:强制让所有相关 key 落在同一个 slot。最可靠的方式是用 {} 包裹 key 的公共部分,让 Redis 哈希时只计算花括号内的字符串。
- 错误写法:
INCR "user:123:visit:2024-06-15"→ slot 由整个字符串决定,不可控 - 正确写法:
INCR "{user:123}:visit:2024-06-15"→ slot 只由user:123决定,所有带{user:123}的 key 都在同个 slot - 脚本中所有 key(包括限流阈值、时间窗口标记等)都必须共用同一组
{}前缀
EVAL 脚本里怎么安全实现“日访问≤100次”?
不能分开调 INCR 和 EXPIRE,因为集群不保证原子性;也不能依赖 SET key val EX 86400 NX,它只对首次设值有效,无法累计计数。必须用一个 Lua 脚本完成:读当前值 → 判断是否超限 → 未超则自增并设过期 → 返回结果。
示例脚本(传入 KEYS[1] 为带槽对齐的计数 key,ARGV[1] 为阈值,ARGV[2] 为 TTL 秒数):
if redis.call("INCR", KEYS[1]) == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[2])
end
if tonumber(redis.call("GET", KEYS[1])) > tonumber(ARGV[1]) then
return 0
else
return 1
end
- 第一行
INCR同时完成初始化和计数,返回值为自增后结果 - 仅当返回 1(即首次访问)时才设
EXPIRE,避免重复覆盖 TTL - 必须用
tonumber()转换,否则字符串比较"10">"9"会出错 - 返回
1表示放行,0表示拒绝,业务层据此响应
如何把用户 ID 映射成带 {} 的安全 key?
不是所有用户 ID 都适合直接塞进 {}。比如数字 ID 123 没问题,但邮箱 user@example.com 里有 @ 和 .,虽然 Redis 允许,但可读性和调试成本高;更麻烦的是,如果用户 ID 本身含 } 或嵌套 {},会破坏 slot 计算逻辑。
- 推荐做法:统一前缀 + 哈希截断,例如
"{user:" .. md5(user_id):sub(1,8) .. "}:visit:" .. os.date("%Y-%m-%d") - 避免用原始 ID 拼接,防止特殊字符干扰或长度失控
- 哈希后取前 8 位足够保证分布均匀,又不会太长(Redis key 长度建议
- 务必在客户端和服务端使用**完全一致**的生成规则,否则 key 对不上 slot
为什么不用 redis.call("PTTL", KEYS[1]) 判断是否过期?
看起来合理:先查剩余 TTL,为 -2(key 不存在)就重置。但实际会踩坑——集群环境下,PTTL 返回 -2 仅表示该 slot 上 key 不存在,不代表其他 slot 没这个 key(虽然按对齐规则不该出现,但逻辑上不能假设)。更重要的是,PTTL + SET 两步非原子,中间可能被并发写入覆盖。
- 正确姿势是依赖
INCR的副作用:它在 key 不存在时自动初始化为 1,且天然原子 - 只要确保所有访问都走同一组
{}key,就无需额外判断 key 是否存在 - 如果业务需要区分“新用户首次访问”和“老用户今日首次”,可在脚本里加
GET判断初始值是否为 1,但不要用EXISTS或PTTL做控制分支
真正容易被忽略的是:Lua 脚本里所有 key 必须通过 KEYS 数组传入,不能硬编码或拼接字符串,否则 cluster 模式下会路由失败;另外,ARGV 里的数值一定要用 tonumber() 转,Redis 的 Lua 环境默认把所有参数当字符串处理。


















