《游戏服务端编程实践》12.2 Redis + MySQL 混合持久化

从 Redis 与 MySQL 的职责边界出发,比较写穿、回写与双删的代价,分析更新顺序、并发窗口、缓存失效与热点 key 的处理,并用 Go 的内存 cache + store 实现版本号协同、回源读与并发写测试,说明缓存不能作为唯一存储的原因,最后给出可运行的验证与排错清单。

一、Redis 与 MySQL 的职责边界

Redis 在游戏服里通常承担缓存、会话、排行榜、计数器、分布式锁和临时队列。它的优势是内存访问快、数据结构丰富、过期策略现成;它的限制同样明显:内存容量有限,键可能被淘汰,主从切换可能丢少量写入,持久化文件也不是事务日志。

MySQL 承担账号、角色、背包、订单、邮件、充值记录这类需要事务和长期一致性的数据。它慢,但能给出明确的提交语义、唯一约束和崩溃恢复能力。两者不是互相替代的关系,而是“主库负责真相,缓存负责速度”。

数据主要存放访问模式丢失后果能否从主库重建
角色基础属性MySQL登录加载,低频写进度丢失不能
背包与货币MySQL事务写,读较频繁资产错误不能
在线会话Redis高频读写,带 TTL玩家需重连部分可以
排行榜Redis高频范围查询可重算可以
热点配置Redis + 本地只读为主回源即可可以

“缓存不能作为唯一存储”不是一句口号。Redis 开启 RDB 或 AOF 之后仍然可能因为淘汰策略、主从异步复制、实例故障而丢掉最近的写入;它能提高可用性,但不能替代数据库的提交与约束。判断标准很简单:这份数据丢了,能不能从别处完整重建?不能,就必须落在主库。

会话数据适合放 Redis,但要区分“可丢”和“不可丢”。登录令牌、临时验证码丢了可以重新登录;角色当前所在场景、队伍编号丢了,重连后必须能从主库或对局服务重建。把不可丢的会话字段只放 Redis,是把可用性问题伪装成性能优化。

排行榜通常采用 Redis 有序集合,主库只保存结算结果。活动期间榜单可以实时更新,活动结束后把名次与奖励落库;即使 Redis 数据丢失,也能从事件流水重算。关键是提前定义“重算口径”,而不是等故障发生后再猜。

二、写穿、回写与双删

缓存与数据库的写策略没有银弹,只有不同的失败代价。

策略写入路径读后写一致性写延迟主要风险
写穿先写库,再写缓存较好较高缓存写入失败留下不一致
回写先写缓存,异步刷库较弱低进程崩溃丢未刷数据
双删写前删缓存,写库,延迟再删最终一致中第二次删除时机难确定
只失效写库,删缓存,读时回源最终一致低删除失败后靠 TTL 兜底
只写缓存不写库,异步补偿无保证最低数据只存在于缓存

游戏里的资产、货币、订单适合写穿或“写库 + 删缓存”,因为不能接受已确认的写入丢失。排行榜、在线状态、最近活跃时间可以用回写或纯缓存,因为它们可以从主数据重算,短时间不一致不影响核心玩法。

TTL 是所有策略的最后一道保险。即使删除缓存失败、双删的第二刀没有打中,TTL 也会让旧值在有限时间内消失。没有 TTL 的缓存键,一旦出现一次顺序错误,就可能永久保留错误值。

写穿并不等于“写完库再写缓存就结束”。缓存写入可能失败,失败后需要重试、删除或标记失效;如果直接忽略错误,数据库已经是新值,缓存仍是旧值,读路径又缺少版本校验,就会长期不一致。工程上通常把缓存写失败降级为删除缓存,让下一次读回源,而不是在写路径反复重试拖长响应。

回写策略还要定义刷库失败的补偿。进程崩溃会丢掉内存中尚未刷新的写入,因此只适合可重算的数据;如果业务坚持使用回写,就要引入本地日志或消息队列,把“已确认但未落库”的窗口缩到可恢复的范围内。

三、更新顺序与并发窗口

最常见的错误顺序是“先删缓存,再写数据库”。单线程看起来没问题,一旦有并发读,就会形成下面这条时间线:

时刻写请求读请求缓存数据库
t1删除缓存空旧值
t2未命中,读数据库空旧值
t3写数据库新值空新值
t4把旧值写回缓存旧值新值

从 t4 开始,缓存里是旧值,数据库里是新值。如果这个键没有 TTL,错误会一直存在;后续读全部命中旧缓存,连“读主库修复”的机会都没有。

