Redis ZSet不支持直接存储属性,需用Lua脚本原子性地ZADD+HSET同步更新排名与用户元数据,并统一key前缀、设置TTL、外部清理过期Hash键。

为什么不能直接用 ZADD 更新带属性的成员?
Redis 有序集合(ZSet)本身只存 score 和 member,不支持附加字段。你想“同步更新 Score 与 Member 属性”,比如把用户昵称、等级、头像 URL 一起刷新,但 ZADD 的 member 是字符串键,不是结构体——它没法自动携带 JSON 或哈希字段。
常见错误是:先用 ZADD 更新排名,再用 HSET user:1001 name "Alice" level 5 单独存属性,结果在高并发下出现 ZSet 里有 ID、但 Hash 里没数据,或反之。这不是原子问题,而是业务逻辑断层。
真正需要的是:一次操作完成「写入/更新 ZSet 排名 + 同步落库用户元数据」,且整个过程对同一 member 具备强一致性。
用 Lua 脚本封装 ZADD + HSET 的关键约束
Lua 脚本能保证原子性,但要注意 Redis 的单线程模型只针对脚本内命令——它不阻塞其他客户端对 *不同 key* 的访问。所以设计时必须收敛 key 空间,避免跨 slot(集群模式下会报 CROSSSLOT Keys in request don't hash to the same slot)。
- 所有 key 必须基于同一个业务标识构造,例如用
rank:2024q3作前缀,member作为后缀:rank:2024q3:zset存 ZSet,rank:2024q3:user:1001存属性 - 脚本接收 3 个参数:
ARGV[1]是 score,ARGV[2]是 member ID,ARGV[3]是 JSON 字符串(含 nickname、level 等) - 必须用
redis.call("HSET", ...)而非redis.pcall,否则 HSET 失败不会中断 ZADD,导致数据不一致
示例脚本(保存为 update_rank.lua):
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
local zset_key = KEYS[1]
local user_hash_key = "rank:" .. string.match(zset_key, "rank:(.*)") .. ":user:" .. ARGV[2]
redis.call("ZADD", zset_key, ARGV[1], ARGV[2])
redis.call("HSET", user_hash_key, "data", ARGV[3])
return 1客户端调用时必须传对 KEYS 和 ARGV
很多人卡在参数顺序和类型上。Lua 脚本中 KEYS 是 Redis key 列表(必须是真实存在的 key 名),ARGV 是任意值。如果你传错,比如把 member ID 放进 KEYS,集群模式下直接报错;如果把 JSON 当成 number 传(如漏了引号),HSET 会存空值。
以 redis-cli 为例,正确调用方式:
redis-cli --eval update_rank.lua rank:2024q3:zset , 95.5 1001 '{"nickname":"Alice","level":5,"avatar":"a1.png"}'- 注意
,是分隔符,左边是KEYS,右边是ARGV -
95.5和1001是字符串,但 Redis 会自动转 number;JSON 必须是完整字符串,不能换行或含未转义双引号 - Node.js 使用
client.eval(script, 1, "rank:2024q3:zset", 95.5, "1001", jsonStr),第一个数字是 KEYS 数量
实时刷新排行榜时,别忽略 ZSet 容量与内存膨胀
很多人以为“实时”就是每秒刷几百次,结果发现内存暴涨、BGREWRITEAOF 卡住。问题出在:你每更新一个用户,就写一个独立的 rank:2024q3:user:1001 key,而 ZSet 本身不感知这些 Hash key —— 它们不会随 ZSet 成员过期或删除而自动清理。
必须配套做两件事:
- 给所有用户 Hash key 设置 TTL,比如
EXPIRE user_key 86400(在 Lua 脚本末尾加一行) - 定期用
SCAN扫描rank:*:user:*匹配的 key,比对 ZSet 中是否存在对应 member,不存在的就DEL - 如果排行榜按周期滚动(如每月重置),务必在切换周期前用
UNLINK批量删旧 Hash key,避免阻塞主线程
最易被忽略的一点:Lua 脚本里不能用 SCAN 或 KEYS,所以清理动作必须由外部定时任务触发,不能塞进刷新逻辑里。

















