《MMO 服务器开发》9.2 性能预算达标

装配好之后它达标了吗?本节先写死三条硬预算——每 tick P99 不超过 33ms、单进程连接数下限、请求往返 P99 上限,再用一次真实负载去撞它:10000 实体跑帧分位、400 条真连接跑心跳分位,落成 JSON,逐字段与正文对应。

9.2 性能预算达标

9.1 把《晨曦大陆》装配了起来,9.2 要回答的是:装起来之后,它达标了吗?「达标」不能靠感觉,得先有一组预先写死的阈值,再用一次真实负载去撞它。这一节的产物是一份 JSON 和一条硬判据——JSON 给出实测的每个分位,硬判据逐条比对预算,任何一条不满足就算不达标。

本节的测量沿用 2.1 帧预算与性能剖析 的帧预算口径,以及 1.3 承载评估与容量规划 的承载口径。区别在于:前两节讲「怎么量」,本节讲「量完怎么判」。

预算:三条硬阈值

预算是一组先写死、后测量的阈值,顺序不能反——先测再定预算,就会把现状当成目标。本节给三条:

预算阈值含义
帧预算每 tick P99 ≤ 33ms一个地图实例的每帧逻辑必须塞进 30fps 的一帧里
承载预算单进程连接数 ≥ 300一个网关进程同时维持的连接数下限
延迟预算请求往返 P99 ≤ 20ms心跳类请求的端到端往返尾巴

阈值写在代码里,测量结果与它比对,全部满足才 pass。这三条覆盖了「算得快不快(帧)」「接得多不多(连接)」「回得及不及时(延迟)」三个面。定义与判定逻辑如下(L1,go test ./internal/dawnrealm/ 可跑):

// snippet: internal/dawnrealm/budget.go 的预算定义与判定
// Budget is the acceptance threshold set for one 《晨曦大陆》 process.
type Budget struct {
	MaxTickMs float64 `json:"max_tick_ms"` // per-tick frame budget
	MinConns  int     `json:"min_conns"`   // concurrent connections per process
	MaxP99Ms  float64 `json:"max_p99_ms"`  // request round-trip P99
}

// Evaluate applies the budget gates to a report and records every miss.
func (b Budget) Evaluate(r *Report) {
	r.Failures = nil
	if r.TickP99Ms > b.MaxTickMs {
		r.Failures = append(r.Failures, fmt.Sprintf("tick p99 %.2fms > %.2fms", r.TickP99Ms, b.MaxTickMs))
	}
	if r.Conns < b.MinConns {
		r.Failures = append(r.Failures, fmt.Sprintf("conns %d < %d", r.Conns, b.MinConns))
	}
	if r.LatencyP99Ms > b.MaxP99Ms {
		r.Failures = append(r.Failures, fmt.Sprintf("latency p99 %.2fms > %.2fms", r.LatencyP99Ms, b.MaxP99Ms))
	}
	r.Pass = len(r.Failures) == 0
}

基准纪律:三条不能破的规矩

性能数字最容易做成「自嗨」——跑一个好看的结果,其实测的是缓存命中或一次偶然的空闲窗口。本节的 B 层基准逐条遵守三条纪律。

第一,进程隔离。 帧逻辑(Scene.Tick)和连接负载(心跳客户端)跑在不同的对象里,连接是真实的 TCP 连接,不和被测逻辑抢同一份状态。真实部署里它们本就是不同进程,测试里也用独立连接集模拟这个边界。

第二,预热到稳定。 map 扩容会让前几次 tick 明显偏慢,连接首次建链也会慢。所以先跑 200 次 tick、再给每条连接发一次心跳热身,热身样本一律不计入分位统计,只统计稳定后的 1000 次 tick 与 8 轮心跳。

第三,报同机相对量级,禁绝对话。 数字必须来自本机实跑,不许写「快 N 倍」这类绝对表述。本节只回答「这一套在这台机器上的相对量级」,不回答「线上能带多少人」——后者还要加上网络出口、持久化与运营成本。

实测:跑一次负载并判定

下面这段是 B 层,go test -run TestBudgetPass -v ./internal/dawnrealm/ 会真跑:起一个 10000 实体的场景跑 1000 次 tick 取分位,再起一个真实 TCP 心跳服务、开 400 条连接、每条约 8 次往返取分位,最后按预算判定并把结果落成 JSON:

