8.1 Redis 使用与失效策略
玩家上线要读角色、读背包、读公会、读排行榜,这些数据十有八九是从缓存里拿的。缓存收益巨大,但它同时是游戏服务端最常见的事故来源:装备莫名其妙回退、金币凭空消失、跨服积分对不上,很多都能追到「缓存和数据库不一致」这条根因。
难点在于,缓存和数据库是两套存储,你没法用一个原子操作同时更新它们。任何「先 A 后 B」的写法,在并发下都有一个窗口能让读者看到旧值。本节不回避这个窗口,而是把它讲清楚:窗口有多大、哪些场景不能容忍、延迟双删这类手段到底覆盖了多少。
本章所有实验都连本机独立容器 127.0.0.1:26379(不是你本机的 16379),测试 key 统一前缀 aegis:test:ch08:,收尾用 SCAN + DEL 清理干净;各节 _test.go 共用的测试助手 newTestClient / cleanupNS 落在 internal/cache/helpers_test.go。
缓存键设计:命名空间 + 版本号
缓存键看起来是小事,却决定了后面所有失效策略能不能干净落地。最省事也最危险的做法是用裸 ID 当键。一旦同一个 Redis 被多个服务共用,或者某个实体结构变了要整体作废,裸键就会让你陷入「删哪些、删没删干净」的泥潭。
下面这段是 L1,纯逻辑,go test 可以直接跑。它把键固定成「命名空间 : 版本 : 类型 : ID」四段:
// file: internal/cache/key.go
// tier: L1
// verify: go test ./internal/cache/ -run TestKeyBuilder
package cache
import "fmt"
// DefaultNamespace 是本项目缓存键的统一前缀,避免与其它系统共用 Redis 时互相污染。
const DefaultNamespace = "aegis"
// KeyBuilder 把实体映射成「命名空间 : 版本 : 类型 : ID」四段式缓存键。
// 版本号用于整体失效:结构变更时版本 +1,所有读路径自动落到新键,旧键靠 TTL 淘汰。
type KeyBuilder struct {
ns string
version int
}
// NewKeyBuilder 构造键构建器;ns 为空回退默认,version <= 0 归一为 1。
func NewKeyBuilder(ns string, version int) *KeyBuilder {
if ns == "" {
ns = DefaultNamespace
}
if version <= 0 {
version = 1
}
return &KeyBuilder{ns: ns, version: version}
}
// Key 生成形如 aegis:v1:player:1001 的缓存键。
func (b *KeyBuilder) Key(kind string, id uint64) string {
return fmt.Sprintf("%s:v%d:%s:%d", b.ns, b.version, kind, id)
}
// Version 返回当前版本号。
func (b *KeyBuilder) Version() int { return b.version }
// Bump 返回版本号 +1 的新构建器(整体失效)。
func (b *KeyBuilder) Bump() *KeyBuilder {
return &KeyBuilder{ns: b.ns, version: b.version + 1}
}
四段各有用处。命名空间(aegis)保证多系统共用 Redis 时不互相踩;版本号(v1)是整体失效的开关——玩家属性表加了字段,不必逐个 DEL 老键,只要 Bump() 到 v2,所有读路径立刻落到新键,老键靠 TTL 自然淘汰;类型段让同一个 ID 的不同实体(角色 / 公会 / 房间)互不冲突。版本号比批量删键安全的地方在于:删键是「可能删不干净、可能删太多」的操作,而版本 +1 只影响读路径,不可能漏删,也不可能误删别人的数据。
缓存与 DB 的更新顺序
接着是最核心的决策:写的时候,先动缓存还是先动数据库?
常见有三种写法:先写库再删缓存、先删缓存再写库、以及更新缓存。更新缓存基本被淘汰——并发写两次,后到的写可能用旧值覆盖新值。剩下两种的顺序之争,本质是在比「谁的脏窗口更小」。
下面这段是 L2,真连本机 Redis(26379)。它把缓存、源(用另一个 Redis key 模拟数据库表)、两种更新顺序,以及延迟双删都实现出来:
// file: internal/cache/invalidate.go
// tier: L2
// verify: go test ./internal/cache/ -run TestConcurrentEventuallyConsistent
package cache
import (
"context"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// Store 抽象「源」(线上是 MySQL/Postgres)。本节用另一个 Redis key 模拟,
// 让缓存与源落在同一台机器上,便于复现并发交错。
type Store interface {
Load(ctx context.Context, id uint64) (string, error)
Save(ctx context.Context, id uint64, val string) error
}
// RedisStore 用独立命名空间下的另一个 key 模拟数据库表。
type RedisStore struct {
rdb *redis.Client
ns string
}
// NewRedisStore 构造一个以 Redis 为后端的模拟源。
func NewRedisStore(rdb *redis.Client, ns string) *RedisStore {
return &RedisStore{rdb: rdb, ns: ns}
}
func (s *RedisStore) srcKey(id uint64) string { return fmt.Sprintf("%s:src:player:%d", s.ns, id) }
// Load 读取源;源不存在时返回 redis.Nil。
func (s *RedisStore) Load(ctx context.Context, id uint64) (string, error) {
return s.rdb.Get(ctx, s.srcKey(id)).Result()
}
// Save 写入源。
func (s *RedisStore) Save(ctx context.Context, id uint64, val string) error {
return s.rdb.Set(ctx, s.srcKey(id), val, 0).Err()
}
// Cache 是 cache-aside 模式的最小实现。
type Cache struct {
rdb *redis.Client
kb *KeyBuilder
ttl time.Duration
}
// NewCache 构造缓存;ttl 为回填值的存活时间。
func NewCache(rdb *redis.Client, kb *KeyBuilder, ttl time.Duration) *Cache {
return &Cache{rdb: rdb, kb: kb, ttl: ttl}
}
// ErrNotFound 表示源里也没有这条数据。
var ErrNotFound = errors.New("cache: not found")
// GetOrLoad 是读路径:先查缓存,未命中回源并回填。
func (c *Cache) GetOrLoad(ctx context.Context, s Store, id uint64) (string, error) {
key := c.kb.Key("player", id)
v, err := c.rdb.Get(ctx, key).Result()
if err == nil {
return v, nil
}
if err != redis.Nil {
return "", err
}
val, err := s.Load(ctx, id)
if err != nil {
if err == redis.Nil {
return "", ErrNotFound
}
return "", err
}
_ = c.rdb.Set(ctx, key, val, c.ttl).Err()
return val, nil
}
// WriteThenEvict:先写源、再删缓存。绝大多数场景推荐的顺序。
func (c *Cache) WriteThenEvict(ctx context.Context, s Store, id uint64, val string) error {
if err := s.Save(ctx, id, val); err != nil {
return err
}
return c.rdb.Del(ctx, c.kb.Key("player", id)).Err()
}
// EvictThenWrite:先删缓存、再写源。
func (c *Cache) EvictThenWrite(ctx context.Context, s Store, id uint64, val string) error {
if err := c.rdb.Del(ctx, c.kb.Key("player", id)).Err(); err != nil {
return err
}
return s.Save(ctx, id, val)
}
// DelayedDoubleDelete:写源前后各删一次,第二次延迟 delay 执行,覆盖
// 「读者在第一次删之后、写源之前把旧值回填」的窗口。
func (c *Cache) DelayedDoubleDelete(ctx context.Context, s Store, id uint64, val string, delay time.Duration) error {
key := c.kb.Key("player", id)
if err := c.rdb.Del(ctx, key).Err(); err != nil {
return err
}
if err := s.Save(ctx, id, val); err != nil {
return err
}
go func() {
t := time.NewTimer(delay)
defer t.Stop()
<-t.C
_ = c.rdb.Del(context.Background(), key).Err()
}()
return nil
}
为什么推荐「先写库再删缓存」?因为「先删缓存再写库」有一个更宽的脏窗口:删完缓存、还没写库的这段时间里,任何读请求都会回源读到旧值并回填进缓存,缓存里就留下一个旧值,直到下一次写才被纠正。而「先写库再删缓存」里读者的回源发生在写库之后,即使短暂回填,也是较新的值。两种顺序都不绝对干净,但前者的窗口更小、更少见。
延迟双删与失效风暴
「先写库再删缓存」仍有残留窗口:读者在 Del 之前读到旧值、在 Del 之后才把旧值 SET 回缓存。延迟双删就是针对它:写库前后各删一次,第二次延迟一小段再删,把「读者回填」这个动作覆盖掉。delay 取多大,取决于系统里「回源 + 回填」最长要多久,通常取几百毫秒。
// file: internal/cache/invalidate_test.go
// tier: L2
// verify: go test ./internal/cache/ -run TestConcurrentEventuallyConsistent -v
package cache
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestKeyBuilder(t *testing.T) {
kb := NewKeyBuilder(testNS, 3)
if got := kb.Key("player", 1001); got != testNS+":v3:player:1001" {
t.Fatalf("unexpected key: %s", got)
}
if kb.Bump().Version() != 4 {
t.Fatalf("bump version: got %d want 4", kb.Bump().Version())
}
}
// TestConcurrentEventuallyConsistent:并发读写停止后,经一次「写源 + 延迟双删」,
// 缓存与源最终一致。这是 8.1 的核心可运行断言。
func TestConcurrentEventuallyConsistent(t *testing.T) {
rdb := newTestClient(t)
ctx := context.Background()
kb := NewKeyBuilder(testNS, 1)
c := NewCache(rdb, kb, 30*time.Second)
s := NewRedisStore(rdb, testNS)
const playerID = 1001
if err := s.Save(ctx, playerID, "v0"); err != nil {
t.Fatalf("seed: %v", err)
}
stop := make(chan struct{})
var readers, writers sync.WaitGroup
var reads, writes int64
for i := 0; i < 8; i++ {
readers.Add(1)
go func() {
defer readers.Done()
for {
select {
case <-stop:
return
default:
}
if _, err := c.GetOrLoad(ctx, s, playerID); err == nil {
atomic.AddInt64(&reads, 1)
}
}
}()
}
for w := 0; w < 4; w++ {
writers.Add(1)
go func(base int) {
defer writers.Done()
for i := 0; i < 50; i++ {
v := fmt.Sprintf("w%d-%d", base, i)
if err := c.WriteThenEvict(ctx, s, playerID, v); err == nil {
atomic.AddInt64(&writes, 1)
}
}
}(w)
}
writers.Wait()
close(stop)
readers.Wait()
// 收尾:写终值 + 延迟双删,等第二次删除生效后再读,断言缓存与源一致。
const final = "final-value"
if err := c.DelayedDoubleDelete(ctx, s, playerID, final, 50*time.Millisecond); err != nil {
t.Fatalf("final write: %v", err)
}
time.Sleep(150 * time.Millisecond)
got, err := c.GetOrLoad(ctx, s, playerID)
if err != nil {
t.Fatalf("read final: %v", err)
}
if got != final {
t.Fatalf("cache/source inconsistent: got %q want %q", got, final)
}
t.Logf("reads=%d writes=%d final=%s", atomic.LoadInt64(&reads), atomic.LoadInt64(&writes), got)
}
测试里起 8 个读协程和 4 个写协程并发读写同一个角色,每个写协程用 WriteThenEvict 写 50 次;写协程全部结束后写一个终值并用延迟双删收尾,再读一次断言缓存与源一致。本机实测输出:
reads=355 writes=200 final=final-value
读 355 次、写 200 次,最终缓存与源一致。要诚实说明:这个断言验证的是「静默后最终一致」,不是「任意时刻都一致」——并发期间某个瞬间读者完全可能读到旧值。对玩家自己的资产,这种瞬间读旧不可接受,解法留到 8.3 讲。
最后提一个容易忽略的点:失效风暴。如果一大批键在同一时刻同时过期(比如所有角色缓存都设了固定 30 分钟 TTL、又是同一批预热的),到期那一刻所有请求会同时回源,把数据库打出一个尖峰。规避手段有三种:TTL 加随机抖动、用逻辑过期(8.2)、或在低峰期主动重建。TTL 抖动最省事:ttl + rand.IntN(60) 秒就能把尖峰摊平。
与站内既有架构文的分工
本节负责「代码怎么写」,站内既有的三篇架构文负责「方案怎么选、边界在哪」:
| 站内既有文章 | 它负责什么 | 本节补充什么 |
|---|---|---|
/game-server-cache-consistency-and-invalidation/ | 缓存一致性与失效的总体方案与取舍 | 给出可跑的键设计、更新顺序与延迟双删实现 |
/game-server-cross-service-cache-warming-architecture/ | 跨服务缓存预热的架构与时机 | 8.2 落地预热与击穿防护代码 |
/game-server-shard-local-cache-coherency-architecture/ | 分片本地缓存的相干性 | 本节讲集中式 Redis 失效,本地缓存另论 |
本节小结 / 实测结论
- 缓存键固定为「命名空间 : 版本 : 类型 : ID」,版本号用于整体失效,比批量删键安全。
- 更新顺序推荐「先写库再删缓存」,脏窗口比「先删缓存再写库」更小。
- 延迟双删覆盖「读者回填旧值」的残留窗口,
delay取「回源 + 回填」的最长耗时。 - 本机实测(同机相对量级,绝对数值不可移植):并发读 355 次、写 200 次后经延迟双删收尾,缓存与源最终一致。
- 失效风暴用 TTL 抖动 / 逻辑过期 / 主动重建摊平。
阅读导航:上一节:时钟回拨与容错 · 下一节:缓存预热与击穿防护 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。