《游戏服务端架构·核心系统与契约》5.1 战斗服模型:房间内、旁路与托管

《裂隙战场》的 30 Hz 对抗、《群岛纪元》的异步战报和《泡泡工坊》的轻量对局,对战斗进程的要求完全不同。本节用 tick 预算、战斗时长、回放要求和团队运维能力四组约束,对比房间内计算、独立战斗服与托管算力三种模型的延迟、成本与故障域,并给出崩溃恢复语义和可运行的选型函数。

战斗跑在哪,先看 tick 预算

北辰工作室的三款产品在同一个平台上运行,但战斗的位置完全不同。《裂隙战场》一局 5v5,三十赫兹,打十五分钟;《群岛纪元》的行军与守军交战在服务端异步结算,玩家看到的是战报;《泡泡工坊》一局八人、十赫兹、六分钟,输赢只影响一局内的分数。

如果只看“有没有战斗服”,很容易得出“竞技品类必须独立战斗服、休闲品类不需要”的结论。这个结论在《泡泡工坊》上会翻车:它的房间服务本来就要持有对局状态,把战斗放进房间进程最便宜;而在《裂隙战场》上,房间服务如果同时承担匹配、聊天和战斗,一次发布就会把正在进行的比赛全部打断。

选型的第一步不是比较架构名词,而是把 tick 预算算出来:

单帧预算 = 1000 ms / tick rate
模拟耗时 ≈ 实体数 × 单实体更新成本 + 裁决与广播成本
安全余量 = 单帧预算 - 模拟耗时 p99

《裂隙战场》30 Hz 时单帧预算约 33.3 ms;十名玩家、技能与投射物峰值按示例值 8 ms 估算,还剩 25 ms 给网络与落库。《泡泡工坊》10 Hz 时预算 100 ms,八人局几乎没有压力。预算越紧,越需要把战斗从通用房间循环里拆出去;预算越松,拆出去只是在为运维付钱。

三种模型不是三个技术名词

模型计算位置延迟画像(示例值)单局成本(示例值)单进程故障影响回放能力适用条件
房间内房间服务进程内p99 约 8 ms6 分同进程的房间一起掉可选,取决于是否记帧小局、低频、团队没有独立战斗组
独立战斗服专用战斗进程/节点p99 约 4 ms18 分通常只影响一个战斗实例权威帧日志高 tick、竞技、需要回放或强反作弊
托管算力通用函数/容器平台p99 约 25 ms11 分单实例失败,平台重调度通常只有事件记录异步结算、无实时 tick、团队无人运维

表格里的数字是容量评审用的示例值,不是线上测量。它们的作用不是精确报价,而是把讨论从“哪个架构更先进”拉回到“我们愿意为哪一列付钱”。

房间内模型最大的优点是省掉了跨进程通信和一套独立的发布流水线。它的代价不是延迟,而是故障域:战斗和匹配、聊天、房间管理共享一个进程,任何一次热更新或内存泄漏都会同时打断比赛。独立战斗服把故障域收窄到一个实例,但要求团队能维护第二套发布、扩缩容和 on-call。托管算力把运维转嫁给平台,代价是冷启动、网络抖动和可观测性缺口——异步结算可以接受,实时对抗通常不能。

选型判据:硬约束在前,成本在后

北辰工作室把选型写成固定顺序,避免每次评审都从偏好吵起:

  1. 先看硬约束。 tick 大于等于 30 Hz,或单局参战者大于等于 20,房间内模型直接出局;没有战斗团队时,这不是“再压一压”能解决的问题。
  2. 再看回放与争议成本。 竞技品类需要权威帧日志,独立战斗服是默认答案;如果团队已经有战斗 on-call,这条几乎不需要讨论。
  3. 再看异步性。 战斗在离线批量任务里结算,不需要 tick 循环,托管算力比自建战斗服更合适。
  4. 最后看成本。 小局、低频、峰值可控时,房间内模型最便宜;峰值超出单进程承载能力时,再考虑独立战斗服。

这里有一个容易忽略的边界:“房间内”不等于“没有战斗逻辑”,只等于“战斗和房间共享生命周期”。《泡泡工坊》把战斗放在房间进程里,但仍然把战斗状态机做成独立包,目的是将来可以整体搬到独立进程,而不是把战斗代码揉进房间生命周期。

