《MMO 服务器开发》1.2 什么时候该拆、什么时候不该

上一节定下了集群拓扑,但拆不拆是另一回事——拆分从来不是免费的。这一节先列出一份拆分成本清单:一次 RPC 换来多少容量、又付出多少延迟与运维面,再把「单服能扛到多少 CCU 才值得拆」拆成三道可判定的闸门,最后用一个能跑的容量-拆分决策函数把这三道闸门编码成代码。

1.2 什么时候该拆、什么时候不该

上一节把集群拆成了五类节点,读者很容易顺着这个思路往前冲:既然拆了更灵活,那就多拆几层。这恰恰是分布式系统最常见的翻车方式。 拆开的每一刀都不是免费的——它把一个进程内的函数调用,换成一次跨机器的网络往返;把一次「不会失败」的赋值,换成一次「可能超时、可能重试、可能重复」的 RPC。拆得越早、拆得越碎,你还没享受到扩展的好处,先被自己制造的分布式问题拖垮了。

所以这一节不搭集群,先回答一个更前置、也更容易被跳过的问题:这台机器到底能扛到多少人才值得拆?拆开之后,换来的和付出的到底谁多? 这个问题在站内既有的架构文里有方案层面的讨论,本节把它落到数字上——不靠直觉,靠一道能算、能复现的判定。

需要先摆正一个态度:「该不该拆」不是「想不想拆」。绝大多数时候答案是「先别拆,把单机的热点修掉」。真正需要拆的信号只有一个——单机的容量已经用尽,且没有更便宜的修法了。这一节要做的,就是把「用尽」和「更便宜」这两个模糊词,变成能算的数。

拆分不是免费的:一份成本清单

先看拆一刀换来了什么、又付出了什么。下面这张表把一次「把一个模块从单机挪成独立节点」的账摊开:

拆开换来什么同时付出什么
单节点容量上限提高(可水平扩展)一次玩家动作多 N 次跨节点 RPC,p99 变长
故障域变小(一个节点挂了不牵连全部)状态归属从「进程内引用」变成「按 id 路由」,多一层查找
可独立发布(玩法节点单独更新)发布时连接要迁移,会话粘性会断
可按玩法独立扩容(战斗服多开)运维面成倍增长:部署、监控、日志、灰度都要乘

这张表的左列是收益,右列是成本,而成本里最容易被低估的是后两行。很多人算拆分账时只算「容量够不够」,把「发布要迁移连接」「运维面翻倍」当成以后再说的事——可这两项恰恰是上线后最疼的。一次发布如果不能让玩家无感,那么拆出来的「独立发布」能力就白拿了;监控面如果从一套变成五套,排障时间会成倍增长,而排障慢就是在线人数掉。

所以判断拆分,至少要同时过三关:容量够不够、延迟付不付得起、运维扛不扛得住。只过第一关就拆,是新手最常犯的错。下面把三关逐个说清楚,再用代码把它们钉死。

单服能扛到多少 CCU 才值得拆

「单服能扛到多少 CCU」没有一个通用数字,它取决于你的玩法、你的硬件、你的单帧预算。但它可以被测出来,然后变成一个门槛值。aegis 的算法是三步:

第一步,测单节点容量 NodeCCU。 在一台目标规格的机器上,把世界节点压到目标 CPU(比如 60%),记录它稳定服务的 CCU。这个数是本机实测量级,绝对数值不可移植——换硬件、换玩法、换怪物 AI 复杂度都会变,必须自己压出来。本书示例里用一个量级数字 12000 代表「一台目标规格机器能服务的 CCU」。

第二步,留出余量 Headroom。 线上永远有登录高峰和活动脉冲,节点不能贴着满载跑。通常留 25%–30% 给尖峰、给 GC 停顿、给慢客户端。于是单节点可用容量 = NodeCCU × (1 − Headroom)。示例里取 30%,于是 12000 × 0.7 = 8400。

