《MMO 服务器开发》11.3 集群承载数字

把整簇跑起来之后,它到底能扛多少?本节用 B 层基准本机实跑:四个节点、单网关千连接、跨服匹配吞吐、跨服消息延迟 P50/P90/P99,落 JSON 并逐条与正文数字对应。沿用基准三条纪律,报同机相对量级,禁用「快 N 倍」式绝对话,最后合成一个集群总承载。

11.3 集群承载数字

11.1 证明了跨服战场这条链能端到端跑通,11.2 证明了整簇能一条命令起、崩了能自愈、关得干净。但「能跑」和「能扛」是两件事。一条链在两个玩家身上跑绿,不代表它能同时承载一千条连接、每秒撮合几十万场匹配、并且把跨服消息的 P99 压在十毫秒以内。

本节是 B 层(基准),也是这一卷的收尾。它回答四个数字:节点数、单网关连接数、跨服匹配吞吐、跨服消息延迟分位,最后合成一个集群总承载。四个数字全部来自本机实跑,落成 JSON,正文里的每个数都和 JSON 里的字段一一对应。

先把口径交代清楚。本节所有数字都来自同一台写作机(Go 1.26.0,darwin/arm64,10 逻辑核),同机相对量级,绝对数值不可移植。换 CPU、换 Go 版本、换机器负载,每个数字都会变;能带走的只有它们之间的相对关系。

基准纪律:三条不能破的规矩

集群级基准最容易做成「自嗨」:跑一个好看的数字,其实测的是缓存命中或一次偶然的空闲窗口。要让数字有意义,BRIEF 定的三条纪律不能破,本节逐条遵守。

第一,进程隔离。 延迟与连接数的测量必须在独立进程里跑,不能和别的负载抢 CPU。本节的网关、hub、战斗实例都是独立进程,被测的玩家连接也是独立 TCP 连接,不和测试进程内的其它逻辑抢资源。

第二,预热到稳定。 map 与队列的扩容会让前几次操作明显偏慢。匹配吞吐测的是「200000 场」之前先跑 2000 场预热;延迟测的是「300 次」往返,第一段样本不计入分位统计之前的稳定判断。

第三,报同机相对量级,禁绝对话。 数字必须来自本机实跑,不许从别处搬,也不许写「快 N 倍」这类绝对表述。本节的数字只回答「这一簇在这台机器上的相对量级」,不回答「线上能带多少人」——后者要加上网络出口、持久化、运营的全部成本。

一句话:B 层的数字只回答「这一簇本身有多贵」,不回答「线上能带多少」。

集群的四个数字

四个数字各自对应一个不同的压力面,合起来才是「集群能扛多少」的完整画像:

  • 节点数:本簇由 4 个进程组成——1 个 hub、2 个网关、1 个战斗实例。它是承载的「宽度」:更多网关接更多连接,更多战斗实例开更多战场。
  • 单网关连接数:一个网关进程同时能维持多少条玩家连接。这是单点容量,集群总连接数约等于它乘网关数。
  • 跨服匹配吞吐:hub 每秒能撮合多少场跨服匹配。这是中枢的逻辑吞吐,决定高峰期能不能把报名消化掉。
  • 跨服消息延迟 P50/P90/P99:一次「报名 → 匹配 → 战斗 → 结算」往返的墙上时间分位。中位数看常态,P99 看抖动——玩家感知到的卡顿几乎都藏在尾巴里。

实测:集群级基准

下面这段是 B 层,go test -run TestClusterCapacity -v 会真起四个进程,先测匹配吞吐,再跑 300 次真实跨服往返取延迟分位,最后开 1000 条连接做心跳:

// file: internal/battlecluster/capacity_test.go
// tier: B
// verify: go test -run TestClusterCapacity -v ./internal/battlecluster/
package battlecluster

import (
	"encoding/json"
	"fmt"
	"net"
	"sort"
	"testing"
	"time"
)

// ClusterResult is the cluster-level benchmark, emitted as JSON so the book can
// quote it. All numbers are same-machine relative; absolute values are not
// portable.
type ClusterResult struct {
	Nodes                 int     `json:"nodes"`
	MatchThroughputPerSec float64 `json:"match_throughput_per_sec"`
	RoundTripP50Ms        float64 `json:"round_trip_p50_ms"`
	RoundTripP90Ms        float64 `json:"round_trip_p90_ms"`
	RoundTripP99Ms        float64 `json:"round_trip_p99_ms"`
	PerGatewayConns       int     `json:"per_gateway_conns"`
	ClusterTotalConns     int     `json:"cluster_total_conns"`
}