崩溃之后:重开、快照还是判负

战斗进程会崩,问题不是“能不能避免”,而是“崩了以后玩家看到什么”。

恢复语义触发条件玩家体验风险
快照恢复有最近一帧的完整快照短暂卡顿后继续快照之后的事件要重放,可能产生分歧
重开休闲局、无排名、无奖励本局作废并重开玩家时间被浪费,但规则简单
判负/作废竞技局、无法恢复、已有投注或排名按规则判负或整局作废需要客服与申诉通道,必须有日志
平台重调度托管模型、无状态任务任务重试或换节点重试必须幂等,否则重复结算

北辰工作室的规则是:有快照优先快照;没有快照的竞技局判负或作废,不静默重开;休闲局才允许重开。 这条规则写进代码,而不是写在值班手册里,因为凌晨三点的值班同学不会去翻手册。

可运行片段:把选型写成函数

下面的 internal/battlemodels 把三种模型的画像、选型顺序和崩溃恢复语义写成可测试的包。它不模拟战斗,只回答“这个 workload 应该落在哪种模型上、为什么”。

// file: internal/battlemodels/battlemodels.go
// tier: L1
// verify: go test ./internal/battlemodels/
package battlemodels

import "errors"

// Model is the deployment shape of the match simulation.
type Model int

const (
	InRoom Model = iota
	Dedicated
	Managed
)

var (
	ErrBadWorkload = errors.New("battlemodels: invalid workload")
	ErrNoFit       = errors.New("battlemodels: no model satisfies constraints")
)

// Workload is the planning input. Async marks battles resolved offline.
type Workload struct {
	PlayersPerMatch int
	TickRate        int
	MatchMinutes    int
	PeakMatches     int
	NeedReplay      bool
	RegionCount     int
	Async           bool
}

func (w Workload) Validate() error {
	if w.PlayersPerMatch < 2 || w.TickRate <= 0 || w.MatchMinutes <= 0 ||
		w.PeakMatches <= 0 || w.RegionCount <= 0 {
		return ErrBadWorkload
	}
	return nil
}

// Team is who will operate the battle tier.
type Team struct {
	Engineers     int
	HasBattleTeam bool
}

// Profile values are example planning numbers, not production measurements.
type Profile struct {
	Model             Model
	TickBudgetMS      float64
	P99LatencyMS      float64
	CostPerMatchCents int
	BlastRadius       int
	ReplayFidelity    string
	OpsHoursPerMonth  int
}

var catalog = []Profile{
	{InRoom, 2, 8, 6, 1, "co-located, replay optional", 8},
	{Dedicated, 4, 4, 18, 8, "authoritative frame log", 40},
	{Managed, 10, 25, 11, 1, "recorded events only", 4},
}

func ProfileOf(m Model) (Profile, bool) {
	for _, p := range catalog {
		if p.Model == m {
			return p, true
		}
	}
	return Profile{}, false
}

type Decision struct {
	Model    Model
	Profile  Profile
	Reasons  []string
	Rejected []string
}

// Select applies hard limits, then replay needs, then cost. A no-fit result is
// a staffing answer, not a code failure.
func Select(w Workload, t Team) (Decision, error) {
	if err := w.Validate(); err != nil {
		return Decision{}, err
	}
	decide := func(m Model, why ...string) Decision {
		p, _ := ProfileOf(m)
		return Decision{m, p, why, []string{
			"in-room: shares the room loop; 10 players and 20 Hz at most",
			"dedicated: needs an on-call battle team",
			"managed: p99 latency example is 25 ms"}}
	}
	if w.Async {
		return decide(Managed, "battles resolve as offline batch jobs",
			"no tick loop and no dedicated tier to operate"), nil
	}
	if w.TickRate >= 30 || w.PlayersPerMatch >= 20 {
		if !t.HasBattleTeam || t.Engineers < 4 {
			return Decision{}, ErrNoFit
		}
		return decide(Dedicated, "tick budget exceeds the shared room loop",
			"the frame log is needed for disputes"), nil
	}
	if w.NeedReplay && t.HasBattleTeam {
		return decide(Dedicated, "competitive replay needs an authoritative frame log",
			"the team already carries a battle rotation"), nil
	}
	if t.Engineers == 0 {
		return decide(Managed, "no game-server engineer to own a runtime",
			"peak matches fit managed capacity"), nil
	}
	if w.PeakMatches <= 200 && w.PlayersPerMatch <= 10 && w.TickRate <= 20 {
		return decide(InRoom, "small matches share the room process",
			"latency stays inside the same host"), nil
	}
	if !t.HasBattleTeam {
		return decide(Managed, "peak matches exceed one shared room process",
			"no battle team to operate a separate tier"), nil
	}
	return decide(Dedicated, "peak matches exceed one shared room process",
		"a dedicated tier bounds the blast radius"), nil
}

