《游戏服务端架构·品类、状态与拓扑》9.1 无状态大厅加有状态房间

以《泡泡工坊》为例,说明休闲品类如何把大厅做成完全无状态、把房间做成带租约与 epoch 归属的有状态单元:大厅令牌只证明入场资格,房间租约决定谁能续约,过期回收把玩家送回大厅。给出可运行 Go 实现与令牌、心跳、加入、重入、过期五类测试,并讨论房间状态放内存还是 Redis 的判据。

一次大厅发布,把整批玩家卡在房间里

《泡泡工坊》是 8 人、3 分钟一局的休闲对战。周六晚大厅服务发布回滚,进程重启后出现了两种互相矛盾的投诉:

  • 一批玩家还在房间里打,但大厅查询说“你不在任何房间”,他们不敢退出,怕丢奖励;
  • 另一批玩家在大厅恢复后重复点击“开始”,一个人占了两间房,其中一间永远没有对手。

第一类问题的根因是:大厅把“玩家在哪个房间”保存在了本地内存,重启即丢。第二类问题的根因是:开房接口不幂等,网络重试被当成了两次新请求。

休闲品类的后端很轻,但“轻”不等于“没有状态边界”。这一节只回答一个问题:哪些状态必须跟着大厅走,哪些必须跟着房间走,以及房间状态放内存还是 Redis。

休闲品类的三层可以更薄

竞技品类常见的三层是“大厅、分配器、房间”。《裂隙战场》需要独立的分配器,因为排队、暖池、容量和租约本身就很复杂。休闲小游戏不需要照搬:

职责放在哪里是否持有权威状态扩容方式
登录、开房、发令牌、租约表无状态大厅 + 共享租约存储只持有租约与归属记录按请求量水平扩容
权威对局状态房间进程是,内存中的唯一副本按房间数扩容
心跳续约、过期回收大厅定时任务或房间侧上报否,读写租约表跟随大厅

关键不是“几个进程”,而是权威只有一个:房间内的血量、手牌、座位只由房间进程裁决;谁有资格进入房间,只由租约记录裁决。大厅进程本身可以随时重启,因为它不保存会话,也不保存对局状态。

如果团队规模很小,大厅和租约表可以跑在同一个进程里;但只要大厅需要多副本,租约就必须落到共享存储,否则同一个房间会在两个副本上得到不同的归属判断。

大厅无状态:令牌只回答一个问题

大厅签发的令牌不是会话,也不是“玩家档案”。它只回答一个问题:这个玩家在某个时刻之前,可以进入某个房间。

令牌里只有三个字段:

payload = player_id "\n" room_id "\n" unix_expiry
token   = base64url(payload) "." base64url(HMAC-SHA256(secret, payload))

三个设计约束:

  • 不放血量、手牌、座位号。这些是房间的可变状态,放进令牌就会产生过期副本,玩家拿着旧令牌进入房间后看到的状态一定是错的。
  • 用绝对过期时间,不用“签发后多少秒”。后者要求验证方知道签发时刻,跨进程时钟一漂移就会误判。
  • 令牌短命,只覆盖“从大厅到房间”的一次跳转。进入房间后的断线重连由房间自己的重连凭证负责,不能拿大厅令牌当长期会话。

令牌由任意大厅副本验证,只要共享同一个密钥。这就是“大厅无状态”的实际含义:不需要粘性会话,不需要在本地缓存玩家上下文。

房间有状态:租约、归属与 epoch

房间进程会崩溃、会被调度走、会因为网络分区暂时失联。租约要能回答“这间房现在归谁”,还要能防止一个已经过期、但进程还活着的旧房间继续操作。

租约存储                                房间进程 host-a
  room-042 owner=host-a epoch=7
       ▲                                      │
       └──────── heartbeat(room, host-a, 7) ──┘  每 10s
       │
  到期未续约 → 回收 → 房间内玩家回大厅

三个字段缺一不可:

  • owner:当前持有房间的进程实例,不是玩家,也不是房间 ID。
  • epoch:房间 ID 每次重新打开都会递增。旧进程即使恢复,拿着 epoch=7 也无法续约 epoch=8 的房间。
  • expires_at:租约的绝对到期时间。心跳间隔必须明显小于 TTL,例如 10 秒心跳、30 秒租约,允许两次丢包。

