战斗跑在哪,先看 tick 预算
北辰工作室的三款产品在同一个平台上运行,但战斗的位置完全不同。《裂隙战场》一局 5v5,三十赫兹,打十五分钟;《群岛纪元》的行军与守军交战在服务端异步结算,玩家看到的是战报;《泡泡工坊》一局八人、十赫兹、六分钟,输赢只影响一局内的分数。
如果只看“有没有战斗服”,很容易得出“竞技品类必须独立战斗服、休闲品类不需要”的结论。这个结论在《泡泡工坊》上会翻车:它的房间服务本来就要持有对局状态,把战斗放进房间进程最便宜;而在《裂隙战场》上,房间服务如果同时承担匹配、聊天和战斗,一次发布就会把正在进行的比赛全部打断。
选型的第一步不是比较架构名词,而是把 tick 预算算出来:
单帧预算 = 1000 ms / tick rate
模拟耗时 ≈ 实体数 × 单实体更新成本 + 裁决与广播成本
安全余量 = 单帧预算 - 模拟耗时 p99
《裂隙战场》30 Hz 时单帧预算约 33.3 ms;十名玩家、技能与投射物峰值按示例值 8 ms 估算,还剩 25 ms 给网络与落库。《泡泡工坊》10 Hz 时预算 100 ms,八人局几乎没有压力。预算越紧,越需要把战斗从通用房间循环里拆出去;预算越松,拆出去只是在为运维付钱。
三种模型不是三个技术名词
| 模型 | 计算位置 | 延迟画像(示例值) | 单局成本(示例值) | 单进程故障影响 | 回放能力 | 适用条件 |
|---|---|---|---|---|---|---|
| 房间内 | 房间服务进程内 | p99 约 8 ms | 6 分 | 同进程的房间一起掉 | 可选,取决于是否记帧 | 小局、低频、团队没有独立战斗组 |
| 独立战斗服 | 专用战斗进程/节点 | p99 约 4 ms | 18 分 | 通常只影响一个战斗实例 | 权威帧日志 | 高 tick、竞技、需要回放或强反作弊 |
| 托管算力 | 通用函数/容器平台 | p99 约 25 ms | 11 分 | 单实例失败,平台重调度 | 通常只有事件记录 | 异步结算、无实时 tick、团队无人运维 |
表格里的数字是容量评审用的示例值,不是线上测量。它们的作用不是精确报价,而是把讨论从“哪个架构更先进”拉回到“我们愿意为哪一列付钱”。
房间内模型最大的优点是省掉了跨进程通信和一套独立的发布流水线。它的代价不是延迟,而是故障域:战斗和匹配、聊天、房间管理共享一个进程,任何一次热更新或内存泄漏都会同时打断比赛。独立战斗服把故障域收窄到一个实例,但要求团队能维护第二套发布、扩缩容和 on-call。托管算力把运维转嫁给平台,代价是冷启动、网络抖动和可观测性缺口——异步结算可以接受,实时对抗通常不能。
选型判据:硬约束在前,成本在后
北辰工作室把选型写成固定顺序,避免每次评审都从偏好吵起:
- 先看硬约束。 tick 大于等于 30 Hz,或单局参战者大于等于 20,房间内模型直接出局;没有战斗团队时,这不是“再压一压”能解决的问题。
- 再看回放与争议成本。 竞技品类需要权威帧日志,独立战斗服是默认答案;如果团队已经有战斗 on-call,这条几乎不需要讨论。
- 再看异步性。 战斗在离线批量任务里结算,不需要 tick 循环,托管算力比自建战斗服更合适。
- 最后看成本。 小局、低频、峰值可控时,房间内模型最便宜;峰值超出单进程承载能力时,再考虑独立战斗服。
这里有一个容易忽略的边界:“房间内”不等于“没有战斗逻辑”,只等于“战斗和房间共享生命周期”。《泡泡工坊》把战斗放在房间进程里,但仍然把战斗状态机做成独立包,目的是将来可以整体搬到独立进程,而不是把战斗代码揉进房间生命周期。
崩溃之后:重开、快照还是判负
战斗进程会崩,问题不是“能不能避免”,而是“崩了以后玩家看到什么”。
| 恢复语义 | 触发条件 | 玩家体验 | 风险 |
|---|---|---|---|
| 快照恢复 | 有最近一帧的完整快照 | 短暂卡顿后继续 | 快照之后的事件要重放,可能产生分歧 |
| 重开 | 休闲局、无排名、无奖励 | 本局作废并重开 | 玩家时间被浪费,但规则简单 |
| 判负/作废 | 竞技局、无法恢复、已有投注或排名 | 按规则判负或整局作废 | 需要客服与申诉通道,必须有日志 |
| 平台重调度 | 托管模型、无状态任务 | 任务重试或换节点 | 重试必须幂等,否则重复结算 |
北辰工作室的规则是:有快照优先快照;没有快照的竞技局判负或作废,不静默重开;休闲局才允许重开。 这条规则写进代码,而不是写在值班手册里,因为凌晨三点的值班同学不会去翻手册。
可运行片段:把选型写成函数
下面的 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 结算契约与防重复发奖 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。