《游戏服务端架构·核心系统与契约》3.2 房间生命周期与幽灵房间

从《泡泡工坊》房间进程重启后的幽灵房间讲起,给出创建、等待、填充、运行、结算、回收六态状态机,说明租约、epoch、幂等键与对账扫描如何发现并安全回收幽灵房间,并讨论结算落库与资源释放的先后顺序,说明回收时如何避免误杀正常对局,并给出可运行 Go 实现。

房间进程重启后,玩家占着座位却进不去

《泡泡工坊》的房间服务部署在一批短生命周期节点上。一次滚动发布之后,监控出现两个互相矛盾的信号:

  • 新建房间的成功率正常,但大厅不断返回“房间已满”;
  • 房间进程的实际 CPU 和连接数都远低于容量。

排查后发现,一批旧房间在进程被杀之前没有走完关闭流程,租约记录仍然挂在注册表里,座位也还被占着。新玩家匹配到这些房间,进入时被拒绝;旧玩家早已掉线,不会再来续约。这些“进程已经没了、记录还活着”的房间,就是幽灵房间。

幽灵房间不是内存泄漏,而是所有权记录与真实进程之间失去了同步。这一节讨论三件事:房间状态机每个阶段由谁负责、幽灵房间如何被发现、以及结算和资源释放的先后顺序。

六态状态机与责任方

房间从创建到回收经过六个状态。每个状态都必须有明确的超时和责任方,否则一次失败就会把房间永久卡在中间态。

状态进入条件超时(示例值)责任方正常退出
CREATED分配器已创建记录,进程尚未接管5 秒分配器房间进程注册成功 → WAITING
WAITING房间进程就绪,等待玩家进入60 秒房间进程人数达到开局条件 → FILLING
FILLING座位已锁定,加载地图和玩家状态15 秒房间进程所有玩家加载完成 → RUNNING
RUNNING对局进行中由玩法时长决定房间进程对局结束 → SETTLING
SETTLING结算已开始,等待落库确认30 秒结算服务 + 房间进程结算提交成功 → CLOSED
CLOSED结算完成,等待资源回收立即分配器释放节点资源并删除记录

超时数字是配置示例值,不是本机测量结果。关键不在具体秒数,而在于每个状态都有人负责推进,且超时后有一个明确的兜底动作。WAITING 超时要解散房间并把玩家送回大厅;FILLING 超时要中止加载;SETTLING 超时要重试结算,而不是直接释放资源。

备选方案:记录放哪里

方案所有权来源优点风险适用场景
纯内存房间进程自己最快、实现简单进程一挂就全丢,无法对账单进程、可接受整批丢失
数据库行一张 rooms 表可查询、可审计进程崩溃后行不会自动失效低频创建、人工运维
租约 + epoch共享租约表 + 心跳过期自动失效,旧主无法续约需要时钟与续约设计大多数在线房间
服务注册中心etcd/Consul 租约与部署体系一致引入额外依赖和运维成本已有注册中心的团队

北辰选择“租约 + epoch”,并把创建幂等键单独保存。数据库行适合审计,但不适合作为“进程是否还活着”的唯一依据;注册中心能解决存活判断,却不能替代结算状态。

租约、epoch 与幂等键

三个字段解决三个不同的问题:

  • owner:这间房当前归哪个进程实例,不是玩家,也不是房间 ID。
  • epoch:同一个房间 ID 每重新打开一次就递增。旧进程即使从 GC 停顿中恢复,也无法用旧 epoch 续约。
  • create_key:一次匹配只能创建一间房。客户端或调度器重试时,相同 key 返回同一间房,而不是再开一间。

只有 TTL 没有 epoch,会出现“僵尸房主”:旧进程在暂停后恢复,继续用同一个房间 ID 写状态,和新房主同时修改同一局。只有幂等键没有租约,会出现“记录永久有效”:创建成功但进程从未启动,房间永远占着座位。

租约表                         房间进程 host-a
  room-042 owner=host-a epoch=7 create_key=match-991
       ▲                                  │
       └────── heartbeat(room, owner, 7) ─┘  每 10 秒
       │
  到期未续约 → 扫描 → 对账 → 回收 → 玩家回大厅

