《游戏服务端架构·规模化、全球化与成本》5.1 本地缓存、分布式缓存与多级缓存

从《群岛纪元》跨服榜单读到旧名次的事故切入,按配置表、玩家快照、榜单、社交关系四类对象划分本地与分布式缓存职责,比较两级缓存的失效路径与写入权归属,说明广播丢失后的版本水位、可疑标记与重同步兜底,并给出可运行 Go 实现、表驱动测试与失败模式清单。

榜单名次为什么晚了两秒

《群岛纪元》的一次跨服战结束后,玩家在结算页看到自己升到了第 3 名,点回榜单却还是第 7 名,两秒后刷新才变过来。同一周还有两个类似反馈:运营改了活动配置,部分网关仍在用旧倍率;一名玩家把好友拉黑后,另一边还能看到他的在线状态。

三个现象看起来都是“缓存没更新”,但性质完全不同。榜单允许短暂旧值,配置表需要版本化发布,社交关系根本不该进本地缓存。把它们塞进同一套缓存策略,才是问题的根源。

这一节讨论三个问题:哪些对象可以缓存、本地缓存与分布式缓存各自怎么失效、广播丢了以后靠什么发现。具体的缓存穿透、击穿与热 key 处理留到 5.2。

三款产品的缓存约束

产品主要读负载可容忍陈旧缓存难点
《裂隙战场》房间状态、对局记录、排行榜房间状态不可旧,战后榜单可旧对局内状态变化快,缓存窗口极短
《群岛纪元》大地图、联盟、跨服榜单、配置地图与榜单可旧,资产不可旧全球同服,节点多,广播覆盖难
《泡泡工坊》大厅配置、活动、好友配置可旧,好友关系不可旧快速迭代,配置发布频繁

三款产品共用 northstar-platform 的缓存客户端,但缓存策略按对象类型配置,不按产品拍一个全局 TTL。下文的比例、延迟和 TTL 都是示例值或设计目标,不是线上实测。

先分类:四种对象,四种答案

对象典型例子变化频率可容忍陈旧默认位置
配置表活动倍率、掉落表、商店价格低,按版本发布分钟级进程内 + 分布式
玩家快照等级、战力、最近登录中,登录与关键操作时变秒级分布式 + 短本地
榜单跨服排名、公会战积分高十秒级分布式 + 很短本地
社交关系好友、黑名单、屏蔽低但要求强一致不可陈旧只读权威存储

分类一旦确定,后面的事情才有唯一答案:

  • 配置表可以长 TTL,因为发布流程本身带版本号,新版本到达时主动失效。
  • 玩家快照可以短 TTL,但写后必须让本节点立即失效,否则玩家会看到自己刚改过的属性没变。
  • 榜单可以接受旧值,但必须把“旧”标注出来,不能和实时值混在一起展示。
  • 社交关系不进本地缓存,读取直接走权威存储或主库;这不是性能妥协,而是正确性要求。

本地缓存与分布式缓存:失效路径不同

维度本地缓存分布式缓存两级缓存
命中延迟(目标值)微秒级亚毫秒到毫秒级多数请求微秒级
失效方式进程内删除 + 广播共享删除,所有节点可见广播删本地,共享层统一删
故障影响单节点读到旧值所有节点同时回源广播丢失时局部旧值
适用对象配置、热榜单玩家快照、跨节点共享读多写少、节点多

两级缓存的典型读路径是:先查本地,未命中查分布式,再未命中回源权威存储。写路径则相反:先写权威存储,再删分布式缓存,最后广播失效消息让各节点删本地。

这里有一个容易忽略的顺序问题。如果先广播、后写存储,其他节点可能在存储更新前回源,把旧值重新装进本地。正确顺序必须是“存储成功之后才广播”。如果存储成功但广播失败,系统会短暂保留旧值,这时只能靠版本水位和定期重同步兜底。

广播丢失在节点多、网络抖动的环境里不是小概率事件。《群岛纪元》全球同服有多个区域的网关,任何一个区域与广播通道断连几十秒,都会积累一批没有删掉的本地副本。TTL 只能限制旧值存在多久,不能保证读到新值,所以不能把 TTL 当成一致性机制。

缓存是不是第二写入者

缓存一旦接受写入,就会变成第二个写入者,必须回答“谁的数据算数”。北辰工作室的规则是:

  1. 只有数据所有者服务可以写权威存储,缓存层不产生新值。
  2. 缓存条目必须携带权威存储分配的版本号,不能由缓存节点本地递增。
  3. 同一个 key 不允许两个服务从不同数据源写入。
  4. 失效消息只删值,不写值,避免旧消息覆盖新值。
  5. 必须写缓存时采用写穿,并比较版本号,旧版本直接丢弃。

