《游戏服务端架构·核心系统与契约》9.3 榜单的读模型与缓存

榜单是读多写少的典型:整榜、分页和“我的排名”会被反复刷新。本节比较整榜缓存、分页缓存和个人名次缓存的收益与失效成本,讨论缓存版本落后时玩家看到的名次偏差,给出热点榜的降级、有界缓存与惊群处理策略,并实现一个带版本失效、过期兜底和统计计数的读模型。

读放大发生在主界面

《裂隙战场》每个玩家登录后都会打开赛季页,看一眼前一百和自己第几;《群岛纪元》的战区榜被做成常驻面板,玩家每次回城都会刷新;《泡泡工坊》的好友榜打开频率低,但一次要读几十个好友的名次。写入已经被 9.2 收敛成批次,读却仍然按玩家、按页面、按刷新次数线性增长。

一张五万人的榜,如果每次请求都重新排序,单次成本是几万次比较;一万个在线玩家每十秒刷新一次,每秒就是一千次排序。真正的问题不是排序慢,而是同一份结果被重复计算。读模型的任务是把“同一版本的榜单”算一次,然后让所有读者共享。

三层读模型

层缓存内容命中收益失效范围适用
整榜缓存完整排序结果最高任意写入小榜、版本变化不频繁
分页缓存某段 offset/limit中该页或整榜大榜、客户端按页拉取
个人名次缓存玩家 id 到名次高该玩家或整榜“我的排名”入口

整榜缓存适合几千人的榜:一次排序,所有分页都从同一份切片里切。它的缺点是任何写入都要整体失效,写入频繁时命中率会掉到零。

分页缓存把失效粒度缩小到页,但页边界会随名次变化而移动。玩家在榜上超过一个人,后面所有页的内容都平移一位;如果只失效“被超过的那一页”,下一页就会重复或漏人。分页缓存的键必须带版本,版本一变就整体重建,而不是逐页打补丁。

个人名次缓存是收益最高的一层,因为“我的排名”是最高频的读。它的键空间随玩家数增长,不能无限缓存;本节用一个固定的容量上限,满了就整体清空,用一次重建换取内存可控。

失效:TTL、版本还是消息

策略一致性实现成本失败时的行为
纯 TTL最多落后一个 TTL低继续读旧值直到过期
版本号版本变化后首次读重建中版本未变时命中旧值
发布订阅失效接近实时高消息丢失后长期脏读

北辰选版本号。榜单写入方在每次封榜或批量更新后递增一个本地计数器,读侧每次请求先读这个计数器;计数器没变就复用缓存,变了就把旧条目标记为不可新鲜命中,下一次读重建。版本号本身必须廉价,最好是与榜单同进程的内存值,而不是再查一次数据库。

发布订阅看起来更实时,但它把正确性押在消息不丢上。榜单不是强实时系统,落后几百毫秒可以接受,落后一个赛季不可接受;版本号用一次廉价的比较换来明确的失效边界,比订阅消息更容易验证。

偏差窗口:玩家看到的“我的排名”

缓存和真实榜单之间永远有一个窗口。玩家刚打完一局,分数已经写入,榜单页却还是旧版本;这时如果页面显示“第 128 名”,而个人名次接口返回“第 96 名”,玩家会认为系统出错。

处理方式不是把两个接口强行对齐,而是让它们暴露同一个版本。分页结果带 Version,个人名次也带 Version;客户端发现两个版本不一致时,显示“排名更新中”,等下一次刷新统一。服务端不应该在缓存层做跨版本的拼接,那会把一个可见的延迟变成不可解释的错误。

降级时同样要带版本和 Stale 标记。玩家可以接受“这是几十秒前的排名”,不能接受“这是当前排名”的谎言。

降级:过期数据比空白好,但要有上限

榜单读失败时,返回上一次的快照通常比返回错误更有用,尤其是主界面和结算页。本节用 maxStale 限制这个窗口:TTL 内是新鲜命中,TTL 到 maxStale 之间允许带 Stale=true 返回旧值,超过 maxStale 才返回 ErrUnavailable。

maxStale 必须是产品参数,不是实现细节。赛季榜可以容忍几十秒,奖励预览页只能容忍几秒;同一个缓存实例不应该服务两种语义。北辰把赛季页设为 30 秒(示例值),奖励预览单独走一条不带降级的读路径。

个人名次缓存还要有容量上限。玩家 id 的键空间远大于榜单页,按一百万在线、每人一条估算,光键本身就要几十 MB;设一个上限并在满时整体清空,比引入 LRU 更简单,也更可预测。

热点榜的读放大

战区榜在跨服战期间会变成热点:同一个版本、同一页被几千个玩家同时请求。缓存能挡住重复计算,但挡不住“同时未命中”的惊群。本节没有实现单飞(single-flight),调用方需要在缓存外面加一层按榜的互斥,或者接受一次重复重建。