心跳间隔必须明显小于租约 TTL,例如 10 秒心跳、30 秒租约,允许连续两次丢包。心跳只延长租约,不改变状态;状态迁移必须单独带 owner 和 epoch 做栅栏校验。

代码:状态机、续约、幽灵扫描与结算交接

下面的 internal/roomlifecycle/lifecycle.go 把六态状态机、幂等创建、租约续期、幽灵扫描和结算交接放在一个包里。它不依赖 Redis 或数据库;生产实现把 rooms 换成共享存储,把锁换成 CAS,但判断顺序不变。

// file: internal/roomlifecycle/lifecycle.go
// tier: L1
// verify: go test ./internal/roomlifecycle/
package roomlifecycle

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

type State string

const (
	Created  State = "CREATED"
	Waiting  State = "WAITING"
	Filling  State = "FILLING"
	Running  State = "RUNNING"
	Settling State = "SETTLING"
	Closed   State = "CLOSED"
)

// allowed is the only place that decides which transitions are legal.
var allowed = map[State][]State{
	Created:  {Waiting, Closed},
	Waiting:  {Filling, Closed},
	Filling:  {Running, Closed},
	Running:  {Settling, Closed},
	Settling: {Closed},
	Closed:   {},
}

func CanTransition(from, to State) bool {
	for _, s := range allowed[from] {
		if s == to {
			return true
		}
	}
	return false
}

var (
	ErrEmptyID       = errors.New("roomlifecycle: empty room id or owner")
	ErrNotFound      = errors.New("roomlifecycle: room not found")
	ErrFence         = errors.New("roomlifecycle: stale owner or epoch")
	ErrIllegal       = errors.New("roomlifecycle: illegal state transition")
	ErrLeaseExpired  = errors.New("roomlifecycle: lease expired")
	ErrSettlePending = errors.New("roomlifecycle: settlement not committed")
	ErrKeyConflict   = errors.New("roomlifecycle: create key already used")
	ErrActive        = errors.New("roomlifecycle: room is still active")
)

type Room struct {
	ID         string
	State      State
	Owner      string
	Epoch      uint64
	LeaseUntil time.Time
	CreateKey  string
	Settled    bool
	UpdatedAt  time.Time
}

type Ghost struct {
	RoomID string
	Epoch  uint64
	State  State
	Reason string
}

// Manager keeps the rooms it has not reclaimed yet. Closed rooms stay in the
// map so an idempotent create replay can still return the same answer.
type Manager struct {
	mu    sync.Mutex
	now   func() time.Time
	ttl   time.Duration
	epoch uint64
	rooms map[string]*Room
	byKey map[string]string
}

func New(ttl time.Duration, now func() time.Time) *Manager {
	if now == nil {
		now = time.Now
	}
	return &Manager{now: now, ttl: ttl, rooms: map[string]*Room{}, byKey: map[string]string{}}
}

func (m *Manager) expired(r *Room) bool { return !m.now().Before(r.LeaseUntil) }

// Create is idempotent by create key. A replay with the same key and room id
// returns the existing room; any other reuse is a conflict, not a new epoch.
func (m *Manager) Create(id, owner, key string) (Room, bool, error) {
	if id == "" || owner == "" {
		return Room{}, false, ErrEmptyID
	}
	m.mu.Lock()
	defer m.mu.Unlock()
	if key != "" {
		if existing, ok := m.rooms[m.byKey[key]]; ok {
			if existing.ID != id {
				return Room{}, false, ErrKeyConflict
			}
			return *existing, true, nil
		}
	}
	if _, ok := m.rooms[id]; ok {
		return Room{}, false, ErrKeyConflict
	}
	m.epoch++
	r := &Room{ID: id, State: Created, Owner: owner, Epoch: m.epoch,
		LeaseUntil: m.now().Add(m.ttl), CreateKey: key, UpdatedAt: m.now()}
	m.rooms[id] = r
	if key != "" {
		m.byKey[key] = id
	}
	return *r, false, nil
}

func (m *Manager) Get(id string) (Room, bool) {
	m.mu.Lock()
	defer m.mu.Unlock()
	r, ok := m.rooms[id]
	if !ok {
		return Room{}, false
	}
	return *r, true
}

