三个产品,三种“榜单”
《裂隙战场》的赛季榜只回答两件事:我排第几、前一百名是谁。十个人一局,分数在结算时一次写入,读的人却包括每个玩家的主界面、结算页和观战端。《群岛纪元》全球同服,同一张大地图上按战区切分,每个战区几万人,行军和采集会持续产生分数,榜单必须跟着赛季滚动。《泡泡工坊》更轻:好友榜只在玩家点开时看一次,数据量小,但“好友”是相对查看者的关系,不是一张固定的全服表。
北辰工作室最早把三张榜都做成“一张全服表加 where 条件”。上线后三个问题同时出现:好友榜按查看者裁剪时,缓存键漏了查看者 id,A 看到了 B 的好友排名;战区榜的写入被全服热点拖慢,一个战区结算会锁住整张表;分段阈值散落在客户端,运营改一次配置,老客户端显示的分段就和新区玩家对不上。
榜单不是“对分数做一次 order by”。它是一套写模型、截断策略和展示契约的组合。本节先把这套组合拆开,给出北辰三款产品的选型;9.2 再处理高频写入和赛季切换,9.3 处理读放大和缓存。
把榜单拆成四个维度
| 维度 | 取值 | 如果含糊会怎样 |
|---|---|---|
| 受众 Scope | 全服、战区、好友、公会 | 好友榜被当成全服榜缓存,串号 |
| 分区 Partition | 空、战区 id、查看者 id、公会 id | 不同战区同名玩家互相覆盖 |
| 分段 Tier | 按分数区间推导 | 阈值写死在客户端,版本不一致 |
| 排序 Ordering | 分数降序、达成时间升序、玩家 id 升序 | 同分玩家分页重复或漏人 |
受众决定“这张榜为谁计算”。全服榜只有一个实例;战区榜按分区各一个;好友榜和公会榜的键里必须带查看者或公会 id,否则不同人的榜单会共用同一份缓存。
分区不是过滤条件,而是榜本身的身份。把十个战区分区塞进一张表再 where zone = ?,写热点仍然落在同一张表上;拆成十个独立的榜,扩容和限流才有下手的地方。
分段是展示属性,不是权限属性。青铜、白银、黄金、钻石只是把分数映射成玩家看得懂的段位;真正限制匹配和奖励的是赛季规则,不要拿分段当准入条件。
排序规则是最后一个维度,也是最容易被当成实现细节的维度。下一节会说明,它其实是结算契约的一部分。
写模型:更新成本与查询成本分开算
| 方案 | 单次写入 | 名次查询 | Top N | 内存 | 适用 |
|---|---|---|---|---|---|
| 有序集合(ZSET) | O(log N) | O(log N) | O(log N + K) | O(N) | 单榜十万级,已有 Redis |
| 最大堆 | O(log N) | O(N) | O(K log N) | O(N) | 只关心 Top K,不答尾部名次 |
| 分桶计数 | O(1) 改计数 | O(桶数 + 桶内) | O(桶数 + K) | O(N + 桶数) | 分数离散、允许近似名次 |
| 全量内存 + 排序快照 | O(1) 改 map | O(N) | O(N log N) 建快照 | O(N) | 单榜几万,读侧有缓存 |
有序集合是默认答案,因为它把“按分数排名”和“按成员更新”都做成了对数复杂度。代价是名次查询仍然要问一次外部存储,高频读会把压力推给 Redis。
最大堆只保证堆顶正确。玩家问“我第几名”时,堆里没有完整顺序,只能全量扫描;它适合“只发前一百名奖励”的场景,不适合展示个人名次。
分桶计数把分数切成固定宽度的桶,写入只改桶计数,名次用前缀和加桶内扫描。它把 O(log N) 的写入换成 O(1),代价是同分玩家在桶内仍然要排序,名次可能是区间而不是精确值。
全量内存加排序快照是最朴素的一种:写入只改一个 map,读的时候排序或读缓存快照。北辰三款产品的单榜规模都在几万人以内,这个方案的写入最简单,排序成本可以摊到读模型里,所以作为起点。
截断不等于丢弃
客户端只渲染前一百名,不代表服务端只能保留一百条。只保留 Top N 会直接丢掉“我的名次”,而玩家对名次的敏感度往往高于对榜首的敏感度。
| 策略 | 保留内容 | 尾部名次 | 成本 |
|---|---|---|---|
| 只存 Top N | N 条 | 不知道 | 最低 |
| 全量 entries + 渲染截断 | 每个玩家一条 | 精确 | O(N) 内存 |
| Top N + 分数直方图 | N 条 + 桶计数 | 近似或并列区间 | 中等 |
北辰选第二种。一个条目只有玩家 id、分数和达成时间,按示例值估算,一百万玩家大约几十 MB,单机可以接受;截断发生在序列化前,而不是写入时。等到单榜超过几十万,再把精确名次换成“Top N 精确 + 尾部桶计数近似”,这是一次明确的产品降级,需要提前和运营对齐。
同分规则是契约
分数相同时,先看谁更早达成,再按玩家 id 升序。三条规则必须同时固定下来,因为榜单会在三个地方被重新计算:写入端的快照、读端的缓存、结算时的奖励名单。任何一处用了不同的顺序,玩家就可能看到“榜单第 10 名”和“奖励名单第 11 名”。
把玩家 id 作为最后的稳定键,是为了让两个进程对同一批数据排出完全相同的顺序。只按分数和时间排序时,两个同分同秒的玩家顺序取决于 map 遍历或数据库返回顺序,分页时会出现重复或漏行。
分段由分数推导
分段策略只保存三个阈值:白银、黄金、钻石的分数下界。条目上不存分段,渲染时用当前策略计算。这样运营调整阈值不会改写历史数据,也不会出现“旧条目还带着上个赛季段位”的脏读。
分段和分区是两件事。分区决定榜单的物理边界,分段决定同一张榜里的展示层级。把分段做成独立榜单,会让玩家在升段时突然从一张榜消失,体验上像是掉线。
选定方案与边界
- 《裂隙战场》:每赛季一张全服榜,展示前 100,分段阈值按赛季投放;写入在结算时批量进入 9.2 的缓冲区。
- 《群岛纪元》:每个战区一张榜,分区键是战区 id;全服榜只做展示,不参与奖励结算,避免跨战区热点。
- 《泡泡工坊》:好友榜按查看者分区,展示前 20;另有一张轻量全服榜用于拉新活动,两者不共用缓存。
边界也很明确:当前实现把全量条目放在单进程内存里,写入方必须按榜串行化;单榜超过几十万玩家,或者需要多进程同时写,就应该换成有序集合并把名次查询下沉到存储层。
可运行片段:分段榜单与精确名次
下面的 internal/rankmodel 把榜本身份、分段策略、保留最优写入和确定性排序写成可测试的包。它只做模型,不碰缓冲和缓存,后面两节可以独立替换。
// file: internal/rankmodel/rankmodel.go
// tier: L1
// verify: go test ./internal/rankmodel/
package rankmodel
import (
"errors"
"fmt"
"sort"
)
var (
ErrSeason = errors.New("rankmodel: empty season")
ErrScope = errors.New("rankmodel: unknown scope")
ErrPartition = errors.New("rankmodel: scope requires or forbids a partition")
ErrLimit = errors.New("rankmodel: limit must be positive")
ErrPlayer = errors.New("rankmodel: empty player id")
ErrScore = errors.New("rankmodel: negative score")
ErrTime = errors.New("rankmodel: negative achieved time")
ErrTierOrder = errors.New("rankmodel: tier thresholds must be strictly increasing")
ErrNotFound = errors.New("rankmodel: player is not ranked")
)
// Scope names the audience a board is computed for. One season may run several
// scopes in parallel; they are independent write models, not filters applied to
// a single global list.
type Scope string
const (
ScopeGlobal Scope = "global"
ScopeWarzone Scope = "warzone"
ScopeFriends Scope = "friends"
ScopeGuild Scope = "guild"
)
func (s Scope) valid() bool {
switch s {
case ScopeGlobal, ScopeWarzone, ScopeFriends, ScopeGuild:
return true
default:
return false
}
}
// needsPartition reports whether the scope is meaningless without a zone,
// viewer or guild id.
func (s Scope) needsPartition() bool {
return s == ScopeWarzone || s == ScopeFriends || s == ScopeGuild
}
// Key identifies one board. Friends and guild boards are per viewer or per
// guild, so Partition carries that owner id instead of a geographic zone.
type Key struct {
Season string
Scope Scope
Partition string
}
// Validate rejects keys that would silently merge two different audiences.
func (k Key) Validate() error {
if k.Season == "" {
return ErrSeason
}
if !k.Scope.valid() {
return fmt.Errorf("%w: %q", ErrScope, k.Scope)
}
if k.Scope.needsPartition() && k.Partition == "" {
return fmt.Errorf("%w: %s needs a partition", ErrPartition, k.Scope)
}
if !k.Scope.needsPartition() && k.Partition != "" {
return fmt.Errorf("%w: %s forbids a partition", ErrPartition, k.Scope)
}
return nil
}
// Tier is the display segment shown next to a rank. It is derived from the
// score, never stored on the entry, so changing a tier policy does not rewrite
// historical rows.
type Tier string
const (
TierBronze Tier = "bronze"
TierSilver Tier = "silver"
TierGold Tier = "gold"
TierDiamond Tier = "diamond"
)
// TierPolicy holds inclusive lower bounds. A zero policy is invalid: the
// caller must state where silver starts.
type TierPolicy struct {
Silver int64
Gold int64
Diamond int64
}
func (p TierPolicy) Validate() error {
if p.Silver < 0 || p.Gold < 0 || p.Diamond < 0 {
return ErrScore
}
if p.Silver >= p.Gold || p.Gold >= p.Diamond {
return ErrTierOrder
}
return nil
}
// Of maps a score to a tier. The chain is ordered from the highest threshold
// so adjacent policy values cannot produce a gap.
func (p TierPolicy) Of(score int64) Tier {
switch {
case score >= p.Diamond:
return TierDiamond
case score >= p.Gold:
return TierGold
case score >= p.Silver:
return TierSilver
default:
return TierBronze
}
}
// Entry is one player's best result in a season.
type Entry struct {
PlayerID string
Score int64
At int64 // unix milliseconds when the score was achieved
}
// better is the single ordering used by ranks, top lists and settlement. Score
// is descending; an earlier achievement wins a tie; the player id is the final
// stable key so two processes sort identically.
func better(a, b Entry) bool {
if a.Score != b.Score {
return a.Score > b.Score
}
if a.At != b.At {
return a.At < b.At
}
return a.PlayerID < b.PlayerID
}
// Rank is the answer to "where am I", including players outside the displayed
// top list. InTop tells the caller whether the entry appears in Top.
type Rank struct {
PlayerID string
Rank int
Score int64
Tier Tier
InTop bool
Total int
}
// Board is an in-memory leaderboard for one Key. It keeps every entry so rank
// queries remain exact; truncation happens only when a top list is rendered.
// It is not safe for concurrent use; callers serialize writes per board.
type Board struct {
key Key
limit int
tiers TierPolicy
entries map[string]Entry
}
// NewBoard validates the key and the display limit. limit is the number of
// rows the client renders; it does not cap how many players can be ranked.
func NewBoard(key Key, limit int, tiers TierPolicy) (*Board, error) {
if err := key.Validate(); err != nil {
return nil, err
}
if limit <= 0 {
return nil, ErrLimit
}
if err := tiers.Validate(); err != nil {
return nil, err
}
return &Board{
key: key,
limit: limit,
tiers: tiers,
entries: make(map[string]Entry),
}, nil
}
// Submit applies a score with keep-best semantics. A lower score, or the same
// score achieved later, is acknowledged but does not change the board. The
// bool reports whether the stored entry changed.
func (b *Board) Submit(e Entry) (bool, error) {
if e.PlayerID == "" {
return false, ErrPlayer
}
if e.Score < 0 {
return false, ErrScore
}
if e.At < 0 {
return false, ErrTime
}
if old, ok := b.entries[e.PlayerID]; ok && !better(e, old) {
return false, nil
}
b.entries[e.PlayerID] = e
return true, nil
}
// Rank scans every entry because the board keeps the complete set. That is
// O(n) and intentional: an exact tail rank needs the full distribution. If a
// board grows past a few hundred thousand entries, move the counts into a
// sorted index instead of trading correctness for a truncated map.
func (b *Board) Rank(playerID string) (Rank, error) {
e, ok := b.entries[playerID]
if !ok {
return Rank{}, fmt.Errorf("%w: %s", ErrNotFound, playerID)
}
rank := 1
for _, other := range b.entries {
if better(other, e) {
rank++
}
}
return Rank{
PlayerID: playerID,
Rank: rank,
Score: e.Score,
Tier: b.tiers.Of(e.Score),
InTop: rank <= b.limit,
Total: len(b.entries),
}, nil
}
// Top returns at most min(n, limit) entries in ranking order. n <= 0 means the
// board's full display limit.
func (b *Board) Top(n int) []Entry {
out := make([]Entry, 0, len(b.entries))
for _, e := range b.entries {
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool { return better(out[i], out[j]) })
if n <= 0 || n > b.limit {
n = b.limit
}
if n > len(out) {
n = len(out)
}
return out[:n]
}
几个实现取舍值得说明:
Key.Validate在构造时拒绝“全服榜带分区”和“战区榜不带分区”。这两类错误不会立刻报错,但会让两张不同的榜共用一个缓存键。Submit采用保留最优:低分和“同分但更晚”的写入被确认但不改动榜单。这个语义必须和 9.2 的写缓冲一致,否则缓冲层会把一条本该丢弃的写入带进快照。Rank扫描全量条目,换来尾部名次的精确性。它适合几万人的榜;如果读频率很高,9.3 会在它前面加缓存。Top只在返回时截断,并且把n夹在展示上限内。调用方传 1000 也不会把整张榜发给客户端。
失败模式与兜底
| 失败 | 表现 | 兜底 |
|---|---|---|
| 缓存键漏了分区 | A 看到 B 的战区或好友排名 | 键里强制包含 scope 与 partition,构造时校验 |
| 只保留 Top N | 玩家查不到自己的名次 | 保留全量条目,渲染时截断 |
| 同分顺序不稳定 | 分页重复、奖励名单与榜单不一致 | 固定“分数、时间、id”三级排序 |
| 分段阈值下发不一致 | 新旧客户端显示不同段位 | 服务端计算分段,客户端只渲染 |
| 负分或空 id 写入 | 榜单出现幽灵条目 | 写入入口做类型化校验并拒绝 |
| 多进程同时写同一榜 | 后写覆盖先写,名次抖动 | 单榜单写者,或改用有序集合并发更新 |
最后一条最容易被低估。内存模型看起来线程安全,是因为测试只跑单协程;一旦两个结算进程同时写同一张榜,map 的并发写会直接让进程崩溃,而不是产生一个“稍微不准”的榜单。
常见错误
第一,把榜单当成查询。每次请求都 order by score limit 100,在几万条数据上看似没问题,读放大后会变成数据库的稳定热点。榜单应该是一个有写模型的读模型,而不是一张随时可查的业务表。
第二,只存 Top N。榜首是给外人看的,名次是给玩家自己看的;砍掉尾部数据等于砍掉玩家最常看的那一行。
第三,同分规则留给数据库。数据库不保证稳定排序,分页查询尤其明显;把排序键写进契约,才能让榜单、缓存和结算三处结果一致。
第四,用分段做权限。分段是展示层,赛季规则才是准入层;两者混在一起,运营改一次阈值就可能把玩家挡在活动门外。
第五,忽略榜本身份。全服、战区、好友、公会不是同一张表的四种过滤条件,而是四类生命周期和缓存键都不同的对象。
小结
榜单模型要先把受众、分区、分段和排序四个维度定死,再选写模型。北辰三款产品都从“全量条目 + 渲染截断”起步,因为单榜规模可控,精确名次比极限写入更重要。模型定好之后,下一个问题是写入频率:结算高峰每秒几千条写入,不能每条都直接改榜单。
阅读导航:上一节:8.3 聊天频道、审核与限速 · 下一节:9.2 写缓冲、结算与赛季边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。