《MMO 服务器开发》17.2 大厅生命周期:防幽灵房间

幽灵房间是匹配系统最典型的线上事故:房间早就没人了,却还占着实例槽位,把新玩家挡在门外。本节用「租约 + 单入口 Tick」把房间生命周期钉死:成员不续约就掉线、空房立刻回收、卡在 Starting 等 ack 超时回滚,并用测试把四种幽灵逐一复现。

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 实例分配与回收 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标