《游戏服务端编程实践》5.2.2 基于 PlayerID / RoomID 的路由

解释为什么同一玩家的请求必须落到同一逻辑实例,对比取模、固定分片表、一致性哈希与 rendezvous 哈希在扩缩容时的迁移成本,给出 RoomID 创建即归属的路由规则和跨玩家交互的选主方案;附可运行 Go 实现与稳定性、迁移失效、房间粘性测试。

一、同一玩家的请求必须落到同一实例

玩家的背包、任务、坐标是内存态。两个实例同时处理同一玩家的请求,会出现“扣了钱没加道具”的写覆盖。按 playerID 路由的本质是单写者:同一时刻,一个玩家只有一个逻辑实例有权修改其状态。

路由键不能是用户名:用户名可改、可重复、大小写规则各异;也不能是 connID:重连就变。playerID 在创建时分配、永不复用,才是稳定的分片键。

二、取模、固定分片表与一致性哈希

方式扩容 16 到 32 的迁移量优点风险
自增 ID 取模几乎全部实现最简单扩容即全量迁移
固定分片表按表迁移可控、可灰度映射膨胀,需要持久化
一致性哈希约 1/N扩缩容平滑实现与排障复杂
Rendezvous 哈希仅受影响键无需虚拟节点、结果稳定每个键要遍历全部分片

取模只适合分片数永远不变的场景。开服时按 16 个分片取模,半年后加到 32,id % 16 与 id % 32 对大多数 ID 不同,等于全量迁移。固定分片表能做灰度,但每条映射都要存;一致性哈希和 rendezvous 哈希把“谁负责”变成计算,扩容时仍会有少量键移动。代码里用 rendezvous 哈希做默认落位,再用 pins 覆盖需要人工控制的玩家。

三、RoomID 的归属在创建时确定

房间是有生命周期的聚合:创建时确定 home 分片,之后加入、准备、开局、结算都发往同一分片。RoomID 不能每次按哈希重算,否则扩容后老房间会被路由到没有房间对象的实例。

阶段路由依据说明
创建创建请求选定分片写入 rooms 表并返回 roomID
运行RoomID 到 home 分片房间内消息不再哈希
缩容房间必须重建迁移记录的目标为空

房间归属写进共享路由表,和玩家 pins 一样持久化。缩容时不能悄悄改哈希:老房间的内存态无法搬走,只能让玩家重连到新房间。

四、跨玩家交互:选一个主实例

交易、组队、切磋涉及两个玩家,若两边各改一半,失败时无法回滚。做法是选主:以房间、队伍或较小的 playerID 为主实例,把另一方的请求转发过来,由主实例串行修改双方状态,再把结果推给客方。

场景主实例客方处理
交易发起方所在分片锁定客方物品,等待确认
组队队伍 ID 所属分片成员变更转发给队长分片
跨服战战场房间分片玩家状态快照进入战场

主实例必须能定位客方:路由表查 playerID 得到分片,发 RPC 加锁,而不是直接写对方内存。任何“两边同时写”的方案都要额外的一致性协议,成本高于选主。

五、失败场景:扩容后全量迁移

某服按 playerID % 16 路由,日活上涨后扩到 32 个实例。发布当晚,几乎所有玩家都被分配到新实例,旧实例的背包缓存全部失效,登录排队雪崩。

根因是把分片数写进了路由算法。修复分三步:先把路由改成 rendezvous 哈希并双读对比,再把房间和需要固定的玩家写入 pins 表,最后按分片灰度切流量。扩容前要预测迁移比例;一次迁移超过 10% 就应拆批,每批观察登录、战斗和支付错误率。

六、Go 实现:ShardMap

ShardMap 保留显式 pins 与 rooms,其余键用 rendezvous 哈希;Rebalance 只返回需要人工处理的迁移,不会静默搬动房间。

// file: ch05/forward/shard.go
// tier: L1
// verify: go test ./ch05/forward/

package forward

import (
	"encoding/binary"
	"errors"
	"hash/fnv"
	"sort"
	"sync"
)

type ShardID string

// Migration 描述重平衡中的显式迁移;To 为空表示宿主消失、房间需要重建。
type Migration struct {
	Kind string
	ID   uint64
	From ShardID
	To   ShardID
}

var (
	ErrNoShards     = errors.New("no active shards")
	ErrEmptyShard   = errors.New("shard id is empty")
	ErrUnknownShard = errors.New("shard is not active")
	ErrZeroKey      = errors.New("key must be non-zero")
)

