热搜榜怎么做:ZSet 累加与定时榜单
热搜榜的关键不是"怎么排序",而是"什么时候排序"。把每次搜索都变成一次 O(log N) 的累加,把排序交给定时任务,ZSet 就是为这个形状量身定做的结构;顺带说清 skiplist 和防刷设计。
做热搜榜最自然的想法是:维护一张关键词计数表,前端要榜单的时候 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 洗地机 内部是三步:
- 在 member 索引里查这个关键词是否已存在(哈希查找,O(1))
- 不存在则插入,存在则更新 score;更新 score 相当于先删后插
- 删除和插入都要经过跳表的查找定位 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_* 四个变量后自动开启。