5.1 实例调度器
单服跑起来之后,第一件要自动化的事不是「多开几个进程」,而是「谁来开、开在哪、什么时候算开好了」。场景服随地图常驻,战斗服从开打到结算只有几分钟,世界服跨场景长期存在——三类实例的生命周期完全不同,但它们都归同一个调度器管。
调度器要回答三个问题:开什么(类型)、开在哪(节点)、什么时候能接玩家(就绪)。最容易出错的是第三个:进程 fork 成功不等于服务可用,端口还没监听、配置还没加载完的实例如果被分出去,玩家就会连到一个连不上的地址。本节把这三件事做成一套代码:一张只有合法迁移的生命周期表,加一个「读到 READY 才算就绪」的调度器。先交代本节与站内既有文章的分工:
| 既有文章 | 负责「架构方案与取舍」 | 本节只做什么 |
|---|---|---|
/game-server-room-cluster-architecture-design/ | 房间 / 副本集群的整体架构与实例编排方案 | 落地可编译的调度器与生命周期状态机,把「创建到就绪」每一步跑出来 |
/game-server-world-partition-handoff-architecture/ | 世界服分区与跨分区交接的所有权设计 | 给出三类实例共享的状态机与就绪判定,作为交接前的准入条件 |
/game-server-regional-shard-directory-architecture/ | 分区服目录与实例寻址方案 | 实现「实例就绪后才登记地址」,让目录里不会出现连不上的地址 |
一句话:别人讲「实例集群该怎么设计」,本节讲「调度器这一层怎么写、怎么证明实例真的就绪了」。
三类实例与生命周期状态机
三类实例的差别不在技术栈,而在生命周期长度:场景服随地图常驻,战斗服从开打到结算只有几分钟,世界服则跨场景长期存在。但「活着」的判据完全一样——从创建到销毁,中间必须依次经过就绪、分配、使用、回收。把这条链写成一张只允许合法迁移的表,是调度器唯一需要的真相来源。下面这段是 L1,go test ./internal/scheduler/ 可以直接跑:
// file: internal/scheduler/types.go
// tier: L1
// verify: go test ./internal/scheduler/
package scheduler
import "fmt"
// ServerID 是区服号(uint16),沿用全卷共享类型:参与 EntityID 高位与雪花 worker id。
type ServerID uint16
// InstanceID 是地图实例 ID(uint64)。
type InstanceID uint64
// NodeID 是集群节点标识,形如 scene-01、gateway-03。
type NodeID string
// Kind 区分三类被调度的实例。
type Kind uint8
const (
KindScene Kind = iota // 场景服:承载一张常驻地图的在线玩家
KindBattle // 战斗服:为一场副本 / 战场临时开出
KindWorld // 世界服:跨场景的全局逻辑(拍卖行、排行、跨服)
)
// State 是实例生命周期状态:创建 → 就绪 → 分配 → 使用 → 回收 → 销毁。
type State uint8
const (
StateCreated State = iota // 进程已起,尚未就绪
StateReady // 已就绪,可被分配
StateAllocated // 已分配给某个玩法
StateInUse // 使用中(有玩家在)
StateRecycling // 回收中(等待玩家排空)
StateGone // 已销毁
)
var stateNames = [...]string{"created", "ready", "allocated", "in-use", "recycling", "gone"}
func (s State) String() string {
if int(s) < len(stateNames) {
return stateNames[s]
}
return "unknown"
}
// allowed 是实例生命周期状态机。只允许表里的迁移;任何跳步都返回错误,
// 而不是静默推进——「实例现在能不能接玩家」永远由这张表回答。
var allowed = map[State][]State{
StateCreated: {StateReady, StateGone},
StateReady: {StateAllocated, StateRecycling},
StateAllocated: {StateInUse, StateReady, StateRecycling},
StateInUse: {StateRecycling},
StateRecycling: {StateGone},
StateGone: {},
}
// Instance 是调度器眼里的一个实例。
type Instance struct {
ID InstanceID
Kind Kind
Node NodeID
Server ServerID
State State
Addr string // 实例对外地址,就绪后才有
}
// Advance 把实例推进到目标状态;非法迁移返回错误,状态保持不变。
func (in *Instance) Advance(to State) error {
for _, ok := range allowed[in.State] {
if ok == to {
in.State = to
return nil
}
}
return fmt.Errorf("instance %d: illegal transition %s -> %s", in.ID, in.State, to)
}
// Assignable 回答「这个实例现在能不能被分配出去」——只有就绪态可以。
func (in *Instance) Assignable() bool { return in.State == StateReady }
三个约定值得注意。Kind 只有三个值,因为「开哪种实例」是有限枚举,不是自由字符串。allowed 表把「就绪才能分配、使用中才能回收」这类约束编码成数据——Advance 遇到跳步(比如从创建直接跳到使用)会报错,而不是静默推进,这样任何状态错乱都会在第一次发生时炸出来。Assignable 只有就绪态返回真,是调度器决定「能不能把玩家塞进去」的唯一入口。
从创建到就绪:调度器真起一个进程
调度逻辑与进程管理必须分开:Scheduler 只关心状态推进,Launcher 才负责真起进程。这样测试可以注入假 Launcher 验证状态机,生产用 ProcessLauncher 真 exec 子进程。就绪的判据不是「进程起来了」,而是「子进程的 stdout 打出了 READY addr=...」——端口没监听就不会打这一行。下面这段是 L2,go test ./internal/scheduler/ -run TestSchedulerSpawnsRealInstance 会真起一个实例进程:
// file: internal/scheduler/scheduler.go
// tier: L2
// verify: go test ./internal/scheduler/ -run TestSchedulerSpawnsRealInstance
package scheduler
import (
"bufio"
"context"
"fmt"
"os/exec"
"strings"
"sync"
)
// Launcher 起一个实例进程,返回它的对外地址。真实实现 fork 子进程;
// 测试里可注入假实现,把调度逻辑与进程管理解耦。
type Launcher interface {
Launch(ctx context.Context, id InstanceID, kind Kind, server ServerID) (addr string, err error)
}
// Scheduler 负责「什么时候开一个实例、开完等它就绪」:分配 ID、调 Launcher
// 起进程、把状态从创建推进到就绪。起不来就推进到销毁,绝不留下半死状态。
type Scheduler struct {
mu sync.Mutex
nextID InstanceID
launcher Launcher
instances map[InstanceID]*Instance
}
func New(launcher Launcher) *Scheduler {
return &Scheduler{nextID: 1, launcher: launcher, instances: make(map[InstanceID]*Instance)}
}
// Create 起一个实例并推进到就绪态:创建 → (launch) → 就绪。
func (s *Scheduler) Create(ctx context.Context, kind Kind, server ServerID, node NodeID) (*Instance, error) {
s.mu.Lock()
id := s.nextID
s.nextID++
in := &Instance{ID: id, Kind: kind, Server: server, Node: node, State: StateCreated}
s.instances[id] = in
s.mu.Unlock()
addr, err := s.launcher.Launch(ctx, id, kind, server)
if err != nil {
_ = in.Advance(StateGone)
return nil, fmt.Errorf("launch instance %d: %w", id, err)
}
in.Addr = addr
if err := in.Advance(StateReady); err != nil {
return nil, err
}
return in, nil
}
// ProcessLauncher 真起一个实例进程:exec 指定二进制,从它的 stdout 读到
// 「READY addr=...」才认为就绪。子进程挂在 ctx 上,ctx 取消(或超时)时
// 被自动杀掉——这是「就绪超时」这一状态的根本保障。
type ProcessLauncher struct {
Bin string
Args []string
}
func (p *ProcessLauncher) Launch(ctx context.Context, id InstanceID, kind Kind, server ServerID) (string, error) {
args := append(append([]string{}, p.Args...), "-id", fmt.Sprint(id), "-kind", fmt.Sprint(int(kind)), "-server", fmt.Sprint(server))
cmd := exec.CommandContext(ctx, p.Bin, args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return "", err
}
if err := cmd.Start(); err != nil {
return "", err
}
sc := bufio.NewScanner(stdout)
for sc.Scan() {
if line := sc.Text(); strings.HasPrefix(line, "READY addr=") {
return strings.Fields(strings.TrimPrefix(line, "READY addr="))[0], nil
}
}
_ = cmd.Process.Kill()
return "", fmt.Errorf("instance %d exited before ready", id)
}
ProcessLauncher 把子进程挂在 ctx 上(exec.CommandContext),ctx 超时子进程被自动杀掉——「就绪超时」不靠上层轮询判断,而是进程根本没机会活过超时。被它起的那个「实例」是 cmd/sceneinstance/main.go:监听一个随机端口、打印 READY addr=... 后阻塞等信号。只有监听成功才打印这一行,所以「读到 READY」等价于「端口已就绪」。就绪之后实例要做的第一件事,是带着自己的地址到注册中心报到——那一步复用服务发现域的命令号 CmdNodeRegisterReq 0x0F01 与心跳 CmdNodeHeartbeatReq 0x0F02(见第 2 章),本节不重复。
真跑断言:进程确实在监听
光看状态机变成就绪还不够,得证明那个地址真的能连上。测试自己 go build 出实例二进制(保证跑的就是当前源码),交给 ProcessLauncher,断言状态为就绪,再拨号连它的地址。下面这段是 L2,go test ./internal/scheduler/ -run TestSchedulerSpawnsRealInstance 真起进程并拨号:
// file: internal/scheduler/spawn_test.go
// tier: L2
// verify: go test ./internal/scheduler/ -run TestSchedulerSpawnsRealInstance
package scheduler
import (
"context"
"net"
"os/exec"
"path/filepath"
"testing"
"time"
)
// buildSceneInstance 把 cmd/sceneinstance 编译到临时目录,返回二进制路径。
// 测试自己编译,保证「跑的就是当前源码」,不依赖预先构建的产物。
func buildSceneInstance(t *testing.T) string {
t.Helper()
bin := filepath.Join(t.TempDir(), "sceneinstance")
cmd := exec.Command("go", "build", "-o", bin, "./cmd/sceneinstance")
cmd.Dir = "../.." // 从 internal/scheduler 回到 module 根
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build sceneinstance: %v\n%s", err, out)
}
return bin
}
// TestSchedulerSpawnsRealInstance 真起一个实例进程:调度器读到 READY 后
// 把它推进到就绪,测试再拨号连它的地址,证明进程确实在监听。
func TestSchedulerSpawnsRealInstance(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
s := New(&ProcessLauncher{Bin: buildSceneInstance(t)})
in, err := s.Create(ctx, KindBattle, 7, "battle-01")
if err != nil {
t.Fatalf("create: %v", err)
}
if in.State != StateReady {
t.Fatalf("state = %s, want ready", in.State)
}
conn, err := net.DialTimeout("tcp", in.Addr, 2*time.Second)
if err != nil {
t.Fatalf("dial instance %s: %v", in.Addr, err)
}
_ = conn.Close()
}
// TestLaunchFailureGoesToGone 用一个必定失败的 Launcher,断言实例被推进
// 到销毁而不是卡在创建态。
func TestLaunchFailureGoesToGone(t *testing.T) {
s := New(failingLauncher{})
in, err := s.Create(context.Background(), KindScene, 1, "scene-99")
if err == nil {
t.Fatal("expected launch error")
}
if in != nil {
t.Fatalf("expected nil instance, got %+v", in)
}
}
type failingLauncher struct{}
func (failingLauncher) Launch(context.Context, InstanceID, Kind, ServerID) (string, error) {
return "", exec.ErrNotFound
}
两个用例覆盖两条路径:成功路径断言状态为就绪且地址可拨号;失败路径(failingLauncher)断言实例被推进到销毁、Create 返回 nil——绝不会留下一个卡在创建态的僵尸实例。
本节小结 / 实测结论
本机实测:TestSchedulerSpawnsRealInstance 起真进程并拨号成功,约 1.2 秒(含一次 go build);TestLaunchFailureGoesToGone 确认起不来就销毁。这一节只解决了「起一个实例」,还没回答「起在哪台机器、开不出来怎么办」——那是下一节的分配 Broker 与容量评估。调度器只提供状态机与就绪判据,把「放哪儿」留给 Broker,两者职责不重叠。全部用例在本机 go test ./internal/scheduler/ 全绿。
阅读导航:上一节:目录 · 下一节:5.2 分配 Broker 与容量评估 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。