正确顺序是“先写数据库,再删除缓存”。并发读在删除之前可能读到旧缓存,但那次读可以线性化到写之前;删除之后的下一次读会未命中并回源到新值。仍有一种较窄的窗口:读请求在删除前未命中,查库拿到旧值,随后写请求完成写库和删除,读请求再把旧值填回缓存。延迟双删就是为这个窗口准备的:写完数据库后等待一个大于“查库 + 回填”耗时的间隔,再删一次。

延迟双删不是精确算法。等待时间靠估算,第二次删除也可能失败,所以仍要配合 TTL 与版本号。把顺序、TTL、版本校验三层叠起来,才能把不一致窗口压到可接受范围。

还有一种常被忽略的窗口:写请求删除缓存后,读请求回源读到旧值,但在回填之前写请求已经完成写库。读请求随后把旧值写回缓存,形成“删除之后又出现旧值”。延迟双删只能降低概率,不能证明消除;版本号或墓碑才能让读请求在回填前再次确认主库版本。

因此不要把“删缓存”当成一个孤立动作。它应该和写库、版本分配、回填校验放在同一条状态机里描述,明确每一步失败后的补偿路径。

写库与删缓存之间还有事务边界。若数据库事务尚未提交就删除缓存,提交失败时缓存已经被清空,读请求会回源到未提交前的旧值,随后事务提交,缓存再次落后。删除动作应放在事务提交成功之后,并记录删除失败的重试任务。

四、缓存失效、降级与热点 key

缓存失效分三种情况:键不存在、键过期、键被主动删除。三者对读路径的含义不同。键不存在时,如果数据库也没有,应该写入一个短 TTL 的空值标记,防止同一个不存在的 ID 被反复打穿到主库;键过期时,读请求回源并重建;主动删除通常发生在写路径,读请求同样回源。

热点 key 是另一个量级的问题。某个全服公告、跨服排行榜、热门商品键每秒被读几十万次,单靠 Redis 集群也可能把某个分片打满。常见手段是本地一级缓存、键拆分、读请求合并和限流降级。

现象优先动作兜底动作
大量请求同时未命中同键请求合并,只放一个回源短时返回旧值或默认值
主库连接被打满对回源做并发上限与超时降级读缓存旧值并告警
单个 key 成为热点本地缓存或拆分为多个子 key限制该 key 的读取频率
Redis 实例不可用切只读主库模式关闭非核心功能,保护核心链路

降级读主库不是无条件放开。主库的容量通常远小于缓存,突然把所有读流量导过去会引发雪崩。降级必须带并发上限、超时和熔断,并且只对核心接口开放;非核心接口宁可返回默认值,也不要把主库拖垮。

缓存穿透、击穿、雪崩经常被混用。穿透是查询一个不存在的键,缓存永远不会命中;击穿是单个热点键过期,大量请求同时回源;雪崩是大量键在同一时间失效或 Redis 整体不可用。三者的处置方式不同:

问题触发条件处置
穿透大量不存在的 ID空值缓存、参数校验、布隆过滤器
击穿单个热点键过期同键请求合并、逻辑过期、互斥重建
雪崩大批键同时过期TTL 加随机抖动、多级缓存、限流

同键请求合并可以用进程内的单键互斥实现:第一个请求回源,其余请求等待结果;等待必须有超时,否则慢查询会把整条链路拖住。逻辑过期则让缓存值先过期、旧值继续服务,由后台异步刷新,适合能接受短暂旧值的排行榜与展示数据。

本地一级缓存能显著降低 Redis 热点压力,但会引入进程间不一致。多实例同时缓存同一个键时,一个实例的删除无法直接清掉另一个实例的内存副本,只能依赖短 TTL、版本校验或广播失效。选择本地缓存前,先确认业务能否接受秒级旧值。

五、版本号与时间戳协同

仅靠“先写库再删缓存”仍不能覆盖所有并发窗口,工程上通常给缓存记录加版本号。写库时生成一个单调递增的版本,缓存记录保存同一个版本;读请求拿到缓存后,与主库当前版本比较,落后就回源刷新。

版本号可以来自数据库自增列、全局序列、或每个角色自己的递增计数。时间戳看似方便,但跨机器时钟回拨、同一毫秒内的多次写入都会让比较失真。如果一定要用时间戳,至少保证同一数据分片的写入由单一生效点分配,并且时间源单调。

删除也需要版本。只删除缓存键而不留下墓碑,可能被一个更早开始、更晚结束的读请求把旧值重新填回来。更稳妥的做法是写入一个“已删除”标记和对应版本,读请求看到墓碑后直接返回不存在,直到新值写入。

字段作用注意
value业务值与版本一起原子写入
version写入序号同一键内单调,不要求全局
expire_at逻辑过期时间用于异步刷新,不替代 TTL

版本号不要求全局唯一,只要求同一键内单调。不同键之间比较版本没有意义,反而会让实现误以为存在全局顺序。若主库使用自增 ID,可以直接复用;若主库只提供更新时间,就要额外维护版本列。

