一间房起不来,先看它需要什么
《裂隙战场》S3 赛季扩容当天的值班记录是这样的:匹配成功 3.2 秒后房间创建失败,错误码 NO_CAPACITY,而同一时刻集群 CPU 使用率 61%、内存 54%。第一反应是容量不够,于是又加了两台机器;失败率没有下降。
把失败请求逐条展开,问题分成三类。第一,新房间需要 UDP 端口段里的一个端口,它落到的宿主机上这段端口已经被旧房间占满。第二,同一场对局的十个玩家被拆到两个房间,两个房间又落在同一台宿主机上,宿主机故障时整场对局一起消失。第三,一部分节点还在跑上一个版本的房间进程,新客户端连上去后协议不匹配。
这三类都不是“总量不够”,而是“约束不满足”。扩容解决总量,调度器解决约束。这一节讨论游戏专用服跑在什么上面、哪些约束必须由调度器保证,以及一个只做约束校验和放置的调度器长什么样。
三种承载形态,约束不一样
| 形态 | 网络 | CPU 与内存 | 隔离与运维 | 北辰的用法 |
|---|---|---|---|---|
| 物理机 | 固定网卡、可整段分配 UDP 端口 | 可绑核、可预留大页 | 隔离最好,交付最慢 | 《裂隙战场》排位服 |
| 虚拟机 | 端口段随虚机下发 | 绑核受宿主机影响 | 迁移方便,网络多一层 | 《群岛纪元》世界服 |
| 容器 | 端口映射或 hostNetwork | 共享内核,绑核需显式配置 | 启动快、密度高 | 《泡泡工坊》房间服 |
| 专用进程 | 进程自己监听端口段 | 由宿主机决定 | 无额外抽象,排查直接 | 三款产品的地图与结算进程 |
差异集中在四个地方。网络方面,游戏房间通常要一段连续 UDP 端口,而不是一个固定端口;容器默认的端口映射会把源端口改掉,PvP 场景要么用 hostNetwork,要么在节点上做端口段直通。CPU 方面,tick 敏感的房间希望绑核,容器里要同时设置 cpuset 和进程亲和性,否则调度器的“绑核”只是标签。内存方面,大页必须在宿主机启动时预留,容器只能继承,不能临时申请。内核参数方面,net.core.rmem_max、net.core.somaxconn 这类参数是节点级的,同一个节点上的所有容器共享,不能按房间单独调。
因此“用容器还是物理机”不是镜像问题,而是约束由谁保证的问题。北辰的选择是:排位服跑物理机,用专用进程和固定端口段;世界服跑虚拟机,按赛区部署;休闲房间跑容器,但端口由房间进程自己从节点端口段里领,不经过容器端口映射。
调度器该管什么,不该管什么
| 约束 | 归属 | 理由 |
|---|---|---|
| 端口唯一 | 调度器 | 放置前即可判断,冲突必须在提交前排除 |
| 镜像与协议版本 | 调度器 | 版本不匹配的房间一定失败,属于硬约束 |
| 房间类型与节点池 | 调度器 | 排位服、练习服、观战服的资源规格不同 |
| 故障域打散 | 调度器 | 需要全局视图,单个房间看不到其他房间 |
| CPU 绑核、大页 | 节点注册时声明 | 调度器只匹配标签,不负责改内核 |
| 匹配算法、玩家分组 | 匹配服务 | 属于业务规则,调度器不应理解段位 |
| 房间内 tick、结算 | 房间进程 | 运行时行为,放置阶段无法判断 |
边界可以压成一句话:能在放置前判断的硬约束进调度器,需要运行时信息的留给服务。调度器不猜容量,也不做业务重试;它返回“哪个节点、哪个端口、第几个 epoch”,失败时返回每个节点被拒绝的原因。
这条边界经常被打破。有的团队让调度器在房间创建失败后自动换节点重试三次,结果把一次端口冲突放大成三次无效分配;也有的团队把段位匹配塞进调度器,最后调度器不得不理解赛季规则。前者应该由房间服务带退避重试,后者应该留在匹配服务。
数据模型:节点、请求与放置记录
节点注册时带上区域、故障域、CPU 与内存总量、可分配的 UDP 端口段、支持的版本集合和标签。Zone 是故障域,不是机房名;同一路交换机、同一路电源都可能是同一个故障域。
放置请求只描述“这间房需要什么”:区域、版本、房间类型、CPU、内存、是否需要 UDP、是否要求同组打散、亲和标签。请求里没有玩家信息,也没有匹配结果,调度器不需要知道这间房为什么存在。
放置记录必须带 epoch。房间崩溃后重建,同一个 RoomID 可能对应新旧两次放置;旧进程迟到的释放请求如果只按房间号删除,会把新放置一起删掉。epoch 是单调递增的栅栏,释放时必须原样带回。
失败原因用短编码而不是自然语言:unhealthy、region、version、affinity:<label>、capacity、spread、port。值班人看到 capacity=7,port=2 就知道七台节点资源不够、两台端口耗尽,这比一句“没有可用节点”有用得多。
放置算法:先过滤,再 best-fit
Place(request)
│
├─ 逐节点 check()
│ unhealthy / region / version / affinity / capacity / spread / port
│ 任一不满足 → 记原因,跳过
│
├─ 剩余候选按 best-fit 排序
│ 剩余 CPU 最小 → 剩余内存最小 → 节点 ID 字典序
│
└─ 提交:占端口 + 扣资源 + group/zone 计数 + epoch++
best-fit 选“放完之后剩余资源最少”的节点,目的是把大块空闲留给后续的大房间,降低长期碎片。代价是当前这间房可能被放到更满的机器上,单房故障影响面略大;如果产品更在意隔离,可以换成 least-fit 或按房间类型分池,这是明确的取舍,不是对错。
故障域打散用 group -> zone -> 计数 实现。同一场对局的所有房间共享一个 group;要求打散时,已经出现过该 group 的 zone 直接拒绝。计数而不是布尔值,是因为不要求打散的请求也可能落在同一 zone,释放时不能把别的房间的占用一起清掉。
代码:约束校验与放置
下面的 internal/dedicatedsched/scheduler.go 把节点、请求、失败原因、best-fit 放置、epoch 释放和节点故障回收放在一个文件里。它不依赖任何外部组件,时间与并发由调用方决定。
// file: internal/dedicatedsched/scheduler.go
// tier: L1
// verify: go test ./internal/dedicatedsched/
package dedicatedsched
import (
"errors"
"fmt"
"maps"
"sort"
"strings"
)
var (
ErrInvalidNode = errors.New("dedicatedsched: invalid node")
ErrInvalidRequest = errors.New("dedicatedsched: invalid request")
ErrDuplicateRoom = errors.New("dedicatedsched: room already placed")
ErrUnknownRoom = errors.New("dedicatedsched: unknown room")
ErrStaleEpoch = errors.New("dedicatedsched: stale placement epoch")
ErrNoCapacity = errors.New("dedicatedsched: no node satisfies the request")
)
// Node is one scheduling unit: a physical host, a VM, or a container host.
// PortStart/PortEnd describe the UDP segment the node may hand to rooms.
type Node struct {
ID string
Region string
Zone string
CPUTotal int
MemTotalMB int
PortStart int
PortEnd int
Versions []string
Labels map[string]string
Healthy bool
CPUTaken int
MemTaken int
ports map[int]bool
}
func (n *Node) supports(version string) bool {
for _, v := range n.Versions {
if v == version {
return true
}
}
return false
}
func (n *Node) freePort() (int, bool) {
if n.PortStart <= 0 || n.PortEnd < n.PortStart {
return 0, false
}
for p := n.PortStart; p <= n.PortEnd; p++ {
if !n.ports[p] {
return p, true
}
}
return 0, false
}
// Request is one room placement. Spread asks the scheduler to keep every room
// of the same Group in a different zone; Affinity pins the node labels.
type Request struct {
RoomID string
Region string
Version string
Type string
CPU int
MemMB int
NeedUDP bool
Group string
Spread bool
Affinity map[string]string
}
type Placement struct {
RoomID string
NodeID string
Zone string
Group string
CPU int
MemMB int
UDPPort int
Epoch uint64
}
type Scheduler struct {
nodes map[string]*Node
byRoom map[string]Placement
group map[string]map[string]int // group -> zone -> placements
epoch uint64
}
func New() *Scheduler {
return &Scheduler{
nodes: map[string]*Node{},
byRoom: map[string]Placement{},
group: map[string]map[string]int{},
}
}
func (s *Scheduler) AddNode(n Node) error {
if n.ID == "" || n.Region == "" || n.Zone == "" || n.CPUTotal <= 0 || n.MemTotalMB <= 0 {
return ErrInvalidNode
}
if n.PortStart <= 0 || n.PortEnd < n.PortStart {
return ErrInvalidNode
}
if old, ok := s.nodes[n.ID]; ok && old.Healthy {
return fmt.Errorf("%w: %s", ErrInvalidNode, n.ID)
}
n.Healthy = true
n.CPUTaken, n.MemTaken = 0, 0
n.Versions = append([]string(nil), n.Versions...)
n.Labels = maps.Clone(n.Labels)
n.ports = map[int]bool{}
s.nodes[n.ID] = &n
return nil
}
func (s *Scheduler) nodeIDs() []string {
ids := make([]string, 0, len(s.nodes))
for id := range s.nodes {
ids = append(ids, id)
}
sort.Strings(ids)
return ids
}
// check returns an empty reason when the node can host the request, otherwise
// a short machine-readable cause used by Explain and by the capacity report.
func (s *Scheduler) check(n *Node, r Request) (int, string) {
if !n.Healthy {
return 0, "unhealthy"
}
if n.Region != r.Region {
return 0, "region"
}
if !n.supports(r.Version) {
return 0, "version"
}
for k, v := range r.Affinity {
if n.Labels[k] != v {
return 0, "affinity:" + k
}
}
if n.CPUTotal-n.CPUTaken < r.CPU || n.MemTotalMB-n.MemTaken < r.MemMB {
return 0, "capacity"
}
if r.Spread && r.Group != "" && s.group[r.Group][n.Zone] > 0 {
return 0, "spread"
}
if r.NeedUDP {
port, ok := n.freePort()
if !ok {
return 0, "port"
}
return port, ""
}
return 0, ""
}
// Explain reports why each node accepts or rejects the request without
// mutating any state. It is the input of the "why did placement fail" panel.
func (s *Scheduler) Explain(r Request) map[string]string {
out := map[string]string{}
for _, id := range s.nodeIDs() {
_, reason := s.check(s.nodes[id], r)
if reason == "" {
reason = "ok"
}
out[id] = reason
}
return out
}
// Place selects a node with a best-fit score: the node that leaves the least
// CPU (then memory, then id) after the room fits. Best-fit lowers long-run
// fragmentation at the cost of refusing a large room on a nearly empty host.
func (s *Scheduler) Place(r Request) (Placement, error) {
if r.RoomID == "" || r.Region == "" || r.Version == "" || r.CPU <= 0 || r.MemMB <= 0 {
return Placement{}, ErrInvalidRequest
}
if _, ok := s.byRoom[r.RoomID]; ok {
return Placement{}, ErrDuplicateRoom
}
var best *Node
var bestPort, bestCPU, bestMem int
for _, id := range s.nodeIDs() {
n := s.nodes[id]
port, reason := s.check(n, r)
if reason != "" {
continue
}
leftCPU := n.CPUTotal - n.CPUTaken - r.CPU
leftMem := n.MemTotalMB - n.MemTaken - r.MemMB
if best == nil || leftCPU < bestCPU ||
(leftCPU == bestCPU && (leftMem < bestMem ||
(leftMem == bestMem && n.ID < best.ID))) {
best, bestPort, bestCPU, bestMem = n, port, leftCPU, leftMem
}
}
if best == nil {
return Placement{}, fmt.Errorf("%w: %s", ErrNoCapacity, s.reasonSummary(r))
}
s.epoch++
if r.NeedUDP {
best.ports[bestPort] = true
}
best.CPUTaken += r.CPU
best.MemTaken += r.MemMB
if r.Group != "" {
if s.group[r.Group] == nil {
s.group[r.Group] = map[string]int{}
}
s.group[r.Group][best.Zone]++
}
p := Placement{RoomID: r.RoomID, NodeID: best.ID, Zone: best.Zone, Group: r.Group,
CPU: r.CPU, MemMB: r.MemMB, UDPPort: bestPort, Epoch: s.epoch}
s.byRoom[r.RoomID] = p
return p, nil
}
func (s *Scheduler) reasonSummary(r Request) string {
counts := map[string]int{}
for _, reason := range s.Explain(r) {
if reason != "ok" {
counts[reason]++
}
}
if len(counts) == 0 {
return "no node registered"
}
parts := make([]string, 0, len(counts))
for reason, n := range counts {
parts = append(parts, fmt.Sprintf("%s=%d", reason, n))
}
sort.Strings(parts)
return strings.Join(parts, ",")
}
// Release frees a room. The epoch prevents a late release from deleting a
// newer placement that reused the same room id after a crash.
func (s *Scheduler) Release(roomID string, epoch uint64) error {
p, ok := s.byRoom[roomID]
if !ok {
return ErrUnknownRoom
}
if p.Epoch != epoch {
return ErrStaleEpoch
}
s.free(p)
delete(s.byRoom, roomID)
return nil
}
func (s *Scheduler) free(p Placement) {
if n := s.nodes[p.NodeID]; n != nil {
if p.UDPPort > 0 {
delete(n.ports, p.UDPPort)
}
n.CPUTaken -= p.CPU
n.MemTaken -= p.MemMB
}
if p.Group != "" && s.group[p.Group] != nil {
s.group[p.Group][p.Zone]--
if s.group[p.Group][p.Zone] <= 0 {
delete(s.group[p.Group], p.Zone)
}
}
}
// FailNode marks a node unhealthy and returns every placement that must be
// re-queued. The caller re-places them elsewhere or returns players to match.
func (s *Scheduler) FailNode(nodeID string) []Placement {
n, ok := s.nodes[nodeID]
if !ok {
return nil
}
n.Healthy = false
var out []Placement
for room, p := range s.byRoom {
if p.NodeID != nodeID {
continue
}
out = append(out, p)
s.free(p)
delete(s.byRoom, room)
}
n.CPUTaken, n.MemTaken = 0, 0
n.ports = map[int]bool{}
sort.Slice(out, func(i, j int) bool { return out[i].RoomID < out[j].RoomID })
return out
}
check是唯一的约束入口,Explain和Place共用它,避免“能解释通过、实际却失败”的两套逻辑。Place在提交前完成全部校验,提交阶段不再返回业务错误;端口、资源、group 计数和 epoch 一次写完。Release校验 epoch,迟到的释放只返回ErrStaleEpoch,不会误删新放置。FailNode把节点上的放置整体摘出并释放资源,调用方拿到列表后重新入队,而不是在原节点上重试。
AddNode 要求端口段必须存在,即使该节点只跑不需要 UDP 的进程。这是刻意的:端口段是节点注册的一部分,缺失说明注册信息不完整,早失败比运行期发现端口不够好。
失败模式与兜底
- 节点故障:
FailNode返回该节点上的全部放置,房间服务把会话重新入队;健康检查恢复后节点重新注册,但不会自动接回旧房间。 - 释放迟到:epoch 不匹配,释放被拒绝;资源由新放置的租约管理,不依赖旧进程的清理。
- 端口泄漏:进程崩溃未释放端口,调度器只信租约;节点上的端口回收器按租约表清理,节点重启是最后兜底。
- 同组打散失败:zone 数量少于房间数时
spread必然失败,调用方应降级为“尽量打散”而不是无限重试。
常见错误与边界
- 用集群总空闲判断能否开房,忽略单节点碎片和端口段。
- 把故障域写成机房名,同一机柜的两台机器仍被当成两个故障域。
- 让调度器理解匹配规则,结果调度器随赛季规则一起发版。
- 释放只带房间号不带 epoch,崩溃重建后误删新房间。
小结
专用服、容器与物理机的差别不在镜像,而在“端口、绑核、大页、内核参数”由谁保证。调度器只做三件事:按硬约束过滤、按 best-fit 打分、带 epoch 提交;匹配、房间逻辑和运行时重试都留在服务侧。把失败原因编码出来,是把调度器从黑盒变成可运维组件的关键一步。
下一节讨论另一个维度:房间已经能放下,但从“决定开房”到“玩家能进”之间还有一段冷启动时间,暖池就是拿钱买这段时间。
阅读导航:上一节:1.3 容量模型与余量 · 下一节:2.2 暖池、冷启动与扩容速度 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。