《泡泡工坊》的活动配置由配置服务发布,大厅只读。曾经有人为了让大厅少一次远程调用,让大厅在读取时顺便把拼装后的配置写回缓存;结果配置服务回滚后,大厅缓存里的拼装结果仍然存在,两个版本混在一起。修复方式不是加 TTL,而是取消大厅的写入权,只保留配置服务的写穿和失效广播。

版本水位:丢广播后怎么发现

每个节点为每个 key 维护一个“已应用失效版本”的水位,记作 floor。广播消息带 key 和 version:

  • version <= floor:旧消息或重复消息,直接忽略。
  • version == floor + 1:连续消息,删除本地副本,水位前移。
  • version > floor + 1:出现跳号,说明中间有消息丢失,删除本地副本并把 key 标记为可疑。
  • 可疑 key 不再读本地,直到执行一次重同步,从权威存储重新对齐水位。
writer ──save v7──> store
   │
   └──publish v7──> node A: floor 7, local deleted
                    node B: floor 5 -> gap, suspect
                    node B ──resync──> store v7, floor 7

第一次收到某个 key 的消息时,floor 还是 0,无法判断之前是否丢过消息,因此不标记跳号,直接把水位设到消息版本。这是有意的保守选择:新节点本来就没有可信的本地副本。

代码:两级缓存、版本水位与失效广播

下面的 internal/multicache/cache.go 把对象分类、本地缓存、版本水位、失效应用和重同步放在一个包里。Store 是权威存储抽象,示例中的 MemoryStore 只用于测试;生产环境应替换为带版本的分布式存储客户端。

// file: internal/multicache/cache.go
// tier: L1
// verify: go test ./internal/multicache/
package multicache

import (
	"errors"
	"sync"
	"time"
)

// Kind 描述一类缓存对象允许的陈旧程度。TTL 越短,本地副本越容易过期。
type Kind int

const (
	Config Kind = iota
	Snapshot
	Leaderboard
	Relationship
)

// LocalTTL 返回该类对象在本地缓存中的存活时间。0 表示不进本地缓存。
func (k Kind) LocalTTL() time.Duration {
	switch k {
	case Config:
		return 10 * time.Minute
	case Snapshot:
		return 30 * time.Second
	case Leaderboard:
		return 15 * time.Second
	default:
		return 0
	}
}

func (k Kind) String() string {
	switch k {
	case Config:
		return "config"
	case Snapshot:
		return "snapshot"
	case Leaderboard:
		return "leaderboard"
	case Relationship:
		return "relationship"
	default:
		return "unknown"
	}
}

// Entry 是缓存条目。Version 由权威存储分配,缓存节点不能自行递增。
type Entry struct {
	Key       string
	Value     string
	Version   uint64
	ExpiresAt time.Time
}

// Invalidation 是广播到各节点的失效消息。
type Invalidation struct {
	Key     string
	Version uint64
}

// Gap 记录一次失效应用的结果。Lost 为真表示版本号出现跳号,可能丢过广播。
type Gap struct {
	Key  string
	Lost bool
	From uint64
	To   uint64
}

var (
	ErrEmptyKey       = errors.New("multicache: empty key")
	ErrInvalidVersion = errors.New("multicache: version must be positive")
	ErrNotFound       = errors.New("multicache: key not found")
)

// Store 是权威存储或分布式缓存的抽象。Load 必须返回带版本的条目。
type Store interface {
	Load(key string) (Entry, error)
	Save(entry Entry) error
}

// Cache 是单节点的两级缓存读侧:本地命中优先,未命中回源 Store。
type Cache struct {
	mu      sync.Mutex
	now     func() time.Time
	store   Store
	local   map[string]Entry
	floor   map[string]uint64
	suspect map[string]bool
	hits    int64
	misses  int64
	lost    int
}

func New(store Store, now func() time.Time) *Cache {
	if now == nil {
		now = time.Now
	}
	return &Cache{
		now:     now,
		store:   store,
		local:   map[string]Entry{},
		floor:   map[string]uint64{},
		suspect: map[string]bool{},
	}
}

// Get 先查本地,再回源。第二个返回值表示是否本地命中。
func (c *Cache) Get(key string, kind Kind) (Entry, bool, error) {
	if key == "" {
		return Entry{}, false, ErrEmptyKey
	}
	if entry, ok := c.lookup(key); ok {
		return entry, true, nil
	}
	entry, err := c.store.Load(key)
	if err != nil {
		return Entry{}, false, err
	}
	c.admit(entry, kind)
	return entry, false, nil
}

func (c *Cache) lookup(key string) (Entry, bool) {
	c.mu.Lock()
	defer c.mu.Unlock()
	entry, ok := c.local[key]
	if !ok {
		c.misses++
		return Entry{}, false
	}
	if c.suspect[key] || entry.Version < c.floor[key] {
		c.misses++
		return Entry{}, false
	}
	if !c.now().Before(entry.ExpiresAt) {
		delete(c.local, key)
		c.misses++
		return Entry{}, false
	}
	c.hits++
	return entry, true
}