第三步,比对峰值 ProjectedCCU。 用「下次版本预估峰值 CCU」除以单节点可用容量,向上取整,得到需要的节点数。若结果是 1,说明单机还有余量,拆了只是白付成本;若大于 1,才进入后两关。

后两关是延迟闸门与运维闸门。延迟闸门:拆分给一次玩家动作新增的跨节点 RPC,累计延迟必须留在该动作的延迟预算之内。本书默认新增延迟不得超过预算的四分之一——留四分之三给原有逻辑,否则拆出来的「能力」会被 p99 拖没。运维闸门:拆分把运维面乘以一个 OpsMultiplier,超过 4 倍就拒绝,因为排障与发布的复杂度已经不是小团队扛得住的。

三关的关系是与,不是或:容量关不过 → 不拆;容量关过了但延迟或运维关不过 → 先别拆,去修热点或加机器,而不是硬拆。这个「先修再拆」的次序,是本节的中心思想。

可运行产物:容量-拆分决策函数

把上面三关写成一个函数,判断就不再靠直觉。下面这段是 L1,go test 可以直接跑。先看 internal/split/decision.go:

// file: internal/split/decision.go
// tier: L1
// verify: go test ./internal/split/
// Package split turns "should we shard this yet" from a gut call into a
// function. Section 1.2 lists what a split costs and what it buys; this file
// puts a number on both sides, so the answer is reproducible.
//
// The model is deliberately small: one node has a known CCU ceiling, we hold
// back a headroom fraction for spikes, and a split adds a known number of
// cross-node hops to a player action. A split is worth it only when the single
// node genuinely runs out of room AND the hops it adds stay inside the latency
// budget AND the operational cost stays under the ceiling. Any one of those
// failing means "scale up one node first, split later".
package split

import (
	"fmt"
	"math"
)

// Capacity describes how much one aegis node can carry, and what we need it to.
type Capacity struct {
	// NodeCCU is the CCU a single node serves while staying inside its CPU
	// budget, measured on the target hardware.
	NodeCCU int
	// HeadroomPct is the fraction of NodeCCU held back for login spikes and
	// bursts, e.g. 0.30 for thirty percent.
	HeadroomPct float64
	// ProjectedCCU is the peak CCU we must serve after the next content update.
	ProjectedCCU int
}

// Cost is what splitting adds. Every field is a real, measurable quantity; none
// of them is a feeling.
type Cost struct {
	// HopsPerAction is the cross-node RPCs added to one player action.
	HopsPerAction int
	// PerHopMs is the p99 latency of one internal hop.
	PerHopMs float64
	// BudgetMs is the latency budget for that action, end to end.
	BudgetMs float64
	// OpsMultiplier is the operational cost of the split relative to one node,
	// e.g. 2.5 for "two and a half times the moving parts", never below 1.
	OpsMultiplier float64
}

// Policy is the line between "split" and "not yet".
type Policy struct {
	// MaxLatencyShare is the largest share of BudgetMs the added hops may take.
	MaxLatencyShare float64
	// MaxOpsMultiplier is the operational multiplier above which a split is
	// refused even if it would fit, because it is not worth running.
	MaxOpsMultiplier float64
}

// DefaultPolicy is the policy used throughout the book's examples: added hops
// may take at most a quarter of the budget, and a split may not multiply the
// operational surface by more than four.
var DefaultPolicy = Policy{MaxLatencyShare: 0.25, MaxOpsMultiplier: 4.0}

// Decision is the answer, carrying the arithmetic that produced it so the
// caller can print why.
type Decision struct {
	Split       bool
	NodesNeeded int
	UsableCCU   float64
	AddedMs     float64
	Reason      string
}

// Decide returns whether the split is worth doing under policy p.
func Decide(c Capacity, cost Cost, p Policy) Decision {
	usable := float64(c.NodeCCU) * (1 - c.HeadroomPct)
	if usable <= 0 {
		return Decision{Reason: "NodeCCU or headroom leaves no usable capacity"}
	}
	nodes := int(math.Ceil(float64(c.ProjectedCCU) / usable))
	added := float64(cost.HopsPerAction) * cost.PerHopMs
	d := Decision{NodesNeeded: nodes, UsableCCU: usable, AddedMs: added}

	// One node still fits: a split would add cost for no capacity gain.
	if nodes <= 1 {
		d.Reason = fmt.Sprintf("one node carries %d CCU with room; do not split", c.ProjectedCCU)
		return d
	}
	// The hops a split adds blow the latency budget: fix the hot path first.
	if cost.BudgetMs > 0 && added > cost.BudgetMs*p.MaxLatencyShare {
		d.Reason = fmt.Sprintf("split adds %.1fms, over the %.1fms budget share", added, cost.BudgetMs*p.MaxLatencyShare)
		return d
	}
	// The split multiplies the operational surface past the ceiling.
	if cost.OpsMultiplier > p.MaxOpsMultiplier {
		d.Reason = fmt.Sprintf("split multiplies ops by %.1fx, over the %.1fx ceiling", cost.OpsMultiplier, p.MaxOpsMultiplier)
		return d
	}
	d.Split = true
	d.Reason = fmt.Sprintf("needs %d nodes, adds %.1fms, ops x%.1f; split is worth it", nodes, added, cost.OpsMultiplier)
	return d
}

再配一个测试 internal/split/decision_test.go,断言五件事:单机够用不拆、容量不够且代价低才拆、延迟超预算拒绝、运维倍数超顶拒绝、以及容量的边界是「恰好等于不拆、超一格才拆」:

// file: internal/split/decision_test.go
// tier: L1
// verify: go test ./internal/split/
package split

import "testing"

func TestSingleNodeFitsSoNoSplit(t *testing.T) {
	// 8000 CCU peak, a node serves 12000 with 30% held back -> 8400 usable.
	// One node fits, so splitting would only add cost.
	c := Capacity{NodeCCU: 12000, HeadroomPct: 0.30, ProjectedCCU: 8000}
	d := Decide(c, Cost{HopsPerAction: 2, PerHopMs: 0.4, BudgetMs: 20, OpsMultiplier: 2}, DefaultPolicy)
	if d.Split {
		t.Fatalf("expected no split, got %+v", d)
	}
	if d.NodesNeeded != 1 {
		t.Errorf("NodesNeeded = %d, want 1", d.NodesNeeded)
	}
}

func TestSplitWhenCapacityRunsOutAndCostIsCheap(t *testing.T) {
	// 20000 CCU peak against 8400 usable needs 3 nodes, and the hops are cheap.
	c := Capacity{NodeCCU: 12000, HeadroomPct: 0.30, ProjectedCCU: 20000}
	d := Decide(c, Cost{HopsPerAction: 2, PerHopMs: 0.4, BudgetMs: 20, OpsMultiplier: 2}, DefaultPolicy)
	if !d.Split {
		t.Fatalf("expected split, got %+v", d)
	}
	if d.NodesNeeded != 3 {
		t.Errorf("NodesNeeded = %d, want 3", d.NodesNeeded)
	}
}

func TestRefuseWhenHopsBlowTheBudget(t *testing.T) {
	// Capacity says split, but 6 hops at 3ms is 18ms against a 20ms budget,
	// which is 90% of it: over the quarter-share policy. Fix the hot path first.
	c := Capacity{NodeCCU: 12000, HeadroomPct: 0.30, ProjectedCCU: 20000}
	d := Decide(c, Cost{HopsPerAction: 6, PerHopMs: 3, BudgetMs: 20, OpsMultiplier: 2}, DefaultPolicy)
	if d.Split {
		t.Fatalf("expected refusal on latency, got %+v", d)
	}
}

func TestRefuseWhenOpsMultiplierTooHigh(t *testing.T) {
	c := Capacity{NodeCCU: 12000, HeadroomPct: 0.30, ProjectedCCU: 20000}
	d := Decide(c, Cost{HopsPerAction: 2, PerHopMs: 0.4, BudgetMs: 20, OpsMultiplier: 5}, DefaultPolicy)
	if d.Split {
		t.Fatalf("expected refusal on ops multiplier, got %+v", d)
	}
}

