一个 38ms 的承诺
《裂隙战场》的官网上写着“服务器 tick 38ms”。这个数字来自一次内部压测:500 个房间、每房间 10 人、单房间 tick 平均 38ms。
上线三个月后,玩家开始抱怨“技能按了没反应”。监控显示服务器 tick 仍然是 38ms,CPU 也没满。问题出在另一个地方:一次技能释放从客户端发出,到玩家看到伤害数字,中间经过了网关排队、房间处理、战斗结算、推送四个环节,端到端 p99 是 310ms。
38ms 只是其中一段。团队把一段当成了全部。
这类错误在游戏服务端非常常见,因为“延迟”至少有三个不同的时间尺度,它们的预算、测量方式和失败后果都不一样。
三类预算不是一件事
帧预算:一次模拟 tick 能用多久
帧预算回答的是:服务器每隔多久推进一次世界状态,这一次推进最多能花多久。
- 竞技游戏常见 30Hz 或 60Hz,对应 33ms 或 16ms;
- SLG 的世界 tick 可能是 1 秒甚至更久;
- 休闲游戏可能没有严格的 tick,只有房间内的定时器。
帧预算的关键约束是稳定性:偶尔一次 100ms 可以接受,持续在 40ms 抖动会让所有玩家感到卡顿。
操作预算:一个玩家操作多久有反馈
操作预算回答的是:玩家按下按钮,到看到结果,最多等多久。
它包含帧预算,但不止帧预算:
客户端输入 → 网络上行 → 网关排队 → 房间处理 → 广播 → 网络下行 → 客户端渲染
竞技游戏的操作预算通常在 100–150ms;超过 200ms,玩家会明显感到“不跟手”。SLG 和卡牌可以放宽到 300–500ms,因为操作不是连续输入。
结算预算:一局或一个赛季多久算完
结算预算回答的是:战斗结束、赛季结束、跨服活动结束后,奖励和排名多久能落定。
它通常不是实时的,玩家可以接受几秒甚至几分钟,但必须有明确上限和进度反馈。结算预算失控的典型表现是:玩家已经回到大厅,奖励还没到账;或者两个服的赛季排名相差几十分钟。
三类预算的关系可以画成这样:
帧预算(33ms)
└── 操作预算(120ms)
└── 结算预算(2s)
时间尺度越大,越允许异步;
但每一层都必须有独立的上限和观测。
预算怎么拆
以《裂隙战场》的一次技能释放为例,端到端操作预算 120ms,可以拆成:
| 分段 | 预算 | 测量点 | 超限后果 |
|---|---|---|---|
| 客户端上行网络 | 25ms | 网关收到包的时间戳 | 玩家操作延迟 |
| 网关排队与转发 | 10ms | 网关入队到房间收到 | 高峰排队 |
| 房间处理 | 33ms | 一帧内处理完 | 技能排队到下帧 |
| 战斗结算 | 15ms | 结算函数耗时 | 帧超时 |
| 广播与下行 | 25ms | 房间发出到客户端收到 | 表现延迟 |
| 客户端渲染 | 12ms | 客户端帧时间 | 手感延迟 |
| 余量 | 0ms | — | — |
这张表有两个作用:
- 任何一段超限,都能定位到具体环节,而不是笼统地说“服务器卡”;
- 预算总和必须等于目标,不能每段都写“尽量快”,最后没有一段负责。
实际工程里通常留 10–20% 余量。上表为了说明“预算必须闭合”,把余量写成 0。
预算不是平均值
用平均数做预算会掩盖长尾。100 次操作里 99 次 30ms、1 次 3 秒,平均 60ms,看起来达标;但那个玩家已经流失了。
所以预算必须用分位数表达:
- 帧预算看 p99 和 max;
- 操作预算看 p99;
- 结算预算看 p99 和“超时未完成率”。
预算的代码表达
把预算写成配置不够,必须写成代码里可检查的结构。下面是 gsarch-lab 的预算包:
// file: internal/budget/budget.go
// tier: L1
// verify: go test ./internal/budget/
package budget
import (
"fmt"
"sort"
)
// Kind separates the three latency budgets a game server must never mix.
type Kind string
const (
Frame Kind = "frame" // one simulation tick
Operation Kind = "operation" // one player-visible action
Settlement Kind = "settlement" // one match or season settlement
)
type Budget struct {
Kind Kind
P99 int64 // milliseconds
Max int64 // milliseconds
}
type Sample struct {
Kind Kind
MS int64
}
type Violation struct {
Kind Kind
MS int64
Limit int64
}
func (v Violation) String() string {
if v.Limit < 0 {
return fmt.Sprintf("%s=%dms has no configured budget", v.Kind, v.MS)
}
return fmt.Sprintf("%s=%dms exceeds max=%dms", v.Kind, v.MS, v.Limit)
}
// Check reports every sample that exceeds its kind's hard max.
func Check(budgets map[Kind]Budget, samples []Sample) []Violation {
var out []Violation
for _, s := range samples {
b, ok := budgets[s.Kind]
if !ok {
out = append(out, Violation{Kind: s.Kind, MS: s.MS, Limit: -1})
continue
}
if s.MS > b.Max {
out = append(out, Violation{Kind: s.Kind, MS: s.MS, Limit: b.Max})
}
}
return out
}
// Quantile returns the nearest-rank q-quantile (0 < q <= 1).
func Quantile(values []int64, q float64) int64 {
if len(values) == 0 {
return 0
}
if q <= 0 {
q = 0.01
}
if q > 1 {
q = 1
}
sorted := append([]int64(nil), values...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
rank := int(q*float64(len(sorted)) + 0.999999)
if rank < 1 {
rank = 1
}
if rank > len(sorted) {
rank = len(sorted)
}
return sorted[rank-1]
}
// Summary is the report a chapter or a load test should publish.
type Summary struct {
Kind Kind
Count int
P50 int64
P99 int64
Max int64
}
func Summarize(kind Kind, samples []Sample) Summary {
values := make([]int64, 0, len(samples))
for _, s := range samples {
if s.Kind == kind {
values = append(values, s.MS)
}
}
if len(values) == 0 {
return Summary{Kind: kind}
}
max := values[0]
for _, v := range values[1:] {
if v > max {
max = v
}
}
return Summary{
Kind: kind,
Count: len(values),
P50: Quantile(values, 0.50),
P99: Quantile(values, 0.99),
Max: max,
}
}
这个包故意做得很小,只解决三件事:
- 三类预算用
Kind隔开,避免把帧耗时和结算耗时混进同一个统计; Check对每个样本做硬上限判断,并显式报告“没有配置预算”的类型;Summarize用最近秩法计算 p50/p99/max,结果可复现,不依赖第三方库。
测试要覆盖分位数边界和“未知预算类型”:
// file: internal/budget/budget_test.go
// tier: L1
// verify: go test ./internal/budget/
package budget
import "testing"
func TestQuantileNearestRank(t *testing.T) {
values := []int64{10, 20, 30, 40, 50, 60, 70, 80, 90, 100}
cases := []struct {
q float64
want int64
}{
{0.50, 50},
{0.90, 90},
{0.99, 100},
{1.00, 100},
}
for _, c := range cases {
if got := Quantile(values, c.q); got != c.want {
t.Fatalf("Quantile(q=%.2f)=%d, want %d", c.q, got, c.want)
}
}
}
func TestCheckSeparatesBudgets(t *testing.T) {
budgets := map[Kind]Budget{
Frame: {Kind: Frame, P99: 33, Max: 50},
Operation: {Kind: Operation, P99: 120, Max: 200},
Settlement: {Kind: Settlement, P99: 1000, Max: 2000},
}
samples := []Sample{
{Kind: Frame, MS: 28},
{Kind: Frame, MS: 64},
{Kind: Operation, MS: 180},
{Kind: Operation, MS: 240},
{Kind: Settlement, MS: 1800},
}
got := Check(budgets, samples)
if len(got) != 2 {
t.Fatalf("got %d violations, want 2: %v", len(got), got)
}
if got[0].Kind != Frame || got[0].MS != 64 {
t.Fatalf("first violation=%v", got[0])
}
if got[1].Kind != Operation || got[1].MS != 240 {
t.Fatalf("second violation=%v", got[1])
}
}
func TestCheckRejectsUnknownKind(t *testing.T) {
got := Check(map[Kind]Budget{}, []Sample{{Kind: "render", MS: 5}})
if len(got) != 1 || got[0].Limit != -1 {
t.Fatalf("got %v", got)
}
}
func TestSummarize(t *testing.T) {
samples := []Sample{
{Kind: Frame, MS: 10}, {Kind: Frame, MS: 20}, {Kind: Frame, MS: 30},
{Kind: Operation, MS: 999},
}
s := Summarize(Frame, samples)
if s.Count != 3 || s.P50 != 20 || s.Max != 30 {
t.Fatalf("got %+v", s)
}
}
本机验证结果:
$ cd .local/checks/gsarch-lab
$ go test ./internal/budget/ -v
=== RUN TestQuantileNearestRank
--- PASS: TestQuantileNearestRank (0.00s)
=== RUN TestCheckSeparatesBudgets
--- PASS: TestCheckSeparatesBudgets (0.00s)
=== RUN TestCheckRejectsUnknownKind
--- PASS: TestCheckRejectsUnknownKind (0.00s)
PASS
ok gsarchlab/internal/budget 0.5s
三款产品的预算矩阵
把三类预算放到北辰三款产品上,会得到完全不同的优先级:
| 产品 | 帧预算 | 操作预算 p99 | 结算预算 p99 | 首要矛盾 |
|---|---|---|---|---|
| 《裂隙战场》 | 33ms | 120ms | 2s | 帧稳定性与公平性 |
| 《群岛纪元》 | 1s | 500ms | 30s | 全球延迟与结算一致性 |
| 《泡泡工坊》 | 无严格 tick | 300ms | 5s | 登录与开房速度 |
这张表说明:同一个“低延迟”目标,在三款产品上对应三套不同的技术投入。
- 竞技游戏愿意为 10ms 的帧优化投入大量工程,因为公平性直接关系留存;
- SLG 愿意为结算一致性等待更久,因为玩家能接受“稍后到账”;
- 休闲游戏最重要的是登录和开房,战斗内的毫秒级延迟不是主要矛盾。
架构评审时,先问“你们的三类预算分别是多少”,通常比问“你们用什么框架”更能看出团队是否想清楚。
预算的失败模式
预算膨胀
每段都写“尽量快”,最后没有一段负责。正确做法是给每段一个数,并让总和闭合。
重试把预算翻倍
一次操作里包含三次重试,每次超时 100ms,端到端预算就变成 300ms。重试必须消耗同一份预算,而不是额外追加。
GC 或锁造成的长尾
p50 很好看,p99 突然飙高,常见原因是 GC 停顿、全局锁争用、连接池耗尽。预算检查要按 p99 和 max 看,不能只看平均。
把结算预算当实时预算
结算可以异步,但不能无限期。必须定义“超时未完成率”,并让玩家看到进度或补偿。
怎么把预算写进压测
压测脚本不应该只报告 QPS 和平均延迟,而应该按三类预算分别输出:
frame: count=120000 p50=28ms p99=41ms max=180ms violations=12
operation: count= 60000 p50=72ms p99=138ms max=900ms violations=31
settlement: count= 500 p50=420ms p99=1800ms max=2600ms violations=3
这份报告能直接回答三个问题:
- 帧是否稳定;
- 玩家操作是否跟手;
- 结算是否在可接受时间内完成。
如果只报告一个“平均延迟 45ms”,上面三个问题都回答不了。
常见错误与边界
第一,把服务器 tick 当成端到端延迟。tick 只是链路中的一段。
第二,用平均值做验收。长尾才是玩家流失的原因。
第三,不给结算设上限。异步不等于可以永远不完成。
第四,重试不占预算。重试是预算的一部分,不是额外的机会。
第五,所有品类用同一套预算。竞技、SLG、休闲的首要矛盾不同。
第六,预算只写在文档里。必须写进代码、压测脚本和告警规则,否则没人会遵守。
小结
“低延迟”必须拆成帧预算、操作预算、结算预算三类时间尺度。帧预算看稳定性,操作预算看玩家感知,结算预算看最终一致性。三类预算都要用分位数表达,并且每段都要有明确的数值和测量点。
这一节的 budget 包把预算检查和分位数统计变成了可执行代码,下一节用它来比较北辰三款产品的品类差异,并给出“品类如何决定拓扑”的第一版决策函数。
阅读导航:上一节:1.1 游戏服务端到底在为谁做架构 · 下一节:1.3 品类如何决定拓扑:北辰三款产品 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。