《MMO 服务器开发》8.3 读写分离与读模型

读写分离换来的读扩展能力,代价是主从复制延迟带来的「写后读旧」。本节用真跑的 Redis 复现「写后立即读可能读到旧值」,对照读主库、版本令牌、等待追上三种解法,并给出 CQRS 轻量版的完整实现:写侧落事件、读侧投影宽表,附本机实测数字与取舍。

8.3 读写分离与读模型

数据库的读通常远多于写,于是几乎所有人都会做读写分离:写主库、读从库,主库再把变更异步复制到从库。它换来的读扩展能力很实在,但代价是一个绕不过去的问题:主从复制有延迟。

延迟意味着「写后立即读」可能读到旧值。玩家刚把装备升到 +10、一刷新还是 +9,或刚买的东西在背包里没出现——这类「我明明刚做过」的可见性问题,是读写分离最典型的副作用。本节先把它复现出来,再给三种解法,最后讲读模型。

主从复制延迟:写后立即读可能读到旧值

真实 MySQL 的主从延迟可能几十毫秒到几秒。本节用同一台 Redis 上的两组 key 模拟「主库 + 从库」:写落 master key,复制按需触发,从而精确控制从库「什么时候追上」。下面这段是 L2,真连本机 Redis(26379):

// file: internal/cache/replica.go
// tier: L2
// verify: go test ./internal/cache/ -run TestReplicationLagStaleReadAndThreeFixes
package cache

