《游戏服务端架构·核心系统与契约》13.1 三款产品共用平台的契约矩阵

把三款产品对十项平台能力的使用方式、SLA、降级策略和责任方收进一张矩阵,区分共用实现、共用接口与各自实现,并用差异原因把品类差异和历史包袱分开;附一个校验完整性、关系一致性、owner 冲突与统计口径的可运行 Go 包,让矩阵能在评审和 CI 里被检查。

矩阵要回答的不是“谁做了什么”

到卷二末尾,北辰工作室已经有了一套跨三款产品的平台:账号、支付、社交、数据四类能力在 northstar-platform,对局、经济、榜单等能力则分散在产品与平台之间。12.3 把平台与产品团队的 SLA 和责任边界写成了双边契约,但那份契约是按“平台能力”组织的,产品负责人看完仍然要问一句:我们这款游戏到底用了哪几种方式?

这个问题不能靠会议纪要回答。会议纪要会过期,人会换,代码会漂移。需要的是一张按“产品 × 能力”展开的矩阵:每一格写清楚使用方式、SLA、降级策略和责任方。矩阵的价值不在于好看,而在于它能被校验:少一格、两格说法冲突、关系标错,都应该在评审时直接报错。

本节不引入新系统,只把前十二章已经做过的决定收进一张表,并给出一个能检查这张表的包。

三种关系与四种差异原因

平台与产品的关系只有三种,必须先定义清楚,否则“共用”会变成一句含糊的口号。

关系含义典型例子主要代价
共用实现所有产品调用同一套部署和同一段代码账号、支付、邮件一处故障影响全部产品
共用接口接口与语义统一,各产品有自己的实现匹配、结算、榜单接口版本与语义漂移
各自实现产品自己拥有能力,平台不参与品类特有的战斗规则重复建设、无法横向复用

关系之外还要记录差异原因。矩阵里每一格都带一个原因:

  • genre:品类导致,换品类就该不同;
  • legacy:历史包袱,理论上应该收敛;
  • compliance:合规要求,不能为了统一而统一;
  • scale:规模差异,当前体量下不值得抽象。

没有原因格的差异,默认是历史包袱。这条默认规则很重要:它把“我们一直是这样”从解释变成了待办。

十项能力的矩阵

下表是北辰三款产品的当前状态。裂隙战场 是 5v5 竞技,群岛纪元 是全球同服 SLG,泡泡工坊 是休闲小游戏。

能力裂隙战场群岛纪元泡泡工坊关系责任方
账号platform-accountplatform-accountplatform-account共用实现平台
登录platform-loginplatform-loginplatform-login共用实现平台
匹配room-matchworld-matchlobby-match共用接口各产品
对局与结算battle-settleasync-settleroom-settle共用接口各产品
经济wallet-ledgerwallet-ledgerwallet-ledger共用实现平台
社交friend-guildfriend-guildfriend-only共用接口平台与产品
榜单season-rankworld-rankfriend-rank共用接口各产品
邮件platform-mailplatform-mailplatform-mail共用实现平台
支付platform-payplatform-payplatform-pay共用实现平台
合规platform-complianceplatform-complianceplatform-compliance共用实现平台与合规

这张表有意把“关系”和“责任方”分成两列。共用接口不等于平台负责:接口由平台维护,实现和线上故障仍然归产品。反过来,共用实现也不等于产品可以不管,SLA 不达标时产品仍然要准备降级。

矩阵不是越共用越好。泡泡工坊 的社交只保留好友,没有公会,这不是历史包袱,而是品类决定的:休闲游戏的组织成本远高于社交收益。把它标成 genre,就不会有人每季度提议“为了统一也上一个公会系统”。

SLA 与降级写在同一格

只写 SLA 不写降级,等于只写了愿望。真正可执行的一格必须同时回答两个问题:正常时承诺什么,异常时退到哪里。

能力SLA 要求(示例值)降级策略降级后的责任方
账号99.95%,读 p99 50 ms已登录会话继续续期,新登录排队平台
登录99.95%,票据校验 p99 30 ms关闭新设备登录,保留重连平台
匹配99.9%,排队 60 s 内出结果放宽分段,返回预估等待产品
对局与结算结算回执 99.99%,5 s 内落库先记账后补发,禁止重复发奖产品
经济扣减 99.99%,p99 100 ms只读余额,暂停交易平台
社交99.9%,关系读 p99 80 ms隐藏公会入口,好友列表走缓存平台
榜单99.9%,读 p99 100 ms返回上一版封榜快照产品
邮件99.9%,5 min 内投递延迟投递,非关键邮件降级平台
支付99.95%,回调 30 s 内处理停止新订单,保留查询与补单平台
合规99.99%,判定 p99 20 ms拒绝进入,不做乐观放行平台与合规

