我不会起名字322· 后端 / 算法 / 数据库
📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL
文章目录
- Redis 从 0 到 1(十五):千万玩家积分榜——ZSet 排行榜的 Go 实现 5 细节
- 一、现象:数据库排行榜接口超时
- 二、细节 1:同分怎么排?——score 的位拼接
- 三、细节 2:加分用 ZINCRBY,但"首次上榜"要用位拼接分数
- 四、细节 3:TopN 与"我的排名"(收藏点 1)
- 五、细节 4:分页拉取全榜(不能用 ZREVRANGE 硬翻)
- 六、细节 5:榜单 key 的周期管理(收藏点 2)
- 七、压测量级参考(收藏点 3)
- 八、总结
Redis 从 0 到 1(十五):千万玩家积分榜——ZSet 排行榜的 Go 实现 5 细节
上一篇《Redis 从 0 到 1(十四):接口被刷爆?3 种限流算法的 Go+Redis 实现》我们做出了限流中间件,链接:https://blog.csdn.net/2501_92769340/article/details/167399719 。
本篇做一个所有 C 端业务都绕不开的功能:排行榜。看起来是"ZSet 一把梭",真写起来有 5 个细节个个能坑人。
一、现象:数据库排行榜接口超时
第一版排行榜用 MySQL 实现:SELECT * FROM score ORDER BY points DESC LIMIT 100。内测时 10 万玩家没问题;一次拉新活动进来 800 万玩家,排行榜接口 P99 从 50ms 涨到 2 秒——ORDER BY 要对全表排序,玩家每打一局还要 UPDATE 一次行锁竞争。
迁移到 Redis ZSet 后:加分 O(logN)、查 Top100 O(logN+100),P99 稳定在 1ms 内。但迁移路上踩了 5 个坑,本篇逐个讲。
二、细节 1:同分怎么排?——score 的位拼接
产品要求:“分数相同先达到的排名靠前”。ZSet 只认 score 大小,不认插入时间。解法是把时间编进分数低位:
score = 分数 × 10^13 + (10^13 - 上榜毫秒时间戳)分数占高位,保证先比分数;时间戳取反放低位,越早时间戳换算出的低位越大,同分时先到者胜。以分数上限 10^9 为例,10^13 的位宽足够且不丢精度(double 有 15~16 位有效数字)。
constscoreShift=1e13funcrankScore(pointsint64,firstTime time.Time)float64{// 时间取反:越早 → 值越大returnfloat64(points)*scoreShift+(scoreShift-float64(firstTime.UnixMilli()))}千万注意:从 double 还原分数时要向下取整,别直接转 int 丢了低位。
三、细节 2:加分用 ZINCRBY,但"首次上榜"要用位拼接分数
ZINCRBY 加分方便,但它不知道"首次上榜时间"。正确处理:
funcAddScore(ctx context.Context,rdb*redis.Client,boardstring,playerstring,deltaint64,firstTime time.Time)error{key:="board:"+board// 1. 用 Hash 记录每个玩家的首次上榜时间(位拼接的素材)first:=rdb.HGet(ctx,key+":meta",player)iferrors.Is(first.Err(),redis.Nil){rdb.HSet(ctx,key+":meta",player,firstTime.UnixMilli())}// 2. 首次上榜:ZADD 位拼接分;之后:ZINCRBY delta × scoreShift// 简化处理:维护两份数据 —— ZSet 存位拼接分,Hash 存原始分(业务展示用)returnrdb.HIncrBy(ctx,key+":raw",player,delta).Err()}更工程化的做法:ZSet 只负责排序(位拼接分),原始分、昵称、头像另存 Hash,榜单接口组装返回——读多写少,读两次完全划算。
四、细节 3:TopN 与"我的排名"(收藏点 1)
// TopN:查榜(ZREVRANGE 从大到小,带分数)funcTopN(ctx context.Context,rdb*redis.Client,boardstring,nint64)([]redis.Z,error){returnrdb.ZRevRangeWithScores(ctx,"board:"+board,0,n-1).Result()}// MyRank:我的名次 + 前后邻居("你击败了 95% 的玩家"页面)funcAroundMe(ctx context.Context,rdb*redis.Client,boardstring,playerstring)error{key:="board:"+board rank,err:=rdb.ZRevRank(ctx,key,player).Result()// 0 起,展示 +1iferr!=nil{returnerr}// 取我前后各 2 名,拼"贴身榜"_,_=rdb.ZRevRange(ctx,key,rank-2,rank+2).Result()total,_:=rdb.ZCard(ctx,key).Result()fmt.Printf("我的名次: %d/%d (超过 %.1f%% 玩家)\n",rank+1,total,float64(total-rank-1)/float64(total)*100)returnnil}五、细节 4:分页拉取全榜(不能用 ZREVRANGE 硬翻)
ZSet 分页的本质是按排名区间拉:
funcPage(ctx context.Context,rdb*redis.Client,boardstring,page,sizeint64)([]string,error){start:=(page-1)*sizereturnrdb.ZRevRange(ctx,"board:"+board,start,start+size-1).Result()}注意点:
- 翻页期间有玩家加分,同一名次区间内容会变——榜单页注明"实时榜,排名随时变化"即可,别追求快照一致;
- 深翻页(第 5000 页)同样 O(logN+M),ZSet 不怕深分页,放心用;
- 用户可能反复下拉刷新:接口侧加第 14 篇的固定窗口限流,防刷榜。
六、细节 5:榜单 key 的周期管理(收藏点 2)
日榜、周榜、月榜、总榜怎么存:
| 榜 | key 设计 | 过期策略 |
|---|---|---|
| 日榜 | board:daily:20261010 | 7 天后 EXPIRE,留核对期 |
| 周榜 | board:weekly:202640(ISO 周序号) | 两周后过期 |
| 月榜 | board:monthly:202610 | 手动归档到 MySQL 后删 |
| 总榜 | board:alltime | 不过期,配合 ZREMRANGEBYSCORE 定期清 0 分玩家 |
归档动作(发奖后清榜):别 DEL,用RENAME把当期 key 改成归档 key,原子且无感:
RENAME board:daily:20261009 board:archive:daily:20261009七、压测量级参考(收藏点 3)
单机 Redis、100 万成员 ZSet 上的典型耗时:
| 操作 | 耗时 |
|---|---|
| ZINCRBY(单次) | ~0.05ms |
| ZADD 1 万成员批量 | ~3ms |
| ZREVRANGE Top100 | ~0.2ms |
| ZRevRank(100 万成员) | ~0.1ms |
| ZCard | ~0.02ms |
千万级成员时 ZRevRank 仍在毫秒内——排行榜是 Redis 最不需要担心的场景,真正要注意的是元数据 Hash 别跟 ZSet 一样大(清榜时两个 key 要一起清)。
八、总结
- 同分排序靠 score 位拼接:分数占高位,取反时间戳占低位;
- ZSet 管排序、Hash 管原始数据与元数据,读两次换写入简单;
- 分页按排名区间拉,深翻页无压力;
- 周期榜用带日期的 key + RENAME 归档,别 DEL。
下一篇预告:排行榜的"基数统计"亲戚来了——下一篇《1 亿 UV 只占 12KB?INCR/HyperLogLog/Bloom 计数去重三件套的 Go 实测》,我们看看 Redis 里三个"省内存魔法"的真实误差与选型。