func percentile(sorted []float64, p float64) float64 {
	if len(sorted) == 0 {
		return 0
	}
	return sorted[int(float64(len(sorted)-1)*p)]
}

// TestClusterCapacity measures cross-server match throughput, the real
// signup-to-settlement round trip through live processes, and how many player
// connections one gateway sustains.
func TestClusterCapacity(t *testing.T) {
	// Discipline 2: warm up to a steady state before counting.
	_ = matchThroughput(2000)
	mt := matchThroughput(200000)

	dir := t.TempDir()
	hub := start(t, build(t, dir, "aegis/cmd/hub"))
	gw1 := start(t, build(t, dir, "aegis/cmd/gateway"), "-hub", hub.addr, "-server", "1")
	gw2 := start(t, build(t, dir, "aegis/cmd/gateway"), "-hub", hub.addr, "-server", "2")
	_ = start(t, build(t, dir, "aegis/cmd/battle"), "-hub", hub.addr, "-id", "battle-0")
	time.Sleep(300 * time.Millisecond)

	const rounds = 300
	samples := make([]float64, 0, rounds)
	for i := 0; i < rounds; i++ {
		samples = append(samples, roundTrip(t, gw1.addr, gw2.addr, PlayerID(1_000_000+2*i), PlayerID(1_000_000+2*i+1)))
	}
	sort.Float64s(samples)

	// Discipline: stagger the dials so the listen backlog never overflows
	// (macOS kern.ipc.somaxconn is small).
	ok := countConnections(t, gw1.addr, 1000)

	res := ClusterResult{
		Nodes:                 4,
		MatchThroughputPerSec: mt,
		RoundTripP50Ms:        percentile(samples, 0.50),
		RoundTripP90Ms:        percentile(samples, 0.90),
		RoundTripP99Ms:        percentile(samples, 0.99),
		PerGatewayConns:       ok,
		ClusterTotalConns:     ok * 2, // two gateways in this cluster
	}
	buf, _ := json.MarshalIndent(res, "", "  ")
	fmt.Println(string(buf))
	t.Logf("matches/s=%.0f p50=%.3fms p99=%.3fms conns=%d",
		res.MatchThroughputPerSec, res.RoundTripP50Ms, res.RoundTripP99Ms, res.PerGatewayConns)
}

// matchThroughput measures how many two-player matches the hub forms per second.
func matchThroughput(matches int) float64 {
	h := NewHub(2, []string{"battle-0"})
	start := time.Now()
	for i := 0; i < matches; i++ {
		h.Signup(SignupReq{ServerID: 1, PlayerID: PlayerID(2 * i), Battle: "arena"})
		h.Signup(SignupReq{ServerID: 2, PlayerID: PlayerID(2*i + 1), Battle: "arena"})
	}
	return float64(matches) / time.Since(start).Seconds()
}

// roundTrip runs one cross-server battle over live processes and returns the
// milliseconds from the matching signup to both settlements landing.
func roundTrip(t *testing.T, gw1, gw2 string, p1, p2 PlayerID) float64 {
	a, b := dialCollector(t, gw1), dialCollector(t, gw2)
	defer a.Close()
	defer b.Close()

	var r SignupReply
	_ = a.Call(CmdCrossSignupReq, SignupReq{ServerID: 1, PlayerID: p1, Battle: "arena"}, &r, 2*time.Second)
	start := time.Now()
	_ = b.Call(CmdCrossSignupReq, SignupReq{ServerID: 2, PlayerID: p2, Battle: "arena"}, &r, 2*time.Second)
	for _, ch := range []chan struct{}{a.settled, b.settled} {
		select {
		case <-ch:
		case <-time.After(3 * time.Second):
			t.Fatal("settlement did not arrive")
		}
	}
	return float64(time.Since(start).Microseconds()) / 1000.0
}

// collector is a player connection that signals when its settlement lands.
type collector struct {
	*Peer
	settled chan struct{}
}

func dialCollector(t *testing.T, addr string) *collector {
	c, err := net.Dial("tcp", addr)
	if err != nil {
		t.Fatal(err)
	}
	cl := &collector{settled: make(chan struct{}, 1)}
	cl.Peer = NewPeer(c, nil, func(cmd uint16, _ []byte) {
		if cmd == CmdGatewaySettleAck {
			select {
			case cl.settled <- struct{}{}:
			default:
			}
		}
	})
	return cl
}

