《游戏服务端编程实践》6.2.2 缓存层(Redis + 本地缓存)

按进程内 LRU、Redis、数据库三级拆解游戏存档缓存,对比 cache-aside、read-through、write-through 与 write-behind 的适用边界,讨论先更新数据库再删缓存、延迟双删和版本号的一致性方案,并给出带 TTL、容量上限与击穿保护的 Go 实现及测试。

一、缓存不是一层,而是三层

游戏服务端说“加个缓存”,通常指在数据库前面放 Redis。但真正跑得稳的系统往往是三层:进程内 LRU、共享 Redis、数据库。三层不是重复建设,而是分别解决延迟、容量和事实来源三个不同问题。

进程内缓存离代码最近,一次 map 查找只要几十纳秒,适合放“读多写少、允许短暂过期”的小对象,比如玩家名片、公会基本信息、配置表的派生结果。它的代价是每个进程一份,多实例之间天然不一致,进程重启就全部丢失。

Redis 是共享层,一次访问通常在一毫秒以内,容量远大于单机内存,适合放跨进程共享的会话、排行榜、跨服查询结果。它的代价是网络往返和运维复杂度,而且它仍然不是事实来源。

数据库是最终事实来源,负责持久化、事务和约束。它的延迟最高,但只有它能在崩溃后恢复数据。缓存层再快,也不能替代数据库承担“写成功”的定义。

把三层放在一起看:

层级典型延迟容量一致性失效方式适合放什么
进程内 LRU数十纳秒单机内存,通常几百 MB多实例之间最终一致TTL、本地删除、广播失效名片、配置派生值、短生命周期查询结果
Redis约 1 毫秒集群内存,可到 TB 级共享,但仍可能落后于数据库TTL、显式删除、版本号会话、排行榜、跨服快照
数据库数毫秒到数十毫秒磁盘,容量最大强一致(取决于隔离级别)事务提交玩家存档、资产、订单

这张表最重要的不是延迟数字,而是最后一列。越靠近进程的缓存,越不应该放“写后必须立刻读到新值”的数据。 玩家刚买完道具,下一次请求就要看到余额变化,这种读己之写的要求不能交给一个可能落后几秒的本地缓存。

游戏里还有一个特殊情况:在线玩家的存档通常由所属场景服或玩家对象持有,本身就在内存里,不需要每读一次都查缓存。缓存更多用于“跨进程读”的场景,比如好友列表展示、排行榜、跨服战读取对手快照、后台运营查询。把这些读流量从数据库引走,才是缓存的主要收益。

二、三级缓存的 TTL 与失效路径

TTL 不是越长越好,也不是越短越安全。它应该由“业务能容忍多长时间的旧值”决定,而不是拍一个整数。

数据本地 TTLRedis 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 适合“读得极频繁、写很少、要求写完立刻读到”的小对象,比如全服开关、活动状态。它要求缓存和数据库在同一个写路径里成功,任何一方失败都要明确回滚或重试,否则会出现缓存说成功、数据库没写的情况。

四、一致性:先更新数据库再删缓存

最常见的错误顺序是“先删缓存,再写数据库”。两个并发请求就能制造经典的不一致:

  1. 请求 A 要更新玩家昵称,先删除缓存。
  2. 请求 B 读缓存未命中,从数据库读到旧昵称,准备回填。
  3. 请求 A 把新昵称写入数据库。
  4. 请求 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 本身也可能主从延迟、分片故障、连接池耗尽,缓存层必须有超时、降级和容量预案。缓存一致性没有零成本方案,区别只是把成本放在写路径、读路径还是运维层面;选择时要看业务能接受多长的旧值窗口,而不是追求理论上不存在的不一致。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist