《游戏服务端架构·规模化、全球化与成本》12.3 《泡泡工坊》的轻量出海

《泡泡工坊》没有全球单世界的包袱,也没有竞技产品的延迟硬约束:本文复盘北辰如何用单区域多可用区、无状态大厅加专用房间和 CDN 把休闲产品送出海,给出部署形态评分、区域筛选与月度成本估算的可运行 Go 实现,并说明什么情况下坚决不上多区域,以及多区域的升级门槛。

轻量出海不是缩小版全球架构

《泡泡工坊》是一款休闲合成小游戏,单局 8 分钟,玩家点开即玩。北辰给它的后端编制是三个人,月度基础设施预算按“不能超过买量预算的十分之一”设定。第一个海外版本上线时,团队直接复用了《群岛纪元》的多区域模板:三个区域、每区一套数据库、全球账号服务。上线两周后,账单和值班表同时失控。

账单的问题很直观:三个区域里有两个的低峰并发不到峰值的 8%,但数据库、缓存、监控和最小机器池一分不少。值班的问题更隐蔽:三个区域意味着三套支付回调、三种语言告警和三个时区的 on-call,三个人排不过来。

这一节看的是《泡泡工坊》如果重做一次,会怎么选部署形态、怎么估算成本,以及在什么情况下坚决不上多区域。它和前两个案例的差别在于:这里的约束不是延迟,也不是全球单世界,而是“固定成本不能把一个小体量产品拖死”。

当年的多区域:能力溢出的代价

把第一版的决定摊开,会发现大部分不是错在技术,而是错在能力与需求的错配。

决定当年的做法上线后的结果
部署形态三个区域 active-active两个区长期低载,固定成本翻倍
大厅有状态,会话粘在单区单区故障时玩家无法重新登录
房间每区独立,无法跨区跨区好友无法同房,产品承诺落空
支付全球账号服务统一处理本地支付渠道接入慢,合规审计被卡
静态资源只有图片走 CDN首包从源站拉,弱网首屏超过 5 秒
成本模型只看 CPU 和带宽数据库、值班、最小池全部漏算

最贵的一条不是机器,而是“三个区域 active-active”带来的组织成本。每个区域都要有人能处理支付回调、语言包和当地渠道的告警;三个人乘以三个区域,等于没有一个人能完整负责一条链路。

重做一次:先算固定成本,再谈区域

重做后的顺序完全反过来。第一版先画全球架构图,再找预算;重做后先算固定成本,再决定能画几张图。

形态区域数固定成本示例(相对值)适用条件
单区域多可用区11.0首发,峰值低,合规只要求本地
双区域热备21.8有明确容灾要求,或两个大市场都需要本地支付
多区域 active-active3+3.2 起多市场同时发行,且有区域团队值班

这张表的数字是示例值,不是报价。它想表达的是:从 1 个区域到 2 个区域,固定成本不是加 50%,而是接近翻倍;从 2 到 3 继续增加。固定成本包括数据库、缓存、监控、日志、最小机器池和值班,不随并发线性下降。

北辰给《泡泡工坊》定的门槛是三条,全部是设计目标,不是行业标准:

  1. 峰值并发低于 20000,且没有跨两个大区的实时玩法,默认单区域。
  2. 没有“支付数据必须留在本地”的硬性要求,不为了“全球化”上多区域。
  3. 运维少于 5 人,不接 active-active;热备已经是上限。

三条同时满足时,单区域多可用区就是正确答案。放弃的是“全球同低延迟”和“单区域故障零感知”,换来的是三个人能维护、预算能预测。

部署形态评分:能力匹配,不是越全越好

决定形态时,北辰不让架构师拍脑袋,而是把产品事实写成一张评分表。评分不追求精确,追求把“为什么不上多区域”说清楚。

评分维度有五个:延迟、合规、运维、成本、上线速度。权重分别是 30%、25%、15%、20%、10%。每个维度 0–10 分,加权后得到 0–100 的总分。

以《泡泡工坊》首发为例:两个发行区域、非实时玩法、不涉及强合规、运维 3 人。示例结果如下。

形态延迟合规运维成本速度总分
单区域多可用区6810101083
双区域热备8856669
多区域 active-active9823359

单区域胜出的原因不是延迟更好,而是运维、成本和速度三项拉开差距。这个结果在评审时比“我们觉得单区域够了”更有说服力,因为它把“放弃”量化了:选择单区域,等于接受跨区玩家多 80–120ms 的示例延迟,接受单区域故障时的一段不可用。