只有 TTL 没有 epoch,会出现典型的“僵尸房主”:旧进程在 GC 停顿或网络分区后恢复,继续用同一个房间 ID 续约,和新房主同时写状态。epoch 把“同一个房间 ID 的不同生命周期”区分开,续约和关闭都必须带上它。

内存还是 Redis:三个判据

房间内的对局状态几乎总是放内存:每 tick 读写,序列化到 Redis 既慢又容易产生两份权威。需要讨论的是租约、归属和座位映射。

判据放内存放 Redis
大厅是否多副本单副本可以多副本必须共享
房间进程是否可能漂移不会会,租约必须跨节点可见
是否需要跨房间查询不需要需要,例如“玩家当前在哪间房”
故障恢复要求重启后可以接受短暂不可用重启后必须立即恢复归属

《泡泡工坊》的选择是:对局状态在房间内存,租约与“玩家 → 房间”映射在 Redis。Redis 里不要只用 SET room owner EX 30:过期删除和续约之间没有原子性。至少要存 owner、epoch、expires_at 三个字段,用 Lua 或事务做“比较 epoch 后再续约”的 CAS。

Room 内存里的状态丢失可以接受,因为一局只有几分钟;租约丢失不可接受,因为它会导致重复开房和双份结算。

代码:租约注册表

下面的 internal/hybrid/registry.go 把租约、归属、过期回收和重入写成一个不依赖外部存储的注册表。生产实现把 rooms 换成 Redis 的哈希键,把 mu 换成 Lua 脚本,但字段和判断顺序不变。now 由外部注入,测试可以精确控制到期边界。

// file: internal/hybrid/registry.go
// tier: L1
// verify: go test ./internal/hybrid/
package hybrid

import (
	"errors"
	"sort"
	"sync"
	"time"
)

// MaxRoomPlayers is the room size for 《泡泡工坊》-style casual matches. A
// room process may allow more, but the lightweight registry stays bounded.
const MaxRoomPlayers = 8

var (
	ErrRoomExists   = errors.New("hybrid: room already open")
	ErrRoomNotFound = errors.New("hybrid: room not found")
	ErrRoomFull     = errors.New("hybrid: room full")
	ErrCapacity     = errors.New("hybrid: room capacity reached")
	ErrStaleOwner   = errors.New("hybrid: stale room owner")
	ErrPlayerBusy   = errors.New("hybrid: player is in another room")
)

// Room is the stateful half. Epoch increases every time a room ID is opened,
// so a restarted host cannot keep renewing the lease of its previous life.
type Room struct {
	ID        string
	Owner     string
	Epoch     uint64
	Players   map[string]struct{}
	ExpiresAt time.Time
}

type RoomView struct {
	ID        string
	Owner     string
	Epoch     uint64
	Players   int
	ExpiresAt time.Time
}

type JoinResult struct {
	RoomID   string
	PlayerID string
	Epoch    uint64
	Rejoined bool
}

type Reclaim struct {
	RoomID  string
	Epoch   uint64
	Players []string
}

// Registry owns the lease table and player placement. In production the same
// records live in Redis; the fencing rules do not change.
type Registry struct {
	mu       sync.Mutex
	now      func() time.Time
	ttl      time.Duration
	capacity int
	epoch    uint64
	rooms    map[string]*Room
	byPlayer map[string]string
}

func NewRegistry(capacity int, ttl time.Duration, now func() time.Time) *Registry {
	if now == nil {
		now = time.Now
	}
	return &Registry{
		now: now, ttl: ttl, capacity: capacity,
		rooms: map[string]*Room{}, byPlayer: map[string]string{},
	}
}

func (g *Registry) expiredLocked(r *Room) bool {
	return !g.now().Before(r.ExpiresAt)
}

func view(r *Room) RoomView {
	return RoomView{ID: r.ID, Owner: r.Owner, Epoch: r.Epoch,
		Players: len(r.Players), ExpiresAt: r.ExpiresAt}
}