type Recovery string

const (
	RestartMatch    Recovery = "restart"
	RestoreSnapshot Recovery = "restore-snapshot"
	Forfeit         Recovery = "forfeit"
)

// RecoveryFor prefers a snapshot; competitive matches that cannot be restored
// are voided rather than replayed with a different outcome.
func RecoveryFor(m Model, hasSnapshot, competitive bool) Recovery {
	if hasSnapshot && m != Managed {
		return RestoreSnapshot
	}
	if competitive {
		return Forfeit
	}
	return RestartMatch
}

几个实现取舍:

  • Workload 把异步战斗单独标成 Async,因为《群岛纪元》的“参战者 40 人”不是实时 tick 的 40 个实体;如果不区分,选型函数会把它误判成需要独立战斗服。
  • Select 先处理硬约束,再处理回放,最后才看成本。顺序本身就是架构决策,换顺序会得到不同答案。
  • Rejected 记录每个候选被跳过的原因。评审时可以直接贴出决策对象,而不是重新跑一遍脑内推理。
  • RecoveryFor 把恢复语义收敛成三个值。托管模型即使有快照也不恢复,因为平台重调度后进程身份已经变了,恢复快照会引入新的不一致。

对应的表驱动测试覆盖画像边界、选型分支、恢复语义和输入校验:

// file: internal/battlemodels/battlemodels_test.go
// tier: L1
// verify: go test ./internal/battlemodels/
package battlemodels

import (
	"errors"
	"testing"
)

func TestCatalogProfilesAreConsistent(t *testing.T) {
	cases := []struct {
		model      Model
		found      bool
		maxLatency float64
		maxCost    int
	}{
		{InRoom, true, 10, 8}, {Dedicated, true, 5, 20}, {Managed, true, 30, 12}, {Model(99), false, 0, 0},
	}
	for _, c := range cases {
		p, ok := ProfileOf(c.model)
		if ok != c.found || (ok && (p.P99LatencyMS > c.maxLatency || p.CostPerMatchCents > c.maxCost)) {
			t.Errorf("%v: found=%v profile=%+v", c.model, ok, p)
		}
	}
}

func TestSelectPicksTheCheapestCoveringModel(t *testing.T) {
	cases := []struct {
		name    string
		w       Workload
		team    Team
		want    Model
		wantErr error
	}{
		{"casual room", Workload{8, 10, 6, 120, false, 1, false}, Team{2, false}, InRoom, nil},
		{"competitive replay", Workload{10, 30, 15, 400, true, 2, false}, Team{6, true}, Dedicated, nil},
		{"twenty players without a team", Workload{20, 20, 20, 50, false, 1, false}, Team{2, false}, 0, ErrNoFit},
		{"slg async", Workload{40, 10, 3, 300, false, 3, true}, Team{2, false}, Managed, nil},
		{"large casual peak", Workload{8, 20, 8, 800, false, 2, false}, Team{4, false}, Managed, nil},
		{"invalid tick rate", Workload{8, 0, 8, 10, false, 1, false}, Team{2, false}, 0, ErrBadWorkload},
	}
	for _, c := range cases {
		got, err := Select(c.w, c.team)
		if !errors.Is(err, c.wantErr) {
			t.Fatalf("%s: err=%v, want %v", c.name, err, c.wantErr)
		}
		if c.wantErr == nil && (got.Model != c.want || len(got.Reasons) == 0 || len(got.Rejected) != 3) {
			t.Errorf("%s: decision=%+v, want %v", c.name, got, c.want)
		}
	}
}