版本号还要考虑批量写入。一次事务修改多个键时,最好为每个键分配独立版本,而不是共用一个全局序号;否则一个键的版本前进会让无关键的缓存全部失效。代价是版本分配次数增加,收益是失效范围可控。

版本比较只能解决“缓存比主库旧”的情况,不能替代事务。角色扣款、道具发放这类操作仍然要在主库里用事务或条件更新保证原子性,缓存只是这些结果的读副本。

六、失败场景:先删缓存再写库

某项目在更新角色金币时,先删除缓存,再执行数据库更新,缓存键没有设置 TTL。平时流量低,删除与写库之间几乎没有读请求,问题被掩盖。一次活动开始后,同一个角色的状态被客户端高频轮询,时间线正好落在删除与写库之间:读请求未命中,读到旧金币并写回缓存,随后数据库更新成功。

之后这个角色每次读取都命中旧缓存,显示的金币比实际少。玩家反复点击购买,服务端按数据库余额判断失败,客户端却一直显示可以购买,最终变成客诉。排查时数据库值是正确的,缓存值也是“某个时刻真实存在过的值”,只有把写路径的顺序和时间线画出来,才能定位到问题。

修复包含三部分:把顺序改成先写库再删缓存;给缓存键加 TTL;在缓存记录中加入版本号,读请求发现版本落后时强制回源。只做其中一项都不够:只改顺序仍有读回填窗口,只加 TTL 要等过期才恢复,只加版本号则依赖每次读都做比较。

这类问题很难靠代码评审发现,因为每一步单看都“成功”了。有效的验证方式是构造并发测试:写请求与读请求按固定交错执行,断言最终缓存与主库一致;再注入删除失败、回填延迟、TTL 关闭等条件,观察系统是否能在有限时间内自愈。没有这些测试,“先删缓存”看起来只是一次普通的缓存操作。

演练时应同时观察三个指标:缓存命中率是否骤降、主库 QPS 是否被击穿、旧值是否在 TTL 内消失。只盯其中一个指标容易误判:命中率下降可能只是正常过期,主库升高也可能是活动流量,旧值持续存在才说明修复没有生效。把指标与时间线对齐,才能确认是顺序问题还是容量问题。

七、Go 实现:hybrid.go

下面的 Hybrid 用两个内存 map 分别模拟 cache 与 store。Set 先写 store 再写 cache,模拟写穿;Delete 先删 store 再删 cache;Get 先读 cache,只有 cache 与 store 的版本一致才直接返回,否则回源 store 并刷新缓存。两次加锁之间保留真实窗口,用来观察并发写时旧版本最后写入缓存的情况。

// file: ch12/hybrid/hybrid.go
// tier: L1
// verify: go test ./ch12/hybrid/

// Package hybrid 用内存 store 与 cache 模拟 Redis + MySQL 的读写路径。
package hybrid

import (
	"sync"
	"sync/atomic"
)

// record 是 cache 与 store 共用的记录。Version 由 store 单调分配。
type record struct {
	Value   string
	Version uint64
}

// Hybrid 演示写穿(write-through)与缓存失效后的回源读取。
type Hybrid struct {
	storeMu sync.RWMutex
	store   map[string]record
	version uint64

	cacheMu sync.RWMutex
	cache   map[string]record

	hits   atomic.Uint64
	misses atomic.Uint64
}

// New 创建空的混合存储。
func New() *Hybrid {
	return &Hybrid{
		store: make(map[string]record),
		cache: make(map[string]record),
	}
}

// Set 先写 store,再写 cache(写穿),返回本次写入的版本号。
// 两次加锁之间是真实存在的窗口:并发写可能让较旧的版本最后写入 cache。
func (h *Hybrid) Set(key, value string) uint64 {
	h.storeMu.Lock()
	h.version++
	v := h.version
	h.store[key] = record{Value: value, Version: v}
	h.storeMu.Unlock()

	h.cacheMu.Lock()
	h.cache[key] = record{Value: value, Version: v}
	h.cacheMu.Unlock()
	return v
}

// Delete 先删 store,再删 cache。
// 反过来先删 cache 再删 store,会让读请求把旧值重新填回缓存。
func (h *Hybrid) Delete(key string) {
	h.storeMu.Lock()
	delete(h.store, key)
	h.storeMu.Unlock()

	h.cacheMu.Lock()
	delete(h.cache, key)
	h.cacheMu.Unlock()
}