// file: internal/dawnrealm/budget_test.go
// tier: B
// verify: go test -run TestBudgetPass -v ./internal/dawnrealm/
package dawnrealm

import (
	"encoding/json"
	"fmt"
	"net"
	"testing"
	"time"
)

func TestFrameRoundTrip(t *testing.T) {
	raw := Encode(Frame{Cmd: CmdHeartbeatReq, Seq: 42, Payload: []byte("hi")})
	if len(raw) != HeaderSize+2 {
		t.Fatalf("frame len = %d, want %d", len(raw), HeaderSize+2)
	}
	if got := int(raw[3]); got != 9 {
		t.Fatalf("length field = %d, want 9", got)
	}
}

// startEcho is a real TCP server that answers heartbeat frames. It is the load
// target for the latency half of the budget.
func startEcho(t *testing.T) (addr string, stop func()) {
	t.Helper()
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	go func() {
		for {
			c, err := ln.Accept()
			if err != nil {
				return
			}
			go func(conn net.Conn) {
				defer conn.Close()
				for {
					f, err := ReadFrame(conn)
					if err != nil {
						return
					}
					if f.Cmd == CmdHeartbeatReq {
						_, _ = conn.Write(Encode(Frame{Cmd: CmdHeartbeatResp, Seq: f.Seq}))
					}
				}
			}(c)
		}
	}()
	return ln.Addr().String(), func() { _ = ln.Close() }
}

// TestBudgetPass runs one load and applies the hard budget gates. It follows
// the three benchmark disciplines: separate processes (client/server split),
// warm up before counting, and report same-machine relative magnitudes only.
func TestBudgetPass(t *testing.T) {
	const entities = 10000
	scene := NewScene(1, entities)

	// Discipline 2: warm up to a steady state before counting ticks.
	for i := 0; i < 200; i++ {
		scene.Tick(0.05)
	}
	const ticks = 1000
	tickSamples := make([]float64, 0, ticks)
	for i := 0; i < ticks; i++ {
		start := time.Now()
		scene.Tick(0.05)
		tickSamples = append(tickSamples, float64(time.Since(start).Microseconds())/1000.0)
	}

	addr, stop := startEcho(t)
	defer stop()

	// Discipline 1: the client is a separate connection set, not the scene.
	const conns = 400
	peers := make([]net.Conn, 0, conns)
	for i := 0; i < conns; i++ {
		c, err := net.Dial("tcp", addr)
		if err != nil {
			t.Fatalf("dial %d: %v", i, err)
		}
		peers = append(peers, c)
		if i%32 == 31 {
			time.Sleep(time.Millisecond) // stagger past the small listen backlog
		}
	}
	defer func() {
		for _, c := range peers {
			_ = c.Close()
		}
	}()

	// Warm the request path before measuring latency.
	latSamples := make([]float64, 0, conns*8)
	for _, c := range peers {
		_ = c.SetDeadline(time.Now().Add(2 * time.Second))
		_, _ = c.Write(Encode(Frame{Cmd: CmdHeartbeatReq, Seq: 0}))
		_, _ = ReadFrame(c)
	}
	for round := 0; round < 8; round++ {
		for _, c := range peers {
			_ = c.SetDeadline(time.Now().Add(2 * time.Second))
			start := time.Now()
			_, _ = c.Write(Encode(Frame{Cmd: CmdHeartbeatReq, Seq: uint32(round)}))
			if _, err := ReadFrame(c); err != nil {
				t.Fatalf("heartbeat: %v", err)
			}
			latSamples = append(latSamples, float64(time.Since(start).Microseconds())/1000.0)
		}
	}

	maxTick := 0.0
	for _, v := range tickSamples {
		if v > maxTick {
			maxTick = v
		}
	}
	rep := Report{
		TickP50Ms:    Percentile(tickSamples, 0.50),
		TickP99Ms:    Percentile(tickSamples, 0.99),
		TickMaxMs:    maxTick,
		Conns:        len(peers),
		LatencyP50Ms: Percentile(latSamples, 0.50),
		LatencyP99Ms: Percentile(latSamples, 0.99),
		Entities:     entities,
	}
	budget := Budget{MaxTickMs: 33, MinConns: 300, MaxP99Ms: 20}
	budget.Evaluate(&rep)

	buf, _ := json.MarshalIndent(rep, "", "  ")
	fmt.Println(string(buf))
	t.Logf("tick p99=%.3fms conns=%d latency p99=%.3fms pass=%v",
		rep.TickP99Ms, rep.Conns, rep.LatencyP99Ms, rep.Pass)

	if !rep.Pass {
		t.Fatalf("budget not met: %v", rep.Failures)
	}
}

