《MMO 服务器开发》14.1 为什么不能只用一张表

排行榜看着只是「按分数排个序」,可一旦塞进一张表用 ORDER BY 去查,读放大、写入热点、并列名次和「我的排名」四件事会同时把它压垮。本节先讲清排行榜真正要回答的问题、为什么一张表不够,再在 internal/rank 包里写出一个正确但注定会崩的朴素榜单,作为后两节优化版的对照基线。

14.1 为什么不能只用一张表

排行榜是玩家最常看、也最容易被低估的系统。产品需求往往一句话就能说完:「按战力排个名,显示前 100,再顺手告诉我我现在第几名。」于是很多团队的第一版就是一张表 player_rank(player_id, score),查询写成 SELECT player_id, score FROM player_rank ORDER BY score DESC LIMIT 100。数据量小的时候它完全正确,甚至称得上优雅。

但排行榜有三个特性会同时咬人。第一,它是读多写多的:几万在线玩家每次战力变化都要写一次,而每个客户端又随时可能拉榜。第二,它每次读都要排序,而排序的代价随人数增长。第三,玩家真正关心的是「我在第几名」,而不是「前 100 是谁」——前者要求对任意一个玩家快速定位名次,LIMIT 100 根本回答不了。

本节不急着上优化。我们要先讲清一张表到底卡在哪,再在 internal/rank 包里写出一个能跑但注定会崩的朴素榜单,把它作为 14.2 优化版的对照基线。本章全部用内存实现,只用标准库,不引入任何中间件。

与站内 practice 文章的分工

本书从零写代码、每一段都能真跑;站内既有的 practice 文章讲的是架构方案与取舍。本章对应的是下面几篇,建议对照阅读:

站内文章它负责回答本书这一章负责
/leaderboard-service-not-just-one-table/排行榜为什么不是一张表能了事、有哪些榜单形态写出一份可运行的 Board,让排名查询真的在 O(log n) 内返回
/game-server-arena-ladder-promotion-architecture/竞技场天梯的升降级、分段与匹配怎么设计分段与并列名次如何落到数据结构上,而不是靠 SQL 兜底
/game-server-season-settlement-architecture-design/赛季结算的时机、发奖与幂等怎么切把「冻结快照 → 按名次发奖 → 可重试」写成可测的代码

这三篇解决的是「方案怎么选」,本章解决的是「代码怎么写、跑起来对不对」。

一张表 + ORDER BY 卡在哪

把需求逐条摊开,单表做法的代价就露出来了:

需求单表做法代价
前 100 名ORDER BY score DESC LIMIT 100每次全量排序,人数越大越慢
我在第几名SELECT COUNT(*) WHERE score > ?又是一次全表扫描
名次稳定ORDER BY score DESC, player_id ASC加了并列键才不抖,但排序更重
高分玩家频繁涨分UPDATE ... WHERE player_id = ?热点行被反复加锁,写冲突集中
前 10 精确、后面近似无法在 SQL 里表达要么全精确(贵),要么不提供

四件事里有三件都指向同一个根因:「排序」这件事被放在了读路径上。读一次排一次,读得越多排得越多;而排序的结果其实在两次写入之间是稳定的,完全可以只在写路径上维护。排行榜的正确形状不是「查询时排序」,而是「写入时就维持有序,读取时直接取」。

还有一个更隐蔽的坑:并列名次。如果只按 score 排序,两个同分玩家的先后是不确定的,榜单会在两次刷新之间「抖动」——同一个玩家一会儿第 7 一会儿第 8。修法是加一个稳定的并列键,用较小的 player_id 排前面。这条规则要贯彻到所有查询里,否则「我的排名」和「前 100」两处算出来的名次会对不上。本章的 better() 函数就是这条规则的唯一出处。

最后是「我的排名」这一类查询。它不是范围查询,LIMIT 帮不上忙,本质是「统计有多少人排在我前面」。这个数字必须能 O(log n) 拿到,而不是每次数一遍。把这三条合起来看,就会明白为什么榜单需要专门的数据结构,而不是一张表加几个索引能糊弄过去。

把代价量化一下会更清楚。假设榜单 2 万人、每秒 500 次名次查询,单表方案每次查询都要排序 2 万个元素,约 2 万 × log₂(2 万) ≈ 28 万次比较;500 次/秒就是每秒上亿次比较,还要额外分配 2 万条记录的临时空间。这个数字随人数线性甚至超线性增长,而写路径的热点行锁又会和读竞争同一份数据页。排行榜要的不是更快的排序算法,而是让排序只发生一次——在写入时做,之后所有读都复用。

