《游戏服务端架构·规模化、全球化与成本》9.3 成本与体验的取舍

扩容不是线性的,降本也不是免费的。《裂隙战场》想把 tick 从 20Hz 降到 15Hz,《群岛纪元》想合并低峰区,《泡泡工坊》想缩短日志保留期,三个提案都需要同一把尺子。本节给出体验影响、成本节省、可逆性三维评分,阈值筛选与组合估算,并用可运行的 Go 夹具演示评分、准入与节省上限。

降本的第一个拐点

《裂隙战场》的房间服容量按对局数扩容。运维给出一张示例表:从 8 个房间进程加到 12 个,每千次对局成本下降;加到 16 个,成本基本持平;再加到 24 个,单位成本反而上升。原因是匹配服务、数据库连接和跨进程广播的开销开始超过新增房间带来的收益。

这就是成本曲线的拐点。扩容不是线性的,降本也不是。 在拐点左侧加容量是划算的,在拐点右侧加容量只是把账单做大。拐点的位置必须用固定流量压测测出来,不能从实例单价推算;实例单价只告诉你一台机器多少钱,不告诉你系统在什么规模下开始互相拖累。

测量方法不复杂:固定一份回放流量,从当前容量开始逐档增加房间进程,每档跑够稳定窗口,记录每千次对局成本、P99 tick 抖动和匹配排队时间。成本最低的那一档就是经济拐点,但还要看体验指标是否仍在预算内。若经济拐点的 P99 已经超预算,真正可用的容量是体验拐点,不是成本拐点。延迟预算的口径见3.1 全球延迟预算与路由 。

跨区部署还有第二个台阶:从单区到双区,成本不是乘以二,而是乘以二再加跨区复制、专线和一致性开销。示例中双区的单位成本比单区高 35%,但延迟覆盖范围扩大了一倍。这个台阶值不值,不能只看成本,要看新增覆盖地区的付费转化。

四个常见手段的边界

降本手段很多,但每个都有体验边界。下面四个是北辰工作室真实讨论过的:

手段做法体验影响节省来源可逆性
tick 降频20Hz 降到 15Hz射击手感、判定精度房间服 CPU高,改配置
区域收敛低峰区合并到邻近区延迟增加 20–40ms区域固定成本中,需迁移
日志采样全量改为 10% 采样排障粒度下降存储与写入高,改采样率
回放保留期30 天缩短到 7 天老回放不可查对象存储中,删除不可逆

tick 降频对《裂隙战场》这类竞技产品不是纯技术决策。20Hz 降到 15Hz 节省的 CPU 示例为 18%,但命中判定窗口变宽,高水平玩家会感知到“打中了却没伤害”。可以先在休闲模式灰度,观察命中率、投诉率和退款率,再决定是否推广到排位。对《泡泡工坊》这种休闲产品,tick 从 30Hz 降到 10Hz 几乎没有感知,因为它不是实时对抗。

区域收敛对《群岛纪元》更合适。SLG 的离线推进和异步结算对延迟不敏感,把低峰区合并到邻近区,代价是部分玩家延迟从 60ms 升到 90ms。要先确认这个区间仍在可接受范围,再检查数据驻留和跨区合规——区域收敛不能违反数据落地要求。

日志采样要区分“审计日志”和“调试日志”。支付、封禁、道具发放属于审计链路,不能采样;房间帧日志、路径寻路日志可以按 10% 采样,但采样必须保留错误全量。回放保留期缩短最容易做,也最容易被忽略:删除是不可逆的,玩家申诉和赛事复核都依赖回放,保留期变更要提前公告。

用一把尺子给手段打分

三个维度:体验影响 0–10、成本节省比例 0–1、可逆性 0–1。评分公式不直接把三者相乘,而是:

score = saving × reversibility / (1 + experience)

分母用 1 + experience 而不是 experience,是为了让零影响的手段保持有限分值,也避免“体验影响为 0 就得到无穷大”的除零问题。这个公式表达的是:节省和可逆性加分,体验影响减分,但减分是渐进的,不是一票否决。一票否决交给阈值。

阈值示例:体验影响不超过 3,节省比例不低于 10%,可逆性不低于 0.5,组合后的体验影响总和不超过 3。这四个数字不是普适真理,是工作室当前阶段的政策;政策要写进配置,随版本评审调整,而不是散落在会议纪要里。

为什么可逆性要单独一维?因为降本提案的最大风险不是“省得少”,而是“改错了回不去”。tick 降频、日志采样改配置就能回滚,适合先做;删除历史回放、合并区域、迁移数据存储,回滚成本极高,必须要求更高的节省和更低的体验影响。可逆性低的提案,即使分数高,也应该走更长的灰度周期。

组合估算:节省不能简单相加

单个手段省 10%,另一个也省 10%,合计不是 20%。原因有两个:作用范围可能重叠,比如日志采样和回放保留期都在动存储;体验影响会累加,两个“轻微”改动叠加可能让玩家明显感知。

