ZINCRBY 单命令虽原子,但复杂业务逻辑(如更新时间戳、阈值通知、日增量限制)需用 Lua 脚本封装以保证原子性;脚本须预加载、避免耗时操作,且注意集群 slot 一致性与客户端缓存失效。

为什么直接用 ZINCRBY 在高并发下会出问题
多个客户端同时对同一个排行榜成员执行 ZINCRBY,看似原子,但「读取当前分数 → 加上增量 → 写回」这个逻辑如果拆成两步(比如先 ZSCORE 再 ZADD),就必然出现竞态。哪怕只用 ZINCRBY,如果业务需要「更新分数的同时刷新最新时间戳」「达到阈值触发通知」「限制单日增量上限」,这些判断和动作无法靠单条命令完成,必须进 Lua 脚本做原子封装。
用 EVAL 执行 Lua 脚本更新排行榜的最小可行写法
Redis 的 Lua 脚本在服务端原子执行,所有 redis.call() 调用都在同一事务上下文中。以下脚本实现「安全累加分数 + 更新时间戳 + 返回新分数」:
redis.call('ZINCRBY', KEYS[1], ARGV[1], ARGV[2])
redis.call('HSET', 'user:meta:' .. ARGV[2], 'updated_at', ARGV[3])
return redis.call('ZSCORE', KEYS[1], ARGV[2])
调用方式(例如给用户 u1001 加 5 分,时间戳为 1717023456):
redis-cli --eval zrank_update.lua my_ranking , 5 u1001 1717023456
-
KEYS[1]是排行榜 key(必须显式传入,不能硬编码) -
ARGV[1]是增量值,ARGV[2]是 member,ARGV[3]是时间戳 - 所有
redis.call()操作共享同一份数据视图,不会被其他请求打断 - 脚本返回值是
ZSCORE结果,可直接用于后续判断(比如是否进入 Top 10)
遇到 (error) BUSY Redis is busy running a script 怎么办
这通常不是脚本太慢,而是你用了 EVALSHA 但没预加载脚本,或脚本里调用了耗时操作(如循环遍历大集合、多次 redis.call('KEYS'))。Lua 脚本执行期间会阻塞整个 Redis 实例(单线程模型),所以必须遵守:
- 避免在脚本中使用
redis.call('KEYS', '*')—— 改用已知 key 名或 SCAN 游标由客户端分页传入 - 不要用
for i=1,10000 do做密集计算;Redis 对 Lua 脚本有 5 秒默认超时(lua-time-limit配置),超时后报BUSY并 kill 脚本 - 高频调用的脚本建议用
SCRIPT LOAD预加载,再用EVALSHA执行,减少网络传输和解析开销 - 如果真需要批量更新 Top 100 用户,别在 Lua 里
ZRANGE后挨个处理——改用客户端拉取后再发多条带参数的EVAL
排行榜更新后怎么让客户端立刻看到变化
Lua 脚本能保证数据一致性,但不解决缓存穿透或客户端本地缓存过期问题。常见疏漏点:
- 前端可能缓存了旧的排行榜 JSON,需配合 HTTP Cache-Control 或主动失效机制
- 如果用了 Redis 代理(如 Twemproxy、Codis),注意它们不支持 Lua 脚本,必须直连 Redis 实例
- 脚本里写入的辅助数据(如
HSET user:meta:u1001 updated_at ...)要和主排行榜 key 保持相同 slot(用{}标记哈希标签),否则集群模式下会跨节点失败 - 测试时别只压测单个 member,要模拟真实分布:大量不同 user_id 同时更新,才能暴露 key 热点问题(比如所有用户都往
global_ranking写,这个 key 就是瓶颈)
真正难的从来不是“怎么写脚本”,而是想清楚哪些逻辑必须进 Lua,哪些可以拆到客户端异步做;以及当 zrank_update.lua 开始变长、带分支和条件时,它就已经该被拆成更小的原子操作了。

