// Get 优先读 cache;cache 缺失或版本落后时回源 store 并刷新缓存。
func (h *Hybrid) Get(key string) (string, bool) {
	h.cacheMu.RLock()
	cached, inCache := h.cache[key]
	h.cacheMu.RUnlock()

	h.storeMu.RLock()
	stored, inStore := h.store[key]
	h.storeMu.RUnlock()

	if inCache && inStore && cached.Version == stored.Version {
		h.hits.Add(1)
		return cached.Value, true
	}
	h.misses.Add(1)
	if !inStore {
		h.cacheMu.Lock()
		delete(h.cache, key)
		h.cacheMu.Unlock()
		return "", false
	}
	h.cacheMu.Lock()
	h.cache[key] = stored
	h.cacheMu.Unlock()
	return stored.Value, true
}

// Invalidate 删除缓存副本;下一次 Get 会回源并重新填充。
func (h *Hybrid) Invalidate(key string) {
	h.cacheMu.Lock()
	delete(h.cache, key)
	h.cacheMu.Unlock()
}

// Stats 返回累计 cache 命中与未命中次数。
func (h *Hybrid) Stats() (hits, misses uint64) {
	return h.hits.Load(), h.misses.Load()
}

Get 的版本比较是防止脏读的关键。即使并发写让较旧的记录最后落进 cache,下一次读也会发现 cached.Version 与 stored.Version 不一致,转而使用 store 中的值。代价是多一次 store 读;如果希望减少这次读,可以在写路径保证同一键的 cache 更新顺序,或者用单键锁把 store 与 cache 的更新合并成一个临界区。

代码没有引入 TTL,是为了把版本校验单独暴露出来。真实系统里 TTL 仍然是必需的:它负责清理长期不再访问的键,也为“删除失败、版本比较遗漏”提供最后兜底。把 TTL 与版本号对立起来是误解,两者解决的是不同层面的问题。

八、测试覆盖

测试断言
TestWriteThroughThenRead写穿后 store 与 cache 的值、版本一致
TestInvalidateReadThrough失效后首次读回源,第二次读命中
TestStaleCacheFallsBackToStore缓存版本落后时返回 store 新值
TestMissingKeyAndDelete删除后两边都不留副本,缺失键返回不存在
TestConcurrentWritesNoDirtyRead并发读写只返回真实写入过的值

并发测试先写入一个初始值,保证键始终存在;写协程在写 store 之前把候选值登记到允许集合,读协程只校验读到的值是否在集合内。这样即使读到旧值也不会误报,只有“读到一个从未写入过的值”才会失败。

测试没有覆盖真实 Redis 与 MySQL 的网络超时、主从延迟和连接池耗尽,这些需要集成测试与故障注入补齐。单元测试固定的是顺序与版本比较这类纯逻辑不变量,不能替代环境级验证。

  • 断网与超时:回源失败时应返回什么,是否允许读旧缓存。
  • 主从延迟:写后立即读从库时,版本号是否仍能识别旧值。
  • 连接池耗尽:降级路径是否真的受限流与熔断保护。

九、运行验证

cd .local/checks/gs-practice-lab
gofmt -l ch12/hybrid
go vet ./ch12/hybrid/
go test ./ch12/hybrid/ -count=1
ok  	gspractice/ch12/hybrid	0.254s

gofmt -l 与 go vet 均无输出。并发相关的改动建议额外执行:

go test -race ./ch12/hybrid/ -count=1

竞态检测通过只说明没有数据竞争,不说明业务顺序正确;两者需要分别验证。并发用例只验证“不脏读”,不验证严格的线性一致性;如果需要更强保证,应增加单键串行化或使用主库事务作为最终裁决。

小结

Redis 提供速度,MySQL 提供真相。写策略要在一致性、延迟与复杂度之间取舍,资产类数据优先写库再处理缓存,排行榜类数据可以接受短暂不一致。更新顺序、TTL、版本号三者缺一不可:顺序减少窗口,TTL 提供兜底,版本号让读请求能识别并修复旧缓存。热点 key 与降级要靠并发控制和熔断保护主库,而不是简单地“全部读主库”。

常见错误与边界

  • 先删缓存再写数据库,并发读把旧值重新填回缓存。
  • 缓存键不设 TTL,一次顺序错误变成永久错误。
  • 把 Redis 当作唯一存储,忽略淘汰、故障切换与持久化丢失。
  • 回写策略在进程崩溃时丢掉尚未刷库的已确认写入。
  • 只比较时间戳,不处理时钟回拨与同毫秒多次写入。
  • 删除缓存不留墓碑,旧读请求可以把已删除的数据复活。
  • 热点 key 未做请求合并,缓存失效瞬间全部打到主库。
  • 降级读主库没有并发上限与超时,保护动作本身造成雪崩。
  • 把 Redis 列表当可靠消息队列,消费者崩溃后任务无人接管。
  • 缓存与主库版本不一致时静默返回缓存值,脏读长期存在。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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