组合估算按作用范围计算绝对节省:节省 = 基线 × 作用范围占比 × 范围内节省比例。作用范围重叠时,宁可低估也不要高估;最终节省 capped 在基线以内,不允许出现负账单。体验影响按算术和累计,再和组合预算比较。

这一步的意义不是算得有多准,而是暴露“单看都合格、合起来超预算”的组合。三个体验影响为 2 的手段,单独都过 3 的阈值,合起来是 6,已经超过组合预算 3。这时要砍掉分数最低的那个,而不是等玩家反馈。

数据模型与可运行片段

internal/costexperience 实现三件事:Score 计算三维评分,Evaluate 按政策阈值筛选并保留拒绝原因,Combine 估算组合后的账单与体验总和。拒绝原因要保留,因为评审需要看到“为什么没选”,而不只是“选了什么”。

// file: internal/costexperience/costexperience.go
// tier: L1
// verify: go test ./internal/costexperience/
package costexperience

import (
	"errors"
	"sort"
)

// Lever is one cost-reduction measure with the two numbers that decide
// whether it is allowed: how much it hurts and how easily it is undone.
type Lever struct {
	ID            string
	Experience    float64 // 0..10, expected player-visible impact
	SavingPct     float64 // 0..1, share of the addressed scope saved
	Reversibility float64 // 0..1, 1 means a config flag flips it back
	ScopePct      float64 // 0..1, share of the total bill the lever addresses
}

// Policy is the review gate. A lever must pass every threshold on its own.
type Policy struct {
	MaxExperience     float64
	MinSavingPct      float64
	MinReversibility  float64
	MaxCombinedImpact float64 // sum of admitted experience scores
}

// Decision records one lever after the gate, including the reason it lost.
type Decision struct {
	Lever    Lever
	Score    float64
	Admitted bool
	Reason   string
}

// Validate rejects values outside the documented ranges. A bad number in a
// cost review is worse than a missing one because it silently wins.
func Validate(l Lever) error {
	switch {
	case l.Experience < 0 || l.Experience > 10:
		return errors.New("costexperience: experience must be within [0,10]")
	case l.SavingPct < 0 || l.SavingPct > 1:
		return errors.New("costexperience: saving must be within [0,1]")
	case l.Reversibility < 0 || l.Reversibility > 1:
		return errors.New("costexperience: reversibility must be within [0,1]")
	case l.ScopePct < 0 || l.ScopePct > 1:
		return errors.New("costexperience: scope must be within [0,1]")
	}
	return nil
}

// Score rewards savings and reversibility, and discounts experience damage.
// The denominator keeps a zero-impact lever finite and a ten-impact lever
// from being selected by a large saving alone.
func Score(l Lever) float64 {
	if err := Validate(l); err != nil {
		return 0
	}
	return l.SavingPct * l.Reversibility / (1 + l.Experience)
}

// Evaluate applies the per-lever thresholds and returns a deterministic,
// score-descending list. Rejected levers stay in the result so the review
// can show what was considered and why it lost.
func Evaluate(levers []Lever, p Policy) []Decision {
	out := make([]Decision, 0, len(levers))
	for _, l := range levers {
		d := Decision{Lever: l, Score: Score(l)}
		switch {
		case Validate(l) != nil:
			d.Reason = "invalid range"
		case l.Experience > p.MaxExperience:
			d.Reason = "experience impact above policy"
		case l.SavingPct < p.MinSavingPct:
			d.Reason = "saving below policy"
		case l.Reversibility < p.MinReversibility:
			d.Reason = "not reversible enough"
		default:
			d.Admitted = true
		}
		out = append(out, d)
	}
	sort.SliceStable(out, func(i, j int) bool {
		if out[i].Admitted != out[j].Admitted {
			return out[i].Admitted
		}
		if out[i].Score != out[j].Score {
			return out[i].Score > out[j].Score
		}
		return out[i].Lever.ID < out[j].Lever.ID
	})
	return out
}

// Selected returns only admitted levers, preserving score order.
func Selected(decisions []Decision) []Lever {
	var out []Lever
	for _, d := range decisions {
		if d.Admitted {
			out = append(out, d.Lever)
		}
	}
	return out
}

// Estimate is the combined result of admitted levers. Savings are additive
// against disjoint scopes and capped at the baseline; experience is summed
// because several small degradations can add up to one bad session.
type Estimate struct {
	BaselineMicros int64
	AfterMicros    int64
	SavingMicros   int64
	ExperienceSum  float64
	WithinBudget   bool
}

