一、同一玩家的请求必须落到同一实例
玩家的背包、任务、坐标是内存态。两个实例同时处理同一玩家的请求,会出现“扣了钱没加道具”的写覆盖。按 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 当路由键,改名、重连后落到不同实例。
- 跨玩家操作两边各改一半,失败后无法回滚。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。