《游戏服务端架构·规模化、全球化与成本》5.2 热 key、穿透与击穿

从《裂隙战场》开赛瞬间单 key 被打爆的现象出发,说明游戏里热 key 的三种形态、按房间与分片识别的方法,区分穿透、击穿与雪崩的具体成因,比较本地化、拆 key、请求合并与逻辑过期的代价,并给出带空值缓存与旧值兜底的可运行 Go 实现。

开赛十秒,同一个 key 被打了八万次

《裂隙战场》每个赛季首日,排位赛在 20:00 整点开放。开赛后的十秒内,所有客户端同时拉取赛季规则、段位门槛和好友排行榜。监控上,单个规则 key 的读请求在十秒内达到约八万次,缓存客户端所在节点的 CPU 先被打满,随后所有依赖这个客户端的分片一起变慢。

同一时间,《群岛纪元》的世界 boss 血量 key 在 20:00:00 过期,几百个分片同时回源;《泡泡工坊》的活动配置在凌晨统一刷新,所有 key 的 TTL 相同,刷新瞬间数据库连接数翻倍。

三个问题分别对应热 key、击穿和雪崩。它们经常被混为一谈,但触发条件、观测信号和兜底手段都不同。这一节按“先识别、再决定怎么扛”的顺序拆开。

热 key 在游戏里的三种形态

形态例子特征更适合的处理
单实体热点头部主播的玩家资料、榜单第一的详情一个 key 被大量读本地副本、只读副本、请求合并
单房间热点观战人数高的对局、世界 boss 房间一个房间的状态被所有观众读房间内广播快照,不逐客户端回源
全服单点活动配置、赛季规则、世界 boss 血量所有分片读同一个 key分层推送、逻辑过期、读写分离

还有一类是“分片热点”:key 本身分散,但某个分片承载了过多请求,比如按玩家 ID 哈希后,某个大区恰好集中了大量活跃玩家。这类问题不是单 key 热,而是分片不均,处理方式是扩分片或改哈希,不是加缓存。

先识别:按房间、分片统计,而不是全服总数

全服总 QPS 看不出热 key。一个 key 占单分片读请求的 40%,在全服总量里可能只有 3%,但已经足以打满这个分片。识别要在靠近数据访问的位置做:

信号采集点示例阈值用途
单 key 窗口计数缓存客户端单分片 1 分钟内超过 5000 次触发本地化或合并
单 key 占分片读比例分片代理超过 20%判断是否需要拆 key
回源次数存储客户端单 key 每秒超过 200 次判断缓存是否失效
空结果比例业务查询层单接口超过 30%判断是否存在穿透

计数本身也有成本。高频路径上不要对每个请求做完整哈希和打点,可以采样,比如每 100 次记录一次,再按采样率放大。阈值要按分片容量设,不要照搬全服数字。

发现热 key 后有两个方向:

  • 本地化:把只读或可旧的数据复制到每个节点,读请求不再跨网络。代价是失效广播要覆盖所有节点,内存占用上升。
  • 拆 key:把 worldboss:hp 拆成 worldboss:hp:0..N,读请求按随机或玩家 ID 取一个分片,写请求更新所有分片或写主值后异步扩散。代价是聚合逻辑变复杂,读写不一致窗口变大。

只读配置适合本地化,可写的计数和血量适合拆 key 后聚合,强一致资产两个都不适合。

穿透、击穿、雪崩的游戏形态

问题游戏中的样子触发机制主要兜底
穿透查询不存在的角色 ID、已删除账号的档案空结果不缓存,每次都回源空值缓存、参数校验、布隆过滤器
击穿世界 boss 血量 key 在整点过期单个热点 key 失效,请求同时回源请求合并、逻辑过期、互斥重建
雪崩所有活动配置在同一秒过期大量 key 同时失效TTL 打散、分层刷新、预热

穿透在游戏里常来自恶意构造的 ID 或客户端 bug。空值缓存的关键是 TTL 要短,否则新创建的角色会被“不存在”的缓存挡住。一般设几秒,并在角色创建成功后主动删除对应空值。

击穿的核心不是“过期”,而是“过期瞬间所有请求都去重建”。如果只有一个请求回源、其他请求等它的结果,数据库压力就从 N 倍降到 1 倍。请求合并解决的是这个。

雪崩则是时间对齐问题。给 TTL 加随机抖动只能缓解,不能解决配置类数据的一致性;配置发布本身应该带版本和推送,而不是等所有节点自然过期。

请求合并与逻辑过期

请求合并(singleflight)的规则很简单:同一个 key 同时只有一个回源请求,其他请求等待并复用结果。它有三个必须明确的边界:

  1. 等待者要不要设置超时?如果回源卡住,等待者不能无限等。
  2. 回源失败时,等待者拿到同样的错误,还是各自重试?
  3. 回源返回空值时,空值缓存和等待者如何共享?

