《MMO 服务器开发》8.2 缓存预热与击穿防护

冷启动、批量过期、热点活动都会把请求同时打到数据库上。本节用真跑的 Redis 给出四道防线:预热、单飞(singleflight)、逻辑过期、热点 key 探测,并给出「1000 并发请求同一个未缓存 key、源只被查 1 次」的可运行断言与实测数字。

8.2 缓存预热与击穿防护

8.1 讲的是「缓存里的值什么时候作废」。这一节讲另一个方向的灾难:缓存里什么都没有的时候会发生什么。

冷启动、批量过期、热点事件(全服公告、限时活动、跨服战场开赛)都会把一大批请求同时打到数据库上。数据库扛不住瞬时并发,于是延迟飙升、连接池打满,最终整个服务雪崩。缓存本是用来挡数据库的,这一刻它反而成了压垮数据库的帮凶。本节用真跑的 Redis 给出四道防线:预热、单飞、逻辑过期、热点探测。

缓存预热:别让第一波请求回源

缓存预热就是在流量到来之前,把热点数据主动塞进缓存,回答一个问题:这批数据现在没人读,但十分钟后一定会被读,要不要现在就加载? 常见触发点有三个:服务启动时加载全服类数据(排行榜基线、活动配置);定时任务在低峰期刷新当天可能变热的数据;大版本更新后按「预期热点清单」批量回填。

预热有一个容易踩的坑:预热的键必须与线上读路径完全一致——同一个 KeyBuilder、同一个版本号。否则你预热的和线上读的是两批键,等于没预热。本节把预热写成对 Loader 的一次批量调用,读路径仍是下面 Guard.Get,两者共用同一个 key 构造器。

缓存击穿:单飞与分布式锁

缓存击穿指的是:某个 key 过期的一瞬间,成百上千个请求同时发现缓存未命中,于是同时回源。哪怕只有一个 key,也能把数据库打穿。解法是让「同一个 key 的并发回源」合并成一次。

第一道防线是进程内的单飞。下面这段是 L1,纯逻辑,go test 可以直接跑:

// file: internal/cache/singleflight.go
// tier: L1
// verify: go test ./internal/cache/ -run TestSingleflight1000ConcurrentOneSourceHit
package cache

import "sync"

// Group 是单飞(singleflight)实现:同一时刻对同一 key 的并发请求,
// 只有第一个真正执行 fn,其余全部等待并共享同一个结果。
//
// 标准库没有 singleflight,而本卷依赖白名单里没有 golang.org/x/sync,
// 所以从零实现一份,语义与官方一致:key 在飞时合并,结束后从表中删除。
type Group struct {
	mu sync.Mutex
	m  map[string]*call
}

type call struct {
	wg  sync.WaitGroup
	val string
	err error
}

// Do 对 key 执行 fn。若同一 key 已有调用在飞,则等待并返回同一个结果。
func (g *Group) Do(key string, fn func() (string, error)) (string, error) {
	g.mu.Lock()
	if g.m == nil {
		g.m = make(map[string]*call)
	}
	if c, ok := g.m[key]; ok {
		g.mu.Unlock()
		c.wg.Wait()
		return c.val, c.err
	}
	c := new(call)
	c.wg.Add(1)
	g.m[key] = c
	g.mu.Unlock()

	c.val, c.err = fn()
	c.wg.Done()

	g.mu.Lock()
	delete(g.m, key)
	g.mu.Unlock()

	return c.val, c.err
}

Group 把「同 key 的并发回源」合并成一次,但它只在一个进程内有效。集群里同一个 key 可能被多台网关、多个场景服同时回源,这时需要第二道防线——分布式锁。下面这段是 L2,真连本机 Redis(26379),它把单飞、分布式锁、逻辑过期都封装进缓存读路径:

// file: internal/cache/breakdown.go
// tier: L2
// verify: go test ./internal/cache/ -run TestSingleflight1000ConcurrentOneSourceHit -v
package cache

import (
	"context"
	"encoding/json"
	"sync/atomic"
	"time"

	"github.com/redis/go-redis/v9"
)

// Locker 是基于 Redis SET NX PX 的分布式锁,用于跨进程互斥(单飞只管进程内)。
type Locker struct {
	rdb *redis.Client
}

// NewLocker 构造分布式锁。
func NewLocker(rdb *redis.Client) *Locker { return &Locker{rdb: rdb} }

// TryLock 尝试加锁,成功返回 true。token 用于安全释放(只删自己加的锁)。
func (l *Locker) TryLock(ctx context.Context, key, token string, ttl time.Duration) (bool, error) {
	return l.rdb.SetNX(ctx, key, token, ttl).Result()
}

