因为排行榜需按热度排序、实时更新、去重及限数量,zset天然支持分数增减、自动排序、成员唯一和zrevrange取top n;hash需手动排序,list无法高效更新单个元素且难保原子性。

为什么用 ZSet 而不是 Hash 或 List
因为排行榜本质是「按热度排序 + 实时更新 + 去重 + 限数量」,ZSet 天然支持:分数(热度值)可增减、自动按分排序、成员唯一、支持 ZREVRANGE 取 Top N。用 Hash 得自己排序,List 无法高效更新单个元素分数,且都难保证原子性。
ZINCRBY 更新热度要避开自增陷阱
热门文章的“热度”通常来自阅读、点赞、分享等行为,但直接对文章 ID 执行 ZINCRBY hot_articles 1 article:123 会埋坑:
- 不同行为权重不同(比如一次分享应算 5 分,阅读只算 1 分),不能硬写死
1 - 并发写入时,多个请求同时
ZINCRBY同一 article ID 是安全的(Redis 单命令原子),但业务层若先ZSCORE再ZADD就可能丢分 - 冷启动时,新文章首次进入排行榜需初始化分数(否则
ZREVRANGE不返回),建议统一用ZINCRBY hot_articles 0 article:123预埋
Hyperf 中封装 ZSet 操作的实操要点
别直接在 Controller 里写 $this->redis->zIncrBy(...),推荐抽成 Service 方法,并处理以下细节:
- Key 命名带业务前缀和环境隔离,例如
hot_articles:{env}:v2,避免测试/生产混用 - 设置过期时间(
EXPIRE)——排行榜不是永久数据,用$this->redis->expire('hot_articles:prod:v2', 86400)每日刷新更稳妥 - 取 Top N 时用
ZREVRANGE+WITHSCORES,Hyperf Redis 客户端对应zRevRange('hot_articles:prod:v2', 0, 9, ['withscores' => true]),返回的是[article_id => score, ...]关联数组,注意 key 是 string 类型,需转 int 再查 DB - 如果需要带文章标题/封面,别在 Redis 存冗余字段,而是用查出的 ID 批量
select * from articles where id in (?),减少序列化开销
排行榜数据一致性怎么兜底
Redis 是缓存,不是唯一数据源。当文章被删除或下架,仅删 DB 不够,必须同步清理 ZSet:
- 在文章软删除逻辑里加
$this->redis->zRem('hot_articles:prod:v2', 'article:'.$id) - 定期(如每天凌晨)用
ZCARD校验总数,再用ZRANGE批量查 ID,反查 DB 确认是否仍有效,无效则ZREM—— 这步容易漏,但积压失效 ID 会导致排行榜“卡住” - 不建议用
ZREMRANGEBYSCORE清零低分项,因为热度有自然衰减需求,更好的做法是写个定时任务给所有分数乘以 0.99(ZUNIONSTORE配权重),但 Hyperf 里要注意 Lua 脚本超时限制
真正麻烦的不是写 ZSet,而是让它的生命周期和业务状态咬合上。比如编辑文章时改了分类,是否要降权?用户举报后要不要临时冻结该 ID 的分数更新?这些边界比命令本身更耗神。