逻辑过期的做法不同:值到期后不立即删除,而是标记为过期,由一个请求负责刷新,其他请求继续读旧值。刷新失败时,旧值在“宽限期”内继续可用,但必须把 Stale 和 Age 传给调用方。

t0        t1(逻辑过期)      t2(宽限期结束)
 |--- 新鲜 ---|------ 可兜底旧值 ------|
              |                         |
         一个请求刷新          刷新仍失败则报错

旧值兜底不是免费的:排行榜显示“5 秒前”可以接受,资产余额显示旧值就是事故。所以 Result 里必须带新鲜度,由业务决定能不能用,而不是缓存层偷偷返回。

代码:热点检测、请求合并、空值缓存与旧值兜底

下面的 internal/hotkeyguard/guard.go 把四件事放在一个包里:窗口计数、同 key 请求合并、空值缓存、逻辑过期后的旧值兜底。now 由外部注入,测试可以精确推进时间;load 是业务回源函数,包内不依赖任何真实缓存或数据库。

// file: internal/hotkeyguard/guard.go
// tier: L1
// verify: go test ./internal/hotkeyguard/
package hotkeyguard

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

// Source 说明本次返回值从哪里来,调用方据此决定是否给客户端打“旧值”标记。
type Source int

const (
	SourceFresh Source = iota
	SourceLoaded
	SourceCoalesced
	SourceStale
	SourceNegative
)

func (s Source) String() string {
	switch s {
	case SourceFresh:
		return "fresh"
	case SourceLoaded:
		return "loaded"
	case SourceCoalesced:
		return "coalesced"
	case SourceStale:
		return "stale"
	case SourceNegative:
		return "negative"
	default:
		return "none"
	}
}

var (
	ErrEmptyKey  = errors.New("hotkeyguard: empty key")
	ErrNilLoader = errors.New("hotkeyguard: nil loader")
)

// Config 是热点保护的全部时间参数。
type Config struct {
	TTL          time.Duration // 逻辑过期时间
	StaleGrace   time.Duration // 过期后仍可用旧值兜底的窗口
	NullTTL      time.Duration // 空值缓存时间,防止穿透
	Window       time.Duration // 热 key 统计窗口
	HotThreshold int           // 窗口内达到该次数即视为热 key
}

// Result 带数据新鲜度:Age 从最近一次成功加载算起,Stale 为真时必须告知调用方。
type Result struct {
	Value  string
	Source Source
	Stale  bool
	Age    time.Duration
}

type entry struct {
	value      string
	storedAt   time.Time
	expiresAt  time.Time
	staleUntil time.Time
}

type call struct {
	done   chan struct{}
	value  string
	source Source
	stale  bool
	err    error
}

// Guard 合并同一 key 的并发回源请求,并提供空值缓存与旧值兜底。
type Guard struct {
	mu          sync.Mutex
	cfg         Config
	now         func() time.Time
	entries     map[string]entry
	negative    map[string]time.Time
	inflight    map[string]*call
	counts      map[string]int
	windowStart time.Time
}

func New(cfg Config, now func() time.Time) *Guard {
	if now == nil {
		now = time.Now
	}
	if cfg.TTL <= 0 {
		cfg.TTL = 30 * time.Second
	}
	if cfg.StaleGrace < 0 {
		cfg.StaleGrace = 0
	}
	if cfg.NullTTL <= 0 {
		cfg.NullTTL = 5 * time.Second
	}
	if cfg.Window <= 0 {
		cfg.Window = time.Minute
	}
	if cfg.HotThreshold <= 0 {
		cfg.HotThreshold = 100
	}
	return &Guard{
		cfg:         cfg,
		now:         now,
		entries:     map[string]entry{},
		negative:    map[string]time.Time{},
		inflight:    map[string]*call{},
		counts:      map[string]int{},
		windowStart: now(),
	}
}

