《MMO 服务器开发》1.3 承载评估与容量规划

压测器就位,接下来回答那个最初的问题:一台机器到底能扛多少。本节先设计两条扫描——连接扫描逼出单进程的连接天花板,负载扫描逼出吞吐与延迟曲线,再把本机实测数字(约两千连接、每秒数千消息、P99 分档)套进容量公式,算出集群要几台、留多少余量,以及瓶颈究竟落在连接数、CPU、内存还是带宽上。

1.3 承载评估与容量规划

1.2 造好了工具,1.3 用它回答那个从卷首就悬着的问题:一台机器到底能扛多少,集群又该开几台。 这件事有两条常见的错法:一是拍脑袋——「按经验一台机器两千人」;二是拿压测器算出来的 QPS 直接除以目标 CCU——但压测器自己的上限往往先到顶,你算出来的是测试机的容量,不是被测系统的容量。

这一节按三条基准纪律来:进程隔离(服务端与压测器是两个进程,绝不共享 CPU)、预热到稳定(先跑一轮丢弃的预热,再采数)、同机相对量级(下面所有数字都来自本机实跑,绝对数值不可移植,也绝不写成「快 N 倍」)。先把两条扫描写出来,再读数字,最后套进容量公式。

单机上限怎么测:两条扫描

一台机器的承载上限不是一个数,而是两个:能挂多少连接和每秒能退休多少消息。它们由不同资源决定,必须分别逼。cmd/bot 的 sweep 模式就是这两条扫描——连接扫描让 N 条连接挂住不发消息,只测「能不能全挂上」;负载扫描固定总请求数、逐步抬高并发,逼出吞吐与延迟曲线。下面这段是 B(基准):

// file: cmd/bot/main.go
// tier: B
// verify: go build ./cmd/bot/
// Command bot is aegis's self-contained load-testing entry point. It has three
// modes: serve a reference loopback server (in its own process, so the bots
// never share a CPU with it), run one pool of bots, or sweep a series of
// connection counts and write the whole capacity curve to JSON. The sweep is
// what section 1.3 reads its numbers from.
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"log"
	"os"
	"runtime"
	"sync"
	"time"

	"aegis/internal/loadtest"
)

// sample is one load-test report plus the server's own memory and connection
// count, read over /stats so the numbers describe the server, not the bot.
type sample struct {
	loadtest.Report
	ServerRSSMiB float64 `json:"server_rss_mib"`
	ServerConns  int     `json:"server_conns"`
}

// curve is the whole sweep plus the host it ran on, so the numbers are
// self-describing and never mistaken for portable.
type curve struct {
	Host      string   `json:"host"`
	GoVersion string   `json:"go_version"`
	MaxProcs  int      `json:"gomaxprocs"`
	LoadSweep []sample `json:"load_sweep"`
	ConnSweep []sample `json:"conn_sweep"`
}

func main() {
	mode := flag.String("mode", "sweep", "serve|pool|sweep")
	addr := flag.String("addr", "127.0.0.1:19001", "listen or dial address")
	stats := flag.String("stats", "127.0.0.1:19002", "serve: stats endpoint; sweep: query address")
	conns := flag.Int("conns", 200, "pool mode: connection count")
	moves := flag.Int("moves", 2, "move steps per bot before heartbeats")
	reqs := flag.Int("reqs", 100, "timed heartbeats per bot")
	timeout := flag.Duration("timeout", 10*time.Second, "per-request timeout")
	out := flag.String("out", "", "JSON output path (sweep mode)")
	flag.Parse()

	switch *mode {
	case "serve":
		runServe(*addr, *stats)
	case "pool":
		rep := loadtest.RunPool(loadtest.PoolConfig{
			Label: "pool", Addr: *addr, Conns: *conns, Moves: *moves,
			Reqs: *reqs, Stagger: 2 * time.Second, Timeout: *timeout, Barrier: true,
		})
		printReport(rep)
	case "sweep":
		runSweep(*addr, *stats, *timeout, *out)
	default:
		fmt.Fprintf(os.Stderr, "unknown mode %q\n", *mode)
		os.Exit(2)
	}
}

