《MMO 服务器开发》14.2 写缓冲与分段榜单

朴素榜单每次查询都重排,读路径太贵;真要把排名做到 O(log n),得让写入时就维持有序。本节给 aegis 的 rank 包加两样东西:把同一玩家的连续涨分压成一次改动的写缓冲,以及带 width 的跳表——它每一层记着跨过的节点数,于是「我第几名」不用数一遍就能直接算出来。

14.2 写缓冲与分段榜单

14.1 的朴素榜单证明了一件事:把排序留在读路径上,读一次就要付一次排序的钱。这一节把它反过来——写入时维持有序,读取时直接取。但要真正做到,得解决两个各自独立的成本。

第一个成本在写路径。一个玩家在一场战斗里可能连续涨十几次分,每次都是一个写操作;如果每次都去改榜单结构,写放大会把 CPU 吃掉。解决办法是写缓冲:先攒着,同一个玩家只留最后一次分数,定期批量落到榜单上。

第二个成本在「我的排名」这类查询。有序列表天然能回答 Top-N,却回答不了「我第几」——除非从头数。解决办法是让结构自己记住「跨过了多少个节点」,这就是分段:跳表的每一层带一个 width,记录这一跳越过多少个底层节点,于是排名可以在 O(log n) 内累加出来。下面两节分别实现这两样,最后用真实基准把它们和朴素版本摆在一起。

写缓冲:把 N 次提交压成 M 次改动

写缓冲的语义很朴素:一个玩家在一个刷新窗口内无论提交多少次分数,只有最后一次有意义——榜单只关心当前分数,不关心中间过程。缓冲用一个 map[uint64]int64 就够了,Submit 直接覆盖。

Service 的关键设计是单一写入者:只有 Flush 会碰榜单,读操作走榜单自己的方法。这样一来跳表不需要任何内部锁,Flush 在哪个 goroutine 跑,榜单就归它独占;而缓冲本身要加锁,因为 Submit 可能从多个网络协程并发进来。下面这段是 L1,go test 可以直接跑:

// file: internal/rank/buffer.go
// tier: L1
// verify: go test ./internal/rank/ -run TestService -v

package rank

import "sync"

// WriteBuffer collapses repeated score updates per player inside one flush
// window. A player's hot loop can Submit thousands of times; only the last
// score per player reaches the board, so board mutations stay bounded by the
// number of distinct players, not by the number of updates.
type WriteBuffer struct {
	mu   sync.Mutex
	pend map[uint64]int64
}

func NewWriteBuffer() *WriteBuffer {
	return &WriteBuffer{pend: make(map[uint64]int64)}
}

// Submit records the latest score for a player and reports whether this call
// coalesced into an update that was already pending.
func (w *WriteBuffer) Submit(id uint64, score int64) bool {
	w.mu.Lock()
	defer w.mu.Unlock()
	_, dup := w.pend[id]
	w.pend[id] = score
	return dup
}

// Drain returns the pending updates and clears the buffer.
func (w *WriteBuffer) Drain() map[uint64]int64 {
	w.mu.Lock()
	defer w.mu.Unlock()
	out := w.pend
	w.pend = make(map[uint64]int64)
	return out
}

// Pending reports how many distinct players have an unflushed update.
func (w *WriteBuffer) Pending() int {
	w.mu.Lock()
	defer w.mu.Unlock()
	return len(w.pend)
}

// Service is a leaderboard plus its write buffer. Flush is the only place the
// board is mutated, so a single goroutine owns it and the skip list needs no
// internal locking.
type Service struct {
	board *Board
	buf   *WriteBuffer
}

func NewService(seed1, seed2 uint64) *Service {
	return &Service{board: NewBoard(seed1, seed2), buf: NewWriteBuffer()}
}

// SubmitScore records a score update without touching the board.
func (s *Service) SubmitScore(id uint64, score int64) { s.buf.Submit(id, score) }

// Flush applies buffered updates and returns how many board mutations ran. It
// is always <= the number of SubmitScore calls since the last Flush.
func (s *Service) Flush() int {
	pend := s.buf.Drain()
	for id, score := range pend {
		s.board.Set(id, score)
	}
	return len(pend)
}