// Open creates a room under a new epoch. An expired room with the same ID is
// reclaimed first, so a crash never blocks the ID forever.
func (g *Registry) Open(id, owner string) (RoomView, error) {
	if id == "" || owner == "" {
		return RoomView{}, ErrEmptyID
	}
	g.mu.Lock()
	defer g.mu.Unlock()
	if old, ok := g.rooms[id]; ok {
		if !g.expiredLocked(old) {
			return RoomView{}, ErrRoomExists
		}
		g.dropLocked(old)
	}
	if len(g.rooms) >= g.capacity {
		return RoomView{}, ErrCapacity
	}
	g.epoch++
	r := &Room{ID: id, Owner: owner, Epoch: g.epoch, Players: map[string]struct{}{},
		ExpiresAt: g.now().Add(g.ttl)}
	g.rooms[id] = r
	return view(r), nil
}

// Join is idempotent: a surviving player re-entering the same room gets the
// same seat instead of a duplicate occupancy.
func (g *Registry) Join(roomID, playerID string) (JoinResult, error) {
	if roomID == "" || playerID == "" {
		return JoinResult{}, ErrEmptyID
	}
	g.mu.Lock()
	defer g.mu.Unlock()
	r, ok := g.rooms[roomID]
	if !ok {
		return JoinResult{}, ErrRoomNotFound
	}
	if g.expiredLocked(r) {
		g.dropLocked(r)
		return JoinResult{}, ErrRoomNotFound
	}
	if _, seated := r.Players[playerID]; seated {
		return JoinResult{RoomID: roomID, PlayerID: playerID, Epoch: r.Epoch, Rejoined: true}, nil
	}
	if other, busy := g.byPlayer[playerID]; busy {
		if o, ok := g.rooms[other]; ok && !g.expiredLocked(o) {
			return JoinResult{}, ErrPlayerBusy
		}
		delete(g.byPlayer, playerID)
	}
	if len(r.Players) >= MaxRoomPlayers {
		return JoinResult{}, ErrRoomFull
	}
	r.Players[playerID] = struct{}{}
	g.byPlayer[playerID] = roomID
	return JoinResult{RoomID: roomID, PlayerID: playerID, Epoch: r.Epoch}, nil
}

// Heartbeat renews a lease only for the current owner and epoch. An expired
// lease is dropped before replying, so a paused host cannot revive it.
func (g *Registry) Heartbeat(roomID, owner string, epoch uint64) (time.Time, error) {
	g.mu.Lock()
	defer g.mu.Unlock()
	r, ok := g.rooms[roomID]
	if !ok {
		return time.Time{}, ErrRoomNotFound
	}
	if r.Owner != owner || r.Epoch != epoch {
		return time.Time{}, ErrStaleOwner
	}
	if g.expiredLocked(r) {
		g.dropLocked(r)
		return time.Time{}, ErrRoomNotFound
	}
	r.ExpiresAt = g.now().Add(g.ttl)
	return r.ExpiresAt, nil
}

// Expire reclaims every lease at or past its deadline and returns the players
// that must be sent back to the lobby.
func (g *Registry) Expire() []Reclaim {
	g.mu.Lock()
	defer g.mu.Unlock()
	out := make([]Reclaim, 0)
	for _, r := range g.rooms {
		if g.expiredLocked(r) {
			out = append(out, g.dropLocked(r))
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].RoomID < out[j].RoomID })
	return out
}

// Close removes a room immediately, but only under the caller's epoch.
func (g *Registry) Close(roomID, owner string, epoch uint64) (Reclaim, error) {
	g.mu.Lock()
	defer g.mu.Unlock()
	r, ok := g.rooms[roomID]
	if !ok {
		return Reclaim{}, ErrRoomNotFound
	}
	if r.Owner != owner || r.Epoch != epoch {
		return Reclaim{}, ErrStaleOwner
	}
	return g.dropLocked(r), nil
}

func (g *Registry) dropLocked(r *Room) Reclaim {
	delete(g.rooms, r.ID)
	players := make([]string, 0, len(r.Players))
	for p := range r.Players {
		delete(g.byPlayer, p)
		players = append(players, p)
	}
	sort.Strings(players)
	return Reclaim{RoomID: r.ID, Epoch: r.Epoch, Players: players}
}