还有一个常被忽略的点:排行榜往往不止一张。战力榜、等级榜、竞技场天梯、公会贡献榜、单场伤害榜……形态不同,更新频率也差很多(战力每秒都变,等级几天才动一次)。如果每张榜都套一份单表实现,读放大和写入热点会成倍叠加。所以真正要设计的不是一个查询,而是一套可复用的榜单抽象:同一份有序结构,配上不同的比较键和刷新策略。

顺带说一句:站内很多方案会用 Redis 的 ZSET 来做榜单——它天然有序、ZREVRANK 直接给名次,是现成答案。本书不用它,一是本卷统一「内存实现、只用标准库」的口径,二是 ZSET 的 ZREVRANK 内部同样靠「带跨度的跳表」实现。搞懂那层结构,你才知道什么时候可以放心用现成的,什么时候它的 O(log N) 也扛不住。本章就是把 ZSET 里那层结构自己写一遍。

排行域要回答的问题与命令号

先把查询语义定死,后面两节才有的放矢。排行榜要回答四类问题:

  1. Top-N:前 N 名是谁,按名次从高到低。
  2. 我的排名:给定 PlayerID,返回名次(1 起)。
  3. 区间:第 50 到第 100 名,用于翻页。
  4. 分段:前 10 名精确、前 1% 近似即可,用于「分档奖励」。

第 1、3 类可以从「有序列表」直接切出来;第 2 类需要额外的结构支持;第 4 类则允许我们在精度上做取舍。这四类问题的精度要求并不一样:Top-N 和区间必须精确;「我的排名」在榜单很大时可以接受近似(比如「约第 3200 名」),但竞技场天梯这种要按名次发奖、升降级的场景必须精确。把前若干名做精确、后面的名次用分段计数近似,是很多大型榜单的真实做法。本节先把精确版本做对,14.2 再用带 width 的跳表把精确版做到 O(log n),把「近似」留作可选的降级手段。

用 §3.5 定下的帧格式,排行域的两条消息长这样:

榜单的更新不是「玩家自己算分」,而是别的系统把分数推过来。战力变化由属性系统算完通知排行榜,竞技场名次由战斗结算写回,等级榜则由升级事件触发。排行榜不主动去别处捞数据,它只接受「谁、哪个榜、多少分、什么时刻」这四个字段的更新——这条边界让它能独立于业务系统演进,也是 14.3 里「时间戳由服务端盖章」的前提。

把这四类查询和它们各自的目标复杂度列在一起,后面的设计就有了一张验收清单:

查询语义目标复杂度
Top-N前 N 名是谁O(k)
我的排名我第几O(log n)
区间第 a 到第 b 名O(log n + k)
分段前 10 / 前 1%精确,或近似降级
CmdRankQueryReq 0x0901  客户端 -> 服务端
  boardType(1) | boardID(4, BE) | offset(2, BE) | limit(2, BE)

CmdRankQueryResp 0x0902 服务端 -> 客户端
  boardType(1) | boardID(4, BE) | total(4, BE) | myRank(4, BE) | count(2, BE)
  count × { playerID(8, BE) | score(8, BE) | nameLen(1) | name(nameLen) }

帧 = length(4) | cmd(2) | seq(4) | flags(1) | payload      // 固定头 11 字节
length = 7 + N,帧总长 = 11 + N,HeaderSize = 11

myRank 和分页结果放在同一条响应里:客户端翻页时顺带就能拿到自己的名次,不必再发一次请求。boardType 用来区分战力榜、等级榜、竞技场天梯等不同榜单,boardID 在赛季制榜单里就是赛季号。注意 myRank 为 0 表示「不在榜上」,这和名次 1 起是两回事,客户端要分开处理。

从零实现:一个正确但会崩的朴素榜单

先把「一张表」的思路原样搬进内存,作为基线。下面这段是 L1,go test 可以直接跑。它每次查询都从头排序,正确性没问题,代价会在基准里露馅:

// file: internal/rank/naive.go
// tier: L1
// verify: go test ./internal/rank/ -run TestNaive -v

package rank

import "sort"

// Entry is one row on a leaderboard: a player and their score.
type Entry struct {
	PlayerID uint64
	Score    int64
}

// better reports whether a outranks b: higher score wins, and ties go to the
// smaller player id. The order must be total, otherwise ranks flicker between
// two players who happen to share a score.
func better(a, b Entry) bool {
	if a.Score != b.Score {
		return a.Score > b.Score
	}
	return a.PlayerID < b.PlayerID
}

// NaiveBoard answers every rank query by re-sorting the whole board. It is the
// "one table plus ORDER BY" model moved into memory: correct, and fine until
// the board grows or the query rate climbs.
type NaiveBoard struct {
	entries []Entry
	index   map[uint64]int
}

