18.3 单服承载数字
18.2 证明了《晨曦大陆》单服能端到端跑通。但「能跑通」和「能带多少人」是两件事。一条八步链路在三个玩家身上跑绿,不代表它在同屏一千人时还能在 30Hz 的一帧里跑完。要回答「玩法系统接进来之后,单服能带多少同屏玩家」,必须真跑一组由小到大的负载。
本节是 B 层(基准)。它做三件事:交代基准的三条纪律;用 go test -run TestCapacitySweep 由小到大扫 50 到 1000 同屏玩家,落 JSON、给出每 tick 成本表;最后把结果与卷一《网络与并发内核》的端到端承载数字对照,说明「玩法接进来之后,瓶颈在哪一层」。
先把口径交代清楚。本节所有数字都来自同一台写作机(Go 1.26.0,darwin/arm64,10 逻辑核),同机相对量级,绝对数值不可移植。换 CPU、换 Go 版本、换样本大小,每一个数字都会变。能带走的只有它们之间的相对关系。
还有一处口径要先说:本节的基准不含网络 I/O。它测的是玩法结算本身——移动、施法、掉落掷点——在一个 tick 里花多少毫秒,而不是一条消息从客户端到服务端再回来要多久。后者是卷一的题目,数字在 17.3 与 18.2 里。把两卷的数字放在一起之前,必须先分清这两层,否则会得出「玩法比网络快两个数量级」的错误结论——那不是快,那是没测同一件事。
本节的结构是:先讲基准的三条纪律,再给实测的每 tick 成本,然后追问「每玩家成本为什么随密度上升」,最后与卷一的承载数字对照,回答标题的问题。全文只有一个目标——把「单服能带多少同屏玩家」从一句感觉,变成一组能重跑、能对账的数字。
基准纪律:三条不能破的规矩
玩法系统的基准最容易做成「自嗨」:跑一个数字,看起来很快,其实测的是缓存命中或编译期优化。要让它有意义,三条纪律不能破。
第一,进程隔离。 基准必须在独立的进程里跑,不能和别的负载抢 CPU。go test 默认就是独立进程,但要注意别在同一台机器上并行跑多个基准——本节的扫描是顺序跑的,上一档跑完才跑下一档。
第二,预热到稳定。 map 与 AOI 九宫格的扩容会让前几个 tick 明显偏慢。本节的每一档都先跑 20 个 tick 预热,再跑 200 个 tick 计分,把「扩容成本」从计分窗口里剔出去。
第三,报不依赖 JIT 的硬指标。 Go 是 AOT 编译,没有 JIT 预热问题,但仍要报确定性指标(每 tick 成本、每玩家成本),而不是「优化后能到多少」。数字必须来自本机实跑,不许从别处搬。
一句话:B 层的数字只回答「玩法结算本身有多贵」,不回答「线上能带多少」——后者要加上网络、持久化、运营的全部成本。
实测:玩法接进来之后的每 tick 成本
扫描的负载是:同一个世界里放 n 个玩家、n 只怪,每个玩家每 tick 做三件事——移动、施法、掷一次掉落。这就是「玩法接进来之后」的真实负载:不是空跑心跳,而是每个玩家都在改状态。下面这段是 B 层,go test -run TestCapacitySweep -v 可以直接跑:
// file: internal/gameplay/capacity_test.go
// tier: B
// verify: go test -run TestCapacitySweep -v ./internal/gameplay/
package gameplay
import (
"encoding/json"
"fmt"
"testing"
"time"
)
// CapacityResult 是一次承载扫描的结果,直接落 JSON 供书稿引用。
// 所有数字都是同机相对量级,绝对数值不可移植。
type CapacityResult struct {
Players int `json:"players"`
Ticks int `json:"ticks"`
MsgsPerTick int `json:"msgs_per_tick"`
TotalMs float64 `json:"total_ms"`
PerTickMs float64 `json:"per_tick_ms"`
MsgsPerSec float64 `json:"msgs_per_sec"`
}
// runCapacity 在同一个世界里放 n 个玩家、n 只怪,跑 ticks 个 tick。
// 每个玩家每 tick 做三件事:移动、施法、掷一次掉落——这就是「玩法接进来之后」的负载。
func runCapacity(n, ticks int) CapacityResult {
srv := NewServer(1, 1)
srv.skill.Cooldown = 0 // 让每次施法都走完整结算路径
const bigHP = 1 << 40 // 怪物不死,隔离出纯结算成本
mons := make([]EntityID, n)
for i := 0; i < n; i++ {
pos := Vec2{X: float64(i%40) * GridSize, Y: float64(i/40) * GridSize}
srv.EnterWorld(PlayerID(i+1), pos)
mons[i] = srv.SpawnMonster(pos, bigHP)
}
start := time.Now()
for tk := 0; tk < ticks; tk++ {
srv.Combat.Advance()
for i := 0; i < n; i++ {
p := PlayerID(i + 1)
srv.Move(p, Vec2{X: float64(i%40)*GridSize + float64(tk%4), Y: float64(i/40) * GridSize})
eid, _ := srv.Entity(p)
srv.Combat.Cast(srv.skill, eid, mons[i])
srv.LootTable.Roll(p, uint64(tk), 1)
}
}
elapsed := time.Since(start)
res := CapacityResult{Players: n, Ticks: ticks, MsgsPerTick: n * 3}
res.TotalMs = float64(elapsed.Microseconds()) / 1000.0
res.PerTickMs = res.TotalMs / float64(ticks)
if res.PerTickMs > 0 {
res.MsgsPerSec = float64(res.MsgsPerTick) * 1000.0 / res.PerTickMs
}
return res
}
// TestCapacitySweep 是本节的 B 层:由小到大扫一遍玩家数,落 JSON。
// 软告警不硬失败——数字随机器负载浮动,只做方向参考。
func TestCapacitySweep(t *testing.T) {
const ticks = 200
sizes := []int{50, 100, 200, 500, 1000}
results := make([]CapacityResult, 0, len(sizes))
for _, n := range sizes {
// 预热一轮,让 map 与 grid 扩容到稳定。
runCapacity(n, 20)
res := runCapacity(n, ticks)
results = append(results, res)
t.Logf("players=%-5d per_tick=%.3fms msgs/s=%.0f", res.Players, res.PerTickMs, res.MsgsPerSec)
}
buf, _ := json.MarshalIndent(results, "", " ")
fmt.Println(string(buf))
// 30Hz 单帧预算 33.3ms:估算单服可持续的同屏玩家数。
for _, res := range results {
if res.PerTickMs > 33.3 {
t.Logf("players=%d 已超出 30Hz 单帧预算(%.3fms)", res.Players, res.PerTickMs)
}
}
}
本机跑出来的 JSON 如下(同机相对量级,绝对数值不可移植):
[
{ "players": 50, "ticks": 200, "msgs_per_tick": 150, "total_ms": 20.424, "per_tick_ms": 0.10212, "msgs_per_sec": 1468860.16 },
{ "players": 100, "ticks": 200, "msgs_per_tick": 300, "total_ms": 66.333, "per_tick_ms": 0.331665, "msgs_per_sec": 904527.16 },
{ "players": 200, "ticks": 200, "msgs_per_tick": 600, "total_ms": 158.17, "per_tick_ms": 0.79085, "msgs_per_sec": 758677.37 },
{ "players": 500, "ticks": 200, "msgs_per_tick": 1500, "total_ms": 454.38, "per_tick_ms": 2.2719, "msgs_per_sec": 660240.33 },
{ "players": 1000, "ticks": 200, "msgs_per_tick": 3000, "total_ms": 952.208, "per_tick_ms": 4.76104, "msgs_per_sec": 630114.43 }
]
把每 tick 成本换算成「每玩家每 tick」与「占 30Hz 帧预算的比例」,就是本节最该记住的一张表。30Hz 一帧的预算是 33.3 毫秒:
| 同屏玩家 | 每 tick 成本 | 每玩家每 tick | 占 30Hz 帧预算 |
|---|---|---|---|
| 50 | 0.102 ms | 2.04 µs | 0.3% |
| 100 | 0.332 ms | 3.32 µs | 1.0% |
| 200 | 0.791 ms | 3.95 µs | 2.4% |
| 500 | 2.272 ms | 4.54 µs | 6.8% |
| 1000 | 4.761 ms | 4.76 µs | 14.3% |
两件事从表里读出来。其一,每玩家每 tick 的成本随同屏密度缓慢上升:50 人时 2.04 µs,1000 人时 4.76 µs,涨了约 2.3 倍。这不是代码变慢了,而是同屏玩家越多,AOI 九宫格越密、每个玩家视野里的邻居越多,移动与广播的邻居集合越大。其二,即使 1000 人同屏,玩法结算也只占 30Hz 一帧预算的 14.3%:如果每玩家成本不随密度上升,按 1000 人这一点线性外推,单帧预算能容纳约 33.3 / 4.761 × 1000 ≈ 7000 个同屏玩家;但因为每玩家成本在上升,实际单服玩法上限要低于这个线性外推值。
每玩家成本为什么随密度上升
表里最值得追问的一列是「每玩家每 tick」:它从 2.04 µs 涨到 4.76 µs。如果玩法结算是纯常数操作,这一列应该基本持平;它翻了一倍多,说明成本随同屏密度上升。原因在 AOI 九宫格:世界按 GridSize = 8.0 切格,每个玩家的「邻居集合」是它所在格与周围 3×3 格里的实体。
同屏 50 人:格子里稀疏,多数格只有 1 个实体
┌───┬───┬───┐
│ · │ · │ · │ ← 每个玩家的 3×3 视野里平均 1–2 个邻居
├───┼───┼───┤
│ · │ ● │ · │ ← 移动 / 广播要遍历的邻居集合很小
├───┼───┼───┤
│ · │ · │ · │
└───┴───┴───┘
同屏 1000 人:格子被填满,每格 ~1 个实体
┌───┬───┬───┐
│ ● │ ● │ ● │ ← 每个玩家的 3×3 视野里稳定 ~8 个邻居
├───┼───┼───┤
│ ● │ ● │ ● │ ← 移动 / 广播要遍历的邻居集合大得多
├───┼───┼───┤
│ ● │ ● │ ● │
└───┴───┴───┘
扫描里玩家按 40 列排布,50 人时只填满一行多,1000 人时铺满 40×25 格。于是同一个 Move 调用,50 人时要比较的邻居少、1000 人时要比较的邻居多;同一个 Cast 也是同理——它要遍历的邻居集合随密度变大。这就是「每玩家成本上升」的来源,也是为什么不能简单线性外推:线性外推假设每玩家成本不变,而它实际在涨。
要在自己机器上重跑,直接执行 go test -run TestCapacitySweep -v ./internal/gameplay/ 即可;多跑几遍看波动,别把一次结果当成常数。如果只想要数字不想要日志,用 go test -run TestCapacitySweep ./internal/gameplay/ 并忽略 t.Logf。
与卷一承载数字对照
卷一的 17.3 与 18.2 跑出过端到端承载数字:吞吐天花板约 3000–3200 消息/秒(50×400 与 100×200 两组持续负载),1000 连接时 P50 283.8 ms、P99 1642.1 ms 并出现第一个失败连接。本节的数字是 63 万–147 万消息/秒,看着高出一两个数量级——但两者不是同一口径,不能直接比大小。
| 维度 | 卷一(/mmo-net-17-3-bot-and-first-benchmark/
、/mmo-net-18-2-benchmark-summary/
) | 卷二(本节) |
|---|---|---|
| 测的是什么 | 真起进程 + 真 socket 往返:登录 → 进世界 → 心跳请求-响应 | 纯内存逻辑:移动 + 施法 + 掉落掷点,无 socket |
| 负载形态 | 机器人客户端按 RTT 顺序发请求,N 条连接同时在线 | 一个世界里的 N 个同屏玩家,每 tick 各改状态 |
| 观测口径 | 端到端 QPS 与 P50/P90/P99 分位 | 每 tick 成本、每玩家成本、占 30Hz 帧预算比例 |
| 代表数字 | 天花板约 3000–3200 QPS;1000 连接 P99 塌方到 1.6 s | 1000 同屏玩家 4.76 ms/tick,占帧预算 14.3% |
| 瓶颈在哪 | 网络往返 + 单线程世界 goroutine 排队 | 玩法结算本身(AOI 邻居集合随密度放大) |
对照之后,结论只有一句:玩法系统接进来之后,单服能带多少同屏玩家,不由玩法结算决定,而由卷一的网络路径与单点世界 goroutine 决定。 1000 同屏玩家的玩法结算只吃掉 30Hz 一帧的 14.3%,还剩八成余量;而卷一的 1000 连接已经让 P99 塌方到 1.6 秒。也就是说,如果今天要往上推单服容量,先要解决的是卷一的排队问题——把世界拆成多个 actor、把扇出从单点挪出去——而不是去优化背包或掉落表的常数。
这也和卷一 18.2 的一句总结接得上:延迟是排队,不是协议栈;承载能力的单位是「请求/秒」,不是「连接数」。 玩法加进来以后,这句话依然成立,只是「请求」从心跳变成了移动 + 施法 + 掉落。
再补一句两卷之间的分工:卷一的数字告诉你「消息怎么到、到得有多快」,本节的数字告诉你「消息到了之后改状态有多贵」。两卷合起来才是「单服能带多少」的完整答案——先看网络层能送进来多少请求/秒,再看玩法层能不能在 30Hz 的帧预算里把这些请求改完。只报一半,就会得出片面的容量结论:只看卷一,会低估单服的玩法余量;只看卷二,会高估单服的网络承载。
怎么读这三个数字
JSON 里有三个数字容易读错:per_tick_ms、per_tick_ms 换算出的每玩家成本、以及 msgs_per_sec。三条读法要一起记住。
第一,per_tick_ms 是唯一的主指标。 它回答「一个 30Hz 的 tick 里,玩法结算占多少毫秒」。1000 人 4.761 ms,对比 33.3 ms 的帧预算,一眼就能看出余量。任何容量结论都要从这个数字出发。
第二,msgs_per_sec 是「CPU 满载时的理论吞吐」,不是墙上时钟吞吐。 它按 msgs_per_tick / per_tick_ms × 1000 算,等于假设 CPU 把全部时间都花在玩法结算上。但 30Hz 下玩法一帧只占 4.761 ms,剩下的 28.5 ms 要留给网络 I/O、持久化与其他系统——所以真实的墙上吞吐要按帧预算打折。把 msgs_per_sec 当成线上容量,就会高估。
第三,绝对值不可移植,相对关系可以带走。 本机 10 核、Go 1.26.0 下 1000 人 4.761 ms;换机器这个数字就变。但两条相对关系换机器仍成立:每 tick 成本随同屏玩家近似线性(50→1000 涨约 47 倍),每玩家成本随密度缓慢上升(2.04→4.76 µs)。
顺带交代一次「为什么不用 testing.B」:testing.B 的 b.N 会自动调样本数,适合「单次操作多快」;本节的负载是「一个 tick 里 N 个玩家」,用 testing.T 顺序扫固定档位更好对账——每一档都跑同样多的 tick,只有玩家数在变。两种口径都合法,关键是在同一份代码里保持同一种口径,别混用。
最后交代一次「为什么档位取 50–1000」:50 是稀疏场景(一行多玩家),1000 是密集场景(铺满 40×25 格)。这两端分别对应「玩法成本接近常数」与「玩法成本随密度上升」两种形态;中间三档(100/200/500)用来确认趋势是单调的,而不是在某一档突然跳变。如果只跑一档,就看不出每玩家成本的上升趋势,也就发现不了 AOI 邻居集合这个真正的成本来源。
单服能带多少同屏玩家:一句话答案
回到本节标题的问题。把两卷的数字拼起来,答案分两层。
玩法层:还有约 7 倍余量。 1000 同屏玩家每 tick 只花 4.761 ms,占 30Hz 一帧预算的 14.3%。按这一点线性外推,玩法本身能撑到约 7000 人;即使把每玩家成本随密度上升的效应算进去,玩法层的余量也远不止「一千」这个量级。玩法不是单服容量的约束。
网络层:几百到一千人就到顶。 卷一的端到端压测里,200 连接以内 P99 还在 10 ms 量级、玩家无感;从 500 连接起 P99 跳到几百毫秒,1000 连接时冲到 1.6 秒并出现失败。网络往返与单点世界 goroutine 的排队,才是单服容量的约束。
所以一句话答案是:在「一个进程、一个世界」的假设下,单服可接受的同屏玩家在几百到一千这个量级,约束来自网络路径与单点世界 goroutine,不来自玩法结算。 要往上推,先拆世界的排队(多 actor、分片、把扇出从单点挪出去),而不是去优化背包或掉落表的常数——那 14.3% 的余量,怎么优化都推不动那个已经塌方的 P99。
这个答案也解释了卷二为什么敢把 54 节玩法全部写成纯内存实现:玩法层有富余,真正的成本在卷一刻意留白的网络与并发那一层。
一句话概括读法:先看 per_tick_ms 与 33.3 ms 的比值,再问这个比值随密度怎么变,最后才谈能带多少人。
反过来读(先看 msgs_per_sec、再看绝对值)只会得到一个漂亮却用不上的数。
本节小结 / 实测结论
- 本节是 B 层,用
go test -run TestCapacitySweep -v ./internal/gameplay/由小到大扫 50 到 1000 同屏玩家,每个玩家每 tick 做移动、施法、掷掉落三件事,落 JSON。 - 基准三条纪律:进程隔离、预热到稳定(先跑 20 tick 再计 200 tick)、报不依赖 JIT 的确定性指标(每 tick 成本、每玩家成本)。
- 本机实测:1000 同屏玩家每 tick 成本 4.761 ms,每玩家 4.76 µs,只占 30Hz 一帧预算的 14.3%;按该点线性外推上限约 7000 人,但每玩家成本随同屏密度上升(50 人 2.04 µs → 1000 人 4.76 µs),实际要低于此。
- 与卷一对照:卷一的端到端天花板约 3000–3200 QPS、1000 连接 P99 塌方到 1.6 s;本节的 63 万–147 万 msgs/s 是纯内存逻辑口径,两者不能直接比大小。
- 结论:玩法结算不是单服的瓶颈,网络路径与单点世界 goroutine 才是;要往上推单服容量,先拆世界的排队,而不是优化玩法常数。所有数字都是同机相对量级,绝对数值不可移植。
- 口径提醒:
msgs_per_sec是「CPU 满载时的理论吞吐」,不是墙上吞吐;per_tick_ms才是主指标;本节的基准不含网络 I/O,与卷一的端到端数字不同口径,不能直接比大小。
三节下来,第 18 章的收尾也就清楚了:18.1 把玩法接进 aegis,18.2 证明它端到端能玩,18.3 给出它能带多少同屏玩家的量化答案。卷二到这里就结束了——它把《晨曦大陆》的玩法一个接一个装进卷一造好的底座,并证明整台机器能跑、能玩、能撑住一千人同屏的玩法结算。再往上——跨服、分布式、线上承载——就不属于这一卷了:那是卷三与卷四的题目。
阅读导航:上一节:18.2 《晨曦大陆》单服可玩 · 下一节:回到目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。