《游戏服务端架构·规模化、全球化与成本》2.1 专用服、容器与调度器

从《裂隙战场》扩容后房间仍然创建失败的现场出发,比较物理机、虚拟机、容器与专用进程在 UDP 端口、CPU 绑定、大页和内核参数上的约束差异,说明端口唯一性、版本匹配、亲和与故障域打散应放进调度器还是留给服务,并给出带失败原因编码的可运行 Go 放置器。

一间房起不来,先看它需要什么

《裂隙战场》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 暖池、冷启动与扩容速度 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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