// Transition fences on owner and epoch before touching the state machine. A
// transition into CLOSED from SETTLING additionally requires a committed
// settlement, so resources are never released before the result is durable.
func (m *Manager) Transition(id, owner string, epoch uint64, to State) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	r, ok := m.rooms[id]
	if !ok {
		return ErrNotFound
	}
	if r.Owner != owner || r.Epoch != epoch {
		return ErrFence
	}
	if r.State == Closed {
		return ErrIllegal
	}
	if m.expired(r) {
		return ErrLeaseExpired
	}
	if !CanTransition(r.State, to) {
		return ErrIllegal
	}
	if to == Closed && r.State == Settling && !r.Settled {
		return ErrSettlePending
	}
	r.State = to
	r.UpdatedAt = m.now()
	return nil
}

// Renew refreshes an unexpired lease. An expired lease is reported, never
// revived: the reclaim path may already have handed the room to a new owner.
func (m *Manager) Renew(id, owner string, epoch uint64) (time.Time, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	r, ok := m.rooms[id]
	if !ok {
		return time.Time{}, ErrNotFound
	}
	if r.Owner != owner || r.Epoch != epoch {
		return time.Time{}, ErrFence
	}
	if r.State == Closed || m.expired(r) {
		return time.Time{}, ErrLeaseExpired
	}
	r.LeaseUntil = m.now().Add(m.ttl)
	return r.LeaseUntil, nil
}

// MarkSettled commits the settlement handoff. It is idempotent so a retried
// "settlement written" callback does not turn into an error.
func (m *Manager) MarkSettled(id, owner string, epoch uint64) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	r, ok := m.rooms[id]
	if !ok {
		return ErrNotFound
	}
	if r.Owner != owner || r.Epoch != epoch {
		return ErrFence
	}
	if r.State != Settling {
		return ErrIllegal
	}
	if m.expired(r) {
		return ErrLeaseExpired
	}
	r.Settled = true
	return nil
}

// ScanGhosts reports rooms whose lease expired or whose owner is missing.
// Closed rooms are skipped: they are retained only for idempotent replays.
func (m *Manager) ScanGhosts() []Ghost {
	m.mu.Lock()
	defer m.mu.Unlock()
	now := m.now()
	var out []Ghost
	for _, r := range m.rooms {
		if r.State == Closed {
			continue
		}
		switch {
		case r.Owner == "":
			out = append(out, Ghost{r.ID, r.Epoch, r.State, "missing_owner"})
		case !now.Before(r.LeaseUntil):
			out = append(out, Ghost{r.ID, r.Epoch, r.State, "lease_expired"})
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].RoomID < out[j].RoomID })
	return out
}

// Reclaim removes a ghost. An active, unexpired room is refused: killing a
// healthy room is worse than leaving a stale record for the next scan.
func (m *Manager) Reclaim(id string, epoch uint64) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	r, ok := m.rooms[id]
	if !ok {
		return ErrNotFound
	}
	if r.Epoch != epoch {
		return ErrFence
	}
	if r.State != Closed && !m.expired(r) {
		return ErrActive
	}
	delete(m.rooms, id)
	if r.CreateKey != "" {
		delete(m.byKey, r.CreateKey)
	}
	return nil
}

几个关键判断:

  • Create 先查幂等键,再查房间 ID。相同 key 但不同房间 ID 是冲突,不是“再开一间”。
  • Transition 先校验 owner 和 epoch,再校验状态迁移。顺序反了,旧房主会收到“状态非法”,看起来像状态机问题,实际是归属冲突。
  • Renew 遇到已过期的租约直接报错,不会复活它。回收流程可能已经把房间交给了新 owner。
  • ScanGhosts 只报告,不删除。删除必须由 Reclaim 在再次确认 epoch 和到期时间后执行,避免扫描和续约之间的竞态。
  • Transition 进入 CLOSED 时,如果当前是 SETTLING 且结算未提交,返回 ErrSettlePending。这是“先落结算、再释放资源”的代码级约束。

幽灵房间的四种成因

