Redis

热搜榜怎么做:ZSet 累加与定时榜单

热搜榜的关键不是"怎么排序",而是"什么时候排序"。把每次搜索都变成一次 O(log N) 的累加,把排序交给定时任务,ZSet 就是为这个形状量身定做的结构;顺带说清 skiplist 和防刷设计。

2026-03-20 约 15 分钟 5837 字 Redis ZSet 跳表 排行榜

做热搜榜最自然的想法是:维护一张关键词计数表,前端要榜单的时候 ORDER BY cnt DESC LIMIT 10。数据量小的时候这跑得通,但很快会遇到两个问题——每次搜索都要写一次数据库行,写热点集中;每次取榜都要对全表排序,成本随关键词数增长。

换一个角度想:热搜是展示型数据,晚五分钟更新完全无所谓。既然这样,为什么要在"用户搜索"这个高频路径上做排序?

正确的形状是:把每次搜索压成一次极廉价的累加,把排序挪到低频的定时任务里。 这就是 ZSet 的主场。

一、整体链路

用户每次搜索 ──→ ZINCRBY hot:keywords 1 <关键词>     ← 只做这一件事,O(log N)
                        │
                        ↓
定时任务每 5 分钟 ──→ ZREVRANGE hot:keywords 0 9 WITHSCORES   ← 取 Top10
                        │
                        ↓
                   写入榜单缓存,供前端展示

整个设计只有一个写命令和一个读命令。下面分别说清它们为什么合适。

为什么选 ZSet,而不是别的结构

排名这件事有几种实现方式,对比一下就很清楚:

方案 累加成本 取 TopN 成本 问题
MySQL 计数表 一次 UPDATE,行锁竞争 ORDER BY 全表排序 O(N log N) 高频写打爆数据库,写热点集中在热门词上
Redis String(INCR) O(1) 无法取 TopN 每个词一个 key,想排榜只能 KEYS 或用额外的索引结构
Redis Hash HINCRBY O(1) 要 HGETALL 拉全量再在应用层排序 O(N log N) 内存和网络都浪费在无关词上
Redis ZSet ZINCRBY O(log N) ZREVRANGE 0 9 约 O(log N + 10) —

ZSet 的独特之处在于它同时维护了两套索引:一套按 member 定位(Hash 表),一套按 score 排序(跳表)。所以"按词累加"和"按分数取 TopN"两边都是对数级,不需要额外的辅助结构。

二、ZINCRBY 为什么是 O(log N)

要理解这一点,得看 ZSet 底层的跳表(skiplist)。

跳表长什么样

有序链表的问题是查找必须从头遍历,复杂度 O(N)。跳表在有序链表之上叠了多层索引:

level 3:  1 ────────────────────────────→ 30
level 2:  1 ──────→ 10 ─────────────────→ 30
level 1:  1 ─→ 5 ─→ 10 ─→ 20 ──────────→ 30
level 0:  1 → 2 → 5 → 8 → 10 → 15 → 20 → 25 → 30    ← 完整的有序链表

查找 25 的过程:从最高层出发,当前节点的下一个值比目标大,就下降一层;比目标小,就前进一个节点。每一层相当于把搜索区间缩小一半左右,所以期望复杂度是 O(log N)。

关键在于层高是随机决定的。插入一个新元素时,它以概率 p 继续往上加一层,所以每个节点的层数服从几何分布。Redis 里 p = 0.25,最大层数限制为 32;期望每个节点带 1/(1-p) ≈ 1.33 个指针,内存开销只比普通链表多约三分之一。用随机化换取"不需要 rebalance"——这是跳表相比平衡树最大的工程优势。

Redis 的 zskiplistNode 大致是这样的:

typedef struct zskiplistNode {
    sds ele;                        // 成员,比如关键词字符串
    double score;                   // 分值,比如热度
    struct zskiplistNode *backward; // 后退指针,支撑 ZREVRANGE 反向遍历
    struct zskiplistLevel {
        struct zskiplistNode *forward;  // 本层的前进指针
        unsigned long span;             // 到 forward 之间跨过多少个节点
    } level[];                      // 柔性数组,每个节点层数不同
} zskiplistNode;

其中 span 值得单独说一下:它记录"这一步跨过了几个节点"。有了它,ZRANK(查某个词的排名)就能在 O(log N) 内算出来,而不需要从头数一遍——如果榜单要展示"该词当前排第几",这就是必需的。

ZINCRBY 到底做了什么

ZINCRBY hot:keywords 1 洗地机 内部是三步:

  1. 在 member 索引里查这个关键词是否已存在(哈希查找,O(1))
  2. 不存在则插入,存在则更新 score;更新 score 相当于先删后插
  3. 删除和插入都要经过跳表的查找定位 O(log N),并顺路维护沿途每一层的 span