// runServe holds a reference server open until the process is killed.
func runServe(addr, stats string) {
	srv, err := loadtest.NewServer(addr)
	if err != nil {
		log.Fatalf("listen %s: %v", addr, err)
	}
	srv.ServeStats(stats)
	log.Printf("reference server on %s, stats on %s", srv.Addr(), stats)
	select {} // serve until Ctrl-C; the bot side runs in another process
}

// runSweep walks two series: a connection sweep (how many sockets one process
// holds) and a load sweep (throughput and tail latency at rising concurrency).
// A warmup pool runs first, and its result is discarded, so the first measured
// point does not pay for cold caches.
func runSweep(addr, statsAddr string, timeout time.Duration, out string) {
	statsURL := "http://" + statsAddr + "/stats"
	_ = loadtest.RunPool(loadtest.PoolConfig{
		Label: "warmup", Addr: addr, Conns: 20, Moves: 2, Reqs: 50,
		Stagger: 300 * time.Millisecond, Timeout: timeout, Barrier: true,
	})

	c := curve{
		Host:      runtime.GOOS + "/" + runtime.GOARCH,
		GoVersion: runtime.Version(),
		MaxProcs:  runtime.GOMAXPROCS(0),
	}

	// Connection sweep: hold many sockets open at once and send nothing, so the
	// only thing under test is how many connections one process can keep. The
	// stagger must scale with the count, because macOS somaxconn is 128.
	for _, n := range []int{500, 1000, 2000, 4000} {
		c.ConnSweep = append(c.ConnSweep, runPoint(statsURL, loadtest.PoolConfig{
			Label: fmt.Sprintf("conns=%d", n), Addr: addr,
			Conns: n, Moves: 0, Reqs: 0, Hold: 3 * time.Second,
			Stagger: 8 * time.Second, Timeout: timeout, Barrier: true,
		}))
	}

	// Load sweep: fixed total requests, rising concurrency, so the throughput
	// ceiling and the latency curve both show up.
	for _, p := range []struct{ conns, reqs int }{
		{50, 400}, {100, 200}, {200, 100}, {500, 40},
	} {
		c.LoadSweep = append(c.LoadSweep, runPoint(statsURL, loadtest.PoolConfig{
			Label: fmt.Sprintf("load=%d", p.conns), Addr: addr,
			Conns: p.conns, Moves: 2, Reqs: p.reqs,
			Stagger: 1500 * time.Millisecond, Timeout: timeout, Barrier: true,
		}))
	}

	buf, _ := json.MarshalIndent(c, "", "  ")
	if out != "" {
		if err := os.WriteFile(out, append(buf, '\n'), 0o644); err != nil {
			log.Fatalf("write %s: %v", out, err)
		}
		fmt.Printf("wrote %s\n", out)
	}
	fmt.Println(string(buf))
}

// runPoint runs one pool and pairs its report with the server's own stats. The
// server is polled while the run is in flight, because the connection count
// falls back to zero as soon as the bots disconnect; peak RSS is a high-water
// mark, so a final query catches it either way.
func runPoint(statsURL string, cfg loadtest.PoolConfig) sample {
	var (
		mu        sync.Mutex
		peakConns int
		peakRSS   float64
	)
	stop := make(chan struct{})
	var sampler sync.WaitGroup
	sampler.Add(1)
	go func() {
		defer sampler.Done()
		t := time.NewTicker(250 * time.Millisecond)
		defer t.Stop()
		for {
			select {
			case <-stop:
				return
			case <-t.C:
				if st, err := loadtest.QueryStats(statsURL); err == nil {
					mu.Lock()
					if st.Conns > peakConns {
						peakConns = st.Conns
					}
					if st.PeakRSSMiB > peakRSS {
						peakRSS = st.PeakRSSMiB
					}
					mu.Unlock()
				}
			}
		}
	}()

	rep := loadtest.RunPool(cfg)
	close(stop)
	sampler.Wait()
	if st, err := loadtest.QueryStats(statsURL); err == nil && st.PeakRSSMiB > peakRSS {
		peakRSS = st.PeakRSSMiB
	}

	printReport(rep)
	return sample{Report: rep, ServerRSSMiB: peakRSS, ServerConns: peakConns}
}