更稳妥的做法是把热点榜预热成只读快照:封榜或批次更新后主动构建一次,读侧只读不建。这样读路径没有排序,也没有惊群;代价是写入方要知道哪些榜是热点,提前构建。

可运行片段:版本失效与有界缓存

下面的 internal/rankread 把分页缓存、个人名次缓存、版本失效和过期兜底写成可测试的包。时钟由调用方注入,测试不需要 sleep。

// file: internal/rankread/rankread.go
// tier: L1
// verify: go test ./internal/rankread/
package rankread

import (
	"errors"
	"fmt"
	"time"
)

var (
	ErrSource       = errors.New("rankread: nil source")
	ErrTTL          = errors.New("rankread: ttl must be positive")
	ErrStaleWindow  = errors.New("rankread: stale window must not be shorter than ttl")
	ErrRankCapacity = errors.New("rankread: rank cache capacity must be positive")
	ErrPage         = errors.New("rankread: offset must be non-negative and limit positive")
	ErrPlayer       = errors.New("rankread: empty player id")
	ErrUnavailable  = errors.New("rankread: cache expired and source is unavailable")
)

// Entry is one rendered leaderboard row. Rank is absolute, so a page at
// offset 100 still tells the client that the first row is rank 101.
type Entry struct {
	PlayerID string
	Score    int64
	Rank     int
}

// Source is the read interface of the authoritative board. Version must be a
// cheap locally published counter, not a query: the cache calls it on every
// request to decide whether its snapshot is still current.
type Source interface {
	Version() uint64
	Page(offset, limit int) ([]Entry, error)
	Rank(playerID string) (int, error)
}

// Stats is a snapshot of cache counters for dashboards and tests.
type Stats struct {
	PageHits      int
	PageMisses    int
	RankHits      int
	RankMisses    int
	StaleServed   int
	Evictions     int
	Invalidations int
}

// PageResult marks whether the rows came from a stale snapshot. Version is the
// board version the rows were fetched at, which may be older than the current
// version when Stale is true.
type PageResult struct {
	Rows    []Entry
	Version uint64
	Stale   bool
}

// RankResult is the personal rank view. Drift is visible to the client as the
// difference between Version and the live board version, not as a silent lie.
type RankResult struct {
	PlayerID string
	Rank     int
	Version  uint64
	Stale    bool
}

type pageKey struct {
	offset int
	limit  int
}

type pageEntry struct {
	rows        []Entry
	version     uint64
	refreshedAt int64
	valid       bool
}

type rankEntry struct {
	rank        int
	version     uint64
	refreshedAt int64
	valid       bool
}

// Cache wraps a Source with whole-page and personal-rank caches. A version
// change invalidates freshness but keeps the last snapshot, so a source outage
// during a deploy can still serve stale rows inside maxStale. It is not safe
// for concurrent use; shard by board or guard it with a mutex.
type Cache struct {
	src      Source
	ttl      int64
	maxStale int64
	rankCap  int
	now      func() int64

	version     uint64
	initialized bool
	pages       map[pageKey]pageEntry
	ranks       map[string]rankEntry
	stats       Stats
}

// New creates a cache. ttl and maxStale are in milliseconds; maxStale must be
// at least ttl. rankCap bounds the number of per-player rank entries because
// that key space grows with the player population, not with the board size.
func New(src Source, ttl, maxStale int64, rankCap int, now func() int64) (*Cache, error) {
	if src == nil {
		return nil, ErrSource
	}
	if ttl <= 0 {
		return nil, ErrTTL
	}
	if maxStale < ttl {
		return nil, ErrStaleWindow
	}
	if rankCap <= 0 {
		return nil, ErrRankCapacity
	}
	if now == nil {
		now = func() int64 { return time.Now().UnixMilli() }
	}
	return &Cache{
		src:      src,
		ttl:      ttl,
		maxStale: maxStale,
		rankCap:  rankCap,
		now:      now,
		pages:    make(map[pageKey]pageEntry),
		ranks:    make(map[string]rankEntry),
	}, nil
}

// sync refreshes the version and invalidates freshness without deleting the
// old rows. Keeping them is the whole point of the stale window.
func (c *Cache) sync() {
	v := c.src.Version()
	if !c.initialized {
		c.initialized = true
		c.version = v
		return
	}
	if v == c.version {
		return
	}
	c.stats.Invalidations++
	c.version = v
	for k, e := range c.pages {
		e.valid = false
		c.pages[k] = e
	}
	for k, e := range c.ranks {
		e.valid = false
		c.ranks[k] = e
	}
}