SLA 数字都是示例值,不是实测。真正写进契约前,每一项都要有监控口径、统计窗口和排除项;否则 99.9% 在事故复盘时会被解释成“我们说的是另一个接口”。

降级策略最关键的是最后一行:合规不能降级成“先放行”。防沉迷和实名判定在 11.3 里已经明确为“判定与执行分离”,矩阵里也必须保持同样的方向,不能因为平台抖动就给未成年玩家开口子。

矩阵的读法:先看同列,再看同行

同一列看产品:如果一列里 legacy 很多,说明这款产品的接入是历史堆出来的,下一次大版本要排迁移,而不是继续加特例。同一行看能力:如果一行里三种关系混用,说明能力边界还没想清楚,通常应该先统一接口,再决定实现归属。

三个典型判断:

第一,裂隙战场 的匹配和结算是 genre,不是 legacy。竞技游戏需要严格的房间生命周期和帧级结算,SLG 的异步结算无法复用。这类差异应该保留,强行统一只会把两边的复杂度叠加。

第二,群岛纪元 的账号和登录与另外两款完全共用,这是 scale 的反面例子:全球同服并没有改变账号模型。真正的规模问题在路由和分区,不在账号表。

第三,泡泡工坊 的社交是 genre,但它的支付与合规完全共用。休闲产品的合规要求不会因为品类轻就变轻,这部分没有“轻量版”。

可运行片段:契约矩阵的完整性校验

下面的 internal/contractmatrix 把矩阵、三种关系、四种原因和校验规则写成一个包。它检查四件事:每个产品 × 能力都有格;每格的 SLA、降级、责任方、实现 id 不为空;共用实现只能有一个实现和一个 owner;共用接口只能有一个接口名但实现必须互不相同;各自实现不能复用别人的实现 id。

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

import (
	"fmt"
	"slices"
	"strings"
)

// Relation describes how one product consumes one platform capability.
type Relation string

const (
	// SharedImpl means every product calls the same deployment and code path.
	SharedImpl Relation = "shared-impl"
	// SharedAPI means the interface is common but each product brings its own
	// implementation behind it.
	SharedAPI Relation = "shared-api"
	// Independent means the product owns the capability end to end.
	Independent Relation = "independent"
)

// Reason records why a cell exists in its current shape. A matrix is read
// differently when a difference comes from the genre than when it is only
// inherited history.
type Reason string

const (
	ReasonGenre      Reason = "genre"
	ReasonLegacy     Reason = "legacy"
	ReasonCompliance Reason = "compliance"
	ReasonScale      Reason = "scale"
)

// Cell is one product's use of one capability. SLA, Degrade and Owner live in
// the same cell on purpose: a capability without a degradation plan and a
// named owner is not a contract, it is an intention.
type Cell struct {
	Product    string
	Capability string
	Relation   Relation
	Impl       string
	Interface  string
	SLA        string
	Degrade    string
	Owner      string
	Reason     Reason
}

// Matrix is a product x capability table. It is a review artifact, not a
// runtime registry: Validate is what turns the plan into a contract that can
// be argued about.
type Matrix struct {
	products     []string
	capabilities []string
	cells        map[string]Cell
}

func key(product, capability string) string {
	return product + "\x00" + capability
}

// New creates an empty matrix. Duplicate names are rejected here because a
// duplicated axis silently breaks the completeness count.
func New(products, capabilities []string) (*Matrix, error) {
	if len(products) == 0 || len(capabilities) == 0 {
		return nil, fmt.Errorf("contractmatrix: both axes are required")
	}
	seen := make(map[string]bool, len(products))
	for _, p := range products {
		if strings.TrimSpace(p) == "" {
			return nil, fmt.Errorf("contractmatrix: empty product name")
		}
		if seen[p] {
			return nil, fmt.Errorf("contractmatrix: duplicate product %q", p)
		}
		seen[p] = true
	}
	seen = make(map[string]bool, len(capabilities))
	for _, c := range capabilities {
		if strings.TrimSpace(c) == "" {
			return nil, fmt.Errorf("contractmatrix: empty capability name")
		}
		if seen[c] {
			return nil, fmt.Errorf("contractmatrix: duplicate capability %q", c)
		}
		seen[c] = true
	}
	return &Matrix{
		products:     append([]string(nil), products...),
		capabilities: append([]string(nil), capabilities...),
		cells:        make(map[string]Cell),
	}, nil
}