// Get 返回 key 的值。同一 key 并发未命中时只有一个 goroutine 真正回源。
func (g *Guard) Get(key string, load func() (string, error)) (Result, error) {
	if key == "" {
		return Result{}, ErrEmptyKey
	}
	if load == nil {
		return Result{}, ErrNilLoader
	}

	g.mu.Lock()
	g.observeLocked(key)
	now := g.now()
	if until, ok := g.negative[key]; ok {
		if now.Before(until) {
			g.mu.Unlock()
			return Result{Source: SourceNegative}, nil
		}
		delete(g.negative, key)
	}
	if e, ok := g.entries[key]; ok && now.Before(e.expiresAt) {
		g.mu.Unlock()
		return Result{Value: e.value, Source: SourceFresh, Age: now.Sub(e.storedAt)}, nil
	}
	if c, ok := g.inflight[key]; ok {
		g.mu.Unlock()
		<-c.done
		return Result{Value: c.value, Source: SourceCoalesced, Stale: c.stale}, c.err
	}
	c := &call{done: make(chan struct{})}
	g.inflight[key] = c
	prev, hasPrev := g.entries[key]
	g.mu.Unlock()

	value, err := load()

	g.mu.Lock()
	now = g.now()
	delete(g.inflight, key)
	switch {
	case err != nil:
		if hasPrev && now.Before(prev.staleUntil) {
			c.value, c.source, c.stale = prev.value, SourceStale, true
		} else {
			c.err = err
		}
	case value == "":
		g.negative[key] = now.Add(g.cfg.NullTTL)
		delete(g.entries, key)
		c.source = SourceNegative
	default:
		g.entries[key] = entry{
			value:      value,
			storedAt:   now,
			expiresAt:  now.Add(g.cfg.TTL),
			staleUntil: now.Add(g.cfg.TTL + g.cfg.StaleGrace),
		}
		c.value, c.source = value, SourceLoaded
	}
	close(c.done)
	result := Result{Value: c.value, Source: c.source, Stale: c.stale}
	if hasPrev && c.stale {
		result.Age = now.Sub(prev.storedAt)
	}
	g.mu.Unlock()
	return result, c.err
}

func (g *Guard) observeLocked(key string) int {
	now := g.now()
	if now.Before(g.windowStart) || now.Sub(g.windowStart) >= g.cfg.Window {
		g.counts = map[string]int{}
		g.windowStart = now
	}
	g.counts[key]++
	return g.counts[key]
}

// Invalidate 主动删除本地值与空值标记,用于配置变更或人工修复。
func (g *Guard) Invalidate(key string) {
	g.mu.Lock()
	defer g.mu.Unlock()
	delete(g.entries, key)
	delete(g.negative, key)
}

几个关键点:

  • Get 在持锁时只做状态判断,真正的 load 在锁外执行,否则请求合并会退化成串行。
  • 领导者在 inflight 中登记后,后来者等待同一个 done 通道,拿到 SourceCoalesced。
  • 空结果写入 negative,在 NullTTL 内直接返回 SourceNegative,不再回源。
  • 过期后如果回源失败,且仍在 staleUntil 内,返回旧值和 Stale=true;Age 是旧值的年龄。
  • observeLocked 按窗口滚动计数,Hot 只返回达到阈值的 key,按次数降序。

失败模式与兜底

失败表现兜底
回源超时等待者一起卡住调用方给 load 设超时,超时后按错误处理
回源 panicinflight 永远不关闭,等待者泄漏在 load 外层 recover,或由调用方保证不 panic
空值缓存过长新建角色仍被判为不存在短 NullTTL,创建成功后 Invalidate
热 key 误判正常 key 被本地化,内存上涨阈值按分片容量设,观察一段时间再调整
旧值过旧业务展示过期数据只对可旧对象启用 StaleGrace,展示层标注 Age

最后一条最容易被忽略。代码能返回旧值,不代表业务应该接受旧值;Stale 标志必须一路传到接口层,不能在中途被吞掉。

三款产品的配置差异

产品TTL(示例值)StaleGrace(示例值)NullTTL(示例值)热 key 处理
《裂隙战场》5 秒30 秒2 秒房间状态合并,榜单本地化
《群岛纪元》15 秒2 分钟5 秒世界 boss 血量拆 key,配置推送
《泡泡工坊》30 秒5 分钟3 秒活动配置本地化,空值短缓存

这些数字是设计起点,不是线上实测。真正的 TTL 要看对象变化频率和业务能接受的旧值窗口,StaleGrace 则取决于故障恢复时间。

常见错误与边界

  • 只统计全服 QPS,看不到单分片热点。
  • 对空结果不做缓存,恶意 ID 直接打到存储。
  • 用同一个固定 TTL 给所有 key,制造整点雪崩。
  • 请求合并没有超时,回源故障时等待者无限堆积。
  • 旧值兜底不标注新鲜度,业务把旧值当成实时值。
  • 把热 key 本地化用在强一致对象上,写后读立刻不一致。

小结

热 key、穿透、击穿、雪崩是四种不同的问题:热 key 要识别和分流,穿透要缓存空值,击穿要合并重建请求,雪崩要打散过期时间。游戏场景的特殊性在于热点形态很集中——一个主播、一个房间、一个世界 boss 就能制造单 key 热点,所以识别必须按房间和分片做。请求合并和逻辑过期是性价比最高的两个手段,但都必须把失败和新鲜度显式传给调用方。下一节处理另一类可见性问题:写主库、读从库时,读己之写怎么保证。

阅读导航:上一节:5.1 本地缓存、分布式缓存与多级缓存 · 下一节:5.3 读写分离的可见性兜底 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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