《游戏服务端架构·平台化与架构治理》10.2 单体到平台、单游戏到多游戏

一款 SLG 成功后要接第二款游戏,账号、支付、匹配、排行榜哪些该抽成平台,哪些必须留在游戏内?本节给出能力抽取的五条判定规则、共享库到独立集群的租户隔离级别、依赖拓扑与抽取顺序,并列出平台化的发布耦合、故障扩散与协调成本,附可运行的 Go 判定与拓扑代码。

第二款游戏带来的问题

《潮汐要塞》上线第三年,工作室用同一套后端接了第二款休闲游戏《泡泡工坊》。最初的做法是复制仓库、改配置、单独部署。三个月后问题集中出现:账号封禁要改两遍,支付回调要修两次,排行榜的防刷规则两边不一致,运营在后台看到两个互不相通的玩家体系。

团队于是提出“把公共能力都抽成平台”。这个方向没错,但“都抽”是危险的。匹配算法在 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 与风险阈值是示例设计目标,真实项目应按自己的容量基线和故障史重新标定,不能直接抄。

测试覆盖了玩法专属、单消费方、依赖过多、频繁发布、低压力五类判定,四级隔离的边界,以及链、菱形、重复边、自环和双节点环。拓扑排序对重复边做了去重,否则同一条依赖会把入度加两次,合法图会被误判成环。

迁移步骤

  1. 冻结边界:先写清楚平台拥有哪些数据、游戏拥有哪些数据,落到接口契约。
  2. 建适配层:旧实现继续服务,新平台通过适配器接入,游戏侧不直接依赖平台 SDK。
  3. 选第一个租户:优先选影响小、配合度高的游戏,不要拿主力游戏做第一次迁移。
  4. 双跑对比:同一请求同时走旧实现与新平台,比较结果与延迟。
  5. 按租户切流:一个游戏一个开关,切流后保留旧实现一个观察期。
  6. 删除旧实现:确认没有调用方后再删,避免“平台化完成、旧代码还在跑”。

失败路径

失败早期信号根因正确处置
平台变成新的单体每次需求都要改平台核心抽取时没有划清数据归属停止扩能力,先拆领域边界
第二款游戏接入成本高于复制适配器越写越厚抽象按第一款游戏设计回退到游戏内实现,保留平台基础能力
一次发版影响所有游戏故障同时出现在多个游戏缺少版本化与灰度补接口版本与租户级开关
平台团队成为瓶颈需求排队超过一个版本协调成本未被计入公开路线图,需求分级,游戏侧保留扩展点

检查表

  • 每个候选能力都标注了数据归属与消费方数量。
  • 玩法公式、数值、活动规则没有被抽进平台。
  • 抽取前依赖数已降到阈值以下,而不是把依赖一起搬走。
  • 租户隔离级别有硬约束依据,不只是“感觉不安全”。
  • 平台接口有版本号与兼容窗口,旧版本有下线计划。
  • 平台关键路径有独立 SLO、容量与值班。
  • 第一个迁移租户不是主力游戏,且有回滚开关。
  • 旧实现删除前确认配置、脚本、客服工具无调用。

平台化是手段,不是目标。真正要回答的问题是:哪些能力需要跨游戏独立演进,哪些能力必须贴着玩法走。答错这个问题,抽得越多,欠得越多。下一节讨论如何把这些判断排进季度路线图,并用技术雷达管理采用节奏。

阅读导航:上一节:10.1 绞杀者与增量迁移 · 下一节:10.3 架构路线图与技术雷达 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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