第一,客户端掉线。这通常不是幽灵房间,只是座位暂时空着。房间应该保留座位并等待重连,而不是立刻回收。把“客户端断开”当成“房间死亡”,会误杀大量正常对局。

第二,房间进程崩溃。进程不会再续约,租约到期后由扫描发现。这是最典型、也最容易处理的一种。

第三,归属表残留。进程正常退出,但删除记录的请求失败或超时,记录留在表里。兜底是对账扫描:对比租约表与进程注册表,发现“表里有、进程列表里没有”的记录。

第四,重复创建。同一次匹配被调度两次,产生两间房,其中一间永远等不到玩家。兜底是 create_key 唯一约束;第二次创建返回已有房间,而不是新建。

还有一种隐蔽情况:节点网络分区。进程还活着,但心跳发不出去,租约到期。此时不能只凭租约就判定死亡,应该先查询进程是否仍在运行;如果进程还在,让它主动停止并上报,而不是让大厅直接回收并分配新房。

检测与回收:宁可慢一步,不要误杀

幽灵检测有三层,成本从低到高:

  1. 心跳与租约:每个房间每 10 秒续约一次,30 秒未续约进入候选。
  2. 对账扫描:每 30 秒扫描候选房间,查询进程注册表或节点上的进程列表。
  3. 人工兜底:对账仍无法判定的房间进入隔离队列,保留记录但不接受新玩家,等待人工确认。

回收动作必须满足三个条件:租约已过期、epoch 与记录一致、进程确认不存在(或已失联超过宽限期)。只满足前两个条件就删除,可能在网络抖动时误杀一个仍在运行的对局。

回收后的玩家处理也要明确:先标记房间为 CLOSED,再通过大厅把玩家送回匹配或结算补偿,不能直接断开连接。玩家看到的是“本局已结束”,而不是“房间不存在”。

结算交接:先落库,再释放

房间结束的顺序只有一种正确写法:

RUNNING → SETTLING → 写结算(幂等,按 match_id)→ MarkSettled → CLOSED → 释放资源

反过来“先释放资源、再异步写结算”看似更快,但一旦结算失败,房间已经没了,无法重试,玩家奖励和对局记录一起丢失。

结算写入必须幂等。重试时用 match_id 做唯一键,重复提交返回已存在的结果,而不是插入第二条。若结算服务持续失败,房间保持在 SETTLING,资源不释放,进入告警和人工处理;不能因为“资源紧张”就跳过结算。

失败模式与兜底

  • 进程崩溃:租约到期 → 扫描 → 回收 → 玩家回大厅,按开局时长决定是否补偿。
  • 心跳丢失但进程存活:先查进程,确认存活则要求其主动停止;不要直接删除记录。
  • 创建重试:create_key 幂等,返回已有房间;不同房间 ID 复用同一 key 直接报冲突。
  • 结算失败:停留在 SETTLING,按退避重试;超过上限转人工,资源不释放。
  • 旧 epoch 续约:拒绝并记录告警;说明旧进程仍在运行,需要排查而不是静默忽略。

常见错误与边界

  • 用数据库行代替租约,进程崩溃后记录永远不会自动失效。
  • 只用心跳判断存活,网络分区时误杀正常房间。
  • 先释放资源再写结算,结算失败后无法补偿。
  • 状态迁移不校验 epoch,旧房主恢复后与新 owner 同时写状态。
  • 扫描发现候选就立即删除,没有二次确认。
  • 把客户端掉线等同于房间死亡,玩家重连时发现房间已经没了。

小结

房间生命周期的核心不是状态数量,而是每个状态的责任方、超时和兜底动作。租约回答“谁还有权写”,epoch 回答“这是第几次生命周期”,create_key 回答“这是不是同一次创建”。幽灵房间靠心跳、对账和二次确认发现,回收时必须宁可慢一步,也不要误杀仍在运行的对局。结算必须先落库再释放资源。

房间结束之后,它占用的节点资源还要回到池子里。下一节讨论实例分配、暖池和回收:为什么“节点还有空闲内存”不等于“还能开房”,以及故障节点上的租约如何批量失效。

阅读导航:上一节:3.1 匹配算法与公平性 · 下一节:3.3 实例分配、暖池与回收 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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