func NewNaiveBoard() *NaiveBoard {
	return &NaiveBoard{index: make(map[uint64]int)}
}

// Set inserts a player or overwrites their score.
func (b *NaiveBoard) Set(id uint64, score int64) {
	if i, ok := b.index[id]; ok {
		b.entries[i].Score = score
		return
	}
	b.index[id] = len(b.entries)
	b.entries = append(b.entries, Entry{PlayerID: id, Score: score})
}

func (b *NaiveBoard) sorted() []Entry {
	cp := make([]Entry, len(b.entries))
	copy(cp, b.entries)
	sort.Slice(cp, func(i, j int) bool { return better(cp[i], cp[j]) })
	return cp
}

// Rank returns the 1-based rank of id, or 0 if the player is not on the board.
// Cost is O(n log n) per call because it sorts from scratch every time.
func (b *NaiveBoard) Rank(id uint64) int {
	if _, ok := b.index[id]; !ok {
		return 0
	}
	for i, e := range b.sorted() {
		if e.PlayerID == id {
			return i + 1
		}
	}
	return 0
}

// Len reports how many players are on the board.
func (b *NaiveBoard) Len() int { return len(b.entries) }

它对应的测试把两条语义钉死:一是同分时小 id 排前面,二是更新分数后名次立刻变化。这两条在 14.2 的跳表版本里必须一字不差地成立:

// file: internal/rank/naive_test.go
// tier: L1
// verify: go test ./internal/rank/ -run TestNaive -v

package rank

import "testing"

func TestNaiveRankAndTieBreak(t *testing.T) {
	b := NewNaiveBoard()
	b.Set(7, 100)
	b.Set(3, 100) // same score: the smaller id outranks
	b.Set(9, 50)
	b.Set(1, 999)

	if got := b.Rank(1); got != 1 {
		t.Fatalf("rank(1)=%d want 1", got)
	}
	if got := b.Rank(3); got != 2 {
		t.Fatalf("rank(3)=%d want 2 (tie -> smaller id)", got)
	}
	if got := b.Rank(7); got != 3 {
		t.Fatalf("rank(7)=%d want 3", got)
	}
	if got := b.Rank(9); got != 4 {
		t.Fatalf("rank(9)=%d want 4", got)
	}
	if got := b.Rank(42); got != 0 {
		t.Fatalf("rank(absent)=%d want 0", got)
	}

	// An update moves the player. The naive board still answers correctly --
	// it just re-sorts on every call to do it.
	b.Set(9, 1000)
	if got := b.Rank(9); got != 1 {
		t.Fatalf("after update rank(9)=%d want 1", got)
	}
	if b.Len() != 4 {
		t.Fatalf("len=%d want 4", b.Len())
	}
}

这个版本为什么「会崩」,不是因为它有 bug,而是因为它的复杂度形状错了:Set 是 O(1) 摊还(只在切片尾部追加),但 Rank 是 O(n log n),而且每次都要复制一份切片再排序——在 14.2 的基准里,这两条会直接变成几毫秒级的单次延迟和每次查询几百 KB 的临时分配。正确但慢,正是基线该有的样子。

注意测试里的 b.Set(9, 1000) 之后 Rank(9) 立刻变成 1:朴素版没有维护任何「位置缓存」,名次永远是「现算现得」。这种无状态查询最不容易出 bug——没有缓存,就没有缓存和真相不一致的可能。后面跳表引入了 width 和 index 两处状态,一致性风险才真正出现,14.2 的对拍测试就是为它们准备的。

关键数字速查

量值来源
榜单规模(示例)2 万人单服中等体量
单表名次查询复杂度O(n log n)每次重排
榜单要回答的问题4 类Top-N / 我的排名 / 区间 / 分段
并列键小 player_id 优先better()
排行域命令号0x0901 / 0x0902§3.5

本节小结

  • 排行榜要回答四类问题:Top-N、我的排名、区间、分段;其中「我的排名」是单表 LIMIT 回答不了的。
  • 单表方案把「排序」放在读路径上,读得越多排得越多,而排序结果在两次写入之间本来是稳定的。
  • 并列名次必须用稳定的并列键(小 player_id 优先),否则榜单会抖动,且「我的排名」与「前 100」会对不上。
  • 正确形状是「写入时维持有序、读取时直接取」,朴素榜单每次查询重排,只是把问题搬进了内存。
  • 排行域用 CmdRankQueryReq 0x0901 / CmdRankQueryResp 0x0902,myRank 为 0 表示不在榜上。

阅读导航:上一节:目录 · 下一节:14.2 写缓冲与分段榜单 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标