func printReport(r loadtest.Report) {
	fmt.Printf("%-10s conns=%4d ok=%4d qps=%8.0f p50=%7.2fms p90=%7.2fms p99=%8.2fms failed=%d\n",
		r.Label, r.Conns, r.ConnOK, r.QPS, r.P50Ms, r.P90Ms, r.P99Ms, r.Failed)
}

服务端跑在另一个进程里(-mode=serve),并通过 /stats 暴露自己的连接数与常驻内存——压测器读的是服务端的内存,不是它自己的。这正是「进程隔离」纪律的落地:机器上同时有服务端和压测器两个进程抢 CPU,所以下面的数字是「同机相对量级」,换台机器绝对数值会变。

实测:一台机器能扛多少

本机(darwin/arm64、Go 1.26、GOMAXPROCS=10)实跑一轮,capacity.json 的节选如下(省略了 max/mean/error_rate/gc 等字段):

{
  "host": "darwin/arm64", "go_version": "go1.26.0", "gomaxprocs": 10,
  "conn_sweep": [
    {"label":"conns=500",  "conns":500,  "conns_ok":500,  "failed":0,    "server_rss_mib":25.0, "server_conns":500},
    {"label":"conns=1000", "conns":1000, "conns_ok":1000, "failed":0,    "server_rss_mib":35.1, "server_conns":1000},
    {"label":"conns=2000", "conns":2000, "conns_ok":2000, "failed":0,    "server_rss_mib":58.1, "server_conns":2000},
    {"label":"conns=4000", "conns":4000, "conns_ok":2332, "failed":1668, "server_rss_mib":78.6, "server_conns":2332}
  ],
  "load_sweep": [
    {"label":"load=50",  "conns":50,  "qps":4933.4, "p50_ms":6.565,  "p90_ms":6.860,   "p99_ms":19.399,  "failed":0},
    {"label":"load=100", "conns":100, "qps":4266.2, "p50_ms":13.196, "p90_ms":15.202,  "p99_ms":461.958, "failed":0},
    {"label":"load=200", "conns":200, "qps":4544.4, "p50_ms":26.633, "p90_ms":38.378,  "p99_ms":515.385, "failed":0},
    {"label":"load=500", "conns":500, "qps":3534.2, "p50_ms":67.085, "p90_ms":408.511, "p99_ms":746.104, "failed":0}
  ]
}

把它读成两张表。连接扫描:500、1000、2000 连接全部建立成功,服务端常驻内存从 25.0 涨到 58.1 MiB;到 4000 时只有 2332 条成功,1668 条失败。所以本机单进程的连接天花板落在 2000 与 4000 之间,稳定区间取 2000。服务端空载约 11.6 MiB,据此每条连接约 23 KiB——这个数最该被记住,因为它随连接数线性增长,是内存瓶颈的源头。

负载扫描:

并发连接达成 QPSP50P90P99
5049336.57 ms6.86 ms19.40 ms
100426613.20 ms15.20 ms461.96 ms
200454426.63 ms38.38 ms515.39 ms
500353467.09 ms408.51 ms746.10 ms

三条结论。其一,吞吐在每秒四千到五千之间饱和,并发从 50 涨到 500、翻了十倍,QPS 反而略降——加连接并不能换来更多吞吐。其二,P50 与并发近似成正比:6.57→13.20→26.63→67.09 ms,几乎每翻一倍并发就翻一倍延迟,这是「请求在某个串行点前排队」的典型形状;按 QPS ≈ 并发 ÷ 平均时延 反推,稳态时延对应约每秒七千五百次的退休速率。其三,尾巴很重:50 并发时 P99 只有 19 ms,100 并发起 P99 跳到几百毫秒,均值被尾巴拉高、把有效吞吐压回四五千。

