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——这个数最该被记住,因为它随连接数线性增长,是内存瓶颈的源头。
负载扫描:
| 并发连接 | 达成 QPS | P50 | P90 | P99 |
|---|---|---|---|---|
| 50 | 4933 | 6.57 ms | 6.86 ms | 19.40 ms |
| 100 | 4266 | 13.20 ms | 15.20 ms | 461.96 ms |
| 200 | 4544 | 26.63 ms | 38.38 ms | 515.39 ms |
| 500 | 3534 | 67.09 ms | 408.51 ms | 746.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。
本节小结
- 一台机器的上限是两个数:能挂多少连接(本机稳定区约 2000)、每秒能退休多少消息(本机约四五千)。它们由不同资源决定,必须分开逼、取小者。
- 两条扫描:连接扫描让连接挂住不发消息、只测能否全挂上;负载扫描固定总请求、抬并发逼出吞吐与延迟曲线。全程进程隔离、先预热、只报同机相对量级。
- 容量公式:
节点数 = ceil(峰值 CCU ÷ (单节点安全承载 × (1 − 余量))),余量按「单点故障后其余节点接得住」反推,连接与吞吐各算一遍取小者。 - 瓶颈会搬家:轻量玩法先撞连接数,战斗玩法先撞吞吐;而本机测到的四五千其实是回环链路的极限,不是世界循环的极限——别把测试机的上限当成系统的上限。
单机数字有了、集群台数有了,接下来进入第 2 章:当瓶颈确实落在世界循环上时,怎么用帧预算、剖析、GC 调优与热区隔离把它一点点推上去。
阅读导航:上一节:1.2 机器人压测平台 · 下一节:2.1 帧预算与性能剖析 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。