// Set writes one cell. Field semantics belong to Validate; Set only guards the
// axes and accidental overwrite, so a draft can be filled in any order.
func (m *Matrix) Set(c Cell) error {
	if !m.hasProduct(c.Product) {
		return fmt.Errorf("contractmatrix: unknown product %q", c.Product)
	}
	if !m.hasCapability(c.Capability) {
		return fmt.Errorf("contractmatrix: unknown capability %q", c.Capability)
	}
	k := key(c.Product, c.Capability)
	if _, ok := m.cells[k]; ok {
		return fmt.Errorf("contractmatrix: cell %s/%s already set", c.Product, c.Capability)
	}
	m.cells[k] = c
	return nil
}

func (m *Matrix) hasProduct(name string) bool {
	return slices.Contains(m.products, name)
}

func (m *Matrix) hasCapability(name string) bool {
	return slices.Contains(m.capabilities, name)
}

func relationValid(r Relation) bool {
	switch r {
	case SharedImpl, SharedAPI, Independent:
		return true
	}
	return false
}

func reasonValid(r Reason) bool {
	switch r {
	case ReasonGenre, ReasonLegacy, ReasonCompliance, ReasonScale:
		return true
	}
	return false
}

func (m *Matrix) cellsFor(capability string) []Cell {
	out := make([]Cell, 0, len(m.products))
	for _, p := range m.products {
		if c, ok := m.cells[key(p, capability)]; ok {
			out = append(out, c)
		}
	}
	return out
}

// Validate checks completeness, required fields and the three relation rules:
// shared-impl cells for one capability must name one implementation and one
// owner; shared-api cells must name one interface but distinct implementations;
// independent cells must not reuse another product's implementation.
func (m *Matrix) Validate() []error {
	var errs []error
	for _, capability := range m.capabilities {
		cells := m.cellsFor(capability)
		if len(cells) != len(m.products) {
			errs = append(errs, fmt.Errorf("capability %q: %d of %d cells present", capability, len(cells), len(m.products)))
			continue
		}
		for _, c := range cells {
			if !relationValid(c.Relation) {
				errs = append(errs, fmt.Errorf("%s/%s: unknown relation %q", c.Product, c.Capability, c.Relation))
				continue
			}
			if !reasonValid(c.Reason) {
				errs = append(errs, fmt.Errorf("%s/%s: unknown reason %q", c.Product, c.Capability, c.Reason))
			}
			if strings.TrimSpace(c.Impl) == "" {
				errs = append(errs, fmt.Errorf("%s/%s: empty implementation id", c.Product, c.Capability))
			}
			if strings.TrimSpace(c.SLA) == "" || strings.TrimSpace(c.Degrade) == "" || strings.TrimSpace(c.Owner) == "" {
				errs = append(errs, fmt.Errorf("%s/%s: SLA, degrade and owner are required", c.Product, c.Capability))
			}
		}
		errs = append(errs, validateRelations(capability, cells)...)
	}
	return errs
}

