排行榜是 SLG 里看起来最"简单"的需求:玩家战力变了,记一笔;玩家要榜单,拉一页。三行代码就能跑:
go
ZADD rank:power:cross 123456 10001
ZREVRANGE rank:power:cross 0 99 WITHSCORES
但上线之后,我们在这个"三行代码"上踩的坑,比整个 actor 架构加起来还多。这篇文章是我们在 Redis 排行榜上实打实踩过的 10 个坑,按写入、读取、生命周期、一致性四类排好,每个坑都带现场、原因和代码解法。
先看清业务:一个 SLG 到底有多少个榜
先盘点一下,你才知道"排行榜"三个字背后是多大的工程:
| 类型 | 例子 | 规模/频率 | 实时性要求 |
|---|---|---|---|
| 常驻榜 | 战力榜、关卡榜、联盟战力、联盟领地 | 全服玩家常驻 | 高,一直有人看 |
| 活动榜 | 送花榜、狩猎榜、熔岩洞穴、节日转盘 | 活动期内,Top 100~1000 | 活动期高,过期作废 |
| 跨服榜 | KVK(玩家/服务器/联盟三级)、跨服龙战 | 多服合并 | 高,还牵扯跨服一致性 |
| 赛季榜 | 战力积分、排名赛 | 按月/双周重置 | 结算瞬间是峰值 |
不同榜单的长度、生命周期、实时性 完全不同------这就是第一个设计决策:一个模板化的榜单管理器,每个榜只配置参数,不重复造轮子(这个设计贯穿全文)。
基础:为什么是 ZSet,命令速查
ZSet 的两个底层结构:跳表 (按 score 有序,插入/查询 O(log n))+ 哈希表(member → score 映射,O(1) 查分数)。这决定了它的能力边界。
| 命令 | 作用 | 复杂度 |
|---|---|---|
ZADD key score member |
写分(存在则更新) | O(log n) |
ZINCRBY key incr member |
增量加分(战力变化时用) | O(log n) |
ZREVRANGE key start stop WITHSCORES |
取 Top 榜(分数从高到低) | O(log n + m) |
ZREVRANK key member |
查某成员排名 | O(log n) |
ZSCORE key member |
查某成员分数 | O(1) |
ZREMRANGEBYRANK key 0 N |
裁剪,只留前 N 名 | O(log n + m) |
ZREM key member |
删除成员 | O(log n) |
ZSet 不是万能:它管不了"分数一样谁排前面"(同分按 member 字典序)、管不了"过期成员自动消失"(要自己 ZREM)、管不了"亿级成员常驻内存"。以下坑大多来自这三点。
写入侧的坑(1-3)
坑 1:分数用浮点------战力是 int64,精度会咬你
现场 :战力按 int64 存,胜率/进度按小数算。直接把 int64 塞进 float64 score,玩家战力到十亿级后,低 6 位开始失真;胜率 0.7321 这种小数,放大 10000 倍后又和整数混在一起,排行榜上出现"战力 123456.0000001"这种怪分数。
原因 :float64 只有 53 位有效整数位(约 9e15),大整数 + 小数混在一个 score 里必然互相污染。
解法:定点数放大,整数域内运算。score 永远用一个整数表达,小数部分提前放大:
go
// 战力榜:主分(战力)放大 1e6 后仍是整数域
// 前提:power*1e6 不超过 9e15(power 上限 9e9,SLG 战力远够用)
func powerScore(power int64) float64 {
return float64(power * 1e6)
}
// 胜率榜:0.7321 → 7321(万分位),整存整取
func rateScore(rate float64) float64 {
return float64(int64(rate*10000 + 0.5))
}
原则:score 只承载"可精确表达的整数";任何小数要么定点放大,要么拆到次分(见坑 2)。
坑 2:同分排名------ZSet 同分按 member 字典序,玩家体验是玄学
现场:战力榜上 100 个人并列 100 万分,ZSet 按 member(玩家 ID 的二进制)排序,结果玩家看到"凭什么 ID 小的排前面",运营要求"先达到的排前面",没法改。
原因:Redis 同分时按 member 的字典序(二进制)比较,业务没法干预。
解法:分数编码,把"达到时间"压进次分。主分在前(战力),次分在后(越早达到越大),用"赛季截止时间 - 达到时间"保证先到先得:
go
const (
Scale = int64(1e6) // 次分位数,主分必须乘以它
SeasonEnd = int64(1e10) // 赛季截止时间戳(秒),超远期兜底
)
// encodeScore:主分 = 战力,次分 = 越早达到越大(分钟粒度)
// 30 天赛季 ≈ 43200 分钟 < 1e6,次分永不溢出到主分
func encodeScore(power int64, reachUnix int64) float64 {
sub := (SeasonEnd - reachUnix) / 60
if sub >= Scale {
sub = Scale - 1
}
return float64(power*Scale + sub)
}
代价 :读取时要 ZSCORE 后解码(power = score / Scale),多一步换算,但换来排名语义完全可控。
坑 3:全量重算的写放大------每天把全服玩家 ZADD 一遍,Redis 会给你颜色
现场 :版本初期,我们每天 0 点"刷新"战力榜------遍历全服玩家,每人一条 ZADD,几十万次写,Redis 高峰期 CPU 打满,主从复制延迟飙到秒级。
原因:战力榜本质是"谁变化谁更新",不是"每天全量算"。
解法:增量更新,谁变谁写 。战力变化走 ZINCRBY(或直接 ZADD 覆盖),只有玩家真实变化时才写一次:
go
// 玩家战力从 old 涨到 new:直接覆盖,而不是全量重算
func (r *RankItem) OnPowerChange(pid int64, newPower int64) {
r.Add(pid, powerScore(newPower))
}
规律:排行榜的写入量和"玩家变化频率"成正比,和"玩家总数"无关------凡是写成"和玩家总数成正比"的更新逻辑,都是写放大。
读取侧的坑(4-5)
坑 4:热点 key 击穿------Top100 被全服同时读
现场 :晚上 8 点活动结算,全服玩家同时打开排行榜,一个 key 的 QPS 冲到几万。Redis 单 key 抗住了读,但业务侧到 Redis 的连接和序列化成了瓶颈,且一旦 key 过期重建,瞬间打穿到 DB。
解法:本地缓存 + 单飞(singleflight) 。关键点:不同榜单不同缓存时长 ------活动榜要新(1020 秒),常驻榜可以旧(12 分钟):
go
// 每个榜单在配置里声明自己的读取缓存时长(活动榜 10s / 常驻榜 2min)
type RankConfig struct {
CacheTTL time.Duration
// ...
}
// GetPage:本地缓存 + singleflight 防击穿
func (r *RankItem) GetPage(key string, start, stop int64) (*RankPage, error) {
cacheKey := fmt.Sprintf("%s:%d:%d", key, start, stop)
if v, ok := r.local.Get(cacheKey); ok {
return v.(*RankPage), nil
}
// singleflight:同 key 同时只有一个人去查 Redis,其余等待
v, err, _ := r.sf.Do(cacheKey, func() (any, error) {
return r.pageFromRedis(key, start, stop)
})
if err == nil {
r.local.Set(cacheKey, v, r.cfg.CacheTTL)
}
return v.(*RankPage), err
}
坑 5:"我的排名"的读放大------每个玩家都要 ZREVRANK
现场 :榜单页 = Top100 + "我的排名"。每个玩家打开一次页面就发一条 ZREVRANK,O(log n) 不贵,但全服玩家高频打开,量变引起质变------排行榜接口成了 Redis 连接数大头。
解法:
- 本地缓存自己的排名(几秒 TTL),玩家高频刷新时只打本地;
- 只对"上榜玩家"实时查------榜单外的玩家直接返回"未上榜",别去 ZREVRANK;
- 玩家战力未变化时,排名结果直接复用缓存,压根不进 Redis。
go
func (r *RankItem) GetSelf(pid int64, power int64) (int64, error) {
// 未上榜判断:先看分数是否可能进 TopN(本地维护一个阈值缓存)
if power < r.topThreshold() {
return -1, nil // 未上榜,省一次 Redis 往返
}
rank, err := r.rdb.ZRevRank(ctx, r.cfg.Key, pid).Result()
return rank, err
}
生命周期与一致性的坑(6-10)
坑 6:跨服榜------成员 ID 撞车,榜单串服
现场 :跨服榜把 s1、s2 的玩家合并到一个 key,两个服的玩家 ID 都叫 10001,ZADD 直接互相覆盖,排名错乱。
解法:成员 ID 加服标记,key 加维度。
go
// 跨服成员 ID:serverID 占高 32 位,playerID 占低 32 位
func crossMember(serverID, playerID int64) int64 {
return serverID<<32 | playerID
}
// key 组织:榜类型:赛季:维度
// KVK 三级榜:player / server / union,三个 key 分开维护
const (
KeyKvkPlayer = "rank:kvk:player"
KeyKvkServer = "rank:kvk:server"
KeyKvkUnion = "rank:kvk:union"
)
注意:跨服榜的"同分先到先得"要跨服统一时间基准(用服务器时间戳而非客户端),否则两个服各说各话。
坑 7:赛季重置------百万成员的大 key 不能 DEL
现场 :赛季结束,脚本 DEL rank:actv:xxx 清空榜单------大 key 删除阻塞 Redis 数秒,期间所有读写排队,全服卡顿。而且旧榜清了,新榜要重新 ZADD 预热,又一阵写风暴。
解法:版本号 namespace,切换代替删除。
go
// 赛季重置:不 DEL,切换到新版本 key,旧 key 交给 TTL 自然过期
func (r *RankItem) ResetSeason(season int64) {
r.cfg.Key = fmt.Sprintf("%s:v%d", r.baseKey, season)
// 新榜从零开始,旧榜数据慢慢过期(内存逐步释放,不阻塞)
}
配套:所有榜单 key 都要带 TTL(活动榜尤其),哪怕常驻榜也设一个超长 TTL 防止"永不回收"。
坑 8:脏数据------封号、删号、改名后,榜里还留着死人
现场 :玩家被封号,登录被挡了,但排行榜里还挂着这个人,玩家截图举报"挂机号都能上榜";改名后旧名还在榜上,怎么都删不掉。
原因:删除链路(封号/删号/改名)没有同步 ZREM,Redis 和 DB 各过各的。
解法 :删除动作进榜单------封号/删号时发一条消息,由榜单 worker 消费 ZREM;改名时 ZADD 新 member + ZREM 旧 member(改名也要走榜单链路):
go
// 删除链路:封号/删号 → 队列 → 榜单 worker ZREM
func (r *RankItem) OnPlayerBanned(pid int64) {
r.rdb.ZRem(ctx, r.cfg.Key, pid)
}
兜底:定期对账(每小时扫一次 DB 封禁名单,和榜单成员做差集),把漏网之鱼捞出来。
坑 9:容量与内存------榜单只留 Top N,别让 ZSet 无限膨胀
现场 :活动榜开赛一周,key 里成员数 = 参与玩家数(几十万),但玩家真正看的只有前 100 名------90% 的内存和扫描时间都花在没人看的成员上。
解法:每次写入后裁剪,只保留 Top N(配置里的 MaxLen),一石二鸟------内存可控 + 后续 ZREVRANGE 更快:
go
func (r *RankItem) Add(member int64, score float64) {
pipe := r.rdb.TxPipeline()
pipe.ZAdd(ctx, r.cfg.Key, redis.Z{Score: score, Member: member})
// 只留前 MaxLen 名:删除 0 ~ -(MaxLen+1) 之间的成员
pipe.ZRemRangeByRank(ctx, r.cfg.Key, 0, -(r.cfg.MaxLen + 1))
// 活动榜续期:每次写入重置 TTL
if r.cfg.TTL > 0 {
pipe.Expire(ctx, r.cfg.Key, r.cfg.TTL)
}
pipe.Exec(ctx)
}
内存预算公式 :榜单数 × MaxLen × (member 大小 + score 大小) 就是排行榜的 Redis 内存上界------设计时先算这笔账。
坑 10:更新与业务不同步------Redis 写失败,榜单和 DB 各说各话
现场:业务事务提交成功(玩家战力已入库),但 Redis ZADD 超时失败------玩家 DB 里战力 100 万,榜里还是 80 万。重试又可能重复 ZADD(幂等还好,非幂等就重复加分)。
原因 :DB 和 Redis 是两个系统,没有原子性。硬要在业务里同步写 Redis,要么阻塞主流程,要么一致性没保障。
解法:异步解耦------业务只发消息,榜单 worker 串行消费(这正好接上我们 Actor 系列里的消息驱动思路):
go
// 业务侧:战力变化只投递一条消息,绝不直接碰 Redis
func (s *Server) OnPowerChange(pid int64, newPower int64) {
rankWorker.Send(func() {
rankItem.Add(pid, powerScore(newPower))
// 失败重试:带重试队列 + 退避(见 Actor 系列第 3 篇)
})
}
// 榜单 worker:单 goroutine 串行消费,天然避免并发写同一榜单
func (w *RankWorker) loop() {
for ci := range w.server.ChanCall {
w.server.Exec(ci) // 串行执行榜单写操作
}
}
收益 :Redis 慢/抖不影响业务主流程;写操作天然串行(同一榜单不会有并发 ZADD 竞争);失败可重试。代价:最终一致(毫秒级延迟,玩家无感)。
我们的榜单管理器长什么样
最后把 10 个坑的解法收拢成一个模板化管理器------每个榜单只是一份配置:
go
type RankConfig struct {
Key string // rank:power:cross
MaxLen int64 // 只保留前 N 名(100/200/1000)
TTL time.Duration // 榜单生命周期(活动榜必设)
CacheTTL time.Duration // 读取缓存时长(活动榜 10s / 常驻榜 2min)
}
type RankItem struct {
cfg RankConfig
rdb *redis.Client
local *LocalCache
sf singleflight.Group
}
接入一个新榜,只需要注册一条配置;10 个坑对应的能力(编码、裁剪、缓存、重置、清理、异步)全部内置。这也是为什么我们说:排行榜的复杂度不在 Redis,在榜单的生命周期和一致性。
总结:10 坑口诀
- 分数:定点放大,整数域运算,别把小数和 int64 混进 float64;
- 同分:主分 × 1e6 + 次分(先到先得),读取时解码;
- 写放大:谁变谁写,别全量重算;
- 击穿:本地缓存 + singleflight,活动榜 10s、常驻榜 2min;
- 读放大:自己的排名本地缓存,未上榜别查;
- 跨服:member 加服标记,key 分维度,时间统一基准;
- 重置:版本号 namespace 切换代替 DEL 大 key;
- 脏数据:删除/改名走榜单链路,定时对账兜底;
- 容量:只留 Top N,先算内存预算;
- 一致性:业务只发消息,榜单 worker 串行消费,最终一致。