17.3 机器人客户端与第一组承载数字
前两节证明了链路是通的,但「通」和「能扛」是两件事。要回答「这套服务端能撑多少连接」,必须有一批会自己登录、进世界、然后不停发请求的客户端——也就是压测机器人。站内没有 k6、wrk、hey 这类工具,所以这一节自己写一个。
机器人有三个必须做对的地方。第一,它要走完整路径,而不是只 dial 一下就挂着——真正压的是登录、进世界、请求-响应这条链路。第二,它要错峰建连:macOS 的 kern.ipc.somaxconn 默认只有 128,一口气 dial 上千条会被内核直接 RST,测出来的数字是假的。第三,它要报告分位延迟而不只是总吞吐——P99 比平均更能说明玩家会不会骂人。
本节给出机器人客户端的完整实现,并在真进程上跑出第一组承载数字。数字是同机相对量级,绝对数值不可移植。
机器人客户端:登录、进世界、再压
机器人本身也是用第 3 章的帧协议说话的普通客户端:dial → 发 CmdLoginReq → 发 CmdEnterWorldReq → 循环发 CmdHeartbeatReq 并测量每次往返。下面这段是 B 层(基准),go build 后即可用:
// file: cmd/bot/main.go
// tier: B
// verify: go build ./cmd/bot/
// Command bot 是 aegis 自带的机器人客户端。站内没有 k6/wrk/hey,所以自己写。
//
// 两种模式:
//
// -mode=bench 压测:N 个连接错峰建立,各自登录进世界后连发请求,汇总 QPS 与分位延迟
// -mode=smoke 冒烟:3 个客户端登录进世界、各自移动,断言「彼此都能看见」
//
// 压测必须错峰连接:macOS 的 kern.ipc.somaxconn 默认只有 128,一次性 dial
// 上千条会被内核直接 RST。
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"math"
"os"
"sort"
"sync"
"time"
"aegis/internal/proto"
)
// Result 是一次压测的汇总,直接落 JSON 供书稿引用。
type Result struct {
Conns int `json:"conns"`
Requests int `json:"requests"`
Failed int `json:"failed_conns"`
ElapsedMs int64 `json:"elapsed_ms"`
QPS float64 `json:"qps"`
P50Ms float64 `json:"p50_ms"`
P90Ms float64 `json:"p90_ms"`
P99Ms float64 `json:"p99_ms"`
MaxMs float64 `json:"max_ms"`
MeanMs float64 `json:"mean_ms"`
}
func main() {
addr := flag.String("addr", "127.0.0.1:19001", "网关地址")
mode := flag.String("mode", "bench", "bench|smoke")
conns := flag.Int("conns", 200, "并发连接数(bench)")
reqs := flag.Int("reqs", 10, "每个连接的请求数(bench)")
stagger := flag.Duration("stagger", 2*time.Second, "连接建立摊开的总时长")
timeout := flag.Duration("timeout", 15*time.Second, "单次请求读超时")
out := flag.String("out", "", "结果 JSON 输出路径,留空则打印")
flag.Parse()
switch *mode {
case "smoke":
if err := runSmoke(*addr, *timeout); err != nil {
log.Fatalf("smoke failed: %v", err)
}
case "bench":
res := runBench(*addr, *conns, *reqs, *stagger, *timeout)
buf, _ := json.MarshalIndent(res, "", " ")
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))
fmt.Printf("conns=%d reqs=%d ok=%d failed=%d qps=%.0f p50=%.2fms p99=%.2fms\n",
res.Conns, res.Requests, res.Conns-res.Failed, res.Failed, res.QPS, res.P50Ms, res.P99Ms)
if res.Failed > 0 {
os.Exit(1)
}
default:
fmt.Fprintf(os.Stderr, "unknown mode %q\n", *mode)
os.Exit(2)
}
}
func runBench(addr string, conns, reqs int, stagger, timeout time.Duration) Result {
var (
mu sync.Mutex
allLat []float64
failed int
step = stagger / time.Duration(max(conns, 1))
start = time.Now()
wg sync.WaitGroup
)
for i := 0; i < conns; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
time.Sleep(time.Duration(i) * step) // 错峰,绕开 accept backlog 上限
lat, err := runBot(addr, reqs, timeout)
mu.Lock()
defer mu.Unlock()
if err != nil {
failed++
return
}
allLat = append(allLat, lat...)
}(i)
}
wg.Wait()
elapsed := time.Since(start)
res := Result{Conns: conns, Requests: conns * reqs, Failed: failed, ElapsedMs: elapsed.Milliseconds()}
sort.Float64s(allLat)
if len(allLat) > 0 {
var sum float64
for _, v := range allLat {
sum += v
}
res.MeanMs = sum / float64(len(allLat))
res.P50Ms = pct(allLat, 0.50)
res.P90Ms = pct(allLat, 0.90)
res.P99Ms = pct(allLat, 0.99)
res.MaxMs = allLat[len(allLat)-1]
}
if res.ElapsedMs > 0 {
res.QPS = float64(len(allLat)) / (float64(res.ElapsedMs) / 1000.0)
}
return res
}
// runBot 走真实路径:dial -> 登录 -> 进世界 -> reqs 次心跳往返,返回每次 RTT。
func runBot(addr string, reqs int, timeout time.Duration) ([]float64, error) {
c, err := dial(addr, timeout)
if err != nil {
return nil, err
}
defer c.Close()
name := fmt.Sprintf("bot-%d-%d", os.Getpid(), time.Now().UnixNano())
if _, err := c.call(proto.CmdLoginReq, []byte(name), proto.CmdLoginResp); err != nil {
return nil, err
}
if _, err := c.call(proto.CmdEnterWorldReq, nil, proto.CmdEnterWorldResp); err != nil {
return nil, err
}
lat := make([]float64, 0, reqs)
for i := 0; i < reqs; i++ {
begin := time.Now()
if _, err := c.call(proto.CmdHeartbeatReq, nil, proto.CmdHeartbeatResp); err != nil {
return lat, err
}
lat = append(lat, float64(time.Since(begin).Microseconds())/1000.0)
}
return lat, nil
}
func pct(sorted []float64, p float64) float64 {
if len(sorted) == 0 {
return 0
}
idx := int(math.Ceil(p*float64(len(sorted)))) - 1
if idx < 0 {
idx = 0
}
if idx >= len(sorted) {
idx = len(sorted) - 1
}
return sorted[idx]
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
每个机器人是顺序发请求的(发一条、等一条再发下一条),所以它测的是「单连接往返时延」;N 个机器人并发,合起来就是「N 条连接同时在线时每条连接看到的时延」。汇总时把所有连接的 RTT 样本并到一起排序取分位,得到全局 P50/P90/P99。
client(在 cmd/bot/client.go 里)按 (cmd, seq) 匹配应答,并把不匹配的帧缓存进 pending——因为进世界时服务端可能先推 CmdAoiAdd 再回 CmdEnterWorldResp,机器人不能假设「下一帧一定是应答」。
错峰连接:为什么不能一口气 dial 一千条
关键在 step := stagger / conns 这一行。第 i 个机器人在 i × step 时刻才发起连接,把 N 条连接摊到 stagger 这段时间里建完:
conns=1000, stagger=5s → step = 5ms
机器人 0 在 0ms 发起
机器人 1 在 5ms 发起
机器人 2 在 10ms 发起
...
机器人 999 在 4995ms 发起
如果去掉错峰,1000 个 goroutine 会几乎同时 DialTimeout,内核的 accept 队列(kern.ipc.somaxconn=128)瞬间被打满,超出的连接会被直接 RST,机器人收到 connection reset,压测结果里混进一堆假失败。错峰之后失败归零。
压测时还要把世界的兴趣半径调到很小(-aoi-radius 1),让机器人(沿 X 轴以 8 为单位间隔出生)互不可见,隔离出纯请求路径;否则连接阶段会夹带一堆 AOI 广播,干扰数字。
第一组承载数字
真起 world 与 gateway 两个进程,world 用 -aoi-radius 1,跑一组由小到大的负载。先看一次持续负载的完整输出:
{
"conns": 100,
"requests": 20000,
"failed_conns": 0,
"elapsed_ms": 6330,
"qps": 3159.55766192733,
"p50_ms": 26.102,
"p90_ms": 29.779,
"p99_ms": 123.732,
"max_ms": 148.119,
"mean_ms": 26.834263349999954
}
把不同并发下的结果排在一起(同机相对量级,绝对数值不可移植):
| 连接 × 请求 | 达成 QPS | P50 | P90 | P99 | 失败 |
|---|---|---|---|---|---|
| 50 × 400 | 3223 | 13.0 ms | 14.1 ms | 88.2 ms | 0 |
| 100 × 200 | 3008–3160 | 26.1 ms | 29.8 ms | 124–171 ms | 0 |
| 200 × 20 | 1962 | 2.0 ms | 6.1 ms | 7.9 ms | 0 |
| 500 × 20 | 1693 | 55.6 ms | 93.9 ms | 477.1 ms | 0 |
| 1000 × 10 | 793 | 283.8 ms | 1083.1 ms | 1642.1 ms | 1 |
三个结论。其一,吞吐天花板在本机约三千消息每秒:50 连接和 100 连接的持续负载都停在 3000–3200 QPS,再加连接数也上不去——瓶颈是那个单线程的世界 goroutine,不是网络。其二,延迟约等于「并发数 ÷ 吞吐」:50 连接 P50 是 13ms,100 连接翻到 26ms,几乎严格翻倍,说明请求在世界 goroutine 前排成了队。其三,200 连接以内 P99 还在 10ms 量级,玩家无感;从 500 连接起 P99 跳到几百毫秒,1000 连接时冲到 1.6 秒并出现第一个失败连接——这就是单点世界的塌方点。
一次压测结束时世界的收摊计数也值得看一眼:frames_in == frames_out、aoi_add=0 aoi_remove=0、drops=0。drops=0 说明瓶颈不在每连接的发送队列(队列没满过),而在世界 goroutine 的处理速度本身——这正好指向第 18 章要谈的方向。
本节小结
机器人客户端的三件事都做对了:走完整登录链路、错峰建连、报告分位延迟。第一组数字给出了一个清晰的起点:单世界 goroutine 的吞吐天花板约三千消息每秒,200 连接以内时延可接受,再往上就是排队。这个数字不是缺陷,而是一把尺子——后面要做的分片、多世界、把热点拆出去,都是为了把这把尺子上的刻度往上推。
阅读导航:上一节:17.2 登录、移动与广播端到端 · 下一节:18.1 卷一回顾 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。