《游戏服务端架构·品类、状态与拓扑》1.2 三类延迟预算:帧、操作、结算

把“低延迟”拆成帧预算、操作预算与结算预算三类互不等价的时间尺度,给出从端到端目标反推排队、处理、持久化与网络分段的预算表,并用 Go 代码把预算检查、分位数统计与超限报告变成可执行约束,最后对比北辰三款产品的预算矩阵,强调长尾而不是平均值。

一个 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——

这张表有两个作用:

  1. 任何一段超限,都能定位到具体环节,而不是笼统地说“服务器卡”;
  2. 预算总和必须等于目标,不能每段都写“尽量快”,最后没有一段负责。

实际工程里通常留 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首要矛盾
《裂隙战场》33ms120ms2s帧稳定性与公平性
《群岛纪元》1s500ms30s全球延迟与结算一致性
《泡泡工坊》无严格 tick300ms5s登录与开房速度

这张表说明:同一个“低延迟”目标,在三款产品上对应三套不同的技术投入。

  • 竞技游戏愿意为 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

这份报告能直接回答三个问题:

  1. 帧是否稳定;
  2. 玩家操作是否跟手;
  3. 结算是否在可接受时间内完成。

如果只报告一个“平均延迟 45ms”,上面三个问题都回答不了。

常见错误与边界

第一,把服务器 tick 当成端到端延迟。tick 只是链路中的一段。

第二,用平均值做验收。长尾才是玩家流失的原因。

第三,不给结算设上限。异步不等于可以永远不完成。

第四,重试不占预算。重试是预算的一部分,不是额外的机会。

第五,所有品类用同一套预算。竞技、SLG、休闲的首要矛盾不同。

第六,预算只写在文档里。必须写进代码、压测脚本和告警规则,否则没人会遵守。

小结

“低延迟”必须拆成帧预算、操作预算、结算预算三类时间尺度。帧预算看稳定性,操作预算看玩家感知,结算预算看最终一致性。三类预算都要用分位数表达,并且每段都要有明确的数值和测量点。

这一节的 budget 包把预算检查和分位数统计变成了可执行代码,下一节用它来比较北辰三款产品的品类差异,并给出“品类如何决定拓扑”的第一版决策函数。

阅读导航:上一节:1.1 游戏服务端到底在为谁做架构 · 下一节:1.3 品类如何决定拓扑:北辰三款产品 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查