func TestBoundaryIsInclusive(t *testing.T) {
	// Exactly at the usable ceiling is still one node.
	c := Capacity{NodeCCU: 10000, HeadroomPct: 0.30, ProjectedCCU: 7000}
	d := Decide(c, Cost{HopsPerAction: 1, PerHopMs: 0.5, BudgetMs: 10, OpsMultiplier: 1.5}, DefaultPolicy)
	if d.NodesNeeded != 1 || d.Split {
		t.Fatalf("exactly-at-ceiling must not split, got %+v", d)
	}
	// One CCU past the ceiling needs two nodes.
	c.ProjectedCCU = 7001
	d = Decide(c, Cost{HopsPerAction: 1, PerHopMs: 0.5, BudgetMs: 10, OpsMultiplier: 1.5}, DefaultPolicy)
	if d.NodesNeeded != 2 || !d.Split {
		t.Fatalf("one past the ceiling must split, got %+v", d)
	}
}

本机实跑结果(go test ./internal/split/ -v):

=== RUN   TestSingleNodeFitsSoNoSplit
--- PASS: TestSingleNodeFitsSoNoSplit (0.00s)
=== RUN   TestSplitWhenCapacityRunsOutAndCostIsCheap
--- PASS: TestSplitWhenCapacityRunsOutAndCostIsCheap (0.00s)
=== RUN   TestRefuseWhenHopsBlowTheBudget
--- PASS: TestRefuseWhenHopsBlowTheBudget (0.00s)
=== RUN   TestRefuseWhenOpsMultiplierTooHigh
--- PASS: TestRefuseWhenOpsMultiplierTooHigh (0.00s)
=== RUN   TestBoundaryIsInclusive
--- PASS: TestBoundaryIsInclusive (0.00s)
PASS
ok  	aegis/internal/split	0.632s

TestRefuseWhenHopsBlowTheBudget 这条断言值得单独说一句。它守住的是**「容量够了不等于就该拆」**:ProjectedCCU 明明需要 3 个节点,但只要这次动作要跨 6 跳、累计 18ms、吃掉 20ms 预算的 90%,函数依然拒绝拆,并给出「先修热点」的信号。这正是本节想立的规矩——拆分是被逼出来的最后手段,不是第一手段。 同样,TestBoundaryIsInclusive 把「恰好等于可用容量不拆、超一格才拆」钉死,避免「差一点点」被算成「不够用」而提前拆分。

DefaultPolicy 里的两个阈值(延迟占四分之一、运维面不超过 4 倍)是本书的默认口径,不是普适真理。它们该按你的动作预算和团队规模调整——但调整的是参数,不是「要不要三关都过」这件事本身。

本节小结

这一节把「该不该拆」从一个拍脑袋的决定,变成了一道能算的判定:

  1. 拆分不是免费的。它换来了容量、故障隔离与独立发布,同时付出跨节点延迟、状态路由与成倍的运维面;后两项最容易被低估,也最常在线上找上门。
  2. 判断要同时过三关:容量关(单节点可用容量 = NodeCCU × (1 − Headroom) 是否被峰值超过)、延迟关(新增跨节点 RPC 是否留在动作预算内)、运维关(运维倍数是否可承受)。只过容量关就拆,是新手最常犯的错。
  3. 三关的次序是「先修再拆」:容量不够时先看热点能不能修、机器能不能加,只有修无可修、加无可加,才动用拆分。这个次序被编码成一个可复现的决策函数,而不是一条口头约定。

拓扑定了、拆不拆也定了,接下来真正的工程问题是:拆开之后,这些节点怎么找到彼此。下一章从服务发现与健康检查讲起。

阅读导航:上一节:1.1 分区分服与集群拓扑 · 下一节:1.3 本书范围与站内服务治理文章的分工 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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