// releaseScript 用 Lua 保证「比较 token 再删」的原子性。
var releaseScript = redis.NewScript(`
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
else
  return 0
end`)

// Unlock 释放锁,只删自己加的锁。
func (l *Locker) Unlock(ctx context.Context, key, token string) error {
	return releaseScript.Run(ctx, l.rdb, []string{key}, token).Err()
}

// Loader 抽象回源函数(线上是一条慢查询或一次下游 RPC)。
type Loader func(ctx context.Context, id uint64) (string, error)

// Guard 把「缓存未命中 -> 单飞回源 -> 回填」串起来,防止击穿。
type Guard struct {
	rdb     *redis.Client
	kb      *KeyBuilder
	ttl     time.Duration
	group   *Group
	srcHits int64 // 累计回源次数,供测试断言「源只被查一次」
}

// NewGuard 构造击穿防护器。
func NewGuard(rdb *redis.Client, kb *KeyBuilder, ttl time.Duration) *Guard {
	return &Guard{rdb: rdb, kb: kb, ttl: ttl, group: new(Group)}
}

// SrcHits 返回累计回源次数。
func (g *Guard) SrcHits() int64 { return atomic.LoadInt64(&g.srcHits) }

// Get 是防击穿的读路径:命中直接返回;未命中时同 key 的并发请求合并为一次回源。
func (g *Guard) Get(ctx context.Context, id uint64, load Loader) (string, error) {
	key := g.kb.Key("player", id)
	if v, err := g.rdb.Get(ctx, key).Result(); err == nil {
		return v, nil
	} else if err != redis.Nil {
		return "", err
	}
	return g.group.Do(key, func() (string, error) {
		// 双检:进入单飞后可能已被先到的协程回填。
		if v, err := g.rdb.Get(ctx, key).Result(); err == nil {
			return v, nil
		}
		atomic.AddInt64(&g.srcHits, 1)
		val, err := load(ctx, id)
		if err != nil {
			return "", err
		}
		_ = g.rdb.Set(ctx, key, val, g.ttl).Err()
		return val, nil
	})
}

// logicalValue 是逻辑过期包装:值本身不过期(Redis TTL 很长),过期时间写在载荷里,
// 由应用判断。这样过期时不会真删键,避免击穿。
type logicalValue struct {
	Data     string `json:"data"`
	ExpireAt int64  `json:"expire_at"` // Unix 毫秒
}

// LogicalCache 实现逻辑过期:读到逻辑已过期的旧值时先返回旧值,同时后台单飞刷新。
type LogicalCache struct {
	rdb   *redis.Client
	kb    *KeyBuilder
	ttl   time.Duration // Redis 物理 TTL,通常远大于逻辑 TTL
	group *Group
}

// NewLogicalCache 构造逻辑过期缓存。
func NewLogicalCache(rdb *redis.Client, kb *KeyBuilder, ttl time.Duration) *LogicalCache {
	return &LogicalCache{rdb: rdb, kb: kb, ttl: ttl, group: new(Group)}
}

// Set 写入一个带逻辑过期时间的值。
func (lc *LogicalCache) Set(ctx context.Context, id uint64, data string, logicalTTL time.Duration) error {
	v := logicalValue{Data: data, ExpireAt: time.Now().Add(logicalTTL).UnixMilli()}
	b, _ := json.Marshal(v)
	return lc.rdb.Set(ctx, lc.kb.Key("player", id), b, lc.ttl).Err()
}

// Get 读取逻辑过期缓存。未过期直接返回;已过期返回旧值并异步刷新(stale=true)。
func (lc *LogicalCache) Get(ctx context.Context, id uint64, load Loader) (string, bool, error) {
	key := lc.kb.Key("player", id)
	raw, err := lc.rdb.Get(ctx, key).Result()
	if err != nil {
		return "", false, err
	}
	var v logicalValue
	if err := json.Unmarshal([]byte(raw), &v); err != nil {
		return "", false, err
	}
	if time.Now().UnixMilli() < v.ExpireAt {
		return v.Data, false, nil
	}
	go func() {
		_, _ = lc.group.Do(key, func() (string, error) {
			bg := context.Background()
			val, err := load(bg, id)
			if err != nil {
				return "", err
			}
			_ = lc.Set(bg, id, val, 5*time.Minute)
			return val, nil
		})
	}()
	return v.Data, true, nil
}

Guard.Get 的读路径是「查缓存 -> 未命中进单飞 -> 单飞内双检 -> 回源并回填」。双检很关键:等待单飞的协程醒来时,缓存可能已被先到的协程填好了,这时直接返回,不必再查源。