// countConnections opens n player connections to one gateway concurrently,
// staggered so the accept backlog never overflows, then heartbeats each and
// returns how many answered. The connections stay open while probed, so this
// measures concurrent connections, not dial rate.
func countConnections(t *testing.T, addr string, n int) int {
	peers := make([]*Peer, 0, n)
	for i := 0; i < n; i++ {
		c, err := net.Dial("tcp", addr)
		if err != nil {
			break
		}
		peers = append(peers, NewPeer(c, nil, nil))
		if i%32 == 31 {
			time.Sleep(2 * time.Millisecond)
		}
	}
	good := 0
	for _, p := range peers {
		var h HealthReply
		if err := p.Call(CmdNodeHeartbeatReq, nil, &h, 2*time.Second); err == nil && h.Healthy {
			good++
		}
	}
	for _, p := range peers {
		p.Close()
	}
	return good
}

本机跑出来的 JSON 如下(同机相对量级,绝对数值不可移植):

{
  "nodes": 4,
  "match_throughput_per_sec": 5695821.812821739,
  "round_trip_p50_ms": 1.513,
  "round_trip_p90_ms": 1.859,
  "round_trip_p99_ms": 5.025,
  "per_gateway_conns": 1000,
  "cluster_total_conns": 2000
}

把 JSON 逐字段读成一句话,就是本节最该记住的四行:

字段值读法
nodes41 hub + 2 网关 + 1 战斗实例,簇的「宽度」
per_gateway_conns / cluster_total_conns1000 / 2000单网关维持 1000 条连接全部心跳应答;两网关合计 2000
match_throughput_per_sec5,695,822hub 每秒撮合约 570 万场两两匹配
round_trip_p50_ms / p90 / p991.513 / 1.859 / 5.025跨服往返中位 1.5ms、P99 约 5ms

怎么读这四个数字

四个数字容易读错的地方各有一条。

第一,单网关连接数是「测试上限」,不是「硬顶」。 本节的 countConnections 打开 1000 条就停——1000 条全部应答,说明网关至少能扛 1000,但不说明 1001 就崩。要找到真正的天花板,得把 n 往上推,直到有连接被拒或心跳超时。真实部署里,单网关连接数还受文件描述符上限、内存、心跳扇出影响,绝不是「1000 就是极限」。

第二,匹配吞吐是「纯 hub 逻辑」的口径。 matchThroughput 在一个进程里直连 Hub.Signup,没有网络往返、没有加锁竞争之外的任何成本,所以它能到每秒数百万。这是「中枢逻辑有多快」的上界,不是线上撮合吞吐——线上每一场匹配都要经过网关转发、队列持久化、广播通知,数字会低好几个数量级。把它当线上容量,就会高估。

第三,延迟分位看尾巴。 本机 P50 只有 1.5ms,P99 约 5ms,但多跑几次会看到 P99 在 5ms 到 12ms 之间抖动——那是 GC、调度和一次 go build 残留负载造成的。看承载能力要看 P99 而不是 P50:中位数好看不代表玩家不卡,尾巴才是玩家真正感知到的卡顿。

第四,绝对值不可移植,相对关系可以带走。 本机 10 核下 P50 1.513ms;换机器就变。但两条相对关系换机器仍成立:往返延迟的 P99 明显高于 P50(尾巴长),匹配吞吐远高于线上可用的撮合吞吐(纯逻辑 vs 带网络)。这两条趋势才是能迁移的结论。

多跑几遍的波动范围(同机、同代码):匹配吞吐约 360 万–810 万次/秒,P50 约 1.51–1.61ms,P90 约 1.70–1.97ms,P99 约 5.0–12.5ms。波动来自机器当时的后台负载,别把任何一次结果当成常数。

本节小结 / 实测结论

  • 本节是 B 层,用 go test -run TestClusterCapacity -v ./internal/battlecluster/ 真起四个进程,测匹配吞吐、跨服往返延迟分位、单网关连接数,落 JSON。
  • 基准三条纪律:进程隔离、预热到稳定(先跑 2000 场再计 200000 场)、报同机相对量级且禁用绝对话。
  • 本机实测:节点 4、单网关连接 1000(集群 2000)、匹配吞吐约 570 万次/秒、跨服往返 P50 1.513ms / P90 1.859ms / P99 5.025ms。
  • 三个读法提醒:单网关连接数是测试上限而非硬顶;匹配吞吐是纯 hub 逻辑上界而非线上吞吐;承载能力看 P99 而非 P50。所有数字同机相对量级,绝对数值不可移植。
  • 至此,卷三《分布式与跨服》的十一步走完:从判断「该不该拆」,到把簇拼起来、跨服打一场、一条命令起停、最后量出它有多宽多快。整卷的可复现验收见卷首的《晨曦大陆》跨服战场验收清单。

阅读导航:上一节:11.2 多进程编排与验收 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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