本机跑出来的 JSON 如下(同机相对量级,绝对数值不可移植):

{
  "tick_p50_ms": 0.128,
  "tick_p99_ms": 0.175,
  "tick_max_ms": 0.237,
  "conns": 400,
  "latency_p50_ms": 0.3,
  "latency_p99_ms": 1.713,
  "entities": 10000,
  "pass": true
}

把 JSON 逐字段读成一句话:

字段值读法
entities10000场景规模:一个地图实例里的实体数
tick_p50_ms / tick_p99_ms / tick_max_ms0.128 / 0.175 / 0.237每帧逻辑中位 0.13ms、P99 0.18ms、最慢一帧 0.24ms
conns400真实 TCP 连接数,全部完成 8 轮心跳
latency_p50_ms / latency_p99_ms0.300 / 1.713心跳往返中位 0.3ms、P99 约 1.7ms
passtrue三条预算全部满足,无 failures 字段

读数字:达标不等于没有风险

第一,帧预算有巨大余量,瓶颈不在算。 10000 实体、每帧 P99 只有 0.175ms,离 33ms 的帧预算还差两个数量级。这条结论很有用:《晨曦大陆》的帧逻辑不是瓶颈,卡顿如果出现,大概率在网络、GC 或持久化,而不是在玩法循环。别因为帧预算达标就以为万事大吉。

第二,延迟预算看尾巴,不看中位。 心跳往返中位只有 0.3ms,但 P99 在 0.7ms 到 4.2ms 之间抖动——多跑几遍会看到 P99 明显高于 P50。玩家感知到的卡顿几乎都藏在尾巴里,所以判据用 P99 而不是 P50。本机 P99 最坏一次约 4.2ms,仍在 20ms 预算内。

第三,连接数是「测试下限」,不是「硬顶」。 本节开 400 条就停,400 条全部应答,说明至少能扛 400,但不说明 401 就崩。真实部署里单进程连接数还受文件描述符上限、内存与心跳扇出影响。要找到天花板,得把连接数往上推直到有连接被拒。

第四,绝对值不可移植,相对关系可以带走。 本机 P50 0.128ms,换机器就变。但两条相对关系换机器仍成立:帧逻辑远快于帧预算(算力不是瓶颈),延迟 P99 明显高于 P50(尾巴长)。这两条趋势才是能迁移的结论。

多跑几遍的波动范围(同机、同代码):帧 P99 约 0.17–0.22ms、最慢帧约 0.22–0.53ms、延迟 P99 约 0.7–4.2ms。波动来自机器当时的后台负载,别把任何一次结果当成常数。

本节小结 / 实测结论

  • 预算是先写死、后测量的三条硬阈值:每 tick P99 ≤ 33ms、单进程连接数 ≥ 300、请求往返 P99 ≤ 20ms;Budget.Evaluate 逐条比对,全满足才 pass。
  • 基准三条纪律:进程隔离、预热到稳定(先 200 次 tick + 一次心跳热身)、报同机相对量级且禁用绝对话。
  • 本机实测(B 层):10000 实体下帧 P50 0.128ms / P99 0.175ms / 最慢 0.237ms,400 条连接全部应答,心跳往返 P50 0.300ms / P99 1.713ms,pass 为 true。
  • 三个读法提醒:帧预算余量巨大说明瓶颈不在算;延迟看 P99 不看 P50;连接数是测试下限而非硬顶。所有数字同机相对量级,绝对数值不可移植。
  • 达标只是「这一刻没超预算」,把它固化成上线前必须过的一关,就是 9.3 的清单。

阅读导航:上一节:9.1 《晨曦大陆》玩法装配 · 下一节:9.3 上线检查清单 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.1 《晨曦大陆》玩法装配