评分表还有一个结构性限制:只有一个发行区域时,热备和多区域根本不进入候选。理由不是分数低,而是没有第二个区域可放;给不存在的选项打分,只会让表格看起来更全面。

成本估算:固定成本先于可变成本

《泡泡工坊》的成本模型只做月度估算,不做实时计费。所有金额用整数分表示,避免浮点误差;模型里没有汇率、折扣和预留实例,这些由财务在模型外处理。

成本分五块:计算、数据库、出网、CDN、支持。计算按“核数 × 小时 × 单价”估算,核数按每千并发需要的毫核数向上取整;数据库按区域数计费;出网和 CDN 按 GB 计费;支持是一个固定值。

示例参数:峰值 8000、每月 730 小时、每千并发 500 毫核、每核小时 3 分、每区数据库 5000 分、出网 200GB、每 GB 2 分、CDN 1000GB、每 GB 1 分、支持 8000 分。计算结果如下。

项目单区域双区域说明
计算876087604 核,按全局池估算
数据库500010000每区一份
出网400800随区域增加
CDN10002000每区独立合同
支持800012000值班与工具
合计2316033560示例值,单位:分

双区域比单区域贵 45%,但只多服务了一个市场。对《泡泡工坊》这种体量,这笔钱更值得花在买量和内容上。模型还漏掉了“最小机器池”和“跨区专线”,这两项在多区域方案里会进一步拉大差距;文章里的表故意保留了这个缺口,提醒读者不要拿一个不完整的模型做最终决策。

成本模型的价值不在于算出精确数字,而在于把“固定成本”从“其他”里拎出来。只看 CPU 和带宽,多区域看起来只贵一点;把数据库、CDN 合同、值班和最小池算进去,结论会完全不同。

区域选择:支付与语言是过滤器,不是加分项

确定了单区域,下一步是选哪一个区域。北辰的规则是“先过滤,再比价”,和《裂隙战场》的赛区打分相反:这里不做加权,因为休闲产品对几十毫秒的差异不敏感,但对“能不能收钱”极其敏感。

硬过滤器有五个:本地支付渠道可用、首发语言支持、RTT 在可接受范围、容量满足峰值、月成本在预算内。五个都通过后,选最便宜的那个;同价按区域 ID 排序,保证结果确定。

示例候选如下。

候选RTT容量月成本支付语言结果
eu45ms200005200是是可选
ap35ms200004100否是出局
na120ms200004800是是可选
sa50ms30003900是是容量出局

亚太的 RTT 最好、价格最低,但没有本地支付渠道,直接出局。这不是“稍微贵一点”的问题,而是“玩家付不了钱”的问题。北辰在这里放弃的是“最低延迟”,选择的是“能完成交易的最便宜区域”。

如果首发必须覆盖两个市场,而预算只允许一个区域,正确做法不是硬上双区域,而是选一个主区域,把另一个市场做成“可玩但支付走主区域”的过渡方案;等留存和付费验证后再开第二个区域。这个过渡方案会牺牲一部分转化率,但比让三个人维护两套支付链路便宜。

本地化:哪些必须本地,哪些可以远程

《泡泡工坊》的本地化清单比《群岛纪元》短,但每一条都要明确“部署在哪”。

能力是否本地部署理由
支付回调适配器是本地渠道要求回调地址与证书在本地
账号渠道 SDK 校验是,薄服务平台票据校验需要本地网络与密钥
游戏逻辑与房间单区域即可休闲玩法对 RTT 不敏感,8 分钟一局
语言包与静态资源CDN 边缘版本化路径,回源一次
客服与工单可远程不涉及实时状态
运营后台可远程只读报表 + 审批流
遥测与埋点全球汇总只上传聚合指标,不回传个人信息

这张表的关键是“薄服务”三个字。支付适配器和账号校验可以部署在本地,但它们只做协议转换和票据校验,不承载游戏状态;主游戏逻辑仍然在单区域。这样既满足本地渠道的要求,又不用把整套游戏复制到每个市场。

数据驻留的边界也要写清楚:支付凭证、账号票据、身份证件留在本地;遥测只上传聚合指标;客服工单如果包含个人信息,要么本地化,要么脱敏后再跨境。不要把“本地化”理解成“每个市场一套完整后端”,那会把固定成本重新推高。

无状态大厅 + 专用房间

《泡泡工坊》的运行形态是“无状态大厅 + 专用房间”。大厅负责登录、匹配、房间分配,不保存对局状态;房间是有状态的,绑定在一个可用区,跑完一局就回收。