需要点破的是:这台机器上真正的串行点不是 aegis 的世界 goroutine。把世界循环直供喂帧(绕开所有 socket),本机实测它能跑到每秒数百万帧(go test ./internal/loadtest/ -run TestWorldLoopRate,多轮在六百万到七百万之间)。所以四五千的吞吐上限来自回环 TCP 的往返链路——每条同步机器人一次只压一个请求,吞吐就等于「并发 ÷ 往返时延」。这正是容量规划最危险的地方:你测到的往往是测试机,不是被测系统。 换到真实网络、真实玩法逻辑,瓶颈才会回到世界循环本身。

从单机数字推算集群

有了单机上限,容量公式才有输入。核心公式只有一行:

集群节点数 = ceil( 峰值 CCU ÷ ( 单节点安全承载 × (1 − 余量) ) )

把它拆成三步。第一步,算单节点安全承载:取连接天花板 2000、扣掉安全水位(内存、fd 都留缓冲),单节点按 1400 条连接规划。第二步,定余量:不是留 10%,而是要让单节点故障时其余节点能接住——若计划 N 台,单点故障后要扛住 峰值 × N/(N−1),所以余量至少 1/N;8 台就是至少 12.5%,工程上取 30% 更稳。第三步,两个天花板取小:连接与吞吐各算一遍,谁先到顶听谁的。

假设峰值 CCU 一万、每玩家每秒 1 条消息:连接维度 ceil(10000 / 1400) = 8 台;吞吐维度 ceil(10000 / (5000 × 0.7)) = 3 台——连接先到顶,取 8 台。若换成每秒 10 条消息的战斗场景:吞吐维度变成 ceil(100000 / 3500) = 29 台,此时吞吐先到顶。同一个产品,玩法不同,瓶颈就从连接数搬到了吞吐。

最后是「瓶颈在哪」这张清单,四个方向各对应一个可测的信号:

瓶颈信号对策
连接数accept 队列溢出、连接被 RST、fd 触顶加网关节点、调 somaxconn、把长连接摊到多进程
CPU世界循环占满单核、goroutine 排队拆世界、把热点移出主循环(第 2 章)
内存每连接 23 KiB × 连接数、OOM缩发送队列深度与缓冲区(内存的大头)
带宽出口打满、AOI 广播放大兴趣过滤、差量同步、压缩(卷一已做)

这张表最反直觉的一格是内存:每连接的常驻内存约 23 KiB,其中大头是发送队列深度乘帧大小——参考服务端给每条连接一个容量 256 的发送通道,256 × 每帧约 32 字节 ≈ 8 KiB,再加 bufio 的 4 KiB 读缓冲。想省内存,最便宜的一刀不是换语言,而是把发送队列从 256 降到 32。

本节小结

  1. 一台机器的上限是两个数:能挂多少连接(本机稳定区约 2000)、每秒能退休多少消息(本机约四五千)。它们由不同资源决定,必须分开逼、取小者。
  2. 两条扫描:连接扫描让连接挂住不发消息、只测能否全挂上;负载扫描固定总请求、抬并发逼出吞吐与延迟曲线。全程进程隔离、先预热、只报同机相对量级。
  3. 容量公式:节点数 = ceil(峰值 CCU ÷ (单节点安全承载 × (1 − 余量))),余量按「单点故障后其余节点接得住」反推,连接与吞吐各算一遍取小者。
  4. 瓶颈会搬家:轻量玩法先撞连接数,战斗玩法先撞吞吐;而本机测到的四五千其实是回环链路的极限,不是世界循环的极限——别把测试机的上限当成系统的上限。

单机数字有了、集群台数有了,接下来进入第 2 章:当瓶颈确实落在世界循环上时,怎么用帧预算、剖析、GC 调优与热区隔离把它一点点推上去。

阅读导航:上一节:1.2 机器人压测平台 · 下一节:2.1 帧预算与性能剖析 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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