下面是本节的核心断言:1000 个协程同时请求同一个未缓存的 key,源只被查 1 次。测试用 atomic 计数器记录回源次数,回源函数故意 Sleep 50 毫秒制造并发重叠窗口(newTestClient 等测试助手见 8.1 的 internal/cache/helpers_test.go):

// file: internal/cache/breakdown_test.go
// tier: L2
// verify: go test ./internal/cache/ -run TestSingleflight1000ConcurrentOneSourceHit -v
package cache

import (
	"context"
	"fmt"
	"sync"
	"sync/atomic"
	"testing"
	"time"
)

// TestSingleflight1000ConcurrentOneSourceHit 是 8.2 的核心可运行断言:
// 1000 个协程同时请求同一个未缓存的 key,源只被查 1 次。
func TestSingleflight1000ConcurrentOneSourceHit(t *testing.T) {
	rdb := newTestClient(t)
	ctx := context.Background()
	kb := NewKeyBuilder(testNS, 1)
	g := NewGuard(rdb, kb, 30*time.Second)

	var srcQueries int64
	load := func(ctx context.Context, id uint64) (string, error) {
		atomic.AddInt64(&srcQueries, 1)
		time.Sleep(50 * time.Millisecond) // 模拟慢查询,制造并发重叠窗口
		return fmt.Sprintf("player-%d", id), nil
	}

	const n = 1000
	const playerID = 2002
	if err := rdb.Del(ctx, kb.Key("player", playerID)).Err(); err != nil {
		t.Fatalf("del: %v", err)
	}

	var wg sync.WaitGroup
	start := make(chan struct{})
	results := make([]string, n)
	errs := make([]error, n)
	for i := 0; i < n; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			<-start
			results[i], errs[i] = g.Get(ctx, playerID, load)
		}(i)
	}
	close(start)
	wg.Wait()

	if got := atomic.LoadInt64(&srcQueries); got != 1 {
		t.Fatalf("source queried %d times, want 1", got)
	}
	want := fmt.Sprintf("player-%d", playerID)
	for i := 0; i < n; i++ {
		if errs[i] != nil {
			t.Fatalf("goroutine %d: %v", i, errs[i])
		}
		if results[i] != want {
			t.Fatalf("goroutine %d: got %q want %q", i, results[i], want)
		}
	}
	t.Logf("1000 concurrent requests -> source queried %d time(s)", atomic.LoadInt64(&srcQueries))
}

本机实测输出:

1000 concurrent requests -> source queried 1 time(s)

1000 个并发请求,源只被查了 1 次。注意:单飞合并的是同一进程内的请求;跨进程仍要靠分布式锁或逻辑过期。

逻辑过期:过期也不回源

LogicalCache 走的是另一条路:键在 Redis 里永不物理过期(TTL 设得很长),真正的过期时间写在载荷的 expire_at 字段里,由应用判断。读到逻辑已过期的值时,它先返回旧值(stale=true),同时后台用单飞刷新一次。这样读者永远不会因为「刚好过期」而阻塞在回源上,代价是可能短暂返回旧值。

逻辑过期和分布式锁是两种取舍:锁保证「同一时刻只有一个回源」,代价是等待;逻辑过期保证「读者永不阻塞」,代价是短时旧值。玩家自己的资产用锁,排行榜这类可用逻辑过期。

热点 key 探测

再进一步是热点 key 探测:进程内按固定窗口统计访问次数,超过阈值就判定为热点,随后对该 key 做本地缓存或多副本读,避免单个 Redis 分片被打垮。核心逻辑很短:

// snippet: internal/cache/hotkey.go 的关键部分
// Observe 记录一次访问,返回该 key 当前是否达到热点阈值。
func (h *HotKeyTracker) Observe(key string) bool {
	h.mu.Lock()
	defer h.mu.Unlock()
	if time.Since(h.start) >= h.window {
		h.counts = make(map[string]int64) // 跨窗口即重置
		h.start = time.Now()
	}
	h.counts[key]++
	return h.counts[key] >= h.threshold
}

本节小结 / 实测结论

  • 预热要在流量到来前把热点数据塞进缓存,且键必须与线上读路径完全一致。
  • 击穿用单飞合并进程内同 key 的并发回源,用分布式锁覆盖跨进程。
  • 逻辑过期让读者永不阻塞,代价是可能短暂返回旧值。
  • 本机实测(同机相对量级,绝对数值不可移植):1000 并发请求同一未缓存 key,源只被查 1 次。

阅读导航:上一节:Redis 使用与失效策略 · 下一节:读写分离与读模型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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