一次大厅发布,把整批玩家卡在房间里
《泡泡工坊》是 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。耗时随机器和缓存变化,不应当作性能指标。
房间崩溃后的玩家体验
租约只解决“谁有权写”,不解决“玩家看到什么”。房间进程崩溃后,玩家客户端通常还连着网关,需要一套明确的兜底:
- 客户端重连到网关,网关按
player_id查询“玩家 → 房间”映射; - 映射还在且租约未过期:返回房间地址和重连令牌,玩家回到原局;
- 映射还在但租约已过期:说明房间已失联,先回收租约,再返回“本局已结束”;
- 映射不存在:回到大厅,按休闲品类的规则决定是否补偿。
补偿规则要写死。常见做法是:开局 30 秒内房间崩溃,不扣分、不记败;超过 30 秒,按当前进度发放保底奖励。不要因为房间崩溃就给玩家判负,也不要把“重连失败”和“主动退出”混为一谈。
另一个边界是“房间进程没崩,只是网络分区”。租约会在 30 秒后过期,房间可能还在跑。epoch 保证它恢复后不能继续写;房间侧的 tick 循环应该在每次写状态前检查自己的租约,发现失效就主动停止并上报,而不是等大厅来杀进程。
常见错误与边界
第一,把会话或房间指针放在大厅本地内存。大厅一旦多副本或重启,玩家就会“消失”。
第二,令牌里放可变状态。玩家进入房间后,令牌里的座位号、血量立刻过期。
第三,只用 Redis TTL,不做 epoch CAS。旧房主恢复后可以和房主同时续约。
第四,心跳间隔等于 TTL。一次网络抖动就会触发回收,玩家频繁被踢回大厅。
第五,Join 不幂等。客户端重试一次,玩家就占两个座位,结算时可能出现两份奖励。
第六,租约过期立刻把玩家踢回匹配,没有重连宽限。玩家只是切了个网络,却已经被判负。
第七,房间崩溃后直接判负或扣分。休闲品类的用户对“非自己原因”的失败极其敏感。
第八,把分配器算法做得和竞技品类一样复杂。休闲房间的生命周期短、匹配简单,过度设计只会增加上线时间。
小结
休闲品类的后端可以很轻,但状态边界不能含糊:大厅无状态,只签发“玩家 + 房间 + 过期时间”的短命令牌;房间有状态,用 owner、epoch 和到期时间表达归属;租约过期后由回收流程把玩家送回大厅。房间内的对局状态放内存,租约与映射放共享存储,两者不要混在一起。
下一节讨论回合制卡牌:当一局的结果需要被申诉、回放和审计时,确定性从哪里来,状态哈希又应该覆盖什么。
阅读导航:上一节:8.3 异步结算、离线收益与补算 · 下一节:9.2 回合制卡牌的确定性与回放 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。