用 EXPIRE NX 配合 ZADD GT 实现带过期语义的滑动分数榜单

排行榜是 Redis 最经典的用法之一:用 ZSET 存成员与分数,ZREVRANGE 取前 N 名,ZINCRBY 累加积分。但只要业务从「累计积分榜」滑向「热度榜 / 互动分榜」,问题立刻出现——成员分数需要被反复更新,而更新又要遵循「只能涨不能跌」或「窗口内有效」的规则。纯 ZSET 方案在这里会暴露三个具体缺陷:分数更新的并发覆盖、过期语义缺失导致僵

排行榜是 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();
    }
}

这段代码有三个要点值得强调:

  1. bump 不判断成员是否存在。GT 对不存在的成员就是添加,省掉一次网络往返。
  2. 返回 boolean 表示分数是否被抬升。业务层可据此触发二次埋点,比如「热度实际变化」的计数。
  3. 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 再合并)。