《MMO 服务器开发》18.3 单服承载数字

玩法系统接进来之后,单服到底能带多少同屏玩家?本节用 B 层基准本机实跑:每个玩家每 tick 做移动、施法、掷掉落三件事,由小到大扫 50 到 1000 同屏玩家,落 JSON 并给出每 tick 成本表,再与卷一的端到端承载数字对照,说明为什么玩法不是瓶颈、网络路径才是。

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 帧预算
500.102 ms2.04 µs0.3%
1000.332 ms3.32 µs1.0%
2000.791 ms3.95 µs2.4%
5002.272 ms4.54 µs6.8%
10004.761 ms4.76 µs14.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 s1000 同屏玩家 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 《晨曦大陆》单服可玩 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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