所以整体是 O(log N)——不是 O(1),但相比"每次取全量排序"的 O(N log N) 已经是数量级的差别。当关键词表有 10 万条时,一次 ZINCRBY 的操作量是十几个指针调整,而一次全量排序要做十几万次比较。

为什么不用平衡树

常见的问题是"为什么不用红黑树 / B+ 树":

维度 跳表 平衡树
范围查询 定位起点后顺着 level 0 顺序走,很自然 需要中序遍历或线索化
实现复杂度 插入删除只需调整局部指针 需要旋转/变色等 rebalance 逻辑
并发友好 修改是局部的(Redis 单线程用不到,但设计上更简单) 旋转影响面大
内存 每节点平均 1.33 个额外指针 需要额外的颜色位/平衡因子
磁盘 IO 不适合(树高由元素数决定) B+ 树因高扇出降低树高,为磁盘而生

Redis 是纯内存结构,不存在磁盘 IO 的考量,所以"高扇出降低树高"这个 B+ 树的核心优势在这里用不上。跳表用更简单的代码换到了同样的对数级复杂度。

编码转换

ZSet 并不总是用跳表。元素较少时它用 listpack(紧凑数组)存储,超过阈值才转成 skiplist:

# Redis 7.0+ 的默认值
zset-max-listpack-entries 128    # 元素个数超过 128 → 转 skiplist
zset-max-listpack-value 64       # 任一 member 长度超过 64 字节 → 转 skiplist

这个转换是单向的,而且在元素很多时可能造成一次明显的卡顿,所以热搜这种必然会超过 128 个词的结构,建议在数据量小的时候就有所预期,或者直接按跳表的行为来估算内存。可以用 OBJECT ENCODING hot:keywords 确认当前编码。

三、定时榜单:写什么、写哪里

ZREVRANGE hot:keywords 0 9 WITHSCORES 取 Top10 本身的成本极低(O(log N + 10)),那为什么还要定时任务落一份缓存?

# 1. 取当前 Top10
ZREVRANGE hot:keywords 0 9 WITHSCORES
# 返回:洗地机 8921 扫地机器人 7734 空气炸锅 6620 ...

# 2. 定时任务把这些结果连同展示字段(排名变化、趋势)写成一份 JSON
SET hot:list:current '{"updatedAt":"2026-03-20 12:00","items":[...]}' EX 600

# 3. 前端只读这个 key,不直接读 ZSet
GET hot:list:current

理由是三条:

  • 榜单通常还要带展示字段:排名升降、是否"新上榜"、热度百分比。这些需要和上一期快照对比,必须在落缓存时算好,不能在读的时候现算
  • 前端请求量远大于 ZSet 的更新量,让它们只读一个 String key,避免大量请求直接落到 ZSet 上(尤其是多个端、多个接口都要读榜单时)
  • 榜单语义要稳定:定时快照保证同一个 5 分钟窗口内,所有用户看到完全一样的榜单。直接读 ZSet 会出现"刷新一次变一个样"的观感问题

为什么不在每次搜索时实时排序

这是这个设计里最需要想清楚的一步:

  • 热搜是展示型数据,晚 5 分钟毫无影响
  • 实时排序意味着每次搜索都要对全量关键词排序,或者维护一套增量排序结构,成本与收益完全不匹配
  • ZINCRBY 是 O(log N),写进来几乎零成本;把排序集中到每 5 分钟一次,成本被摊薄了几万倍

高频路径只做最便宜的事,昂贵的计算挪到低频任务里。 这条原则适用于任何统计类需求。

四、榜单的三个设计问题

问题一:累积热度会让老词永远霸榜

如果 score 只增不减,那么三个月前爆过的词会一直躺在榜单上,热搜就变成了"历史热度榜"。常见处理有三种:

方案 做法 特点
定期重置 每天/每小时一个新 key,到点清空重来 最简单,但窗口边界上的热度会被切断
整体衰减 定时任务把所有 score 乘以 0.9 需要遍历全量(O(N)),只能低频做
时间分片 + 加权合并 按小时分 key,合并最近 24 小时并给不同权重 天然滑动窗口,精度和成本都比较均衡

第三种最值得展开。写入时按小时分片:

# 每小时一个 key,写入天然分散
ZINCRBY hot:keywords:2026032012 1 洗地机

定时任务用 ZUNIONSTORE 把最近 24 小时的分数合并,越新权重越高:

# 合并最近 24 个小时的分片,权重从 0.4 递增到 1.0(越新权重越高)
ZUNIONSTORE hot:keywords:24h 24 \
  hot:keywords:2026031913 hot:keywords:2026031914 ... hot:keywords:2026032012 \
  WEIGHTS 0.4 0.42 ... 1.0
EXPIRE hot:keywords:24h 600

ZUNIONSTORE 是服务端完成的合并,也顺带实现了时间衰减,而且过期的分片 key 可以直接删掉,内存是滚动回收的。

问题二:防刷

热搜能被刷,后果是展示内容完全失真。防线通常是分层的:

-- 去重脚本:同一用户、同一关键词、同一时间窗内只计一次
-- KEYS[1] = 去重 key,KEYS[2] = 热搜 ZSet,ARGV[1] = 关键词,ARGV[2] = 去重 key 的 TTL
local added = redis.call('set', KEYS[1], 1, 'NX', 'EX', ARGV[2])
if added then
    redis.call('zincrby', KEYS[2], 1, ARGV[1])
    return 1
else
    return 0            -- 窗口内已经计过,不加分
end

去重 key 的构造方式是 dedup:hot:{uid}:{关键词摘要}:{时间窗},时间窗取 5 分钟、和榜单刷新周期对齐。这一段必须用 Lua 写成原子操作,否则"判断有没有计过"和"加分"之间的缝隙会漏掉并发请求(和释放分布式锁是同一个道理)。

其他几层防线:

手段 作用
参数合法性校验 空词、超长词、特殊字符直接拒掉
只统计有结果的搜索 刷一个不存在的词不会进榜,这条挡住了大部分无效刷量
权重区分 单纯搜索计 1,点击结果计 3,下单计 10,让刷搜索的性价比变低
单用户贡献上限 同一 uid 在窗口内累计超过 N 次后,后续不再计分
黑名单词过滤 违规词、无意义词在入口拦截

"只统计有结果的搜索"这一条收益最高、成本最低——它把"能不能刷"变成了"刷了也没用",因为刷子的构造词通常不会产生结果。

问题三:ZSet 会无限膨胀

每个被搜过的词都会成为 ZSet 的一个 member,不清理的话这个 key 会越滚越大。清理方式有:

# 只保留热度最高的 1000 个词(按 rank 从小到大删除,即删掉排名 1000 之后的)
ZREMRANGEBYRANK hot:keywords 0 -1001

# 或者按分数清理:删掉热度低于阈值的词
ZREMRANGEBYSCORE hot:keywords -inf 5

采用时间分片方案时,清理更简单:过期的分片 key 直接 DEL,或者给分片设 EXPIRE,让 Redis 自己回收。这也是分片方案在工程上更好维护的一个理由。

另外一个细节:大 key 的删除是阻塞的。如果某个分片累积了几十万个成员,直接 DEL 可能卡住主线程,应该用 UNLINK 异步删除。

常用命令速查

命令 复杂度 用途
ZINCRBY key incr member O(log N) 累加热度,榜单的核心写命令
ZADD key score member O(log N),批量 M 个为 O(M log N) 直接置分,适合导入历史数据
ZREVRANGE key 0 9 WITHSCORES O(log N + 10) 取 Top10(按 score 从大到小)
ZRANGE key 0 -1 WITHSCORES O(log N + M) 取全部,用于全量导出
ZREVRANK key member O(log N) 查某个词的排名,依赖节点的 span
ZSCORE key member O(1) 查某个词当前热度
ZUNIONSTORE dest n key1 key2 ... WEIGHTS ... O(N + M log M) 多分片合并 + 加权,做时间衰减
ZREMRANGEBYRANK / BYSCORE O(log N + M) 裁剪冷门词

小结

  • 热搜榜的关键决策是把排序从高频写路径挪到低频定时任务,每次搜索只做一次 ZINCRBY
  • ZINCRBY 是 O(log N):底层跳表用随机层高实现多级索引,查找、插入、删除都是对数级,不需要 rebalance
  • 节点的 span 字段让 ZRANK / ZREVRANK 也能在 O(log N) 内算出排名,这是榜单展示"排第几"的基础
  • ZSet 在小数据量时用 listpack 编码,超过 zset-max-listpack-entries(默认 128)或单个成员超过 64 字节会转成 skiplist
  • 榜单要定期落一份快照,而不是让前端直接读 ZSet:一是需要排名变化等展示字段,二是保证同一窗口内所有用户看到一致的榜单
  • 累积热度会让老词霸榜,推荐按时间分片 + ZUNIONSTORE 加权合并做滑动窗口,顺带解决内存回收
  • 防刷要分层,其中最有效的一条是只统计有结果的搜索;去重逻辑必须用 Lua 保证原子性

留下点什么

评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。