// Page returns one page. A fresh hit costs no source call; an expired entry is
// refreshed once and served stale if the source fails inside the stale window.
func (c *Cache) Page(offset, limit int) (PageResult, error) {
	if offset < 0 || limit <= 0 {
		return PageResult{}, ErrPage
	}
	c.sync()
	now := c.now()
	key := pageKey{offset: offset, limit: limit}
	old, ok := c.pages[key]
	if ok && old.valid && now < old.refreshedAt+c.ttl {
		c.stats.PageHits++
		return PageResult{Rows: cloneEntries(old.rows), Version: old.version}, nil
	}
	c.stats.PageMisses++
	rows, err := c.src.Page(offset, limit)
	if err != nil {
		if ok && now-old.refreshedAt <= c.maxStale {
			c.stats.StaleServed++
			return PageResult{Rows: cloneEntries(old.rows), Version: old.version, Stale: true}, nil
		}
		return PageResult{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
	}
	c.pages[key] = pageEntry{rows: cloneEntries(rows), version: c.version, refreshedAt: now, valid: true}
	return PageResult{Rows: cloneEntries(rows), Version: c.version}, nil
}

// Rank returns the personal rank. The cache is bounded: when rankCap is
// reached the whole rank map is dropped, which costs a refill burst but never
// grows without limit.
func (c *Cache) Rank(playerID string) (RankResult, error) {
	if playerID == "" {
		return RankResult{}, ErrPlayer
	}
	c.sync()
	now := c.now()
	old, ok := c.ranks[playerID]
	if ok && old.valid && now < old.refreshedAt+c.ttl {
		c.stats.RankHits++
		return RankResult{PlayerID: playerID, Rank: old.rank, Version: old.version}, nil
	}
	c.stats.RankMisses++
	rank, err := c.src.Rank(playerID)
	if err != nil {
		if ok && now-old.refreshedAt <= c.maxStale {
			c.stats.StaleServed++
			return RankResult{PlayerID: playerID, Rank: old.rank, Version: old.version, Stale: true}, nil
		}
		return RankResult{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
	}
	if !ok && len(c.ranks) >= c.rankCap {
		c.ranks = make(map[string]rankEntry)
		c.stats.Evictions++
	}
	c.ranks[playerID] = rankEntry{rank: rank, version: c.version, refreshedAt: now, valid: true}
	return RankResult{PlayerID: playerID, Rank: rank, Version: c.version}, nil
}

// Stats returns a copy of the current counters.
func (c *Cache) Stats() Stats { return c.stats }

// Version is the board version observed by the last call.
func (c *Cache) Version() uint64 { return c.version }

func cloneEntries(in []Entry) []Entry {
	out := make([]Entry, len(in))
	copy(out, in)
	return out
}

几个实现取舍值得说明:

  • sync 在版本变化时只把条目标记为不新鲜,不删除旧数据。删除会让随后的源故障直接变成空白页,保留才能走 maxStale 兜底。
  • 新鲜判断用 valid && now < refreshedAt+ttl,过期兜底用 now-refreshedAt <= maxStale。两个条件分开,版本失效和自然过期才能走同一条降级路径。
  • 个人名次缓存满时整体清空,而不是逐条淘汰。清空会产生一次重建峰值,但实现简单、内存有硬上限。
  • 返回的 Rows 在写入和读出时都复制,避免调用方修改切片污染缓存。

失败模式与兜底

失败表现兜底
版本号不变但数据已改长期读到旧榜写入方保证版本单调递增,写入与版本同事务
版本跳变时源不可用缓存全部不新鲜且无法重建旧条目保留,maxStale 内带标记返回
分页与名次版本不一致两个入口显示不同名次响应带版本,客户端显示更新中
个人名次缓存无界增长内存随在线玩家上涨固定容量,满时整体清空
热点页同时未命中源被瞬时打满预热只读快照,或按榜加单飞
maxStale 设得过长玩家看到过期排名却无提示按页面配置窗口,响应必须带 Stale

常见错误

第一,只缓存整榜。几万人的榜每次写入都整体失效,命中率会被写入频率吃掉;按页和个人名次分层,才能把高频读留在缓存里。

第二,用 TTL 代替版本。TTL 只能限制最长落后时间,不能保证“写入后下一次读能看到”;赛季切换这种版本跳变,必须靠显式版本。

第三,降级不标记。返回旧数据却不告诉客户端,会把可接受的延迟变成信任问题;Stale 和 Version 要和数据一起返回。

第四,缓存键漏维度。赛季、分区、页码、排序规则任何一项缺失,都会让不同榜单共用一份缓存;键的组成要和 9.1 的 Key 对齐。

第五,把缓存当权威。缓存只负责加速读,结算和奖励必须读封榜快照;用缓存结果发奖,重放和失效都会变成资损。

小结

榜单读模型的核心不是“加一层缓存”,而是把整榜、分页和个人名次分成不同失效粒度的层,用版本号给出明确的失效边界,用 maxStale 给出明确的降级窗口。写入侧在 9.2 冻结快照,读取侧在本节共享快照,两者共同保证玩家看到的名次和结算使用的名次来自同一份数据。

下一章转向另一类高频异步系统:邮件。它同样需要去重、幂等和投递状态,但面对的是玩家离线、附件过期和跨服补发。

阅读导航:上一节:9.2 写缓冲、结算与赛季边界 · 下一节:10.1 邮件投递与去重 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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