一、缓存不是一层,而是三层
游戏服务端说“加个缓存”,通常指在数据库前面放 Redis。但真正跑得稳的系统往往是三层:进程内 LRU、共享 Redis、数据库。三层不是重复建设,而是分别解决延迟、容量和事实来源三个不同问题。
进程内缓存离代码最近,一次 map 查找只要几十纳秒,适合放“读多写少、允许短暂过期”的小对象,比如玩家名片、公会基本信息、配置表的派生结果。它的代价是每个进程一份,多实例之间天然不一致,进程重启就全部丢失。
Redis 是共享层,一次访问通常在一毫秒以内,容量远大于单机内存,适合放跨进程共享的会话、排行榜、跨服查询结果。它的代价是网络往返和运维复杂度,而且它仍然不是事实来源。
数据库是最终事实来源,负责持久化、事务和约束。它的延迟最高,但只有它能在崩溃后恢复数据。缓存层再快,也不能替代数据库承担“写成功”的定义。
把三层放在一起看:
| 层级 | 典型延迟 | 容量 | 一致性 | 失效方式 | 适合放什么 |
|---|---|---|---|---|---|
| 进程内 LRU | 数十纳秒 | 单机内存,通常几百 MB | 多实例之间最终一致 | TTL、本地删除、广播失效 | 名片、配置派生值、短生命周期查询结果 |
| Redis | 约 1 毫秒 | 集群内存,可到 TB 级 | 共享,但仍可能落后于数据库 | TTL、显式删除、版本号 | 会话、排行榜、跨服快照 |
| 数据库 | 数毫秒到数十毫秒 | 磁盘,容量最大 | 强一致(取决于隔离级别) | 事务提交 | 玩家存档、资产、订单 |
这张表最重要的不是延迟数字,而是最后一列。越靠近进程的缓存,越不应该放“写后必须立刻读到新值”的数据。 玩家刚买完道具,下一次请求就要看到余额变化,这种读己之写的要求不能交给一个可能落后几秒的本地缓存。
游戏里还有一个特殊情况:在线玩家的存档通常由所属场景服或玩家对象持有,本身就在内存里,不需要每读一次都查缓存。缓存更多用于“跨进程读”的场景,比如好友列表展示、排行榜、跨服战读取对手快照、后台运营查询。把这些读流量从数据库引走,才是缓存的主要收益。
二、三级缓存的 TTL 与失效路径
TTL 不是越长越好,也不是越短越安全。它应该由“业务能容忍多长时间的旧值”决定,而不是拍一个整数。
| 数据 | 本地 TTL | Redis TTL | 失效路径 |
|---|---|---|---|
| 玩家名片(昵称、等级、头像) | 1–5 秒 | 5–30 分钟 | 改名后删 Redis,再广播本地失效 |
| 公会基本信息 | 5–10 秒 | 10–30 分钟 | 公会信息变更时删两级缓存 |
| 排行榜快照 | 1–2 秒 | 1–5 分钟 | 定时重算,整体替换 |
| 跨服角色只读快照 | 不缓存或极短 | 与玩法周期一致 | 版本号变化时主动删除 |
| 玩家资产、背包 | 不建议 | 不建议 | 以数据库或所有者进程为准 |
TTL 要加随机抖动。如果一万个键在同一秒写入、同一秒过期,Redis 和数据库会同时承受一波回源,这就是缓存雪崩。把 TTL 设成“基础值 + 0–20% 随机偏移”,能把过期时间摊开。
本地缓存的失效比 Redis 更难。Redis 删一个键,所有进程下一次读都会回源;本地缓存删一个键,只影响当前进程。多实例部署时,常见做法是写路径先更新数据库,再删 Redis,然后通过消息通道广播“某个 key 失效”,各进程收到后删本地。广播失败时只能依赖短 TTL 兜底,所以本地 TTL 通常控制在秒级。
还要区分“删除”和“更新”。缓存里存的是查询结果时,更新缓存需要知道新值怎么算;删除缓存则把下一次读取交给回源逻辑,天然避免并发写入把旧值算进去。多数场景应优先删除,而不是更新。
三、四种缓存模式
缓存模式不是术语游戏,它们决定了写路径的复杂度和故障时的数据风险。
| 模式 | 读路径 | 写路径 | 优点 | 风险 | 游戏存档适配 |
|---|---|---|---|---|---|
| Cache-Aside | 先查缓存,未命中查库并回填 | 先写库,再删缓存 | 实现简单,缓存故障可降级 | 并发读写可能短暂旧值 | 最适合读多写少的投影数据 |
| Read-Through | 缓存层负责回源 | 同 Cache-Aside | 调用方只面对缓存 | 需要缓存组件支持 loader | 适合统一封装查询 |
| Write-Through | 先查缓存 | 写缓存与写库同步完成 | 缓存通常是最新值 | 写放大,缓存故障阻塞写 | 适合小对象、强一致读 |
| Write-Behind | 先查缓存 | 先写缓存,异步批量落库 | 写延迟最低 | 崩溃丢数据、顺序难保证 | 不适合关键资产 |
玩家存档的关键写路径不适合 write-behind。异步落库意味着进程崩溃时最后几秒的写入会丢,而玩家资产、道具消耗、订单状态通常不能接受这种丢失。真正需要 write-behind 的是日志、埋点、非关键统计这类“丢一点可以接受”的数据。
Cache-Aside 是游戏服务端最常用的模式,因为它把缓存故障的影响降到最低:Redis 挂了,读路径回源数据库,系统变慢但不丢数据;写路径只多一次删除操作,失败也只是让旧值多活一个 TTL。
Read-Through 可以看作 Cache-Aside 的封装。调用方只调用 Get,内部决定查缓存还是回源。它的价值在于统一治理:所有回源都走同一套超时、重试、降级和指标,不会出现某个业务方绕过缓存直接压库。
Write-Through 适合“读得极频繁、写很少、要求写完立刻读到”的小对象,比如全服开关、活动状态。它要求缓存和数据库在同一个写路径里成功,任何一方失败都要明确回滚或重试,否则会出现缓存说成功、数据库没写的情况。
四、一致性:先更新数据库再删缓存
最常见的错误顺序是“先删缓存,再写数据库”。两个并发请求就能制造经典的不一致:
- 请求 A 要更新玩家昵称,先删除缓存。
- 请求 B 读缓存未命中,从数据库读到旧昵称,准备回填。
- 请求 A 把新昵称写入数据库。
- 请求 B 把旧昵称写进缓存。
此后缓存里一直是旧值,直到 TTL 到期。正确顺序是先更新数据库,成功后再删缓存。这样即使 B 在 A 提交前读到旧值,A 的删除发生在提交之后,会把 B 刚回填的旧值删掉。仍然存在一个更窄的窗口:B 在 A 提交前读库、在 A 删除后才回填。这个窗口可以用延迟双删或版本号收敛。
| 方案 | 做法 | 残留窗口 | 代价 |
|---|---|---|---|
| 先写库再删缓存 | 提交成功后删除 key | 极窄的并发回填窗口 | 实现简单,首选 |
| 延迟双删 | 提交后删一次,隔几百毫秒再删一次 | 第二次删除前的窗口 | 多一次删除,延迟值难调 |
| 版本号 | 缓存值带 version,回填时比较 | 版本落后则拒绝写入 | 需要存储和比较版本 |
| 订阅 binlog | 数据变更后异步失效 | 复制延迟 | 架构复杂,适合大规模 |
| 只靠 TTL | 不主动失效 | 最长一个 TTL | 最简单,但读己之写不成立 |
不能只依赖 TTL。 TTL 只能保证“最终会过期”,不能保证“写完立刻读到”。玩家改完昵称,好友列表在接下来三十分钟里仍显示旧名字,这是产品不可接受的。TTL 是兜底,不是一致性方案。
延迟双删的“延迟”应该大于一次读请求的最长耗时,通常几百毫秒到一秒。延迟太短,第二次删除仍可能早于慢请求的回填;延迟太长,旧值窗口变大。更稳妥的做法是让缓存值带版本号:回填时只有版本不低于当前值才写入,旧请求自然被拒绝。版本号的具体实现会在下一篇的乐观锁里展开。
本地缓存的一致性还要多一层。写路径删了 Redis,并不代表其他进程的本地缓存也删了。可行方案是发布订阅广播失效消息,或者把本地 TTL 压到秒级,接受短暂旧值。不要假设“删了 Redis 就等于删了所有缓存”。
五、热 key、大 key 与击穿
热 key 是单个键承受了不成比例的流量,比如全服公告、顶级玩家名片、活动开关。Redis 单分片被打满后,整个集群的延迟都会被拖高。处理方式有三类:本地缓存挡住绝大部分读;把请求合并,同一时刻只允许一个回源;按业务维度拆键,把读压力分散到多个分片。
大 key 是单个键的 value 过大,比如把整个背包序列化成一个几十 MB 的 JSON。读写大 key 会占用网络带宽、阻塞 Redis 单线程,还会让本地缓存迅速膨胀。正确做法是拆分:背包按格子或分类存多个键,只缓存当前页面需要的部分;确实需要整体读取时,压缩后再存,并设置更短的 TTL。
缓存击穿是某个热点 key 过期瞬间,大量请求同时回源。解决办法是请求合并(singleflight)或逻辑过期:缓存不设物理 TTL,value 里带逻辑过期时间,发现过期后由一个请求异步刷新,其余请求继续返回旧值。逻辑过期适合对短暂旧值不敏感的场景,不适合资产类数据。
缓存穿透是查询一个根本不存在的键,每次都绕过缓存打数据库。可以缓存空值(带较短 TTL),或用布隆过滤器先挡一层。空值缓存要注意区分“确实不存在”和“回源失败”,后者不能缓存,否则一次数据库抖动会让错误状态存活一个 TTL。
缓存雪崩是大量 key 同时过期或 Redis 整体不可用。除了 TTL 抖动,还要有降级预案:Redis 超时后是回源、返回默认值还是直接报错,要按业务分级。登录、资产这类关键路径不能因为缓存挂了就整体不可用。
六、Go 实现:LocalCache 与 CacheAside
下面的实现把两个核心能力放在一起:带 TTL 与容量上限的本地 LRU,以及带 per-key 请求合并的 Cache-Aside。它不依赖 Redis 客户端,重点在失效语义和并发边界;生产环境把 LocalCache 换成 Redis 客户端时,接口形状可以保持不变。
// file: ch06/persist/cache.go
// tier: L1
// verify: go test ./ch06/persist/
package persist
import (
"container/list"
"context"
"sync"
"time"
)
type cacheEntry struct {
key string
value any
expiresAt time.Time
}
// LocalCache 是带 TTL 与容量上限的进程内 LRU。
// 读取命中会刷新位置,容量满时淘汰最久未使用的键。
type LocalCache struct {
mu sync.Mutex
capacity int
ttl time.Duration
now func() time.Time
items map[string]*list.Element
order *list.List // 队首最新,队尾最旧
}
// NewLocalCache 的 capacity 必须为正数,ttl 必须大于零。
func NewLocalCache(capacity int, ttl time.Duration, now func() time.Time) *LocalCache {
if capacity <= 0 {
panic("persist: cache capacity must be positive")
}
if ttl <= 0 {
panic("persist: cache ttl must be positive")
}
if now == nil {
now = time.Now
}
return &LocalCache{
capacity: capacity,
ttl: ttl,
now: now,
items: make(map[string]*list.Element, capacity),
order: list.New(),
}
}
// Get 返回缓存值。过期项在读取时惰性删除,并计为未命中。
func (c *LocalCache) Get(key string) (any, bool) {
c.mu.Lock()
defer c.mu.Unlock()
elem, ok := c.items[key]
if !ok {
return nil, false
}
entry := elem.Value.(*cacheEntry)
if !c.now().Before(entry.expiresAt) {
c.order.Remove(elem)
delete(c.items, key)
return nil, false
}
c.order.MoveToFront(elem)
return entry.value, true
}
// Set 写入或覆盖一个键,并把过期时间重置为 now+ttl。
func (c *LocalCache) Set(key string, value any) {
c.mu.Lock()
defer c.mu.Unlock()
expiresAt := c.now().Add(c.ttl)
if elem, ok := c.items[key]; ok {
entry := elem.Value.(*cacheEntry)
entry.value = value
entry.expiresAt = expiresAt
c.order.MoveToFront(elem)
return
}
for c.order.Len() >= c.capacity {
oldest := c.order.Back()
if oldest == nil {
break
}
c.order.Remove(oldest)
delete(c.items, oldest.Value.(*cacheEntry).key)
}
elem := c.order.PushFront(&cacheEntry{key: key, value: value, expiresAt: expiresAt})
c.items[key] = elem
}
// Delete 是写路径的失效入口。返回 false 表示键本来就不在缓存里。
func (c *LocalCache) Delete(key string) bool {
c.mu.Lock()
defer c.mu.Unlock()
elem, ok := c.items[key]
if !ok {
return false
}
c.order.Remove(elem)
delete(c.items, key)
return true
}
func (c *LocalCache) Len() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.order.Len()
}
// Loader 是回源函数,通常查数据库或调用下游服务。
type Loader func(ctx context.Context, key string) (any, error)
type loadCall struct {
done chan struct{}
val any
err error
}
// CacheAside 把“读缓存、未命中回源、回源后回填”串起来,
// 并用 per-key 合并避免同一个热键在失效瞬间被并发打穿。
type CacheAside struct {
cache *LocalCache
loader Loader
mu sync.Mutex
calls map[string]*loadCall
}
func NewCacheAside(cache *LocalCache, loader Loader) *CacheAside {
return &CacheAside{
cache: cache,
loader: loader,
calls: make(map[string]*loadCall),
}
}
// Get 先查本地缓存,未命中时只允许一个调用真正回源。
func (c *CacheAside) Get(ctx context.Context, key string) (any, error) {
if value, ok := c.cache.Get(key); ok {
return value, nil
}
c.mu.Lock()
if call, ok := c.calls[key]; ok {
c.mu.Unlock()
select {
case <-call.done:
return call.val, call.err
case <-ctx.Done():
return nil, ctx.Err()
}
}
call := &loadCall{done: make(chan struct{})}
c.calls[key] = call
c.mu.Unlock()
call.val, call.err = c.loader(ctx, key)
if call.err == nil {
c.cache.Set(key, call.val)
}
close(call.done)
c.mu.Lock()
delete(c.calls, key)
c.mu.Unlock()
return call.val, call.err
}
// Invalidate 必须在数据库写成功之后调用,顺序不能颠倒。
func (c *CacheAside) Invalidate(key string) {
c.cache.Delete(key)
}
几个实现细节需要说明。
LocalCache 用 container/list 维护访问顺序,map 负责 O(1) 定位。Get 命中后把元素移到队首,Set 发现容量已满时从队尾淘汰。过期项不单独启动清理协程,而是在读取时惰性删除;这对游戏服务端通常够用,因为热点键会被频繁访问,冷键占用的内存可以由容量上限控制。
CacheAside.Get 先查本地缓存,未命中后进入 per-key 合并。第一个调用创建 loadCall 并回源,其余调用等待同一个 done 通道。回源成功后写入缓存并关闭通道;失败时不写缓存,错误原样返回给所有等待者。通道关闭建立了 happens-before 关系,等待者读到的 val 和 err 是安全的。
Invalidate 只删本地缓存,不处理 Redis。生产实现里,它应该先删 Redis,再广播本地失效,或者在 Redis 客户端封装层统一完成。顺序仍然是“数据库写成功之后”,不能在写库前删除。
有一个边界必须明确:回源期间如果发生失效,回填仍可能把旧值写回缓存。解决方法是让回填带版本号,或者记录失效代数,回填时比较代数。这段代码为了保持短小没有实现版本比较,下一篇会用 version 把这条路径补完整。
七、失败场景:本地缓存永不过期
某款游戏给玩家名片加本地缓存,为了“减少 Redis 压力”,把 TTL 设成了零,表示永不过期,只在玩家自己改名时删除本进程的缓存。单进程测试一切正常,改名后立刻能看到新名字。
上线后是八个网关进程。玩家在 A 网关改名,A 的本地缓存被删除,B 到 H 的缓存仍然保留旧名字。好友列表、公会成员列表、排行榜分别落在不同进程,于是同一个玩家在不同页面显示不同昵称。客服收到大量“改名没生效”的工单,开发先怀疑数据库主从延迟,查了半天才发现是本地缓存没有跨进程失效。
更严重的是封禁场景。运营在后台把某个账号标记为封禁,后台服务删除了自己的本地缓存,但游戏进程的缓存里仍是“正常”状态,玩家继续在线。直到某次进程重启,缓存清空,封禁才生效。
复盘时有三个根因:
- 本地缓存 TTL 为零,没有兜底过期。
- 写路径只删当前进程的缓存,没有广播失效。
- 把“缓存命中”当成了“数据正确”,没有版本或更新时间可用于判断新旧。
修复方案分三步:本地 TTL 改成秒级,作为广播失败时的兜底;写路径通过 Redis Pub/Sub 广播 key 失效,各进程订阅后删除本地;名片值里带 updated_at,回填时只接受不早于当前值的版本。第三步不是必须,但它能在广播乱序时避免旧值覆盖新值。
这个案例的教训是:本地缓存的失效范围等于进程,不是集群。 只要有多实例,就必须回答“其他进程怎么知道要删”。回答不了,就不要给这个数据加本地缓存,或者把 TTL 压到业务能接受的最大旧值时间。
八、测试覆盖
配套测试覆盖七类行为:写入后可读、TTL 边界过期并惰性删除、容量满时淘汰最久未使用的键、连续读取只回源一次、TTL 到期后重新回源、显式失效后重新回源、回源错误不写缓存。另有一个三十二并发同时读取同一热键的测试,验证请求合并只触发一次回源。
测试使用固定时钟,不依赖 sleep,因此可以在 CI 里稳定重复运行。并发测试里 loader 会阻塞在通道上,确保所有 goroutine 都进入 Get 后才放行,这样才能真正覆盖合并逻辑,而不是碰巧串行执行。
建议在 CI 里加 -race。缓存实现最容易出的问题不是逻辑错误,而是 map 并发读写和 loadCall 字段竞争;这类问题在普通测试里可能几百次才出现一次,race detector 能稳定抓到。
小结
缓存层要按“进程内、Redis、数据库”三层分别设计,而不是把所有东西都塞进 Redis。越靠近进程的缓存越快,也越难保证多实例一致,因此只适合放允许短暂旧值的数据。
写路径的顺序是“先更新数据库,成功后再删缓存”,TTL 只做兜底,不能替代一致性方案。并发回填的窄窗口可以用延迟双删或版本号收敛;本地缓存还要额外解决跨进程失效,广播加短 TTL 是常见组合。
热 key、大 key、击穿、穿透、雪崩分别对应不同的治理手段,但共同前提是:先明确这份数据能容忍多旧的版本,再决定缓存层级、TTL 和失效路径。脱离业务容忍度谈缓存策略,最后只会得到一堆互相矛盾的配置。
常见错误与边界
- 把缓存当数据库:缓存故障后数据仍在,才叫缓存;如果只有缓存里有,那就是存储。关键资产必须以数据库或所有者进程为准。
- 更新后不失效:写完数据库不删缓存,旧值会一直活到 TTL。读己之写要求高的数据,必须主动失效。
- 本地缓存永不过期:多实例之间无法自动一致,广播失败就没有兜底。本地 TTL 必须存在,通常控制在秒级。
- 先删缓存再写库:并发读会把旧值回填,造成长时间不一致。顺序必须是先写库、后删除。
- 只靠 TTL 保证一致:TTL 是最长旧值窗口,不是一致性机制。产品要求“立刻看到”时,TTL 再短也不够。
- 大 key 整体读写:背包、邮件、任务全塞一个 value,网络和内存都会被拖垮。按访问边界拆分,只缓存当前需要的部分。
- 空值缓存不设 TTL:数据库抖动时把错误当“不存在”缓存下来,故障会延长一个 TTL。空值也要短 TTL,并区分回源失败。
- 忽略缓存穿透与雪崩:不存在的键和同时过期都会把压力打回数据库。布隆过滤器、空值缓存、TTL 抖动要一起考虑。
边界方面,进程内 LocalCache 只适合单机或允许短暂不一致的场景,不能替代 Redis 的共享语义。Redis 本身也可能主从延迟、分片故障、连接池耗尽,缓存层必须有超时、降级和容量预案。缓存一致性没有零成本方案,区别只是把成本放在写路径、读路径还是运维层面;选择时要看业务能接受多长的旧值窗口,而不是追求理论上不存在的不一致。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。