读放大发生在主界面
《裂隙战场》每个玩家登录后都会打开赛季页,看一眼前一百和自己第几;《群岛纪元》的战区榜被做成常驻面板,玩家每次回城都会刷新;《泡泡工坊》的好友榜打开频率低,但一次要读几十个好友的名次。写入已经被 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 邮件投递与去重 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。