func validateRelations(capability string, cells []Cell) []error {
	var errs []error
	sharedImplOwner := make(map[string]string)
	sharedAPIInterface := ""
	sharedAPIImpl := make(map[string]string)
	independentImpl := make(map[string]string)
	for _, c := range cells {
		switch c.Relation {
		case SharedImpl:
			if prev, ok := sharedImplOwner[c.Impl]; ok && prev != c.Owner {
				errs = append(errs, fmt.Errorf("capability %q: shared impl %q has owners %q and %q", capability, c.Impl, prev, c.Owner))
			}
			sharedImplOwner[c.Impl] = c.Owner
		case SharedAPI:
			if strings.TrimSpace(c.Interface) == "" {
				errs = append(errs, fmt.Errorf("%s/%s: shared-api needs an interface id", c.Product, c.Capability))
			} else if sharedAPIInterface == "" {
				sharedAPIInterface = c.Interface
			} else if sharedAPIInterface != c.Interface {
				errs = append(errs, fmt.Errorf("capability %q: shared interface is both %q and %q", capability, sharedAPIInterface, c.Interface))
			}
			if other, ok := sharedAPIImpl[c.Impl]; ok {
				errs = append(errs, fmt.Errorf("capability %q: shared-api impl %q is reused by %s and %s", capability, c.Impl, other, c.Product))
			}
			sharedAPIImpl[c.Impl] = c.Product
		case Independent:
			if other, ok := independentImpl[c.Impl]; ok {
				errs = append(errs, fmt.Errorf("capability %q: independent impl %q is shared by %s and %s", capability, c.Impl, other, c.Product))
			}
			independentImpl[c.Impl] = c.Product
		}
	}
	if len(sharedImplOwner) > 1 {
		names := make([]string, 0, len(sharedImplOwner))
		for impl := range sharedImplOwner {
			names = append(names, impl)
		}
		errs = append(errs, fmt.Errorf("capability %q: shared-impl cells name %d implementations: %s", capability, len(names), strings.Join(names, ", ")))
	}
	return errs
}

// Stats counts cells per relation. A capability that is mostly Independent is
// a platform-boundary question, not a code-sharing question.
func (m *Matrix) Stats() map[Relation]int {
	out := map[Relation]int{SharedImpl: 0, SharedAPI: 0, Independent: 0}
	for _, c := range m.cells {
		if relationValid(c.Relation) {
			out[c.Relation]++
		}
	}
	return out
}

// ReasonCounts separates genre-driven differences from legacy ones. A high
// legacy count is the signal to schedule migration, not to write more code.
func (m *Matrix) ReasonCounts() map[Reason]int {
	out := map[Reason]int{}
	for _, c := range m.cells {
		if reasonValid(c.Reason) {
			out[c.Reason]++
		}
	}
	return out
}

// Products returns the product axis in declaration order.
func (m *Matrix) Products() []string {
	return append([]string(nil), m.products...)
}

// Capabilities returns the capability axis in declaration order.
func (m *Matrix) Capabilities() []string {
	return append([]string(nil), m.capabilities...)
}

几个实现取舍值得说明:

  • Set 只检查轴和重复写入,字段语义留给 Validate。这样矩阵可以先填一半再评审,不会因为“还没填完”而无法保存草稿。
  • 共用实现按能力聚合检查:同一能力下出现两个实现 id,说明“共用”已经名不副实,应该在评审时拆开或收敛,而不是继续叫共用。
  • 共用接口要求每个产品有独立实现 id。两个产品填了同一个实现 id,实际关系就是共用实现,矩阵必须如实反映。
  • ReasonCounts 把 legacy 数量单独暴露出来。这个数字比总格数更能说明平台债务的规模。

失败模式与兜底

失败表现兜底
矩阵缺格某产品的能力没人认领Validate 报缺失,评审不通过
共用实现 owner 冲突出故障时两个团队互相等同一实现只允许一个 owner
共用接口实现复用名为共用接口,实为共用实现实现 id 必须唯一,标错关系会被拒绝
降级策略写成“人工处理”真出事时无人知道怎么降降级必须写动作和责任方

常见错误

第一,把矩阵做成 Excel 台账。没有校验的表格三个月后就会和代码脱节;矩阵必须能在 CI 里跑。

第二,共用接口不写版本。接口名相同不代表语义相同,结算字段加一个可选项就可能让老产品算错奖励。

第三,用“业务特殊”解释一切差异。特殊不是原因;genre、legacy、compliance、scale 才是。

第四,SLA 只写可用性。延迟、时效和一致性同样是契约的一部分,特别是结算与支付。

小结

契约矩阵把“平台提供了什么”翻译成“每个产品实际怎么用、出了事谁负责”。它不产生新能力,但能让前十二章的决定变得可检查:缺格会报错,关系标错会报错,差异没有原因会被默认当成历史包袱。

矩阵回答的是横向关系。下一节把视角转到纵向:每个系统拥有哪些数据、谁可以写、谁只能读、保留多久、由谁删除。

阅读导航:上一节:12.3 平台 SLA 与产品团队的契约 · 下一节:13.2 系统边界与数据所有权总表 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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