开赛十秒,同一个 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 同时只有一个回源请求,其他请求等待并复用结果。它有三个必须明确的边界:
- 等待者要不要设置超时?如果回源卡住,等待者不能无限等。
- 回源失败时,等待者拿到同样的错误,还是各自重试?
- 回源返回空值时,空值缓存和等待者如何共享?
逻辑过期的做法不同:值到期后不立即删除,而是标记为过期,由一个请求负责刷新,其他请求继续读旧值。刷新失败时,旧值在“宽限期”内继续可用,但必须把 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 设超时,超时后按错误处理 |
| 回源 panic | inflight 永远不关闭,等待者泄漏 | 在 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 读写分离的可见性兜底 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。