几个容易忽略的细节:

  • Open 发现同 ID 的旧租约已过期时,先回收再分配新 epoch,而不是直接覆盖。覆盖会让旧房间的玩家既不在旧租约里,也不在新租约里。
  • Join 先查“是否已在本房间”,再查“是否在别的房间”。前者是重入,必须幂等;后者是并发开房,必须拒绝。
  • Heartbeat 先校验 owner 和 epoch,再校验到期。顺序反了,旧房主会在回收后拿到 ErrRoomNotFound,看起来像房间不存在,实际是归属冲突。
  • Expire 返回被回收房间的玩家列表。大厅据此把玩家送回大厅,而不是让他们卡在一个已经消失的房间。

四个必须测的场景

internal/hybrid 有 5 个测试函数、23 个子测试,全部表驱动或场景驱动:

  • 令牌:有效、过期、错误密钥、篡改 payload、格式错误、空串;
  • 心跳:正常续约、错误 owner、错误 epoch、到期瞬间、到期之后;
  • 加入:房间不存在、房间满、玩家已在别的房间、空玩家 ID;
  • 回收:未到期、到期瞬间、提前关闭后只剩另一间;
  • 重入:重复加入不增加座位、关房后释放玩家、旧 epoch 无法续约。

本机运行结果:

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/hybrid
$ go vet ./internal/hybrid/
$ go test -count=1 ./internal/hybrid/
ok  	gsarchlab/internal/hybrid	0.538s

gofmt -l 与 go vet 均无输出,退出码为 0。耗时随机器和缓存变化,不应当作性能指标。

房间崩溃后的玩家体验

租约只解决“谁有权写”,不解决“玩家看到什么”。房间进程崩溃后,玩家客户端通常还连着网关,需要一套明确的兜底:

  1. 客户端重连到网关,网关按 player_id 查询“玩家 → 房间”映射;
  2. 映射还在且租约未过期:返回房间地址和重连令牌,玩家回到原局;
  3. 映射还在但租约已过期:说明房间已失联,先回收租约,再返回“本局已结束”;
  4. 映射不存在:回到大厅,按休闲品类的规则决定是否补偿。

补偿规则要写死。常见做法是:开局 30 秒内房间崩溃,不扣分、不记败;超过 30 秒,按当前进度发放保底奖励。不要因为房间崩溃就给玩家判负,也不要把“重连失败”和“主动退出”混为一谈。

另一个边界是“房间进程没崩,只是网络分区”。租约会在 30 秒后过期,房间可能还在跑。epoch 保证它恢复后不能继续写;房间侧的 tick 循环应该在每次写状态前检查自己的租约,发现失效就主动停止并上报,而不是等大厅来杀进程。

常见错误与边界

第一,把会话或房间指针放在大厅本地内存。大厅一旦多副本或重启,玩家就会“消失”。

第二,令牌里放可变状态。玩家进入房间后,令牌里的座位号、血量立刻过期。

第三,只用 Redis TTL,不做 epoch CAS。旧房主恢复后可以和房主同时续约。

第四,心跳间隔等于 TTL。一次网络抖动就会触发回收,玩家频繁被踢回大厅。

第五,Join 不幂等。客户端重试一次,玩家就占两个座位,结算时可能出现两份奖励。

第六,租约过期立刻把玩家踢回匹配,没有重连宽限。玩家只是切了个网络,却已经被判负。

第七,房间崩溃后直接判负或扣分。休闲品类的用户对“非自己原因”的失败极其敏感。

第八,把分配器算法做得和竞技品类一样复杂。休闲房间的生命周期短、匹配简单,过度设计只会增加上线时间。

小结

休闲品类的后端可以很轻,但状态边界不能含糊:大厅无状态,只签发“玩家 + 房间 + 过期时间”的短命令牌;房间有状态,用 owner、epoch 和到期时间表达归属;租约过期后由回收流程把玩家送回大厅。房间内的对局状态放内存,租约与映射放共享存储,两者不要混在一起。

下一节讨论回合制卡牌:当一局的结果需要被申诉、回放和审计时,确定性从哪里来,状态哈希又应该覆盖什么。

阅读导航:上一节:8.3 异步结算、离线收益与补算 · 下一节:9.2 回合制卡牌的确定性与回放 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查