房间匹配不是找一张空桌:Go 游戏服务端如何安全分配战斗房间

不是空桌
游戏房间匹配常被描述成“凑够人数,找一个空房间”。这句话省略了最危险的部分:玩家从排队状态离开以后,战斗节点未必已经成功创建房间;节点返回超时时,房间也可能已经创建,只是响应丢了;进程在两者之间重启,还必须知道玩家究竟应该回到队列,还是继续连接原房间。
因此,Lobby 不只是筛选玩家的算法。它同时承担三个职责:决定哪些玩家可以同局,建立可恢复的房间预留,再把预留安全地提交给某个战斗节点。把这三件事写成一次无状态函数,正常路径会很短,失败路径却无法解释。
队列边界
真实实现首先保护玩家身份。入队请求会检查英雄编号、逻辑版本是否非零,以及玩家是否已经在战斗中。相同玩家重复入队不会新增第二条记录,而是刷新原条目。队列还有明确容量上限,避免异常客户端无限占用内存。
读取玩家资料前先检查一次,准备写入队列时在锁内再检查一次,因为数据库读取期间状态可能变化。前者尽早返回,后者才决定能否入队。
状态不能只有“匹配中”和“成功”,而应区分 idle、queued、allocating 与 matched。其中 allocating 表示玩家已离开队列,但战斗入口尚未确认;客户端应该等待,不能再次入队制造重复房间。
动态分组
分组先复制队列快照,再按入队时间排序。候选玩家必须使用同一逻辑版本,Elo 差值必须落在双方当前允许窗口的较小值内。窗口会随等待时间每五秒扩大,直到配置上限。这样新玩家不能借用老玩家的宽窗口强行进入差距过大的对局,等待较久的人又能逐步换取更快开局。
下面是从实际控制流压缩出的 Go 风格伪代码,不是项目源码,也省略了字段校验和排序细节:
func pickGroups(now time.Time) [][]Player {
snapshot := copyQueue()
sortQueue(snapshot)
groups := selectPlayers(snapshot, now)
lockQueue()
defer unlockQueue()
groups = revalidate(groups, queue)
queue = removeSelected(queue, groups)
return groups
}
快照之外还要在锁内重新确认玩家仍然存在。否则玩家在计算期间取消排队,旧快照仍可能把他送进房间。确认与移出必须是同一次提交,而昂贵的分组选取可以留在锁外完成。
预留与分配
玩家选定后,系统不会直接请求战斗节点,而是先把 room_id 和玩家列表持久化,再把玩家映射到这个房间。此时对外状态是 allocating。预留成功以后,Lobby 才携带稳定的请求标识、分配标识、房间编号、玩家列表和随机种子调用节点。
这个顺序把创建意图留在数据库,为恢复提供依据。但当前实现的启动恢复只处理已激活房间,尚未消费未激活预留。这是一个真实缺口:启动时还需要扫描预留,并依据原节点确认结果继续激活或回滚。反过来先创建远程房间再落库,进程在两步之间退出,就会留下 Lobby 完全不知道的孤儿资源。
以下仍是伪代码,并省略持久化失败、取消失败和确认循环:
func allocate(ctx context.Context, group []Player) {
room := reserveRoom(group) // 先持久化预留
markAllocating(group, room.ID)
result, err := nodes.Create(ctx, room.Key)
switch {
case err == nil:
activate(room, result)
case isUnconfirmed(err):
confirmOriginal(room, err.Pending)
default:
rollback(room, group)
}
}
伪代码的重点不是函数名称,而是先预留、再远程分配,并让每条失败分支都有明确的补偿条件。
每次 Tick 可以同时分配多个房间,但当前实现把并发上限固定为八。限制的不是匹配计算,而是会访问数据库和多个节点的分配流程。它避免一批玩家同时满足条件时瞬间放大下游压力。
节点选择
战斗节点先并发返回容量信息。候选节点必须处于接受状态、最大房间数大于零,并且当前房间数尚未达到上限。排序不能只看活动房间的绝对数量:二十个房间对容量二十的节点已经满载,对容量一百的节点却只有两成负载。
当前实现比较 active_rooms / max_rooms,并用交叉相乘避免浮点数。负载比例相同时,再用稳定的节点编号打破平局。这样的选择器不承诺完美全局均衡,因为容量查询和真正创建之间必然有时间差;它的目标是做出确定、便宜且足够合理的选择,最终容量仍由战斗节点在创建时裁决。
不确定不是失败
最值得保留的架构边界,是把“请求失败”和“结果不确定”分开。若创建请求超时,节点可能已经完成建房。此时立刻改投第二个节点,会让同一批玩家拥有两个房间。
系统使用稳定的请求与分配标识,只向原节点查询结果。确认存在就继续激活;确认不存在后,才允许转向下一候选节点,或在没有候选时回滚;一直无法确认,则保留预留和 allocating 状态。激活数据库记录失败时,也先取消节点资源,取消成功后才清除预留并重新入队。
这是一条实用规则:远程副作用的响应丢失后,不要重做动作,先查询原动作。幂等标识回答“是哪次请求”,确认接口回答“是否发生”,持久化预留为重启恢复留下数据,而恢复流程还必须真正消费它。
怎样验证
本次写作前实际运行了 Lobby 与战斗节点客户端两个 Go 测试包,均通过。测试不只覆盖成功建房,还覆盖了相同玩家重复入队、蛇形分队、超时后的原节点确认、临时未找到后继续确认、禁止跨节点重复创建、未确认房间保持预留、激活失败取消资源,以及单次 Tick 最多八个并发分配。
上线前还应补充运行指标:排队人数与等待分位数、各逻辑版本的队列规模、Elo 窗口扩大次数、房间预留停留时间、节点拒绝率、未确认分配数量和回滚重入队次数。这些是基于当前状态机提出的观测建议,不代表项目已经具备全部指标。
可靠的匹配架构并不追求把所有步骤塞进一个事务,而是明确每一步的所有者、持久状态和补偿条件。先安全地选出玩家,再持久化意图,最后以可确认的幂等请求分配节点。这样写出的房间匹配,即使遇到超时、拒绝和重启,也能回答每一名玩家现在究竟在哪里。