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/op | B/op | allocs/op |
|---|---|---|---|
NaiveBoard.Rank | 约 2 459 606 | 约 327 768 | 4 |
Board.Rank(跳表) | 约 104 | 0 | 0 |
两者在同一台机器上相差约四个数量级,跳表版本还零分配。同机相对量级,绝对数值不可移植——换台机器、换个规模,绝对值都会变,但「读路径排序」和「写路径维护有序」的复杂度差距不会变。这正是 14.1 说朴素版「正确但会崩」的含义:它不是写错了,而是复杂度形状选错了。
本节小结
- 写缓冲把同一玩家一个窗口内的多次提交压成一次改动,
Flush是榜单的唯一写入者,因此跳表可以无锁。 - 榜单改动次数由玩家数决定,不由提交数决定——1000 次提交对 3 个玩家只产生 3 次改动。
- 跳表每层带
width,记录这一跳越过的底层节点数,排名查询靠累加width在 O(log n) 内完成;头节点width恒等于总节点数,是维护的锚点。 - 宽度极易错一位,用「随机写 + 排序参照对拍」的测试兜底,比手工构造用例可靠。
- 不用有序切片是因为插入是 O(n);分桶是另一条路,代价是处理分数分布倾斜。
- 基准只是同机相对量级,跳表版本在 20000 人榜单上零分配,与朴素版差约四个数量级。
阅读导航:上一节:14.1 为什么不能只用一张表 · 下一节:14.3 结算与赛季边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。