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 逐字段读成一句话,就是本节最该记住的四行:
| 字段 | 值 | 读法 |
|---|---|---|
nodes | 4 | 1 hub + 2 网关 + 1 战斗实例,簇的「宽度」 |
per_gateway_conns / cluster_total_conns | 1000 / 2000 | 单网关维持 1000 条连接全部心跳应答;两网关合计 2000 |
match_throughput_per_sec | 5,695,822 | hub 每秒撮合约 570 万场两两匹配 |
round_trip_p50_ms / p90 / p99 | 1.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 多进程编排与验收 · 下一节:回到目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。