《游戏服务端架构·核心系统与契约》9.1 榜单模型与分段

《裂隙战场》《群岛纪元》《泡泡工坊》的榜单需求并不相同。本节把榜单拆成受众、分区、分段和同分排序四个维度,比较有序集合、最大堆与分桶计数的更新成本,说明只展示 Top N 时如何保留精确名次,并给出一个带分段策略、确定性排序和输入校验的可运行榜单模型。

三个产品,三种“榜单”

《裂隙战场》的赛季榜只回答两件事:我排第几、前一百名是谁。十个人一局,分数在结算时一次写入,读的人却包括每个玩家的主界面、结算页和观战端。《群岛纪元》全球同服,同一张大地图上按战区切分,每个战区几万人,行军和采集会持续产生分数,榜单必须跟着赛季滚动。《泡泡工坊》更轻:好友榜只在玩家点开时看一次,数据量小,但“好友”是相对查看者的关系,不是一张固定的全服表。

北辰工作室最早把三张榜都做成“一张全服表加 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) 改 mapO(N)O(N log N) 建快照O(N)单榜几万,读侧有缓存

有序集合是默认答案,因为它把“按分数排名”和“按成员更新”都做成了对数复杂度。代价是名次查询仍然要问一次外部存储,高频读会把压力推给 Redis。

最大堆只保证堆顶正确。玩家问“我第几名”时,堆里没有完整顺序,只能全量扫描;它适合“只发前一百名奖励”的场景,不适合展示个人名次。

分桶计数把分数切成固定宽度的桶,写入只改桶计数,名次用前缀和加桶内扫描。它把 O(log N) 的写入换成 O(1),代价是同分玩家在桶内仍然要排序,名次可能是区间而不是精确值。

全量内存加排序快照是最朴素的一种:写入只改一个 map,读的时候排序或读缓存快照。北辰三款产品的单榜规模都在几万人以内,这个方案的写入最简单,排序成本可以摊到读模型里,所以作为起点。

截断不等于丢弃

客户端只渲染前一百名,不代表服务端只能保留一百条。只保留 Top N 会直接丢掉“我的名次”,而玩家对名次的敏感度往往高于对榜首的敏感度。

策略保留内容尾部名次成本
只存 Top NN 条不知道最低
全量 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 写缓冲、结算与赛季边界 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查