《MMO 服务器开发》5.1 实例调度器

场景服、战斗服、世界服三类实例由同一个调度器管理:分配 ID、起进程、等到就绪、推进生命周期。本节把实例状态机(创建→就绪→分配→使用→回收)写成一张只有合法迁移的表,并用 aegis 真起一个实例进程、读到 READY 后再断言它确实在监听。

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 与容量评估 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标