排行榜是 Redis 最经典的用法之一:用 ZSET 存成员与分数,ZREVRANGE 取前 N 名,ZINCRBY 累加积分。但只要业务从「累计积分榜」滑向「热度榜 / 互动分榜」,问题立刻出现——成员分数需要被反复更新,而更新又要遵循「只能涨不能跌」或「窗口内有效」的规则。纯 ZSET 方案在这里会暴露三个具体缺陷:分数更新的并发覆盖、过期语义缺失导致僵尸成员长期占据榜单、以及跨窗口切换时的数据污染。
这篇文章给出一套组合方案:用 ZADD key GT 保证分数单调不下降,用 EXPIRE key NX 保证键过期时间的幂等设置,再配合时间分片 key 完成窗口滑动。方案在 Redis 6.2 及以上(GT/LT 选项自 6.2.0 引入,NX/XX/GT/LT 属于 ZADD 的 flags,EXPIRE NX 自 7.0.0 引入)可以直接使用,低于该版本需要降级写法,文末 FAQ 给出替代方案。
为什么纯 ZSET 榜单在「滑动分数」场景下会崩
先把问题拆开。假设做的是一个内容社区的热度榜,热度由点赞、评论、浏览加权计算,规则是:
- 同一篇文章的热度分数只增不减(防止恶意刷负分或计算误差导致排名抖动);
- 热度只在 7 天窗口内有效,7 天后自动退出榜单;
- 榜单按滑动窗口滚动,不能出现「昨天的分数算进今天」的情况。
如果用最朴素的写法:
# 错误示范:先读再写,存在竞态
127.0.0.1:6379> ZSCORE hot:rank article:1001
"120"
127.0.0.1:6379> ZADD hot:rank 130 article:1001
(integer) 0
问题一:读-改-写不是原子的。两个 worker 同时读到 120,一个算出 130,一个算出 125,后写的覆盖先写的,分数可能倒退。用 Lua 可以解决,但每篇文章都要走一次脚本,成本不低。
问题二:没有过期语义。ZSET 的成员不会自己消失,只能靠 ZREMRANGEBYSCORE 或定时任务清理。如果忘了清理,三个月前的僵尸文章会靠历史高分长期霸榜。
问题三:整键 TTL 粒度太粗。给 hot:rank 设一个 7 天过期,只能做到「整个榜单 7 天后重建」,做不到「每篇文章从入榜起算 7 天」,更做不到平滑滑动。
ZADD ... GT 解决第一个问题:服务端比较,只有新分数严格大于旧分数才写入,天然幂等且无竞态。EXPIRE ... NX 解决第二、第三个问题的键级部分:只在键还没有 TTL 时设置,避免每次写入把过期时间往后推(这在滑动窗口里是致命的,等于永不过期)。剩下「成员级过期」的部分,用时间分片 key 承担。
ZADD GT:让分数更新变成服务端原子比较
ZADD 的完整语法(Redis 6.2+):
ZADD key [NX | XX] [GT | LT] [CH] [INCR] score member [score member ...]
| 选项 | 语义 | 在本方案中的作用 |
|---|---|---|
NX |
只添加新成员,不更新已存在成员 | 初始化时用,防止覆盖已有分数 |
XX |
只更新已存在成员 | 严格「只更新不新增」的场景 |
GT |
仅当新分数大于当前分数才更新;对不存在的成员等同于添加 | 核心:保证分数单调不降 |
LT |
仅当新分数小于当前分数才更新 | 倒计时、剩余额度类榜单 |
CH |
返回值改为「发生变化的成员数」而非「新增成员数」 | 配合 GT 判断写入是否真正生效 |
INCR |
对 score 做增量,类似 ZINCRBY | 与 GT/LT 互斥,不能同时使用 |
注意:
GT、LT、NX三者互斥,同时出现会报错ERR GT, LT, and/or NX options at the same time are not compatible。INCR与GT/LT也不能共存,报错ERR INCR option supports a single increment-element pair。
实际验证一下 GT 的行为:
127.0.0.1:6379> DEL hot:rank
(integer) 1
127.0.0.1:6379> ZADD hot:rank GT CH 120 article:1001
(integer) 1
127.0.0.1:6379> ZADD hot:rank GT CH 100 article:1001
(integer) 0
127.0.0.1:6379> ZSCORE hot:rank article:1001
"120"
127.0.0.1:6379> ZADD hot:rank GT CH 135 article:1001
(integer) 1
127.0.0.1:6379> ZSCORE hot:rank article:1001
"135"
关键点:第二次写 100 时返回 0,分数保持 120,说明旧值更大时写入被静默丢弃。这就是我们需要的「只涨不跌」。加上 CH 后,返回值可以告诉我们这次写入是否真正改变了数据,方便做埋点统计「无效更新率」。
还要注意一个边界:成员不存在时,GT 的行为等同于普通添加。这与 XX 不同,XX 对不存在的成员不写入。所以 GT 天然可以当「初始化 + 更新」两用,代码里不需要先 ZSCORE 判断存在性。
EXPIRE NX:让过期时间只设置一次
EXPIRE 的完整语法(Redis 7.0+):
EXPIRE key seconds [NX | XX | GT | LT]
NX:只在键没有过期时间时设置;XX:只在键已有过期时间时设置(用于修改已有 TTL);GT:只在新的过期时间大于当前剩余时间时设置;LT:只在新的过期时间小于当前剩余时间时设置。
这里必须讲清楚为什么不能直接 EXPIRE key 604800。假设榜单每来一条热度更新就调一次 EXPIRE,那么只要有任何一篇文章还在被访问,整个 key 的 TTL 就会被永久续期,7 天窗口变成永不过期,数据无限膨胀。这正是滑动窗口场景最常见的线上事故。
127.0.0.1:6379> DEL hot:rank
(integer) 0
127.0.0.1:6379> ZADD hot:rank GT CH 120 article:1001
(integer) 1
127.0.0.1:6379> EXPIRE hot:rank 604800 NX
(integer) 1
127.0.0.1:6379> EXPIRE hot:rank 604800 NX
(integer) 0
127.0.0.1:6379> TTL hot:rank
(integer) 604790
第二次 EXPIRE ... NX 返回 0,TTL 没有被重置。这样键的生命周期就是「第一次写入后 7 天」,后续所有更新都不会延长它。
线上常见报错:
ERR Unsupported option NX。这表示 Redis 版本低于 7.0.0,不支持 EXPIRE 的 NX/XX/GT/LT 选项。用redis-server --version或INFO server里的redis_version确认版本。降级方案见文末 FAQ。
还有一点容易踩:EXPIRE 对不存在的 key 返回 0,但不会报错。如果你把 ZADD 和 EXPIRE 分成两条命令发,中间连接断开会留下「无 TTL 的键」。所以生产环境务必把这两条命令放进同一个 Lua 脚本或 MULTI 事务。
完整实现:时间分片 + GT + EXPIRE NX
键级 TTL 只能解决整键生命周期,成员级过期还得靠分片。设计思路:按窗口粒度(例如按小时)切分 key,读取最近 7×24 个分片做合并,写入只写当前分片。这样每个分片 7 天后自动过期,成员也就被「滚动淘汰」了。
Key 结构
hot:rank:{bucket} bucket = floor(now / 3600),例如 hot:rank:497652
hot:rank:lock:{id} 可选,去重锁,避免重复计算
hot:rank:meta 记录分片命名规则与版本号,便于灰度
写入脚本(Lua,Redis 6.2+/7.0+)
-- KEYS[1] 分片 key,例如 hot:rank:497652
-- ARGV[1] 分数
-- ARGV[2] 成员
-- ARGV[3] 过期秒数
-- 返回 1 表示分数被提升,0 表示未变化
local changed = redis.call('ZADD', KEYS[1], 'GT', 'CH', ARGV[1], ARGV[2])
redis.call('EXPIRE', KEYS[1], ARGV[3], 'NX')
if changed == 1 then
return 1
end
return 0
为什么用 Lua 而不是两条命令:ZADD 与 EXPIRE 之间若发生连接中断,会残留无 TTL 的键;Lua 脚本在 Redis 内单线程执行,两条命令原子完成,不可能只成功一半。脚本里 EXPIRE 带 NX,保证只有第一个写入分片的客户端设置 TTL,之后不再变化。
用 redis-cli 调用验证:
127.0.0.1:6379> SCRIPT LOAD "local c = redis.call('ZADD', KEYS[1], 'GT', 'CH', ARGV[1], ARGV[2]); redis.call('EXPIRE', KEYS[1], ARGV[3], 'NX'); if c == 1 then return 1 end return 0"
"6a1b0f1d3b8a2e5c9d4f7a6e2b1c8d5a4f3e2d1c"
127.0.0.1:6379> EVALSHA 6a1b0f1d3b8a2e5c9d4f7a6e2b1c8d5a4f3e2d1c 1 hot:rank:497652 120 article:1001 604800
(integer) 1
127.0.0.1:6379> EVALSHA 6a1b0f1d3b8a2e5c9d4f7a6e2b1c8d5a4f3e2d1c 1 hot:rank:497652 90 article:1001 604800
(integer) 0
127.0.0.1:6379> TTL hot:rank:497652
(integer) 604800
读取与合并
读取时不能用 ZUNIONSTORE(会写磁盘、有竞态),推荐 ZUNION(Redis 6.2+,只返回不存储)或客户端合并。ZUNION 的 WEIGHTS 可以给不同时间分片加权,实现热度衰减。
# 合并最近 3 个分片,越早的分片权重越低(衰减)
127.0.0.1:6379> ZUNION 3 hot:rank:497650 hot:rank:497651 hot:rank:497652 \
WEIGHTS 0.5 0.8 1.0 AGGREGATE MAX WITHSCORES
1) "article:1001"
2) "120"
3) "article:2048"
4) "95"
AGGREGATE MAX 很关键:同一成员在多个分片都有分数时取最大值,这与 GT「只涨不跌」的语义一致;如果用默认的 SUM,同一篇在 3 个分片里各算一次,分数会被放大 3 倍。
Java 侧完整实现
用 Lettuce(Spring Data Redis 默认客户端)实现,版本要求:Spring Data Redis 3.x + Redis Server 7.x。
import io.lettuce.core.ScriptOutputType;
import io.lettuce.core.api.sync.RedisCommands;
import io.lettuce.core.RedisClient;
import io.lettuce.core.RedisURI;
import io.lettuce.core.ZAddArgs;
import java.util.ArrayList;
import java.util.List;
public class SlidingScoreRank {
private static final long BUCKET_SECONDS = 3600L;
private static final long TTL_SECONDS = 7 * 24 * 3600L;
private static final String KEY_PREFIX = "hot:rank:";
private final RedisCommands<String, String> redis;
private final String bumpScriptSha;
public SlidingScoreRank(String host, int port) {
RedisClient client = RedisClient.create(RedisURI.builder()
.withHost(host).withPort(port).build());
this.redis = client.connect().sync();
String script = "local c = redis.call('ZADD', KEYS[1], 'GT', 'CH', ARGV[1], ARGV[2]) "
+ "redis.call('EXPIRE', KEYS[1], ARGV[3], 'NX') "
+ "if c == 1 then return 1 end return 0";
this.bumpScriptSha = redis.scriptLoad(script.getBytes());
}
/** 当前时间所属分片 */
private String bucketKey(long epochSecond) {
return KEY_PREFIX + (epochSecond / BUCKET_SECONDS);
}
/**
* 提升成员分数,仅在分数变大时生效。返回是否真正写入。
*/
public boolean bump(String member, long score) {
String key = bucketKey(System.currentTimeMillis() / 1000);
List<String> keys = List.of(key);
List<String> args = List.of(String.valueOf(score), member, String.valueOf(TTL_SECONDS));
Long r = redis.evalsha(bumpScriptSha, ScriptOutputType.INTEGER,
keys.toArray(new String[0]), args.toArray(new String[0]));
return r != null && r == 1L;
}
/**
* 不用 Lua 的等价写法:GT + CH 本身已是原子的,
* 只差 EXPIRE 的原子性,用 MULTI 包裹即可。
*/
public boolean bumpWithoutLua(String member, long score) {
String key = bucketKey(System.currentTimeMillis() / 1000);
ZAddArgs args = ZAddArgs.Builder.gt().ch();
redis.multi();
Long added = redis.zadd(key, args, (double) score, member);
redis.expire(key, TTL_SECONDS, ExpireArgs.Builder.nx());
redis.exec();
return added != null && added == 1L;
}
/** 读取最近 windowHours 小时的合并榜单,带指数衰减权重 */
public List<String> top(long windowHours, long limit) {
long now = System.currentTimeMillis() / 1000;
long current = now / BUCKET_SECONDS;
List<String> keys = new ArrayList<>();
List<Long> weights = new ArrayList<>();
for (long i = windowHours - 1; i >= 0; i--) {
keys.add(KEY_PREFIX + (current - i));
// 越早的分片权重越小,最低 0.3
weights.add(Math.max(3L, 10L - i * 7 / windowHours));
}
return redis.zunionWithScores(null, keys.toArray(new String[0]),
ZUnionArgs.builder().weights(weights.stream().mapToLong(Long::longValue).toArray())
.aggregate(Aggregate.MAX).build())
.stream().limit(limit).map(kv -> kv.getValue()).toList();
}
}
这段代码有三个要点值得强调:
- bump 不判断成员是否存在。GT 对不存在的成员就是添加,省掉一次网络往返。
- 返回 boolean 表示分数是否被抬升。业务层可据此触发二次埋点,比如「热度实际变化」的计数。
- ZUNION 的权重用整数。Lettuce 的
weights只接受整数,小数权重需要改用ZUNIONSTORE到临时 key(记得给临时 key 设短 TTL)或在客户端合并。
分片粒度、TTL 与窗口长度的参数取舍
这套方案的效果完全由三个参数决定:分片粒度 BUCKET_SECONDS、TTL、窗口长度。经验值如下:
| 场景 | 分片粒度 | TTL | 窗口 | 单次读取 key 数 |
|---|---|---|---|---|
| 小时级热榜 | 3600s | 24h | 6h | 6 |
| 日榜(7 天滚动) | 86400s | 8d | 7d | 7 |
| 实时互动分榜 | 300s | 1h | 30min | 6 |
| 短时赛(活动榜) | 60s | 900s | 10min | 10 |
取舍原则:
- 分片粒度太粗(如日榜用 86400s),窗口边缘会有最多一整个分片的误差,用户会觉得「昨天的分怎么还没清」;
- 分片粒度太细,读取时要合并的 key 多,
ZUNION的 O(N·logN) 成本上升,且分片数量增长会带来内存碎片; - TTL 必须严格大于窗口长度。窗口内仍会被读到的分片如果提前过期,榜单会突然「掉数据」。建议 TTL = 窗口 × 1.5 以上;
- 分片 key 数量上限 = 窗口长度 / 分片粒度 × 并发写入分片数。写时存在时钟漂移,跨分钟边界可能同时写两个分片,可按 ×1.1 预留。
监控上建议用 INFO keyspace 看 db0:keys 的增长曲线,如果 hot:rank:* 前缀的 key 数量持续上升且不回落,说明 EXPIRE NX 没生效,99% 是 TTL 设置失败或版本不兼容。用 SCAN 0 MATCH hot:rank:* COUNT 1000 抽查 TTL,出现 -1(无过期时间)就是坏味道。
踩坑清单与真实报错对照
| 报错 / 现象 | 原因 | 解决办法 |
|---|---|---|
ERR GT, LT, and/or NX options at the same time are not compatible |
ZADD 同时给了 GT 和 NX | 初始化用 NX,更新用 GT,分两次或只用 GT |
ERR INCR option supports a single increment-element pair |
ZADD INCR 与 GT/LT 混用 | 用 ZINCRBY 替代,或先 GT 后手动加 |
ERR Unsupported option NX |
Redis < 7.0.0 不支持 EXPIRE 选项 | 升级到 7.x,或改用 TTL + EXPIRE 的 Lua 判断 |
ERR syntax error(ZADD 处) |
Redis < 6.2.0 不认识 GT | 升级到 6.2+ 或 7.x |
WRONGTYPE Operation against a key holding the wrong kind of value |
同名 key 曾是 String 类型 | 换 key 前缀或先 DEL,避免类型冲突 |
| 榜单成员永不消失 | 每次写入都调了无 NX 的 EXPIRE,TTL 被反复续期 | 改用 EXPIRE key ttl NX,并在监控里抽查 TTL |
| 分数偶尔倒退 | 用了 ZINCRBY 但业务算的是绝对值,或并发下先读后写 |
统一用 ZADD GT CH |
NOSCRIPT No matching script. Please use EVAL. |
Redis 重启后脚本缓存清空 | 捕获 NOSCRIPT 后回退到 EVAL 并重新 SCRIPT LOAD |
BUSY Redis is busy running a script |
Lua 脚本执行超过 lua-time-limit(默认 5000ms) |
脚本保持轻量,不做循环遍历大集合 |
另外两个容易被忽略的点:一是时钟一致性,分片编号由应用服务器时间计算,多机时钟漂移会导致写入不同分片,建议用 Redis TIME 命令或 NTP 校准,误差控制在秒级;二是大 key 风险,单个分片内成员数应控制在 10 万以内,超出时按成员 ID 哈希再切一层 hot:rank:{bucket}:{shard}。
小结
这套方案的骨架只有三条命令:ZADD key GT CH 负责分数单调更新,EXPIRE key ttl NX 负责键级过期一次到位,时间分片 key 负责成员级滚动淘汰。把前两条塞进同一个 Lua 脚本,就得到原子且幂等的写入路径;读取用 ZUNION ... AGGREGATE MAX WITHSCORES 合并窗口内分片,权重实现自然衰减。
与常见做法相比,它避免了读-改-写的竞态、避免了 TTL 被反复续期导致的无限膨胀,也避免了在应用层做「先查有没有、再决定加不加」的额外往返。代价是读取时要合并多个 key,所以分片粒度不能切得太细——这是唯一需要在容量和精度之间权衡的地方。
FAQ
Q1:Redis 版本低于 7.0,没有 EXPIRE NX 怎么办?
两种降级写法。第一种在 Lua 里判断:
local ttl = redis.call('TTL', KEYS[1])
if ttl == -1 then
redis.call('EXPIRE', KEYS[1], ARGV[3])
end
第二种用 SET key placeholder NX EX ttl 做一个 TTL 哨兵键,业务侧定期巡检。第一种更直接,注意 TTL 返回 -1(无过期)或 -2(键不存在)都要视为「需要设置」。
Q2:为什么不用 ZINCRBY 累加,而是用 ZADD GT 传绝对值?
取决于分数语义。如果分数是「累计点赞数」这类增量,ZINCRBY 更合适且天然原子;但如果分数是「按公式重算的热度」,每次结果都是全量值,用 ZINCRBY 会把旧值和新值叠加,越算越离谱。GT 适合后者,且能防御上游重复投递导致的重复累加。
Q3:GT 模式下,如果成员被删除后又被写入,分数会怎样?
删除后成员不存在,ZADD GT 会按新成员处理,直接写入新分数,不会比较历史值。如果需要「删除后仍保留历史最高分」的语义,得把最高分另存到独立的 Hash 里。
Q4:读取时用 ZUNION 还是 ZUNIONSTORE?
Redis 6.2+ 优先 ZUNION,它不落盘、无临时 key、无竞态。ZUNIONSTORE 只在需要把合并结果缓存复用(比如给前端做分页快照)时用,并且必须给目标 key 设 TTL,否则会积累大量临时数据。低于 6.2 的版本只能用 ZUNIONSTORE + EXPIRE + DEL。
Q5:EXPIRE NX 在键已过期但尚未被惰性删除时会怎样?
Redis 的过期删除是惰性 + 定期混合策略。如果键已过期但尚未被清理,EXPIRE ... NX 仍会视为「有 TTL」而返回 0,但该键实际上不可读。所以 TTL 一定要留足冗余(窗口 × 1.5),不要卡着窗口边界设置。
Q6:分片 key 太多,会拖慢 ZUNION 吗?
会。ZUNION 的复杂度约 O(N) + O(M·logM),N 是输入成员总数,M 是输出成员数。合并 7 个日榜分片,如果每个分片 10 万成员,一次读取要处理 70 万条。生产上建议把「榜单大小」和「窗口 key 数」控制在乘积不超过 50 万的量级,超过就降采样(例如只在读取 top 100 时用 ZREVRANGE 各分片取前 200 再合并)。