func (c *Cache) admit(entry Entry, kind Kind) {
	ttl := kind.LocalTTL()
	if ttl <= 0 {
		return
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.suspect[entry.Key] || entry.Version < c.floor[entry.Key] {
		return
	}
	if entry.Version > c.floor[entry.Key] {
		c.floor[entry.Key] = entry.Version
	}
	entry.ExpiresAt = c.now().Add(ttl)
	c.local[entry.Key] = entry
}

// Put 先写权威存储,再在本地应用同一版本号的失效。
func (c *Cache) Put(entry Entry) (Invalidation, error) {
	if entry.Key == "" {
		return Invalidation{}, ErrEmptyKey
	}
	if entry.Version == 0 {
		return Invalidation{}, ErrInvalidVersion
	}
	if err := c.store.Save(entry); err != nil {
		return Invalidation{}, err
	}
	inv := Invalidation{Key: entry.Key, Version: entry.Version}
	c.Apply(inv)
	return inv, nil
}

// Apply 处理一条来自广播的失效消息。旧消息直接忽略。
func (c *Cache) Apply(inv Invalidation) Gap {
	c.mu.Lock()
	defer c.mu.Unlock()
	if inv.Key == "" || inv.Version == 0 {
		return Gap{}
	}
	floor := c.floor[inv.Key]
	if inv.Version <= floor {
		return Gap{Key: inv.Key, From: floor, To: floor}
	}
	lost := floor > 0 && inv.Version > floor+1
	c.floor[inv.Key] = inv.Version
	delete(c.local, inv.Key)
	if lost {
		c.suspect[inv.Key] = true
		c.lost++
	}
	return Gap{Key: inv.Key, Lost: lost, From: floor, To: inv.Version}
}

// Resync 丢弃本地副本并从 Store 重新对齐版本水位。
func (c *Cache) Resync(key string) (Entry, error) {
	if key == "" {
		return Entry{}, ErrEmptyKey
	}
	entry, err := c.store.Load(key)
	if err != nil {
		return Entry{}, err
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if entry.Version > c.floor[key] {
		c.floor[key] = entry.Version
	}
	delete(c.local, key)
	delete(c.suspect, key)
	return entry, nil
}

几个关键点:

  • Get 只有在本地未过期、版本不低于水位、且未被标记可疑时才命中。
  • admit 在装入本地时同步推进水位。这样一条与本地版本相同的失效消息会被当作旧消息忽略,避免无意义地删除刚装入的副本。
  • Apply 对旧消息直接返回,对跳号消息设置 suspect,此后该 key 的本地读全部绕过。
  • Resync 从权威存储重新加载,并对齐水位、清除可疑标记。
  • Relationship 的本地 TTL 为 0,admit 直接返回,强一致对象永远不会进入本地缓存。

失败模式与兜底

失败表现兜底
广播丢失部分节点持续读旧值版本跳号检测 + 重同步 + TTL 上限
消息乱序旧消息晚到版本水位比较,旧消息忽略
写入成功、广播失败本节点也可能旧写路径本地应用同版本失效,后台重试广播
回源失败读请求报错配置与榜单可回退旧值并标注,关系类直接失败
节点重启本地为空、水位归零从存储重建,首条消息不判跳号

对《裂隙战场》来说,对局内的房间状态根本不该走这套缓存;它属于状态同步链路,读者是房间内的连接,旧值会直接造成操作回滚。榜单和对局记录才适合放进两级缓存。

常见错误与边界

  • 用 TTL 代替失效广播,认为“最多旧 30 秒”可以接受,却不区分对象。
  • 两级缓存都写值而不是删值,旧广播把新值覆盖回去。
  • 失效消息不带版本号,无法判断乱序和丢失。
  • 把好友、黑名单、封禁状态放进本地缓存,事故后才发现强一致要求。
  • 把示例里的 MemoryStore 当成生产实现,忽略网络分区和复制延迟。

小结

缓存策略的起点不是选 Redis 还是本地 map,而是给对象分类:配置表可以长 TTL,玩家快照要短窗口加写后失效,榜单可以旧但必须标注,社交关系不进本地缓存。两级缓存的写路径必须是“存储成功、删共享层、广播删本地”,广播丢失靠版本水位发现,靠重同步修复,TTL 只负责兜底上限。缓存一旦接受写入就是第二写入者,必须带权威版本号,否则它迟早会和权威存储打架。

阅读导航:上一节:4.3 再均衡期间的读写 · 下一节:5.2 热 key、穿透与击穿 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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