第二款游戏带来的问题
《潮汐要塞》上线第三年,工作室用同一套后端接了第二款休闲游戏《泡泡工坊》。最初的做法是复制仓库、改配置、单独部署。三个月后问题集中出现:账号封禁要改两遍,支付回调要修两次,排行榜的防刷规则两边不一致,运营在后台看到两个互不相通的玩家体系。
团队于是提出“把公共能力都抽成平台”。这个方向没错,但“都抽”是危险的。匹配算法在 SLG 里要考虑联盟与战力,在休闲游戏里只考虑延迟和段位;如果抽成一套通用匹配,最后会变成一堆开关和特例。平台化的关键不是抽得多,而是抽对边界。
本节讨论单游戏服务端如何长出平台能力,以及哪些能力必须留在游戏内。文中团队规模、QPS、阈值均为示例值或设计目标,不是线上测量。
先分清共用能力与玩法能力
把候选能力按“数据归属”和“消费方数量”摆开,边界会比按技术分层更清楚:
| 能力 | 数据归属 | 典型消费方 | 建议边界 | 理由 |
|---|---|---|---|---|
| 账号与登录 | 玩家身份,跨游戏 | 所有游戏 | 平台 | 身份必须唯一,重复建设会分裂玩家 |
| 支付与订单 | 交易凭证,跨游戏 | 所有游戏 | 平台 | 对账、退款、风控需要全局视图 |
| 匹配 | 段位与房间规则,偏玩法 | 竞技类游戏 | 平台提供框架,规则留在游戏 | 通用排队可共用,分池与平衡算法不能硬统一 |
| 排行榜 | 榜单口径与奖励,偏运营 | 多数游戏 | 平台存榜,游戏定口径 | 存储与查询可共用,赛季与奖励归游戏 |
| 邮件与公告 | 模板与投递,跨游戏 | 所有游戏 | 平台 | 投递通道与合规要求一致 |
| 战斗结算 | 玩法公式与数值 | 单款游戏 | 游戏内 | 公式就是玩法,抽出去会把平衡绑死 |
| 活动编排 | 活动规则与奖励 | 单款游戏 | 游戏内,平台只提供调度原语 | 活动是运营创意,平台只该提供能力和审计 |
这张表的核心不是“哪些进平台”,而是平台只拥有跨游戏稳定的数据,游戏拥有会随玩法变化的数据。账号 ID 十年不变,战斗公式每个赛季都变;把后者抽进平台,平台就被迫跟着赛季发版。
匹配是最容易被误判的一项。平台可以提供“按分数段排队、按延迟选服、按队伍拼房”的通用框架,但“什么算同段位”“连败是否保护”“跨服是否放开”必须由游戏配置。否则第二款游戏接入时,平台会积累大量 if game == "slg" 的分支,抽象就失败了。
抽取判定:五条规则
判定顺序比判定本身重要。先用数据归属一票否决,再看消费方数量,最后才看发布压力:
| 顺序 | 规则 | 判定 | 说明 |
|---|---|---|---|
| 1 | 数据是玩法专属 | 留在游戏内 | 公式、数值、关卡、活动规则不抽 |
| 2 | 只有一个消费方 | 暂缓 | 平台化收益覆盖不了协调成本 |
| 3 | 下游依赖超过阈值 | 暂缓 | 先降耦合,否则抽出的是纠缠 |
| 4 | 多游戏共用且发布频繁或关键度高 | 抽取 | 独立生命周期收益最大 |
| 5 | 其余 | 留在游戏内 | 没有足够理由就不要增加一层 |
“下游依赖”指这个能力直接调用的游戏内模块数量。一个账号服务如果直接依赖战斗、背包、活动三个模块,抽出去只会把依赖一起搬走,平台仍然被游戏代码绑住。正确顺序是先通过接口倒置把依赖降下来,再抽取。
“发布频繁”不是感觉,而是过去若干个版本的发布次数。支付因为合规和渠道变更可能每季度都要改,账号可能半年不动;前者适合独立发布,后者抽出去收益有限。关键度则看故障影响:账号挂了所有游戏都进不去,邮件挂了只影响通知。
租户模型与隔离级别
平台化以后,第二个问题立刻出现:多款游戏共用一个平台,数据怎么隔离?四级模型,成本和隔离度递增:
| 级别 | 数据形态 | 隔离度 | 成本 | 适用 |
|---|---|---|---|---|
| 共享库 | 同库同表,按 game_id 过滤 | 最低 | 最低 | 内部工具、低风险、可接受互相影响 |
| 共享 schema | 同库不同 schema | 中 | 低 | 多款游戏、查询简单、无强合规 |
| 独立 schema | 每租户独立 schema 或库 | 较高 | 中 | 有 noisy neighbor 风险、需要独立备份 |
| 独立集群 | 每租户独立实例 | 最高 | 最高 | 合规、数据驻留、重大客户 |
选择规则不是“越隔离越好”,而是“先看硬约束,再看风险”。合规与数据驻留是硬约束,直接上独立集群;没有硬约束时,用 QPS 与 noisy neighbor 风险决定是否升级。一个日活很低的休闲游戏和一个高峰明显的 SLG 共用共享库,前者一次批量导入就可能把后者的连接池打满。
隔离级别还要和发布策略一起看。共享库意味着 schema 变更影响所有租户,必须走兼容迁移;独立 schema 可以按租户灰度,但运维复杂度上升。平台团队要明确自己卖的是“共享效率”还是“隔离保障”,两者的成本模型完全不同。
平台化的代价
平台化不是免费抽象,它至少带来三类成本:
| 代价 | 表现 | 缓解 |
|---|---|---|
| 发布耦合 | 平台一次发版,所有游戏一起承担风险 | 版本化接口、按租户灰度、兼容窗口 |
| 故障扩散 | 账号或支付故障从一款游戏扩散到全部 | 隔离级别、降级开关、独立 SLO |
| 协调成本 | 每个需求都要跨团队排期 | 产品契约、需求分级、平台路线图公开 |
发布耦合最容易被低估。平台团队为了修 A 游戏的支付问题发版,B 游戏可能因此进入未验证的代码路径。解决办法不是“平台永远不发版”,而是接口版本化与兼容窗口:新版本先对内部租户灰度,旧版本保留到所有消费方迁移完成。
故障扩散则要求平台有比游戏更高的可用性目标。账号服务挂掉不是“一款游戏登录慢”,而是“所有游戏都进不去”。平台的关键路径要有独立 SLO、独立容量和独立值班,不能和某一款游戏的发布节奏绑在一起。
什么时候不该平台化
以下情况应明确拒绝或暂缓:
- 只有一款游戏在用,且没有明确的上线第二款计划。
- 能力内部依赖大量玩法模块,抽取等于把游戏逻辑搬进平台。
- 两款游戏的业务语义差异大于共性,统一后需要大量特例开关。
- 平台团队没有独立值班与独立发布能力,抽出去只是多一层转发。
- 迁移窗口与赛季、重大活动冲突,且没有回滚方案。
平台化的正确目标不是“减少重复代码”,而是“让跨游戏稳定的能力有独立生命周期”。重复代码可以接受,错误的抽象很难撤销。
抽取顺序:依赖先于被依赖
确定要抽的能力后,不能同时动全部。顺序由依赖图决定:被依赖的能力先抽,依赖方后抽;有环说明边界没划清,先拆环再排期。
例如账号是支付的前置,支付是订单查询的前置,订单查询又是客服工具的前置。正确顺序是账号、支付、订单查询、客服工具。反过来先抽客服工具,它会同时依赖旧账号和旧支付,迁移期需要两套适配器。
依赖图里出现环,通常不是技术问题,而是领域边界问题。账号依赖角色、角色依赖账号,说明“账号”和“角色”被当成了两个实体却共享生命周期。这时应先把共享状态归到一边,而不是强行拓扑排序。
代码:判定、隔离与拓扑
下面的代码把能力判定、租户隔离选择和依赖排序做成纯函数。判定规则按顺序短路,隔离选择先处理硬约束,拓扑排序遇到环直接报错而不是猜一个顺序。
// file: internal/platformevolution/platformevolution.go
// tier: L1
// verify: go test ./internal/platformevolution/
package platformevolution
import (
"errors"
"fmt"
"sort"
)
// Decision is the outcome of one capability review.
type Decision string
const (
// DecisionExtract moves the capability into the shared platform.
DecisionExtract Decision = "extract"
// DecisionKeep leaves the capability inside the game boundary.
DecisionKeep Decision = "keep"
// DecisionDefer waits for a prerequisite before deciding again.
DecisionDefer Decision = "defer"
)
var (
ErrInvalidCapability = errors.New("platformevolution: invalid capability")
ErrInvalidTenant = errors.New("platformevolution: invalid tenant")
ErrDependencyCycle = errors.New("platformevolution: dependency cycle")
ErrUnknownDependency = errors.New("platformevolution: unknown dependency")
ErrDuplicateItem = errors.New("platformevolution: duplicate item")
)
// Capability is the input to an extract-or-keep review. Counts are ordinal
// judgements from the review, not measurements.
type Capability struct {
Name string
Games int
GameSpecificData bool
Dependencies int
ReleaseFrequency int
Criticality int
}
func (c Capability) Validate() error {
switch {
case c.Name == "":
return fmt.Errorf("%w: empty name", ErrInvalidCapability)
case c.Games < 1:
return fmt.Errorf("%w: %s games=%d", ErrInvalidCapability, c.Name, c.Games)
case c.Dependencies < 0:
return fmt.Errorf("%w: %s dependencies=%d", ErrInvalidCapability, c.Name, c.Dependencies)
case c.ReleaseFrequency < 0:
return fmt.Errorf("%w: %s release frequency=%d", ErrInvalidCapability, c.Name, c.ReleaseFrequency)
case c.Criticality < 1 || c.Criticality > 5:
return fmt.Errorf("%w: %s criticality=%d", ErrInvalidCapability, c.Name, c.Criticality)
}
return nil
}
// Recommend applies the review order explicitly: data ownership first, then
// consumer count, then coupling, then lifecycle pressure. The first matching
// rule wins, so the reason string always names the deciding rule.
func Recommend(c Capability) (Decision, string, error) {
if err := c.Validate(); err != nil {
return "", "", err
}
switch {
case c.GameSpecificData:
return DecisionKeep, "玩法数据与单款游戏强绑定,抽出会把游戏规则带进平台", nil
case c.Games < 2:
return DecisionDefer, "只有一个消费方,平台化收益无法覆盖协调成本", nil
case c.Dependencies >= 4:
return DecisionDefer, "下游依赖过多,先降低耦合再抽", nil
case c.ReleaseFrequency >= 4 || c.Criticality >= 4:
return DecisionExtract, "多游戏共用且生命周期压力大,独立发布收益最高", nil
default:
return DecisionKeep, "共用收益有限,留在游戏内更便宜", nil
}
}
// Isolation is the tenant separation level for a shared capability.
type Isolation string
const (
IsolationSharedDB Isolation = "shared_database"
IsolationSharedSchema Isolation = "shared_schema"
IsolationSchemaPerTenant Isolation = "schema_per_tenant"
IsolationDedicatedCluster Isolation = "dedicated_cluster"
)
// Tenant describes the consumers of one platform capability. Thresholds in
// ChooseIsolation are example design targets and must be re-tuned per studio.
type Tenant struct {
Name string
Games int
PeakQPS int
Regulated bool
DataResidency bool
NoisyNeighborRisk int
}
func (t Tenant) Validate() error {
switch {
case t.Name == "":
return fmt.Errorf("%w: empty name", ErrInvalidTenant)
case t.Games < 1:
return fmt.Errorf("%w: %s games=%d", ErrInvalidTenant, t.Name, t.Games)
case t.PeakQPS < 0:
return fmt.Errorf("%w: %s peak qps=%d", ErrInvalidTenant, t.Name, t.PeakQPS)
case t.NoisyNeighborRisk < 1 || t.NoisyNeighborRisk > 5:
return fmt.Errorf("%w: %s noisy neighbor risk=%d", ErrInvalidTenant, t.Name, t.NoisyNeighborRisk)
}
return nil
}
// ChooseIsolation escalates isolation only when a hard requirement or a
// measured risk justifies the extra operating cost.
func ChooseIsolation(t Tenant) (Isolation, error) {
if err := t.Validate(); err != nil {
return "", err
}
switch {
case t.Regulated || t.DataResidency:
return IsolationDedicatedCluster, nil
case t.NoisyNeighborRisk >= 4 || t.PeakQPS >= 20000:
return IsolationSchemaPerTenant, nil
case t.Games >= 3:
return IsolationSharedSchema, nil
default:
return IsolationSharedDB, nil
}
}
// Edge means From depends on To. Extraction must therefore handle To first.
type Edge struct {
From string
To string
}
// ExtractionOrder returns a deterministic topological order with dependencies
// before dependents. It rejects cycles instead of guessing a safe order.
func ExtractionOrder(edges []Edge) ([]string, error) {
nodes := make(map[string]bool)
indegree := make(map[string]int)
next := make(map[string][]string)
seen := make(map[[2]string]bool)
for _, e := range edges {
if e.From == "" || e.To == "" {
return nil, fmt.Errorf("%w: empty node", ErrUnknownDependency)
}
if e.From == e.To {
return nil, fmt.Errorf("%w: self edge %s", ErrDependencyCycle, e.From)
}
key := [2]string{e.From, e.To}
if seen[key] {
continue
}
seen[key] = true
nodes[e.From] = true
nodes[e.To] = true
next[e.To] = append(next[e.To], e.From)
indegree[e.From]++
}
ready := make([]string, 0, len(nodes))
for n := range nodes {
if indegree[n] == 0 {
ready = append(ready, n)
}
}
sort.Strings(ready)
order := make([]string, 0, len(nodes))
for len(ready) > 0 {
n := ready[0]
ready = ready[1:]
order = append(order, n)
children := append([]string(nil), next[n]...)
sort.Strings(children)
for _, child := range children {
indegree[child]--
if indegree[child] == 0 {
ready = append(ready, child)
sort.Strings(ready)
}
}
}
if len(order) != len(nodes) {
return nil, ErrDependencyCycle
}
return order, nil
}
代码里有两个值得注意的取舍。第一,Recommend 返回判定理由,而不只是一个枚举;评审记录里必须能解释“为什么暂缓”,否则下次评审会重新吵一遍。第二,ChooseIsolation 的 QPS 与风险阈值是示例设计目标,真实项目应按自己的容量基线和故障史重新标定,不能直接抄。
测试覆盖了玩法专属、单消费方、依赖过多、频繁发布、低压力五类判定,四级隔离的边界,以及链、菱形、重复边、自环和双节点环。拓扑排序对重复边做了去重,否则同一条依赖会把入度加两次,合法图会被误判成环。
迁移步骤
- 冻结边界:先写清楚平台拥有哪些数据、游戏拥有哪些数据,落到接口契约。
- 建适配层:旧实现继续服务,新平台通过适配器接入,游戏侧不直接依赖平台 SDK。
- 选第一个租户:优先选影响小、配合度高的游戏,不要拿主力游戏做第一次迁移。
- 双跑对比:同一请求同时走旧实现与新平台,比较结果与延迟。
- 按租户切流:一个游戏一个开关,切流后保留旧实现一个观察期。
- 删除旧实现:确认没有调用方后再删,避免“平台化完成、旧代码还在跑”。
失败路径
| 失败 | 早期信号 | 根因 | 正确处置 |
|---|---|---|---|
| 平台变成新的单体 | 每次需求都要改平台核心 | 抽取时没有划清数据归属 | 停止扩能力,先拆领域边界 |
| 第二款游戏接入成本高于复制 | 适配器越写越厚 | 抽象按第一款游戏设计 | 回退到游戏内实现,保留平台基础能力 |
| 一次发版影响所有游戏 | 故障同时出现在多个游戏 | 缺少版本化与灰度 | 补接口版本与租户级开关 |
| 平台团队成为瓶颈 | 需求排队超过一个版本 | 协调成本未被计入 | 公开路线图,需求分级,游戏侧保留扩展点 |
检查表
- 每个候选能力都标注了数据归属与消费方数量。
- 玩法公式、数值、活动规则没有被抽进平台。
- 抽取前依赖数已降到阈值以下,而不是把依赖一起搬走。
- 租户隔离级别有硬约束依据,不只是“感觉不安全”。
- 平台接口有版本号与兼容窗口,旧版本有下线计划。
- 平台关键路径有独立 SLO、容量与值班。
- 第一个迁移租户不是主力游戏,且有回滚开关。
- 旧实现删除前确认配置、脚本、客服工具无调用。
平台化是手段,不是目标。真正要回答的问题是:哪些能力需要跨游戏独立演进,哪些能力必须贴着玩法走。答错这个问题,抽得越多,欠得越多。下一节讨论如何把这些判断排进季度路线图,并用技术雷达管理采用节奏。
阅读导航:上一节:10.1 绞杀者与增量迁移 · 下一节:10.3 架构路线图与技术雷达 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。