// ShardMap 用显式归属 + 一致性哈希两级路由:pins 与 rooms 是事实源,其余键按哈希落位。
type ShardMap struct {
	mu     sync.RWMutex
	shards []ShardID
	set    map[ShardID]struct{}
	pins   map[uint64]ShardID
	rooms  map[uint64]ShardID
}

func NewShardMap(shards ...ShardID) (*ShardMap, error) {
	normalized, set, err := normalizeShards(shards)
	if err != nil {
		return nil, err
	}
	return &ShardMap{shards: normalized, set: set, pins: map[uint64]ShardID{}, rooms: map[uint64]ShardID{}}, nil
}

func normalizeShards(shards []ShardID) ([]ShardID, map[ShardID]struct{}, error) {
	if len(shards) == 0 {
		return nil, nil, ErrNoShards
	}
	set := make(map[ShardID]struct{}, len(shards))
	out := make([]ShardID, 0, len(shards))
	for _, shard := range shards {
		if shard == "" {
			return nil, nil, ErrEmptyShard
		}
		if _, ok := set[shard]; ok {
			continue
		}
		set[shard] = struct{}{}
		out = append(out, shard)
	}
	sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
	return out, set, nil
}

// Assign 固定玩家归属,用于手动迁移、灰度或合服;固定归属不随哈希变化。
func (m *ShardMap) Assign(playerID uint64, shard ShardID) error {
	return m.assign(&m.pins, playerID, shard)
}

// AssignRoom 在房间创建时写入归属,此后房间消息按 RoomID 直达该分片。
func (m *ShardMap) AssignRoom(roomID uint64, shard ShardID) error {
	return m.assign(&m.rooms, roomID, shard)
}

func (m *ShardMap) assign(table *map[uint64]ShardID, key uint64, shard ShardID) error {
	if key == 0 {
		return ErrZeroKey
	}
	m.mu.Lock()
	defer m.mu.Unlock()
	if _, ok := m.set[shard]; !ok {
		return ErrUnknownShard
	}
	(*table)[key] = shard
	return nil
}

func (m *ShardMap) Lookup(playerID uint64) (ShardID, bool) {
	if playerID == 0 {
		return "", false
	}
	m.mu.RLock()
	defer m.mu.RUnlock()
	if len(m.shards) == 0 {
		return "", false
	}
	if shard, ok := m.pins[playerID]; ok {
		return shard, true
	}
	return m.hashLocked(playerID), true
}

func (m *ShardMap) LookupRoom(roomID uint64) (ShardID, bool) {
	if roomID == 0 {
		return "", false
	}
	m.mu.RLock()
	defer m.mu.RUnlock()
	shard, ok := m.rooms[roomID]
	return shard, ok
}

// Rebalance 切换活跃分片:仍在线的显式归属保持,落在下线分片上的玩家重新哈希,
// 房间删除归属并交给上层重建,返回值列出所有待处理迁移。
func (m *ShardMap) Rebalance(shards ...ShardID) ([]Migration, error) {
	normalized, set, err := normalizeShards(shards)
	if err != nil {
		return nil, err
	}
	m.mu.Lock()
	defer m.mu.Unlock()
	m.shards, m.set = normalized, set
	moves := make([]Migration, 0)
	for playerID, shard := range m.pins {
		if _, ok := set[shard]; ok {
			continue
		}
		delete(m.pins, playerID)
		moves = append(moves, Migration{Kind: "player", ID: playerID, From: shard, To: m.hashLocked(playerID)})
	}
	for roomID, shard := range m.rooms {
		if _, ok := set[shard]; ok {
			continue
		}
		delete(m.rooms, roomID)
		moves = append(moves, Migration{Kind: "room", ID: roomID, From: shard})
	}
	sort.Slice(moves, func(i, j int) bool {
		if moves[i].Kind != moves[j].Kind {
			return moves[i].Kind < moves[j].Kind
		}
		return moves[i].ID < moves[j].ID
	})
	return moves, nil
}

// hashLocked 用 rendezvous 哈希;分片集合变化时只有被删除分片上的键需要移动。
func (m *ShardMap) hashLocked(playerID uint64) ShardID {
	var key [8]byte
	binary.LittleEndian.PutUint64(key[:], playerID)
	var best ShardID
	var bestScore uint64
	for _, shard := range m.shards {
		h := fnv.New64a()
		h.Write(key[:])
		h.Write([]byte(shard))
		if score := h.Sum64(); best == "" || score > bestScore {
			best, bestScore = shard, score
		}
	}
	return best
}

