轻量出海不是缩小版全球架构
《泡泡工坊》是一款休闲合成小游戏,单局 8 分钟,玩家点开即玩。北辰给它的后端编制是三个人,月度基础设施预算按“不能超过买量预算的十分之一”设定。第一个海外版本上线时,团队直接复用了《群岛纪元》的多区域模板:三个区域、每区一套数据库、全球账号服务。上线两周后,账单和值班表同时失控。
账单的问题很直观:三个区域里有两个的低峰并发不到峰值的 8%,但数据库、缓存、监控和最小机器池一分不少。值班的问题更隐蔽:三个区域意味着三套支付回调、三种语言告警和三个时区的 on-call,三个人排不过来。
这一节看的是《泡泡工坊》如果重做一次,会怎么选部署形态、怎么估算成本,以及在什么情况下坚决不上多区域。它和前两个案例的差别在于:这里的约束不是延迟,也不是全球单世界,而是“固定成本不能把一个小体量产品拖死”。
当年的多区域:能力溢出的代价
把第一版的决定摊开,会发现大部分不是错在技术,而是错在能力与需求的错配。
| 决定 | 当年的做法 | 上线后的结果 |
|---|---|---|
| 部署形态 | 三个区域 active-active | 两个区长期低载,固定成本翻倍 |
| 大厅 | 有状态,会话粘在单区 | 单区故障时玩家无法重新登录 |
| 房间 | 每区独立,无法跨区 | 跨区好友无法同房,产品承诺落空 |
| 支付 | 全球账号服务统一处理 | 本地支付渠道接入慢,合规审计被卡 |
| 静态资源 | 只有图片走 CDN | 首包从源站拉,弱网首屏超过 5 秒 |
| 成本模型 | 只看 CPU 和带宽 | 数据库、值班、最小池全部漏算 |
最贵的一条不是机器,而是“三个区域 active-active”带来的组织成本。每个区域都要有人能处理支付回调、语言包和当地渠道的告警;三个人乘以三个区域,等于没有一个人能完整负责一条链路。
重做一次:先算固定成本,再谈区域
重做后的顺序完全反过来。第一版先画全球架构图,再找预算;重做后先算固定成本,再决定能画几张图。
| 形态 | 区域数 | 固定成本示例(相对值) | 适用条件 |
|---|---|---|---|
| 单区域多可用区 | 1 | 1.0 | 首发,峰值低,合规只要求本地 |
| 双区域热备 | 2 | 1.8 | 有明确容灾要求,或两个大市场都需要本地支付 |
| 多区域 active-active | 3+ | 3.2 起 | 多市场同时发行,且有区域团队值班 |
这张表的数字是示例值,不是报价。它想表达的是:从 1 个区域到 2 个区域,固定成本不是加 50%,而是接近翻倍;从 2 到 3 继续增加。固定成本包括数据库、缓存、监控、日志、最小机器池和值班,不随并发线性下降。
北辰给《泡泡工坊》定的门槛是三条,全部是设计目标,不是行业标准:
- 峰值并发低于 20000,且没有跨两个大区的实时玩法,默认单区域。
- 没有“支付数据必须留在本地”的硬性要求,不为了“全球化”上多区域。
- 运维少于 5 人,不接 active-active;热备已经是上限。
三条同时满足时,单区域多可用区就是正确答案。放弃的是“全球同低延迟”和“单区域故障零感知”,换来的是三个人能维护、预算能预测。
部署形态评分:能力匹配,不是越全越好
决定形态时,北辰不让架构师拍脑袋,而是把产品事实写成一张评分表。评分不追求精确,追求把“为什么不上多区域”说清楚。
评分维度有五个:延迟、合规、运维、成本、上线速度。权重分别是 30%、25%、15%、20%、10%。每个维度 0–10 分,加权后得到 0–100 的总分。
以《泡泡工坊》首发为例:两个发行区域、非实时玩法、不涉及强合规、运维 3 人。示例结果如下。
| 形态 | 延迟 | 合规 | 运维 | 成本 | 速度 | 总分 |
|---|---|---|---|---|---|---|
| 单区域多可用区 | 6 | 8 | 10 | 10 | 10 | 83 |
| 双区域热备 | 8 | 8 | 5 | 6 | 6 | 69 |
| 多区域 active-active | 9 | 8 | 2 | 3 | 3 | 59 |
单区域胜出的原因不是延迟更好,而是运维、成本和速度三项拉开差距。这个结果在评审时比“我们觉得单区域够了”更有说服力,因为它把“放弃”量化了:选择单区域,等于接受跨区玩家多 80–120ms 的示例延迟,接受单区域故障时的一段不可用。
评分表还有一个结构性限制:只有一个发行区域时,热备和多区域根本不进入候选。理由不是分数低,而是没有第二个区域可放;给不存在的选项打分,只会让表格看起来更全面。
成本估算:固定成本先于可变成本
《泡泡工坊》的成本模型只做月度估算,不做实时计费。所有金额用整数分表示,避免浮点误差;模型里没有汇率、折扣和预留实例,这些由财务在模型外处理。
成本分五块:计算、数据库、出网、CDN、支持。计算按“核数 × 小时 × 单价”估算,核数按每千并发需要的毫核数向上取整;数据库按区域数计费;出网和 CDN 按 GB 计费;支持是一个固定值。
示例参数:峰值 8000、每月 730 小时、每千并发 500 毫核、每核小时 3 分、每区数据库 5000 分、出网 200GB、每 GB 2 分、CDN 1000GB、每 GB 1 分、支持 8000 分。计算结果如下。
| 项目 | 单区域 | 双区域 | 说明 |
|---|---|---|---|
| 计算 | 8760 | 8760 | 4 核,按全局池估算 |
| 数据库 | 5000 | 10000 | 每区一份 |
| 出网 | 400 | 800 | 随区域增加 |
| CDN | 1000 | 2000 | 每区独立合同 |
| 支持 | 8000 | 12000 | 值班与工具 |
| 合计 | 23160 | 33560 | 示例值,单位:分 |
双区域比单区域贵 45%,但只多服务了一个市场。对《泡泡工坊》这种体量,这笔钱更值得花在买量和内容上。模型还漏掉了“最小机器池”和“跨区专线”,这两项在多区域方案里会进一步拉大差距;文章里的表故意保留了这个缺口,提醒读者不要拿一个不完整的模型做最终决策。
成本模型的价值不在于算出精确数字,而在于把“固定成本”从“其他”里拎出来。只看 CPU 和带宽,多区域看起来只贵一点;把数据库、CDN 合同、值班和最小池算进去,结论会完全不同。
区域选择:支付与语言是过滤器,不是加分项
确定了单区域,下一步是选哪一个区域。北辰的规则是“先过滤,再比价”,和《裂隙战场》的赛区打分相反:这里不做加权,因为休闲产品对几十毫秒的差异不敏感,但对“能不能收钱”极其敏感。
硬过滤器有五个:本地支付渠道可用、首发语言支持、RTT 在可接受范围、容量满足峰值、月成本在预算内。五个都通过后,选最便宜的那个;同价按区域 ID 排序,保证结果确定。
示例候选如下。
| 候选 | RTT | 容量 | 月成本 | 支付 | 语言 | 结果 |
|---|---|---|---|---|---|---|
| eu | 45ms | 20000 | 5200 | 是 | 是 | 可选 |
| ap | 35ms | 20000 | 4100 | 否 | 是 | 出局 |
| na | 120ms | 20000 | 4800 | 是 | 是 | 可选 |
| sa | 50ms | 3000 | 3900 | 是 | 是 | 容量出局 |
亚太的 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 更重要,静态资源不值得从源站拉。
第五,保留预算硬门槛。形态评分再高,预算付不起就不进入候选;这条规则能挡住大部分“架构很好看、账单很难看”的方案。
什么情况下才上多区域
北辰给《泡泡工坊》写了一个明确的升级条件,满足任意一条才重新评估多区域:
- 单个市场的峰值并发超过单区域容量上限的 80%,且扩容已经到顶。
- 两个以上市场要求支付数据本地留存,且本地支付占比超过示例值 30%。
- 实时玩法上线,跨区 RTT 超过产品可接受上限,且无法通过房间分区解决。
- 运维团队达到 5 人以上,能够承担多区域值班。
四条都不满足时,多区域不是“提前布局”,而是“提前背上固定成本”。这个判断标准比任何架构图都重要。
常见错误与边界
第一,照搬大厂的多区域架构。大厂的区域数对应的是他们的用户分布和合规要求,不是你的。
第二,为了“全球化”上多区域,却没有本地支付和语言。玩家能连上,但付不了钱。
第三,把大厅做成有状态。大厅有状态,故障切换就会变成数据迁移。
第四,只让图片走 CDN。首包和 JS 才是弱网首屏的大头。
第五,成本模型只算 CPU 和带宽。数据库、CDN 合同、值班和最小池才是固定成本的大头。
第六,用机器翻译代替语言包。首发语言是过滤器,不是可以事后补的选项。
小结
《泡泡工坊》的轻量出海,重做一次的核心不是“把架构做小”,而是“让形态与能力匹配”。单区域多可用区、无状态大厅、专用房间、CDN 优先、支付适配器本地部署,这五件事撑起了三个人的后端。放弃的是全球同低延迟、单区域故障零感知和对局零中断,换来的是可预测的固定成本和可维护的值班表。
三个案例到这里给出了三种答案:《裂隙战场》用赛区换公平,《群岛纪元》用状态拆分换全球单世界,《泡泡工坊》用单区域换成本。下一章把这三组取舍抽成容量表、成本表与合规表,让它们可以在评审会上被逐项对照。
阅读导航:上一节:12.2 《群岛纪元》的全球同服 · 下一节:13.1 容量表、成本表与合规表 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。