client
  ├─ static bundle ──▶ CDN
  ├─ login / match ──▶ lobby (any AZ, stateless)
  └─ room session ──▶ dedicated room (single AZ)

大厅无状态带来两个好处:可以水平扩容,也可以在可用区故障时直接切到另一个可用区,玩家重新登录即可。房间有状态则意味着不能中途迁移;8 分钟一局的休闲产品,迁移的收益远低于成本。

北辰的选择是:房间所在可用区故障时,这一局直接结束,按规则补偿玩家,不尝试跨可用区恢复。放弃的是“对局零中断”,换来的是房间实现简单、故障边界清晰。竞技产品不能这么做,休闲产品可以,因为单局时间短、沉没成本低。

大厅的会话也要有边界:登录态可以放在共享缓存,但缓存故障时允许降级为“重新登录”,不要把大厅做成有状态服务。大厅一旦有状态,就会重蹈第一版“会话粘在单区”的覆辙。

CDN 与静态资源

《泡泡工坊》的首屏体验主要取决于首包,而不是战斗中的 RTT。第一版只有图片走 CDN,JS 和资源包从源站拉,弱网首屏超过 5 秒。重做后,所有静态资源都走 CDN,路径带版本号,回源只发生在版本发布时。

资源缓存策略版本方式备注
JS / WASM长缓存文件名带 hash发布即新路径
图片 / 音频长缓存目录带版本不做覆盖写
配置表短缓存版本号 + ETag允许 5 分钟延迟
语言包长缓存语言 + 版本按市场预取

CDN 的边界是“动态请求不要回源到 CDN”。大厅和房间走独立域名,避免 CDN 缓存规则误伤 API;静态资源和动态接口分开证书与限流策略。这样做会增加一点配置成本,但能避免“CDN 把登录请求缓存了”这类低级事故。

代码:internal/bubbleglobal

下面的 deploy.go 把部署形态评分写成纯函数:输入产品事实,输出三种形态的得分与理由;scores.go 是评分细则,cost.go 是同包的成本估算,choose.go 是区域过滤与推荐。它们都不依赖云厂商 SDK。

// file: internal/bubbleglobal/deploy.go
// tier: L1
// verify: go test ./internal/bubbleglobal/
// Package bubbleglobal scores deployment shapes and estimates the fixed cost
// of Bubble Workshop's lightweight global launch. It is a planning aid: every
// price is an example value that must be replaced by a current provider quote.
package bubbleglobal

import (
	"errors"
	"fmt"
	"sort"
)

var (
	ErrBadInput = errors.New("bubbleglobal: invalid input")
	ErrNoFit    = errors.New("bubbleglobal: no option fits the constraints")
)

// Shape is a deployment topology, not a provider product name.
type Shape string

const (
	ShapeSingleRegion Shape = "single-region-multi-az"
	ShapeWarmStandby  Shape = "two-region-warm-standby"
	ShapeMultiRegion  Shape = "multi-region-active-active"
)

var shapeOrder = []Shape{ShapeSingleRegion, ShapeWarmStandby, ShapeMultiRegion}

// Profile is the product fact sheet used for ranking. It intentionally holds
// no prices; cost enters later through CostInput.
type Profile struct {
	PeakConcurrent     int
	LaunchZones        []string
	Regulated          bool // payer data must stay in its zone
	Realtime           bool // player-visible RTT changes the outcome
	OpsHeadcount       int
	MonthlyBudgetCents int // zero means no budget gate
}

func (p Profile) validate() error {
	if p.PeakConcurrent <= 0 {
		return fmt.Errorf("%w: peak concurrent must be positive", ErrBadInput)
	}
	if len(p.LaunchZones) == 0 {
		return fmt.Errorf("%w: at least one launch zone is required", ErrBadInput)
	}
	if p.OpsHeadcount < 0 || p.MonthlyBudgetCents < 0 {
		return fmt.Errorf("%w: negative headcount or budget", ErrBadInput)
	}
	return nil
}

// Score explains a shape. Parts are 0..10; Total is a 0..100 weighted sum.
type Score struct {
	Shape   Shape
	Total   int
	Parts   map[string]int
	Reasons []string
}

