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 逐字段读成一句话:
| 字段 | 值 | 读法 |
|---|---|---|
entities | 10000 | 场景规模:一个地图实例里的实体数 |
tick_p50_ms / tick_p99_ms / tick_max_ms | 0.128 / 0.175 / 0.237 | 每帧逻辑中位 0.13ms、P99 0.18ms、最慢一帧 0.24ms |
conns | 400 | 真实 TCP 连接数,全部完成 8 轮心跳 |
latency_p50_ms / latency_p99_ms | 0.300 / 1.713 | 心跳往返中位 0.3ms、P99 约 1.7ms |
pass | true | 三条预算全部满足,无 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 上线检查清单 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。