import (
	"context"
	"fmt"
	"strconv"
	"strings"
	"time"

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

// Versioned 是带版本号的值:版本号单调递增,用于「写后读」可见性判断。
type Versioned struct {
	Version int64
	Data    string
}

func encode(v Versioned) string { return strconv.FormatInt(v.Version, 10) + "|" + v.Data }

func decode(s string) (Versioned, error) {
	i := strings.IndexByte(s, '|')
	if i < 0 {
		return Versioned{}, fmt.Errorf("cache: malformed payload %q", s)
	}
	ver, err := strconv.ParseInt(s[:i], 10, 64)
	if err != nil {
		return Versioned{}, err
	}
	return Versioned{Version: ver, Data: s[i+1:]}, nil
}

// Replica 用同一台 Redis 上的两组 key 模拟「主库 + 从库」:写落 master key,
// 复制按需触发(SyncOnce)或按 lag 节奏进行,复现真实的主从复制延迟。
type Replica struct {
	rdb *redis.Client
	ns  string
	lag time.Duration
}

// NewReplica 构造主从模拟器。
func NewReplica(rdb *redis.Client, ns string, lag time.Duration) *Replica {
	return &Replica{rdb: rdb, ns: ns, lag: lag}
}

func (r *Replica) masterKey(id uint64) string { return fmt.Sprintf("%s:m:player:%d", r.ns, id) }
func (r *Replica) slaveKey(id uint64) string  { return fmt.Sprintf("%s:s:player:%d", r.ns, id) }
func (r *Replica) verKey(id uint64) string    { return fmt.Sprintf("%s:ver:player:%d", r.ns, id) }

// Write 写主库,返回本次写入的版本号(由版本计数器自增得到)。
func (r *Replica) Write(ctx context.Context, id uint64, data string) (int64, error) {
	ver, err := r.rdb.Incr(ctx, r.verKey(id)).Result()
	if err != nil {
		return 0, err
	}
	if err := r.rdb.Set(ctx, r.masterKey(id), encode(Versioned{Version: ver, Data: data}), 0).Err(); err != nil {
		return 0, err
	}
	return ver, nil
}

// SyncOnce 手动触发一次复制(测试里精确控制从库「什么时候追上」)。
func (r *Replica) SyncOnce(ctx context.Context, id uint64) error {
	v, err := r.rdb.Get(ctx, r.masterKey(id)).Result()
	if err != nil {
		return err
	}
	return r.rdb.Set(ctx, r.slaveKey(id), v, 0).Err()
}

// ReadMaster 直接读主库(强一致,但压力大)。
func (r *Replica) ReadMaster(ctx context.Context, id uint64) (Versioned, error) {
	s, err := r.rdb.Get(ctx, r.masterKey(id)).Result()
	if err != nil {
		return Versioned{}, err
	}
	return decode(s)
}

// ReadSlave 读从库(可能读到旧值)。
func (r *Replica) ReadSlave(ctx context.Context, id uint64) (Versioned, error) {
	s, err := r.rdb.Get(ctx, r.slaveKey(id)).Result()
	if err != nil {
		return Versioned{}, err
	}
	return decode(s)
}

// ReadAfterWriteVersion 是解法二:读从库,若版本落后于本次写入要求,则回主库。
func (r *Replica) ReadAfterWriteVersion(ctx context.Context, id uint64, required int64) (Versioned, error) {
	v, err := r.ReadSlave(ctx, id)
	if err == nil && v.Version >= required {
		return v, nil
	}
	return r.ReadMaster(ctx, id)
}

// ReadAfterWriteWait 是解法三:轮询从库直到版本追上,超时则回主库。
func (r *Replica) ReadAfterWriteWait(ctx context.Context, id uint64, required int64, timeout time.Duration) (Versioned, error) {
	deadline := time.Now().Add(timeout)
	for {
		v, err := r.ReadSlave(ctx, id)
		if err == nil && v.Version >= required {
			return v, nil
		}
		if time.Now().After(deadline) {
			return r.ReadMaster(ctx, id)
		}
		select {
		case <-ctx.Done():
			return Versioned{}, ctx.Err()
		case <-time.After(5 * time.Millisecond):
		}
	}
}

三种解法对照

// file: internal/cache/replica_test.go
// tier: L2
// verify: go test ./internal/cache/ -run TestReplicationLagStaleReadAndThreeFixes -v
package cache

import (
	"context"
	"testing"
	"time"
)

// TestReplicationLagStaleReadAndThreeFixes 复现「写后立即读可能读到旧值」,
// 并对照三种解法。这是 8.3 的核心可运行断言。
func TestReplicationLagStaleReadAndThreeFixes(t *testing.T) {
	rdb := newTestClient(t)
	ctx := context.Background()
	const playerID = 5005
	r := NewReplica(rdb, testNS, 100*time.Millisecond)

	// 基线:写 lvl1 并复制到位。
	if _, err := r.Write(ctx, playerID, "lvl1"); err != nil {
		t.Fatalf("write lvl1: %v", err)
	}
	if err := r.SyncOnce(ctx, playerID); err != nil {
		t.Fatalf("sync: %v", err)
	}

	// 写 lvl2 后立刻读从库:从库还没复制,读到旧值。
	ver, err := r.Write(ctx, playerID, "lvl2")
	if err != nil {
		t.Fatalf("write lvl2: %v", err)
	}
	slave, err := r.ReadSlave(ctx, playerID)
	if err != nil {
		t.Fatalf("read slave: %v", err)
	}
	if slave.Data != "lvl1" {
		t.Fatalf("expected stale lvl1, got %q", slave.Data)
	}
	t.Logf("reproduced: master=v%d(lvl2) but slave=%q(v%d)", ver, slave.Data, slave.Version)

	// 解法一:写后强制读主。
	if m, err := r.ReadMaster(ctx, playerID); err != nil || m.Data != "lvl2" {
		t.Fatalf("fix1 read-master: err=%v data=%q", err, m.Data)
	}
	// 解法二:版本令牌,从库落后则回主。
	if v, err := r.ReadAfterWriteVersion(ctx, playerID, ver); err != nil || v.Data != "lvl2" {
		t.Fatalf("fix2 version-token: err=%v data=%q", err, v.Data)
	}
	// 解法三:等待从库追上(后台延迟复制一次)。
	go func() {
		time.Sleep(30 * time.Millisecond)
		_ = r.SyncOnce(ctx, playerID)
	}()
	if v, err := r.ReadAfterWriteWait(ctx, playerID, ver, time.Second); err != nil || v.Data != "lvl2" {
		t.Fatalf("fix3 wait: err=%v data=%q", err, v.Data)
	}
}

本机实测输出:

reproduced: master=v2(lvl2) but slave="lvl1"(v1)

写 lvl2(版本 2)之后立刻读从库,从库还停在 lvl1(版本 1)——这就是「写后读旧」。三种解法:

解法做法代价
读主库写后强制读主主库压力大,但强一致
版本令牌记住写返回的版本,读从库版本落后就回主需把版本一路传到读请求
等待追上轮询从库直到版本满足,超时回主读延迟不可控

读模型:写侧落事件、读侧构建宽表

// file: internal/cache/readmodel.go
// tier: L2
// verify: go test ./internal/cache/ -run TestCQRSReadModelProjection
package cache

import (
	"context"
	"encoding/json"
	"fmt"

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

// Event 是写侧产生的事件。CQRS 轻量版里,写侧只追加事件,不做读优化。
type Event struct {
	Type  string `json:"type"`
	Value string `json:"value"`
}

// WriteModel 是写侧:把事件追加到该角色的私有事件流。
type WriteModel struct {
	rdb *redis.Client
	ns  string
}

// NewWriteModel 构造写侧。
func NewWriteModel(rdb *redis.Client, ns string) *WriteModel { return &WriteModel{rdb: rdb, ns: ns} }

func (w *WriteModel) streamKey(id uint64) string {
	return fmt.Sprintf("%s:stream:player:%d", w.ns, id)
}

// Append 追加一个事件,返回事件流当前长度。
func (w *WriteModel) Append(ctx context.Context, id uint64, e Event) (int64, error) {
	b, err := json.Marshal(e)
	if err != nil {
		return 0, err
	}
	return w.rdb.RPush(ctx, w.streamKey(id), b).Result()
}

// Events 按顺序读回该角色的事件流。
func (w *WriteModel) Events(ctx context.Context, id uint64) ([]Event, error) {
	raw, err := w.rdb.LRange(ctx, w.streamKey(id), 0, -1).Result()
	if err != nil {
		return nil, err
	}
	out := make([]Event, 0, len(raw))
	for _, s := range raw {
		var e Event
		if err := json.Unmarshal([]byte(s), &e); err != nil {
			return nil, err
		}
		out = append(out, e)
	}
	return out, nil
}

// Projector 是读侧投影器:重放事件流,把它折叠成一张「宽表」(Redis Hash)。
type Projector struct {
	rdb *redis.Client
	wm  *WriteModel
	ns  string
}

// NewProjector 构造投影器。
func NewProjector(rdb *redis.Client, wm *WriteModel, ns string) *Projector {
	return &Projector{rdb: rdb, wm: wm, ns: ns}
}

// Project 重放事件流,重建宽表,返回应用的事件条数。
func (p *Projector) Project(ctx context.Context, id uint64) (int, error) {
	events, err := p.wm.Events(ctx, id)
	if err != nil {
		return 0, err
	}
	key := fmt.Sprintf("%s:wide:player:%d", p.ns, id)
	if err := p.rdb.Del(ctx, key).Err(); err != nil {
		return 0, err
	}
	for _, e := range events {
		if err := p.rdb.HSet(ctx, key, e.Type, e.Value).Err(); err != nil {
			return 0, err
		}
	}
	return len(events), nil
}

// ReadModel 是读侧查询接口:只读宽表,不碰事件流。
type ReadModel struct {
	rdb *redis.Client
	ns  string
}

// NewReadModel 构造读侧。
func NewReadModel(rdb *redis.Client, ns string) *ReadModel { return &ReadModel{rdb: rdb, ns: ns} }

// Get 读取宽表全部字段。
func (rm *ReadModel) Get(ctx context.Context, id uint64) (map[string]string, error) {
	return rm.rdb.HGetAll(ctx, fmt.Sprintf("%s:wide:player:%d", rm.ns, id)).Result()
}

WriteModel.Append 追加事件到角色私有事件流,Projector.Project 重放事件流折叠成宽表,ReadModel.Get 只读宽表。投影是幂等重建的——重放同一批事件得到同一张宽表,所以投影器可随时重跑。对应测试 TestCQRSReadModelProjection 断言:投影前宽表为空,投影后字段齐备,追加事件重投影后宽表更新(测试助手见 8.1 的 internal/cache/helpers_test.go)。

本节小结 / 实测结论

  • 读写分离换读扩展能力,代价是主从复制延迟带来的「写后读旧」;本机实测(同机相对量级,绝对数值不可移植)写 lvl2 后立即读从库仍读到 lvl1。
  • 三种解法:读主库(强一致、压主)、版本令牌(推荐)、等待追上(延迟不可控)。
  • CQRS 轻量版:写侧落事件、读侧投影宽表,投影幂等可重建。

阅读导航:上一节:缓存预热与击穿防护 · 下一节:角色数据模型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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