func (s *Service) Rank(id uint64) (int, bool) { return s.board.Rank(id) }
func (s *Service) TopK(k int) []Entry         { return s.board.TopK(k) }
func (s *Service) Len() int                    { return s.board.Len() }

配套的测试把两条语义钉死:同一玩家多次提交只留最后一次,以及 1000 次提交只产生 3 次榜单改动(因为只有 3 个不同玩家)。第二点正是写缓冲的全部意义——榜单改动次数由玩家数决定,不由提交数决定。

分段榜单:带 width 的跳表

跳表本身不难:它是一串有序链表,每个节点随机多长出几层「快速通道」,查找时从最高层一路往右下走,平均 O(log n)。难的是排名查询——有序链表能 O(1) 拿到头节点,但「第 5000 名是谁」要数 5000 次。

分段榜单的做法是给每一跳加一个宽度。每个节点的第 i 层记两样东西:forward[i] 指向同层下一个节点,width[i] 记录从本节点沿第 i 层跳到 forward[i] 时底层链表跨过了多少个节点。查找时边走边累加 width,走到目标位置时累加值就是它前面有多少个节点——也就是 0 基名次。头节点的 width 恒等于总节点数,这让「在最高层新开一层」时的宽度维护有个稳定的锚点。

下面这段是 L1,go test 可以直接跑。它实现了 Set(增改)、Rank(O(log n) 名次)、TopK(O(k) 取前 k):

// file: internal/rank/skiplist.go
// tier: L1
// verify: go test ./internal/rank/ -run TestBoard -v

package rank

import "math/rand/v2"

const (
	maxLevel = 16
	pFactor  = 0.25
)

// node: forward[i] is the next node on level i; width[i] is how many base-list
// nodes that hop skips. Width is what makes rank queries O(log n).
type node struct {
	e       Entry
	forward []*node
	width   []int
}

func newNode(e Entry, level int) *node {
	return &node{e: e, forward: make([]*node, level), width: make([]int, level)}
}

// Board orders entries by better(): higher score first, ties by smaller id.
type Board struct {
	head  *node
	level int // level count, 1-based
	n     int
	index map[uint64]*node
	rng   *rand.Rand
}

func NewBoard(s1, s2 uint64) *Board {
	return &Board{head: newNode(Entry{}, maxLevel), level: 1,
		index: map[uint64]*node{}, rng: rand.New(rand.NewPCG(s1, s2))}
}

func (b *Board) randomLevel() int {
	lvl := 1
	for lvl < maxLevel && b.rng.Float64() < pFactor {
		lvl++
	}
	return lvl
}

func (b *Board) insert(e Entry, level int) *node {
	update := make([]*node, maxLevel)
	rank := make([]int, maxLevel)
	x := b.head
	for i := b.level - 1; i >= 0; i-- {
		rank[i] = 0
		if i != b.level-1 {
			rank[i] = rank[i+1]
		}
		for x.forward[i] != nil && better(x.forward[i].e, e) {
			rank[i] += x.width[i]
			x = x.forward[i]
		}
		update[i] = x
	}
	if level > b.level {
		for i := b.level; i < level; i++ {
			update[i] = b.head
			update[i].width[i] = b.n
		}
		b.level = level
	}
	nd := newNode(e, level)
	for i := 0; i < level; i++ {
		nd.forward[i] = update[i].forward[i]
		update[i].forward[i] = nd
		nd.width[i] = update[i].width[i] - (rank[0] - rank[i])
		update[i].width[i] = (rank[0] - rank[i]) + 1
	}
	for i := level; i < b.level; i++ {
		update[i].width[i]++
	}
	b.n++
	return nd
}

func (b *Board) remove(e Entry) {
	update := make([]*node, maxLevel)
	x := b.head
	for i := b.level - 1; i >= 0; i-- {
		for x.forward[i] != nil && better(x.forward[i].e, e) {
			x = x.forward[i]
		}
		update[i] = x
	}
	x = x.forward[0]
	if x == nil || x.e != e {
		return
	}
	for i := 0; i < b.level; i++ {
		if update[i].forward[i] == x {
			update[i].width[i] += x.width[i] - 1
			update[i].forward[i] = x.forward[i]
		} else {
			update[i].width[i]--
		}
	}
	for b.level > 1 && b.head.forward[b.level-1] == nil {
		b.level--
	}
	b.n--
}