// Rank scores every shape that is structurally possible for the profile. A
// single-zone launch cannot rank standby or multi-region shapes: those would
// be diagrams without a second location to run in.
func Rank(p Profile) ([]Score, error) {
	if err := p.validate(); err != nil {
		return nil, err
	}
	multiZone := len(p.LaunchZones) > 1
	out := make([]Score, 0, len(shapeOrder))
	for _, s := range shapeOrder {
		if s != ShapeSingleRegion && !multiZone {
			continue
		}
		parts := map[string]int{
			"latency":    latencyScore(s, p),
			"compliance": complianceScore(s, p),
			"ops":        opsScore(s, p),
			"cost":       costScore(s),
			"speed":      speedScore(s),
		}
		total := (parts["latency"]*30 + parts["compliance"]*25 + parts["ops"]*15 +
			parts["cost"]*20 + parts["speed"]*10) / 10
		out = append(out, Score{Shape: s, Total: total, Parts: parts, Reasons: reasons(s, p)})
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Total != out[j].Total {
			return out[i].Total > out[j].Total
		}
		return out[i].Shape < out[j].Shape
	})
	return out, nil
}

五组表驱动测试覆盖:实时合规产品的形态排序、单区域只保留一个候选、成本分项与非法输入、区域数计算、支付语言与预算的联合过滤。

失败模式与兜底

失败现象兜底
本地支付回调失败玩家扣款但不到账回调重试 + 对账任务;本地适配器落库
大厅可用区故障无法登录或匹配无状态大厅切到另一可用区;客户端重试
房间可用区故障对局中断结束本局并按规则补偿,不跨区恢复
CDN 缓存旧版本玩家加载到旧资源版本化路径;发布时刷新入口 HTML
预算被多区域击穿月度账单超支预算作为硬门槛;形态评审前先跑成本模型
首发语言缺失某市场无法上线语言包作为区域过滤器,不做机器翻译兜底

前两项是可用性问题,中间两项是发布问题,最后两项是决策问题。共同原则是:能用无状态和重试解决的,不要引入跨区状态;能用预算门槛挡住的,不要等到账单出来再回滚。

重做一次会保留什么

第一,保留“单区域多可用区”作为默认形态。它不是能力不足,而是与产品体量匹配。

第二,保留支付适配器本地部署。它很薄,但它是本地渠道的硬要求,不能用“全球统一支付”替代。

第三,保留无状态大厅。大厅一旦有状态,扩容和故障切换都会变成跨区迁移问题。

第四,保留 CDN 优先。休闲产品的首屏体验比战斗 RTT 更重要,静态资源不值得从源站拉。

第五,保留预算硬门槛。形态评分再高,预算付不起就不进入候选;这条规则能挡住大部分“架构很好看、账单很难看”的方案。

什么情况下才上多区域

北辰给《泡泡工坊》写了一个明确的升级条件,满足任意一条才重新评估多区域:

  1. 单个市场的峰值并发超过单区域容量上限的 80%,且扩容已经到顶。
  2. 两个以上市场要求支付数据本地留存,且本地支付占比超过示例值 30%。
  3. 实时玩法上线,跨区 RTT 超过产品可接受上限,且无法通过房间分区解决。
  4. 运维团队达到 5 人以上,能够承担多区域值班。

四条都不满足时,多区域不是“提前布局”,而是“提前背上固定成本”。这个判断标准比任何架构图都重要。

常见错误与边界

第一,照搬大厂的多区域架构。大厂的区域数对应的是他们的用户分布和合规要求,不是你的。

第二,为了“全球化”上多区域,却没有本地支付和语言。玩家能连上,但付不了钱。

第三,把大厅做成有状态。大厅有状态,故障切换就会变成数据迁移。

第四,只让图片走 CDN。首包和 JS 才是弱网首屏的大头。

第五,成本模型只算 CPU 和带宽。数据库、CDN 合同、值班和最小池才是固定成本的大头。

第六,用机器翻译代替语言包。首发语言是过滤器,不是可以事后补的选项。

小结

《泡泡工坊》的轻量出海,重做一次的核心不是“把架构做小”,而是“让形态与能力匹配”。单区域多可用区、无状态大厅、专用房间、CDN 优先、支付适配器本地部署,这五件事撑起了三个人的后端。放弃的是全球同低延迟、单区域故障零感知和对局零中断,换来的是可预测的固定成本和可维护的值班表。

三个案例到这里给出了三种答案:《裂隙战场》用赛区换公平,《群岛纪元》用状态拆分换全球单世界,《泡泡工坊》用单区域换成本。下一章把这三组取舍抽成容量表、成本表与合规表,让它们可以在评审会上被逐项对照。

阅读导航:上一节:12.2 《群岛纪元》的全球同服 · 下一节:13.1 容量表、成本表与合规表 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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