《MMO 服务器开发》17.3 机器人客户端与第一组承载数字

站内没有 k6/wrk,压测器得自己写。本节给出 aegis 自带的机器人客户端:用错峰建连绕开 macOS 的 accept backlog 上限,登录进世界后连发请求、按往返时延汇总 QPS 与 P50/P90/P99,最后交出本机第一组真实承载数字——吞吐天花板约三千消息每秒,以及连接数继续上升后延迟如何塌方。

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
}

把不同并发下的结果排在一起(同机相对量级,绝对数值不可移植):

连接 × 请求达成 QPSP50P90P99失败
50 × 400322313.0 ms14.1 ms88.2 ms0
100 × 2003008–316026.1 ms29.8 ms124–171 ms0
200 × 2019622.0 ms6.1 ms7.9 ms0
500 × 20169355.6 ms93.9 ms477.1 ms0
1000 × 10793283.8 ms1083.1 ms1642.1 ms1

三个结论。其一,吞吐天花板在本机约三千消息每秒: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 卷一回顾 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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