// Set inserts a player or moves them to a new score.
func (b *Board) Set(id uint64, score int64) {
	if old, ok := b.index[id]; ok {
		b.remove(old.e)
	}
	b.index[id] = b.insert(Entry{PlayerID: id, Score: score}, b.randomLevel())
}

// Rank returns the 1-based rank of id in O(log n), or false if absent.
func (b *Board) Rank(id uint64) (int, bool) {
	nd, ok := b.index[id]
	if !ok {
		return 0, false
	}
	x, rank := b.head, 0
	for i := b.level - 1; i >= 0; i-- {
		for x.forward[i] != nil && better(x.forward[i].e, nd.e) {
			rank += x.width[i]
			x = x.forward[i]
		}
	}
	return rank + 1, true
}

// TopK returns the best k entries, best first, in O(k).
func (b *Board) TopK(k int) []Entry {
	if k > b.n {
		k = b.n
	}
	out := make([]Entry, 0, k)
	for x := b.head.forward[0]; x != nil && len(out) < k; x = x.forward[0] {
		out = append(out, x.e)
	}
	return out
}

func (b *Board) Len() int { return b.n }

宽度维护最容易错,错一位排名就整体偏移,所以测试不写「手工构造一个已知榜单」,而是对拍:随机写 2000 次,把结果和「map 存分数 + 每次 sort.Slice」这个绝对正确的参照逐个名次比对。任何一处 width 算错都会在这里被抓住(go test ./internal/rank/ -run TestBoard)。

那为什么不用「有序切片 + 二分」?它的 Rank 也是 O(log n)、TopK 也是 O(k),看起来一样好。差别在写入:有序切片插入一个元素要把后面的元素整体后移,是 O(n) 的;榜单人数上万、每次 Flush 又落一批新分数时,这个后移会累积成明显的写放大。跳表插入只改指针,是 O(log n)。代价是实现复杂、要维护 width,且用随机层高换来的是期望 O(log n) 而非最坏保证——但对「读多写多、写入分散」的榜单,这个交换划算。分桶(按分数段切桶、桶内再有序)是另一条路,代价是要处理分数分布倾斜。

基准:朴素榜单 vs 跳表

口说无凭,跑一次基准。下面这条命令是 B,产出数字,只作同机相对比较(榜单 20000 人,随机取 256 个 id 反复查名次):

$ go test ./internal/rank/ -run TestNothing -bench BenchmarkRank -benchmem
goos: darwin  goarch: arm64  cpu: Apple M1 Pro
BenchmarkRankNaive-10          462    2459606 ns/op    327768 B/op    4 allocs/op
BenchmarkRankSkiplist-10  11559003        104.4 ns/op         0 B/op    0 allocs/op
PASS
实现ns/opB/opallocs/op
NaiveBoard.Rank约 2 459 606约 327 7684
Board.Rank(跳表)约 10400

两者在同一台机器上相差约四个数量级,跳表版本还零分配。同机相对量级,绝对数值不可移植——换台机器、换个规模,绝对值都会变,但「读路径排序」和「写路径维护有序」的复杂度差距不会变。这正是 14.1 说朴素版「正确但会崩」的含义:它不是写错了,而是复杂度形状选错了。

本节小结

  • 写缓冲把同一玩家一个窗口内的多次提交压成一次改动,Flush 是榜单的唯一写入者,因此跳表可以无锁。
  • 榜单改动次数由玩家数决定,不由提交数决定——1000 次提交对 3 个玩家只产生 3 次改动。
  • 跳表每层带 width,记录这一跳越过的底层节点数,排名查询靠累加 width 在 O(log n) 内完成;头节点 width 恒等于总节点数,是维护的锚点。
  • 宽度极易错一位,用「随机写 + 排序参照对拍」的测试兜底,比手工构造用例可靠。
  • 不用有序切片是因为插入是 O(n);分桶是另一条路,代价是处理分数分布倾斜。
  • 基准只是同机相对量级,跳表版本在 20000 人榜单上零分配,与朴素版差约四个数量级。

阅读导航:上一节:14.1 为什么不能只用一张表 · 下一节:14.3 结算与赛季边界 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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