《MMO 服务器开发》8.1 Redis 使用与失效策略

缓存与数据库不一致是游戏服务端最常见的事故源。本节用真跑的 Redis 讲清三件事:缓存键怎么设计成命名空间加版本号、写库和删缓存到底谁先谁后、延迟双删怎么覆盖并发窗口;并给出并发读写下缓存与源最终一致的可运行断言、本机实测数字,以及失效风暴的三种摊平手段。

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 抖动 / 逻辑过期 / 主动重建摊平。

阅读导航:上一节:时钟回拨与容错 · 下一节:缓存预热与击穿防护 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标