ZINCRBY单独使用不满足排行榜结算需求,因其仅原子增减分数,无法实现“查分→比较→条件更新”的完整原子逻辑,高并发下易导致丢失更新;须用Lua脚本将三步封装为单次原子操作,并返回明确业务结果。

因为 ZINCRBY 本身原子,但“查分数→比大小→决定是否更新”这整条链路不是原子的;Lua 把这三步压进 Redis 单线程执行,彻底消灭竞态。
为什么 ZINCRBY 单独用不满足排行榜结算需求
ZINCRBY 确实是原子命令,但它只做一件事:给 member 的 score 加一个 delta。排行榜结算常见逻辑是「新分数更高才覆盖旧分」,这需要三步:ZSCORE 查当前分 → 客户端判断 new_score > old_score → 再调 ZADD 或 ZINCRBY 写入。
并发时两个请求几乎同时查到相同旧分,各自算出新分后写回,后到者必然覆盖前者——这就是典型的丢失更新(lost update)。
真正要原子的不是单个命令,而是整个业务判断逻辑。
怎么写一个安全的 Lua 结算脚本
核心是把「查、判、写」全塞进一个脚本里,并让返回值明确表达业务结果(成功/拒绝),而不是依赖命令返回值。
-
redis.call("ZSCORE", KEYS[1], ARGV[1])获取原分,注意返回nil表示 member 不存在 - 用
tonumber()强转,避免字符串比较出错(比如"10" > "9"是 false) - 只在
tonumber(ARGV[2]) > tonumber(old_score)成立时调redis.call("ZADD", KEYS[1], ARGV[2], ARGV[1]),可加XX选项防止误插入新 member - 最后
return 1或return 0,上层代码靠这个做幂等或重试决策,别用ZADD返回值(它返回的是插入数量)
示例脚本(一行压缩版):
eval "local old = redis.call('ZSCORE', KEYS[1], ARGV[1]); if not old then old = '-inf' end; if tonumber(ARGV[2]) > tonumber(old) then redis.call('ZADD', KEYS[1], ARGV[2], ARGV[1]); return 1 else return 0 end" 1 leaderboard:user:weekly user_123 876
EVALSHA 比 EVAL 更适合线上,但容易漏掉预加载
高频调用时必须用 EVALSHA,否则每次传脚本字符串会浪费带宽和解析时间。
常见错误是没做 SCRIPT LOAD 预加载,导致首次 EVALSHA 报 NOSCRIPT 错误;或者服务重启后 Redis 清空脚本缓存,代码仍硬编码旧 SHA 值直接调用。
正确做法:
- 上线前用
SCRIPT LOAD加载脚本,记录返回的 SHA1 值 - 代码中先尝试
EVALSHA,捕获NOSCRIPT后 fallback 到EVAL并重试一次 - 不要把 SHA 值写死在配置里——不同 Redis 实例(尤其集群节点或版本差异)可能生成不同哈希
分页查榜别用 Lua,那是自找麻烦
ZREVRANGE 和 ZRANGE 本身就是高效原子命令,支持 WITHSCORES、BYSCORE、LIMIT offset count,完全够用。
有人试图在 Lua 里做分页裁剪(比如取第 100–109 名),结果脚本执行期间阻塞 Redis 单线程,反而拖慢整体响应——原生命令由 C 实现,而 Lua 脚本解释执行开销更大。
真正该用 Lua 的,永远是「读+判+写」闭环操作;纯读场景,交给原生命令更稳更快。
最容易被忽略的一点:脚本里所有 key 必须显式通过 KEYS 数组传入,不能硬编码;否则在 Redis Cluster 模式下会因 key 不在同一 slot 导致 CROSSSLOT 错误——这问题在线上压测时才暴露,但修复成本很高。











