幽灵房间:一局永远不会结束的比赛
周六晚上九点四十,《裂隙战场》的排位等待从 20 秒涨到 12 分钟,在线人数却没涨。值班同学抽了一个房间 ID:tick 还在跑,五个玩家位置全空,结算队列里没有这局记录,最后一条事件是四小时前的 ClientDisconnected。周末累计有 1400 个房间停在 RUNNING——分配器认为有人,房间认为比赛还在继续,但没有任何客户端持有它。
幽灵房间有两个来源:泄漏,客户端崩溃后没有释放,分配记录永远不回收;重复,网络重试让同一个人拿到第二间房,第一间从此无人认领。它不会立刻报错,只安静占用容量,等到排位变长通常已经积累了几小时。这一节讨论竞技品类的标准三层,以及分配器到底能防住哪一半幽灵。
大厅无状态,房间有状态
《裂隙战场》是 5v5 竞技,一局 20–30 分钟。服务端被拆成三层,每层的状态责任完全不同:
| 层 | 职责 | 状态 | 扩容依据 | 重启后果 |
|---|---|---|---|---|
| 大厅 | 登录校验、匹配队列、组队、房间查询 | 不持有权威对局状态 | QPS 与队列长度 | 客户端重连即可恢复 |
| 房间 | 输入排序、tick、权威战斗状态、结算触发 | 内存中的房间状态 | 房间数与 tick 预算 | 该房间内一局丢失 |
| 分配器 | 会话与房间绑定、暖池、容量、租约 | 租约记录 | 分配 QPS 与租约数 | 从租约存储恢复 |
三条边界必须同时成立:
client ──► lobby (stateless) ──► allocator ──► room N
│ │
└─ matchmaking └─ lease store (TTL)
三条边界必须同时成立:大厅只知道“这个玩家应该在 room-042”,不持有血量、技能和位置;房间只接受已分配成员,不做匹配;分配器只回答“谁可以用哪间房”,不 tick。这样大厅可以随便重启,房间崩溃只影响一局,分配器慢只会让开房变慢。
分配器的五项职责
分配器看起来只是一个 map[session]room,但至少要承担五件事:
| 职责 | 失败时的表现 | 本节的防线 |
|---|---|---|
| 从暖池分配房间 | 开房等待冷启动,排队变长 | Acquire 从预热池取房 |
| 回收房间 | 房间泄漏,容量慢慢耗尽 | Release 归还暖池 |
| 限制并发租约 | 超卖,节点过载 | capacity 上限 |
| 回收过期租约 | 崩溃客户端永久占房 | ExpiresAt 与惰性回收 |
| 拒绝重复分配 | 一人两房、双份结算 | bySession 去重 |
Acquire 内部的检查顺序是有意设计的:
回收过期租约 → 拒绝重复会话 → 检查容量 → 从暖池取房
- 先回收、再查重:崩溃租约必须先释放,重试请求应拿到
ErrDuplicate而不是消耗另一间房; - 再查容量、最后取房:容量是硬上限,暖池为空返回
ErrPoolExhausted,而不是现场冷启动。
还有一个容易混淆的边界:分配器释放的是“租约”,不是房间进程。租约过期只说明会话不再有资格占房,房间进程还要通过心跳或租约检查决定何时关闭;否则房间号被重新分配,旧房间却还在跑。
用代码表达租约
下面是 gsarch-lab 的暖池分配器。它不依赖外部存储,但把生产环境必须有的四个约束都显式化了:
// file: internal/alloc/allocator.go
// tier: L1
// verify: go test ./internal/alloc/
package alloc
import (
"errors"
"fmt"
"sort"
"sync"
"time"
)
var (
ErrInvalidSession = errors.New("alloc: empty session id")
ErrDuplicate = errors.New("alloc: session already holds a room")
ErrCapacity = errors.New("alloc: concurrent lease capacity reached")
ErrPoolExhausted = errors.New("alloc: warm pool exhausted")
ErrUnknownSession = errors.New("alloc: session holds no room")
)
type Room struct {
ID string
}
type Lease struct {
RoomID string
SessionID string
ExpiresAt time.Time
}
type Allocator struct {
mu sync.Mutex
now func() time.Time
capacity int
leaseTTL time.Duration
warm []Room
active map[string]Lease
bySession map[string]string
nextRoom int
}
// New caps concurrently leased rooms at capacity. leaseTTL bounds how long a
// client may hold a room without renewing; capacity is not the warm-pool size.
func New(capacity int, leaseTTL time.Duration, now func() time.Time) *Allocator {
if capacity < 0 {
capacity = 0
}
if now == nil {
now = time.Now
}
return &Allocator{
now: now, capacity: capacity, leaseTTL: leaseTTL,
active: make(map[string]Lease), bySession: make(map[string]string),
}
}
// Warm pre-creates n idle rooms, never exceeding capacity.
func (a *Allocator) Warm(n int) {
a.mu.Lock()
defer a.mu.Unlock()
for i := 0; i < n && len(a.warm) < a.capacity; i++ {
a.nextRoom++
a.warm = append(a.warm, Room{ID: fmt.Sprintf("room-%03d", a.nextRoom)})
}
}
// Acquire reclaims expired leases first, then checks duplicate and capacity,
// then pops a warm room. A crashed client therefore cannot pin a room forever.
func (a *Allocator) Acquire(sessionID string) (Lease, error) {
if sessionID == "" {
return Lease{}, ErrInvalidSession
}
a.mu.Lock()
defer a.mu.Unlock()
a.reclaimLocked()
if _, ok := a.bySession[sessionID]; ok {
return Lease{}, ErrDuplicate
}
if len(a.active) >= a.capacity {
return Lease{}, ErrCapacity
}
if len(a.warm) == 0 {
return Lease{}, ErrPoolExhausted
}
room := a.warm[len(a.warm)-1]
a.warm = a.warm[:len(a.warm)-1]
lease := Lease{
RoomID: room.ID, SessionID: sessionID,
ExpiresAt: a.now().Add(a.leaseTTL),
}
a.active[room.ID] = lease
a.bySession[sessionID] = room.ID
return lease, nil
}
// Release returns a room to the warm pool. Releasing an expired or unknown
// session is an error, not a no-op: it signals a stale client.
func (a *Allocator) Release(sessionID string) error {
a.mu.Lock()
defer a.mu.Unlock()
roomID, ok := a.bySession[sessionID]
if !ok {
return ErrUnknownSession
}
delete(a.bySession, sessionID)
delete(a.active, roomID)
a.returnToPoolLocked(Room{ID: roomID})
return nil
}
// ReclaimExpired frees every lease past its deadline, sorted by room id.
func (a *Allocator) ReclaimExpired() []Lease {
a.mu.Lock()
defer a.mu.Unlock()
return a.reclaimLocked()
}
func (a *Allocator) reclaimLocked() []Lease {
now := a.now()
var expired []Lease
for roomID, lease := range a.active {
if now.Before(lease.ExpiresAt) {
continue
}
expired = append(expired, lease)
delete(a.active, roomID)
delete(a.bySession, lease.SessionID)
a.returnToPoolLocked(Room{ID: roomID})
}
sort.Slice(expired, func(i, j int) bool { return expired[i].RoomID < expired[j].RoomID })
return expired
}
func (a *Allocator) returnToPoolLocked(room Room) {
if len(a.warm) < a.capacity {
a.warm = append(a.warm, room)
}
}
三个实现选择值得注意:暖池用栈,刚释放的房间缓存更热;租约不自动续期,TTL 从分配时刻起算,强制团队回答“一局最长多久”;释放未知会话返回错误,静默成功会掩盖重复结算。
四个必须覆盖的测试
分配器的测试不能只测“正常开一局”。至少要覆盖正常分配、池耗尽、租约过期回收、重复分配被拒:
// file: internal/alloc/allocator_test.go
// tier: L1
// verify: go test ./internal/alloc/
package alloc
import (
"errors"
"testing"
"time"
)
func newTestAllocator(capacity, warm int, ttl time.Duration) (*Allocator, *time.Time) {
now := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
a := New(capacity, ttl, func() time.Time { return now })
a.Warm(warm)
return a, &now
}
func TestAcquireReleaseReusesWarmRoom(t *testing.T) {
a, _ := newTestAllocator(10, 2, 30*time.Second)
first, err := a.Acquire("session-a")
if err != nil {
t.Fatalf("Acquire: %v", err)
}
if err := a.Release("session-a"); err != nil {
t.Fatalf("Release: %v", err)
}
second, err := a.Acquire("session-b")
if err != nil {
t.Fatalf("Acquire after release: %v", err)
}
if second.RoomID != first.RoomID {
t.Fatalf("recycled room = %s, want %s", second.RoomID, first.RoomID)
}
}
func TestPoolExhausted(t *testing.T) {
a, _ := newTestAllocator(10, 2, 30*time.Second)
for _, id := range []string{"s1", "s2"} {
if _, err := a.Acquire(id); err != nil {
t.Fatalf("Acquire(%s): %v", id, err)
}
}
if _, err := a.Acquire("s3"); !errors.Is(err, ErrPoolExhausted) {
t.Fatalf("error = %v, want ErrPoolExhausted", err)
}
}
func TestCapacityLimit(t *testing.T) {
a, _ := newTestAllocator(2, 5, 30*time.Second)
for _, id := range []string{"s1", "s2"} {
if _, err := a.Acquire(id); err != nil {
t.Fatalf("Acquire(%s): %v", id, err)
}
}
if _, err := a.Acquire("s3"); !errors.Is(err, ErrCapacity) {
t.Fatalf("error = %v, want ErrCapacity", err)
}
}
func TestLeaseExpiryReclaimsRoom(t *testing.T) {
a, clock := newTestAllocator(10, 1, 30*time.Second)
expired, err := a.Acquire("crashed-client")
if err != nil {
t.Fatalf("Acquire: %v", err)
}
*clock = clock.Add(30 * time.Second)
replacement, err := a.Acquire("new-client")
if err != nil {
t.Fatalf("Acquire after expiry: %v", err)
}
if replacement.RoomID != expired.RoomID {
t.Fatalf("replacement = %s, want %s", replacement.RoomID, expired.RoomID)
}
if err := a.Release("crashed-client"); !errors.Is(err, ErrUnknownSession) {
t.Fatalf("stale Release = %v, want ErrUnknownSession", err)
}
}
func TestDuplicateAllocationRejected(t *testing.T) {
a, _ := newTestAllocator(10, 2, 30*time.Second)
if _, err := a.Acquire("same-session"); err != nil {
t.Fatalf("first Acquire: %v", err)
}
if _, err := a.Acquire("same-session"); !errors.Is(err, ErrDuplicate) {
t.Fatalf("second Acquire = %v, want ErrDuplicate", err)
}
if err := a.Release("same-session"); err != nil {
t.Fatalf("Release after duplicate: %v", err)
}
if err := a.Release("same-session"); !errors.Is(err, ErrUnknownSession) {
t.Fatalf("second Release = %v, want ErrUnknownSession", err)
}
}
func TestReclaimExpiredIsSortedAndIdempotent(t *testing.T) {
a, clock := newTestAllocator(10, 2, 10*time.Second)
for _, id := range []string{"s1", "s2"} {
if _, err := a.Acquire(id); err != nil {
t.Fatal(err)
}
}
*clock = clock.Add(10 * time.Second)
got := a.ReclaimExpired()
if len(got) != 2 || got[0].RoomID > got[1].RoomID {
t.Fatalf("reclaimed = %v", got)
}
if again := a.ReclaimExpired(); len(again) != 0 {
t.Fatalf("second reclaim = %v", again)
}
}
本机实际运行结果:
$ cd .local/checks/gsarch-lab
$ gofmt -l .
$ go vet ./...
$ go test -count=1 -v ./internal/alloc/
=== RUN TestAcquireReleaseReusesWarmRoom
--- PASS: TestAcquireReleaseReusesWarmRoom (0.00s)
=== RUN TestPoolExhausted
--- PASS: TestPoolExhausted (0.00s)
=== RUN TestCapacityLimit
--- PASS: TestCapacityLimit (0.00s)
=== RUN TestLeaseExpiryReclaimsRoom
--- PASS: TestLeaseExpiryReclaimsRoom (0.00s)
=== RUN TestDuplicateAllocationRejected
--- PASS: TestDuplicateAllocationRejected (0.00s)
=== RUN TestReclaimExpiredIsSortedAndIdempotent
--- PASS: TestReclaimExpiredIsSortedAndIdempotent (0.00s)
PASS
ok gsarchlab/internal/alloc 0.392s
gofmt -l 无输出,go vet 无告警。六个测试分别对应:正常复用、池耗尽、容量上限、租约回收、重复拒绝、回收幂等。
幽灵房间复盘:分配器防住了哪一半
| 事故路径 | 分配器是否防住 | 机制或缺口 |
|---|---|---|
| 客户端崩溃不释放 | 是 | TTL 到期后惰性回收 |
| 同会话重试拿两间房 | 是 | bySession 返回 ErrDuplicate |
| 重试时换了新会话 ID | 否 | 需要匹配令牌或房间加入幂等键 |
| 房间进程在租约过期后仍在跑 | 否 | 分配器只释放租约,房间需自检 |
| 结算未完成就释放房间 | 否 | 释放时机必须放在结算入队之后 |
最关键的是“房间进程仍在跑”。分配器租约和房间心跳是两把锁:前者说“会话不再有资格”,后者要定期确认租约有效,失效就收尾并交给结算补偿。只做前者,房间号会被重新分配;只做后者,崩溃客户端会一直占容量。“换了新会话 ID”也要靠匹配令牌贯穿重连,由房间加入接口做幂等:同一令牌第二次加入返回同一间房。
参数怎么定
| 参数 | 约束 | 《裂隙战场》示例值 |
|---|---|---|
| 单分配器容量 | 战斗节点数 × 单节点房间上限 × 0.8 | 40 × 250 × 0.8 = 8000 |
| 暖池大小 | 峰值开房速率 × 冷启动秒数 × 1.5 | 120/s × 3s × 1.5 = 540 |
| 租约 TTL | 单局最大时长 + 结算宽限 | 30 分钟 + 90 秒 |
这些是示例值,不是实测值。上线前要用压测确认:冷启动是否真在 3 秒内完成,单节点 250 间房时 tick p99 是否仍在预算内。另外,租约时间必须使用租约存储的时钟,否则多实例的本地时钟漂移会让同一份租约在不同节点上有不同到期时间。
常见错误与边界
第一,把分配器做成大厅进程里的一个 map。大厅多副本时每个副本都以为自己拥有全部租约,重复分配立刻出现;生产实现必须把租约放到带条件写和 TTL 的共享存储里。
第二,TTL 太短。一局 30 分钟、TTL 设 10 分钟,比赛进行到一半房间被回收,玩家直接掉线;TTL 必须覆盖最坏情况。第三,断线立即释放,玩家进隧道 20 秒房间就被回收,重连后比赛没了;释放应发生在“确认不再回来”之后。只监控房间数、不监控租约数也不行:前者健康而租约堆积说明释放链路坏了,反过来说明 tick 或结算有问题。
第四,把容量上限设成“当前在线人数”。容量约束的是同时进行的对局,不是在线人数;大厅里还有大量未开局玩家。暖池也要按水位持续补充,只预热一次会在几小时后耗尽,开房全部变成冷启动。
小结
竞技品类的标准形态是三层:大厅无状态、房间有状态、分配器管绑定。分配器用暖池、容量、租约和去重把“谁可以用哪间房”变成可检查的约束,能防住泄漏和同会话重复,但防不住换会话重连和房间进程自身卡死。幽灵房间从来不是单一组件的问题,它由分配器租约、房间心跳、匹配令牌和结算时机共同决定。下一节把视角从“一局房间”拉到“整个世界”:分区、分服与全球同服分别把状态切在哪里,以及数据驻留如何反向约束拓扑。
阅读导航:上一节:3.1 单体、分层与模块化单体 · 下一节:3.3 分区、分服与全球同服 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。