七、测试:稳定性、迁移失效与房间归属

三个测试分别验证同一 ID 跨调用稳定、缩容后旧分片映射失效、房间归属在分片集合不变时保持粘性。

// file: ch05/forward/shard_test.go
// tier: L1
// verify: go test ./ch05/forward/

package forward

import (
	"errors"
	"testing"
)

func TestLookupStableAcrossCalls(t *testing.T) {
	m, err := NewShardMap("s2", "s1", "s3")
	if err != nil {
		t.Fatal(err)
	}
	other, err := NewShardMap("s3", "s2", "s1")
	if err != nil {
		t.Fatal(err)
	}
	seen := map[ShardID]int{}
	for id := uint64(1); id <= 600; id++ {
		first, ok := m.Lookup(id)
		second, ok2 := m.Lookup(id)
		if !ok || !ok2 || first != second {
			t.Fatalf("player %d lookup unstable", id)
		}
		if peer, _ := other.Lookup(id); peer != first {
			t.Fatalf("player %d depends on shard order: %q vs %q", id, first, peer)
		}
		seen[first]++
	}
	if len(seen) != 3 {
		t.Fatalf("600 players used %d shards, want 3", len(seen))
	}
}

func TestRebalanceInvalidatesRemovedShard(t *testing.T) {
	m, err := NewShardMap("s1", "s2", "s3")
	if err != nil {
		t.Fatal(err)
	}
	if err := m.Assign(42, "s3"); err != nil {
		t.Fatal(err)
	}
	if err := m.AssignRoom(7, "s3"); err != nil {
		t.Fatal(err)
	}
	moves, err := m.Rebalance("s2", "s1")
	if err != nil {
		t.Fatal(err)
	}
	if len(moves) != 2 {
		t.Fatalf("moves=%+v, want 2", moves)
	}
	if moves[0].Kind != "player" || moves[0].ID != 42 || moves[0].From != "s3" {
		t.Fatalf("player migration=%+v", moves[0])
	}
	if moves[0].To != "s1" && moves[0].To != "s2" {
		t.Fatalf("player target=%q, want active shard", moves[0].To)
	}
	if moves[1].Kind != "room" || moves[1].ID != 7 || moves[1].To != "" {
		t.Fatalf("room migration=%+v", moves[1])
	}
	got, ok := m.Lookup(42)
	if !ok || got == "s3" {
		t.Fatalf("player lookup=%q/%v, want active shard", got, ok)
	}
	if _, ok := m.LookupRoom(7); ok {
		t.Fatal("room on removed shard must be invalid")
	}
}

func TestRoomOwnershipIsSticky(t *testing.T) {
	m, err := NewShardMap("s1", "s2", "s3")
	if err != nil {
		t.Fatal(err)
	}
	if err := m.AssignRoom(7, "s2"); err != nil {
		t.Fatal(err)
	}
	moves, err := m.Rebalance("s3", "s1", "s2")
	if err != nil {
		t.Fatal(err)
	}
	if len(moves) != 0 {
		t.Fatalf("moves=%+v, want none", moves)
	}
	got, ok := m.LookupRoom(7)
	if !ok || got != "s2" {
		t.Fatalf("room lookup=%q/%v, want s2", got, ok)
	}
	if err := m.AssignRoom(8, "s9"); !errors.Is(err, ErrUnknownShard) {
		t.Fatalf("error=%v, want ErrUnknownShard", err)
	}
	if err := m.Assign(0, "s1"); !errors.Is(err, ErrZeroKey) {
		t.Fatalf("error=%v, want ErrZeroKey", err)
	}
}

八、运行测试

cd .local/checks/gs-practice-lab && gofmt -l ch05/forward && go vet ./ch05/forward/ && go test ./ch05/forward/ -count=1
ok  	gspractice/ch05/forward	0.565s

小结

按 playerID 路由是为了单写者,按 RoomID 路由是为了让房间聚合落在同一实例。分片算法要选扩容时只移动少量键的方案,房间归属必须在创建时落表,跨玩家交互要先选主再转发。路由表是事实源,哈希只是默认值的计算方式。

常见错误与边界

  • 对自增 ID 取模,扩容时几乎全量迁移。
  • 用用户名或 connID 当路由键,改名、重连后落到不同实例。
  • 跨玩家操作两边各改一半,失败后无法回滚。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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