// Combine estimates the bill after all admitted levers. A zero
// MaxCombinedImpact means the combined gate is not configured.
func Combine(baselineMicros int64, levers []Lever, p Policy) Estimate {
	if baselineMicros < 0 {
		baselineMicros = 0
	}
	est := Estimate{BaselineMicros: baselineMicros, AfterMicros: baselineMicros}
	for _, d := range Evaluate(levers, p) {
		if !d.Admitted {
			continue
		}
		saving := int64(float64(baselineMicros) * d.Lever.ScopePct * d.Lever.SavingPct)
		est.SavingMicros += saving
		est.ExperienceSum += d.Lever.Experience
	}
	if est.SavingMicros > est.BaselineMicros {
		est.SavingMicros = est.BaselineMicros
	}
	est.AfterMicros = est.BaselineMicros - est.SavingMicros
	est.WithinBudget = p.MaxCombinedImpact <= 0 || est.ExperienceSum <= p.MaxCombinedImpact
	return est
}

本机验证命令与结果:

cd .local/checks/gsarch-lab
go test -count=1 ./internal/costexperience/
ok  gsarchlab/internal/costexperience

该包共有 5 个表驱动测试,覆盖取值校验、评分、阈值筛选与排序、组合估算、节省上限与拒绝隔离;gofmt -l 无输出,go vet 退出码为 0。

一个示例决策矩阵

把三款产品的候选手段放进同一张表,评分和阈值的作用就清楚了。下表的体验影响、节省比例、可逆性、作用范围都是示例值,只用于演示判定过程:

手段产品体验节省可逆范围结论
tick 30→10Hz《泡泡工坊》10.221.00.35通过,先灰度
日志采样 10%三款通用0.50.451.00.10通过,优先做
回放保留 30→7 天《裂隙战场》20.300.40.08可逆性不足,评审
区域合并《群岛纪元》40.250.30.15体验与可逆性双超限
tick 20→15Hz《裂隙战场》40.181.00.30体验超限,仅休闲模式试点

这张表最有价值的一列是“结论”,因为它把争论变成了可复查的判定。认为《裂隙战场》降频可以做的同学,需要先解释为什么体验影响低于 3;认为回放可以缩短的同学,需要给出可逆性高于 0.5 的方案,例如先缩短到 14 天并保留赛事回放白名单。

矩阵还要有产品维度。同一个手段在不同品类的体验影响不同:tick 降频对休闲产品几乎无感,对竞技产品是核心手感;区域合并对 SLG 可接受,对 5v5 竞技会直接破坏跨区匹配的公平性。不要把“手段”当成全局开关,它是产品级的决策。

组合时先做高可逆、低体验的手段,再用节省出来的预算评估低可逆手段。示例顺序是日志采样、休闲 tick 降频、回放保留评审、区域合并评审。每一步都保留对照组,后一步的基线是前一步生效后的真实账单,而不是最初的理论值。

灰度与回滚门槛

评分决定“能不能做”,灰度决定“做完是否保留”。每个手段上线前要写清三件事:

  1. 观察指标:tick 降频看命中率、P99 输入延迟、投诉率;日志采样看排障平均时长;区域收敛看目标区延迟与留存。
  2. 观察窗口:至少覆盖一个完整高峰和一个低峰,竞技产品还要覆盖一个排位周期。
  3. 回滚门槛:指标恶化超过示例 5% 或出现阻断性投诉,自动回滚,不等待评审。

灰度比例从 5% 开始,逐步放大到 25%、50%、100%。每一步都要对比对照组,而不是和上周的自己比;版本、活动、买量都会影响指标,只有同周期对照才能分离出手段本身的影响。

三种错误决策

只算节省,不算体验。 账单当月下降,三个月后留存下降,省下的钱不够买回用户。节省必须和体验指标一起看,且体验指标的权重不能低于成本。

把不可逆手段当试点。 删除回放、合并大区、迁移存储,一旦执行就很难回退。可逆性低于 0.5 的手段不应进入快速试点,应该走完整评审和更长灰度。

用平均值掩盖长尾。 平均帧时间没变,P99 却恶化了;平均延迟可接受,特定地区玩家已经掉线。降本手段的体验评估要看 P95、P99 和分地区分品类,不能只看大盘均值。

小结

扩容有拐点,降本有边界。先用固定流量测出成本拐点和体验拐点,再讨论具体手段。tick 降频、区域收敛、日志采样、回放保留期各有适用品类和不可逆程度。评分用 节省 × 可逆性 / (1 + 体验影响),阈值负责一票否决,组合估算负责暴露叠加风险。最后用灰度、对照和回滚门槛把决策变成可撤回的实验。

常见错误有四个:把节省比例直接相加、忽略作用范围重叠、可逆性低的改动快速试点、用平均值代替长尾指标。它们的共同点是把“账面省钱”误当成“系统变好”。

本章从归因、配额走到体验取舍,回答的是“钱花在哪、最多能花多少、省得值不值”。下一章进入合规:版号、实名与防沉迷如何落到架构上,以及它们带来的固定成本。

阅读导航:上一节:9.2 资源配额与混部 · 下一节:10.1 版号、实名、防沉迷的架构落点 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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