func TestRecoveryForUsesSnapshotBeforeForfeit(t *testing.T) {
	cases := []struct {
		name        string
		model       Model
		snapshot    bool
		competitive bool
		want        Recovery
	}{
		{"casual with snapshot", InRoom, true, false, RestoreSnapshot},
		{"competitive with snapshot", Dedicated, true, true, RestoreSnapshot},
		{"competitive without snapshot", Dedicated, false, true, Forfeit},
		{"casual without snapshot", InRoom, false, false, RestartMatch},
		{"managed never restores", Managed, true, false, RestartMatch},
	}
	for _, c := range cases {
		if got := RecoveryFor(c.model, c.snapshot, c.competitive); got != c.want {
			t.Errorf("%s: got %s, want %s", c.name, got, c.want)
		}
	}
}

func TestWorkloadValidate(t *testing.T) {
	cases := []struct {
		name string
		w    Workload
		ok   bool
	}{
		{"valid", Workload{8, 10, 6, 100, false, 1, false}, true},
		{"one player", Workload{1, 10, 6, 100, false, 1, false}, false},
		{"zero tick", Workload{8, 0, 6, 100, false, 1, false}, false},
		{"zero duration", Workload{8, 10, 0, 100, false, 1, false}, false},
		{"zero peak", Workload{8, 10, 6, 0, false, 1, false}, false},
		{"zero region", Workload{8, 10, 6, 100, false, 0, false}, false},
	}
	for _, c := range cases {
		if err := c.w.Validate(); (err == nil) != c.ok {
			t.Errorf("%s: err=%v, want ok=%v", c.name, err, c.ok)
		}
	}
}

本机验证

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/battlemodels
$ go vet ./internal/battlemodels/...
$ go test -count=1 ./internal/battlemodels/
ok  	gsarchlab/internal/battlemodels

四条表驱动测试全部通过,覆盖画像字段边界、六种 workload 的选型结果、五种崩溃恢复组合和非法输入。gofmt -l 与 go vet 均无输出。

失败模式与兜底

失败表现兜底
tick 预算估得太乐观高峰期单帧超时,技能后摇被拉长按 p99 而非平均值留 30% 余量,压测时注入网络抖动
房间内战斗拖垮房间服务匹配、聊天、战斗一起超时战斗包独立、可整体迁出;关键路径加超时与熔断
独立战斗服发布打断比赛每次热更都有对局被判负排空后再发布;旧版本进程继续服务到对局结束
托管冷启动首帧延迟飙升,玩家看到“卡住”预热实例池;对延迟敏感的对局不走托管
快照恢复后分歧重放事件与客户端预测不一致快照带版本与校验和;恢复后强制全量校正
崩溃后静默重开竞技玩家发现战绩消失竞技局禁止静默重开,必须判负/作废并留审计日志

最危险的一项不是性能,而是“崩溃后静默重开”。它看起来最省事,却把一次故障变成了玩家无法解释的战绩变化。只要战斗涉及排名、奖励或投注,恢复语义就必须是显式的、可审计的。

常见错误

第一,把“独立战斗服”当成性能银弹。如果瓶颈是协议带宽或数据库写入,换一个进程位置不会让字节数变少。先定位瓶颈,再决定是否拆。

第二,用平均负载选模型。战斗峰值通常是平均值的数倍,按平均值选的房间密度会在晚高峰集中超时。

第三,忽略发布对局的影响。房间内模型的热更新会直接打断战斗;独立战斗服如果没有排空机制,同样会打断,只是范围小一点。

第四,把托管算力用于实时对抗。托管的冷启动和网络抖动对异步结算是可接受的,对 30 Hz 对战通常不可接受。

第五,恢复语义只写在文档里。崩溃恢复必须由代码返回明确枚举,并进入结算与审计流程,否则值班同学只能临场发挥。

小结

战斗服选型的顺序是:先算 tick 预算和峰值,再看回放与争议成本,最后才比较房间内、独立战斗服和托管算力的价格。北辰工作室的三款产品分别落在房间内、独立战斗服和托管算力上,但代码里共用同一套选型函数和恢复语义,避免每个项目各写一套“拍脑袋规则”。

战斗跑起来之后,下一节处理它留下的账:一场战斗结束,奖励怎么发、重复请求怎么防、争议结果怎么冲正。

阅读导航:上一节:4.3 世界事件与全服广播 · 下一节:5.2 结算契约与防重复发奖 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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