房间进程重启后,玩家占着座位却进不去
《泡泡工坊》的房间服务部署在一批短生命周期节点上。一次滚动发布之后,监控出现两个互相矛盾的信号:
- 新建房间的成功率正常,但大厅不断返回“房间已满”;
- 房间进程的实际 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 唯一约束;第二次创建返回已有房间,而不是新建。
还有一种隐蔽情况:节点网络分区。进程还活着,但心跳发不出去,租约到期。此时不能只凭租约就判定死亡,应该先查询进程是否仍在运行;如果进程还在,让它主动停止并上报,而不是让大厅直接回收并分配新房。
检测与回收:宁可慢一步,不要误杀
幽灵检测有三层,成本从低到高:
- 心跳与租约:每个房间每 10 秒续约一次,30 秒未续约进入候选。
- 对账扫描:每 30 秒扫描候选房间,查询进程注册表或节点上的进程列表。
- 人工兜底:对账仍无法判定的房间进入隔离队列,保留记录但不接受新玩家,等待人工确认。
回收动作必须满足三个条件:租约已过期、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 实例分配、暖池与回收 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。