17.2 大厅生命周期:防幽灵房间
上一节的匹配器只产出了「成局」这一个事件。事件之后,服务端要真的建一个房间:等玩家点确认、收齐 ack 才能开局、开局后还要能被打完并回收。这段过程就是大厅(lobby)的职责,也是匹配系统里最容易出事故的地方。
事故的名字叫幽灵房间:房间早就没人了,却还稳稳地占着实例槽位,把后来的玩家挡在门外。它的成因几乎都是「少了一条超时路径」——玩家掉线但没走正常的退房流程,房间就永远停在 Waiting;或者全员点了准备,其中一个客户端在加载时崩了,房间永远停在 Starting。更麻烦的是,幽灵房间不会报错、不会打日志,只会让「可用的房间数」缓慢地漏光,等到高峰期才以「匹配不上」的形式爆发。
本节用两条原则把生命周期钉死。第一条是租约:在线不是一个状态,而是一份会过期的凭证,成员必须周期性续约。第二条是单一清理入口:所有回收逻辑都收敛到一个 Tick(now),任何非终态房间只要「没人续约」或「超过该状态的超时」就必须走向 Closed。全部代码是 L1,go test ./internal/match/ 可以直接跑。
房间状态机:四个非终态与一个终态
先把状态定清楚。房间有五个状态,其中四个是非终态:Waiting(等人齐)、Starting(人齐,等各客户端 ack)、InGame(对局中)、Settling(结算中),唯一的终态是 Closed。
状态机的关键约束是:每个非终态都必须有一条通往 Closed 的超时边。Waiting 有 WaitTimeout,Starting 有 StartAckTimeout,InGame/Settling 有 InGameIdleTimeout;而所有状态共享一条「房间空了就回收」的兜底边。少画任何一条边,就会多出一种幽灵。
成员用「租约」表示在线:Members 不是一组 ID,而是一张 PlayerID -> 租约到期时刻(ms) 的表。这样「谁在线」不再依赖客户端主动发退房包——客户端崩了、网线拔了,租约自然过期,服务端就能把这个人从房间里删掉。时间全部用毫秒整数,避免 time.Time 带来的时区与单调时钟差异,测试才能用固定时间点精确断言。
租约与超时:Tick 是唯一的清理入口
整个大厅只有一个清理函数。下面这段是 L1,go test ./internal/match/ 可以直接跑:
// file: internal/match/lobby.go
// tier: L1
// verify: go test ./internal/match/
package match
// RoomState 是房间的生命周期状态。幽灵房间几乎总是「停在某个非终态、却已经没人」。
type RoomState uint8
const (
RoomWaiting RoomState = iota // 已建房,等人齐
RoomStarting // 人齐,等各客户端 ack 开局
RoomInGame // 对局进行中
RoomSettling // 结算中
RoomClosed // 终态:槽位已释放
)
// Room 是一个大厅房间。成员用「租约」表示在线:成员必须周期性续约,
// 租约到期即视为掉线并从房间移除——这是防幽灵的第一道闸。
type Room struct {
ID uint64
Owner PlayerID
State RoomState
Members map[PlayerID]int64 // 成员 -> 租约到期时刻(ms)
CreatedAt int64
LastActivity int64
Deadline int64 // Waiting 状态的最晚匹配时刻
StartAckAt int64 // Starting 状态开始等 ack 的时刻
ClosedReason string // 终态原因,便于排障与埋点
}
// LobbyConfig 是房间的超时参数。全部用毫秒,避免 time.Time 带来的时区/单调时钟差异。
type LobbyConfig struct {
LeaseTTL int64 // 成员租约 TTL
WaitTimeout int64 // Waiting 最长等待
StartAckTimeout int64 // Starting 等 ack 的上限
InGameIdleTimeout int64 // InGame 无活动上限
}
// DefaultLobbyConfig 是一套保守的默认值。
func DefaultLobbyConfig() LobbyConfig {
return LobbyConfig{
LeaseTTL: 15000,
WaitTimeout: 60000,
StartAckTimeout: 10000,
InGameIdleTimeout: 300000,
}
}
// Lobby 管理单服内的全部房间。单线程 tick 驱动,不做内部加锁(由外层 actor 串行调用)。
type Lobby struct {
cfg LobbyConfig
rooms map[uint64]*Room
nextID uint64
slotCap int
}
// NewLobby 建房上限 slotCap;超过即拒绝 Create,防止实例槽位被无限占用。
func NewLobby(cfg LobbyConfig, slotCap int) *Lobby {
return &Lobby{cfg: cfg, rooms: make(map[uint64]*Room), slotCap: slotCap}
}
// Create 建房并把房主加入。槽位满时返回 nil。
func (l *Lobby) Create(owner PlayerID, now int64) *Room {
if len(l.rooms) >= l.slotCap {
return nil
}
l.nextID++
r := &Room{
ID: l.nextID,
Owner: owner,
State: RoomWaiting,
Members: map[PlayerID]int64{owner: now + l.cfg.LeaseTTL},
CreatedAt: now,
LastActivity: now,
Deadline: now + l.cfg.WaitTimeout,
}
l.rooms[r.ID] = r
return r
}
// Join 加入房间并把该成员的租约重置。房间不存在或已关则返回 false。
func (l *Lobby) Join(id uint64, p PlayerID, now int64) bool {
r, ok := l.rooms[id]
if !ok || r.State == RoomClosed {
return false
}
r.Members[p] = now + l.cfg.LeaseTTL
r.LastActivity = now
return true
}
// Heartbeat 续约单个成员;租约到期后被移除的成员不会被自动复活。
func (l *Lobby) Heartbeat(id uint64, p PlayerID, now int64) bool {
r, ok := l.rooms[id]
if !ok || r.State == RoomClosed {
return false
}
if _, alive := r.Members[p]; !alive {
return false
}
r.Members[p] = now + l.cfg.LeaseTTL
r.LastActivity = now
return true
}
// MarkStarting 把房间从 Waiting 推进到 Starting,并记下等 ack 的起点。
func (l *Lobby) MarkStarting(id uint64, now int64) bool {
r, ok := l.rooms[id]
if !ok || r.State != RoomWaiting {
return false
}
r.State = RoomStarting
r.StartAckAt = now
r.LastActivity = now
return true
}
// AckStart 收齐 ack 后把房间推进到 InGame。
func (l *Lobby) AckStart(id uint64, now int64) bool {
r, ok := l.rooms[id]
if !ok || r.State != RoomStarting {
return false
}
r.State = RoomInGame
r.LastActivity = now
return true
}
// Tick 是唯一的清理入口,返回本轮被关闭的房间 ID。四条规则都指向同一件事:
// 任何非终态房间,只要「没人续约」或「超过该状态的超时」,就必须走向 Closed。
func (l *Lobby) Tick(now int64) []uint64 {
var closed []uint64
for id, r := range l.rooms {
// 规则 1:租约到期的成员视为掉线。
for p, exp := range r.Members {
if exp <= now {
delete(r.Members, p)
}
}
// 规则 2:房间空了就是幽灵,立刻回收。
if len(r.Members) == 0 {
l.close(r, now, "empty")
closed = append(closed, id)
continue
}
// 规则 3:各状态各自的超时。
switch r.State {
case RoomWaiting:
if now >= r.Deadline {
l.close(r, now, "wait_timeout")
closed = append(closed, id)
}
case RoomStarting:
if now >= r.StartAckAt+l.cfg.StartAckTimeout {
// 有人没 ack:回滚到 Waiting 重新等,而不是卡死在 Starting。
r.State = RoomWaiting
r.Deadline = now + l.cfg.WaitTimeout
r.LastActivity = now
}
case RoomInGame, RoomSettling:
if now >= r.LastActivity+l.cfg.InGameIdleTimeout {
l.close(r, now, "game_idle")
closed = append(closed, id)
}
}
}
return closed
}
func (l *Lobby) close(r *Room, now int64, reason string) {
r.State = RoomClosed
r.ClosedReason = reason
r.LastActivity = now
r.Members = map[PlayerID]int64{}
delete(l.rooms, r.ID)
}
三个设计点值得说清楚。其一,close 里先清空成员再删除房间,并把 ClosedReason 记下来——线上排障时,「这个房间是被 empty 还是 wait_timeout 关掉的」决定了你该查客户端掉线还是查匹配量。其二,Starting 超时回滚到 Waiting 而不是直接关闭:一个客户端加载慢不该毁掉另外三个人的对局,重等一轮的成本远低于解散。其三,Tick 遍历的是 map,删除发生在遍历中——Go 允许在遍历 map 时删除当前元素,这一点是安全的;但要切记不能依赖遍历顺序,所以 Tick 的返回值只当集合用,测试里也只做成员判断。
用测试把四种幽灵钉死
防幽灵不能靠「我加了个超时」,得让四种典型场景各自有一条会变红的测试。下面是 internal/match/lobby_test.go 里两段的摘录,属于同一个已跑通的 L1 包:
// snippet: internal/match/lobby_test.go(摘录)
// 幽灵一:建房后所有人都不续约,租约到期后房间必须被回收。
func TestGhostRoomIsReaped(t *testing.T) {
cfg := DefaultLobbyConfig()
l := NewLobby(cfg, 10)
r := l.Create(1, 0)
if len(l.rooms) != 1 {
t.Fatalf("活跃房间 %d,期望 1", len(l.rooms))
}
if closed := l.Tick(1000); len(closed) != 0 { // 未到 TTL:健在
t.Fatalf("t=1000 不应回收,实际回收 %v", closed)
}
closed := l.Tick(cfg.LeaseTTL + 1) // 超过 TTL:掉线 -> 空房 -> 回收
if len(closed) != 1 || closed[0] != r.ID {
t.Fatalf("t=%d 应回收房间 %d,实际 %v", cfg.LeaseTTL+1, r.ID, closed)
}
}
// 幽灵二:卡在 Starting 等 ack,超时必须回滚到 Waiting。
func TestStartAckTimeoutRollsBack(t *testing.T) {
cfg := DefaultLobbyConfig()
l := NewLobby(cfg, 10)
r := l.Create(1, 0)
l.MarkStarting(r.ID, 0)
l.Heartbeat(r.ID, 1, 0)
l.Tick(cfg.StartAckTimeout - 1) // 未超时:仍是 Starting
if r.State != RoomStarting {
t.Fatalf("未超时应保持 starting,实际 %v", r.State)
}
l.Heartbeat(r.ID, 1, cfg.StartAckTimeout)
l.Tick(cfg.StartAckTimeout) // 超时:回滚
if r.State != RoomWaiting {
t.Fatalf("ack 超时应回滚到 waiting,实际 %v", r.State)
}
}
TestGhostRoomIsReaped 对应最常见的一种:客户端直接杀进程,没发退房包。断言分两半——t=1000 时房间必须还活着(否则就是误杀在线玩家),t=LeaseTTL+1 时必须已回收。TestStartAckTimeoutRollsBack 对应「有人点确认后加载卡死」,断言状态从 Starting 回到 Waiting,而不是留在 Starting 等一个永远不会来的 ack。
另外两条幽灵由别的测试覆盖:TestExpiredMemberRemoved 验证「只有部分成员掉线时,掉线者被移出成员表而房间保留」;TestInGameEmptyRoomReaped 验证「哪怕已经进了 InGame,全员掉线也要按 empty 回收」——这条最容易漏,因为很多人以为开局之后房间就该交给战斗系统管了。TestSlotCapReleases 则从另一个角度兜底:槽位满时 Create 返回 nil,房间被回收后立刻恢复可建。
本节小结 / 实测结论
本机(Go 1.26.0,darwin/arm64)实跑结果:
$ go test ./internal/match/
ok aegis/internal/match 1.170s (19 个 test 全绿,含 6 个大厅用例)
大厅的六条用例把四种幽灵全部复现并钉死:全掉线空房、部分掉线、Starting 卡死、InGame 空房。断言的时间点都是精确的——LeaseTTL+1 回收、StartAckTimeout-1 不回收、StartAckTimeout 回滚——因为时间用毫秒整数传入,没有任何 time.Now() 参与。绝对数值不可移植:换一套 LobbyConfig 只会改变时间点,不会改变状态迁移的正确性。
房间被回收了,但实例槽位真的释放了吗?大厅只管「房间」,谁把实例从池子里拿出来、用完又怎么还回去,是下一节的事。
阅读导航:上一节:17.1 匹配算法与公平性 · 下一节:17.3 实例分配与回收 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。