榜单名次为什么晚了两秒
《群岛纪元》的一次跨服战结束后,玩家在结算页看到自己升到了第 3 名,点回榜单却还是第 7 名,两秒后刷新才变过来。同一周还有两个类似反馈:运营改了活动配置,部分网关仍在用旧倍率;一名玩家把好友拉黑后,另一边还能看到他的在线状态。
三个现象看起来都是“缓存没更新”,但性质完全不同。榜单允许短暂旧值,配置表需要版本化发布,社交关系根本不该进本地缓存。把它们塞进同一套缓存策略,才是问题的根源。
这一节讨论三个问题:哪些对象可以缓存、本地缓存与分布式缓存各自怎么失效、广播丢了以后靠什么发现。具体的缓存穿透、击穿与热 key 处理留到 5.2。
三款产品的缓存约束
| 产品 | 主要读负载 | 可容忍陈旧 | 缓存难点 |
|---|---|---|---|
| 《裂隙战场》 | 房间状态、对局记录、排行榜 | 房间状态不可旧,战后榜单可旧 | 对局内状态变化快,缓存窗口极短 |
| 《群岛纪元》 | 大地图、联盟、跨服榜单、配置 | 地图与榜单可旧,资产不可旧 | 全球同服,节点多,广播覆盖难 |
| 《泡泡工坊》 | 大厅配置、活动、好友 | 配置可旧,好友关系不可旧 | 快速迭代,配置发布频繁 |
三款产品共用 northstar-platform 的缓存客户端,但缓存策略按对象类型配置,不按产品拍一个全局 TTL。下文的比例、延迟和 TTL 都是示例值或设计目标,不是线上实测。
先分类:四种对象,四种答案
| 对象 | 典型例子 | 变化频率 | 可容忍陈旧 | 默认位置 |
|---|---|---|---|---|
| 配置表 | 活动倍率、掉落表、商店价格 | 低,按版本发布 | 分钟级 | 进程内 + 分布式 |
| 玩家快照 | 等级、战力、最近登录 | 中,登录与关键操作时变 | 秒级 | 分布式 + 短本地 |
| 榜单 | 跨服排名、公会战积分 | 高 | 十秒级 | 分布式 + 很短本地 |
| 社交关系 | 好友、黑名单、屏蔽 | 低但要求强一致 | 不可陈旧 | 只读权威存储 |
分类一旦确定,后面的事情才有唯一答案:
- 配置表可以长 TTL,因为发布流程本身带版本号,新版本到达时主动失效。
- 玩家快照可以短 TTL,但写后必须让本节点立即失效,否则玩家会看到自己刚改过的属性没变。
- 榜单可以接受旧值,但必须把“旧”标注出来,不能和实时值混在一起展示。
- 社交关系不进本地缓存,读取直接走权威存储或主库;这不是性能妥协,而是正确性要求。
本地缓存与分布式缓存:失效路径不同
| 维度 | 本地缓存 | 分布式缓存 | 两级缓存 |
|---|---|---|---|
| 命中延迟(目标值) | 微秒级 | 亚毫秒到毫秒级 | 多数请求微秒级 |
| 失效方式 | 进程内删除 + 广播 | 共享删除,所有节点可见 | 广播删本地,共享层统一删 |
| 故障影响 | 单节点读到旧值 | 所有节点同时回源 | 广播丢失时局部旧值 |
| 适用对象 | 配置、热榜单 | 玩家快照、跨节点共享 | 读多写少、节点多 |
两级缓存的典型读路径是:先查本地,未命中查分布式,再未命中回源权威存储。写路径则相反:先写权威存储,再删分布式缓存,最后广播失效消息让各节点删本地。
这里有一个容易忽略的顺序问题。如果先广播、后写存储,其他节点可能在存储更新前回源,把旧值重新装进本地。正确顺序必须是“存储成功之后才广播”。如果存储成功但广播失败,系统会短暂保留旧值,这时只能靠版本水位和定期重同步兜底。
广播丢失在节点多、网络抖动的环境里不是小概率事件。《群岛纪元》全球同服有多个区域的网关,任何一个区域与广播通道断连几十秒,都会积累一批没有删掉的本地副本。TTL 只能限制旧值存在多久,不能保证读到新值,所以不能把 TTL 当成一致性机制。
缓存是不是第二写入者
缓存一旦接受写入,就会变成第二个写入者,必须回答“谁的数据算数”。北辰工作室的规则是:
- 只有数据所有者服务可以写权威存储,缓存层不产生新值。
- 缓存条目必须携带权威存储分配的版本号,不能由缓存节点本地递增。
- 同一个 key 不允许两个服务从不同数据源写入。
- 失效消息只删值,不写值,避免旧消息覆盖新值。
- 必须写缓存时采用写穿,并比较版本号,旧版本直接丢弃。
《泡泡工坊》的活动配置由配置服务发布,大厅只读。曾经有人为了让大厅少一次远程调用,让大厅在读取时顺便把拼装后的配置写回缓存;结果配置服务回滚后,大厅缓存里的拼装结果仍然存在,两个版本混在一起。修复方式不是加 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、穿透与击穿 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。