矩阵要回答的不是“谁做了什么”
到卷二末尾,北辰工作室已经有了一套跨三款产品的平台:账号、支付、社交、数据四类能力在 northstar-platform,对局、经济、榜单等能力则分散在产品与平台之间。12.3 把平台与产品团队的 SLA 和责任边界写成了双边契约,但那份契约是按“平台能力”组织的,产品负责人看完仍然要问一句:我们这款游戏到底用了哪几种方式?
这个问题不能靠会议纪要回答。会议纪要会过期,人会换,代码会漂移。需要的是一张按“产品 × 能力”展开的矩阵:每一格写清楚使用方式、SLA、降级策略和责任方。矩阵的价值不在于好看,而在于它能被校验:少一格、两格说法冲突、关系标错,都应该在评审时直接报错。
本节不引入新系统,只把前十二章已经做过的决定收进一张表,并给出一个能检查这张表的包。
三种关系与四种差异原因
平台与产品的关系只有三种,必须先定义清楚,否则“共用”会变成一句含糊的口号。
| 关系 | 含义 | 典型例子 | 主要代价 |
|---|---|---|---|
| 共用实现 | 所有产品调用同一套部署和同一段代码 | 账号、支付、邮件 | 一处故障影响全部产品 |
| 共用接口 | 接口与语义统一,各产品有自己的实现 | 匹配、结算、榜单 | 接口版本与语义漂移 |
| 各自实现 | 产品自己拥有能力,平台不参与 | 品类特有的战斗规则 | 重复建设、无法横向复用 |
关系之外还要记录差异原因。矩阵里每一格都带一个原因:
genre:品类导致,换品类就该不同;legacy:历史包袱,理论上应该收敛;compliance:合规要求,不能为了统一而统一;scale:规模差异,当前体量下不值得抽象。
没有原因格的差异,默认是历史包袱。这条默认规则很重要:它把“我们一直是这样”从解释变成了待办。
十项能力的矩阵
下表是北辰三款产品的当前状态。裂隙战场 是 5v5 竞技,群岛纪元 是全球同服 SLG,泡泡工坊 是休闲小游戏。
| 能力 | 裂隙战场 | 群岛纪元 | 泡泡工坊 | 关系 | 责任方 |
|---|---|---|---|---|---|
| 账号 | platform-account | platform-account | platform-account | 共用实现 | 平台 |
| 登录 | platform-login | platform-login | platform-login | 共用实现 | 平台 |
| 匹配 | room-match | world-match | lobby-match | 共用接口 | 各产品 |
| 对局与结算 | battle-settle | async-settle | room-settle | 共用接口 | 各产品 |
| 经济 | wallet-ledger | wallet-ledger | wallet-ledger | 共用实现 | 平台 |
| 社交 | friend-guild | friend-guild | friend-only | 共用接口 | 平台与产品 |
| 榜单 | season-rank | world-rank | friend-rank | 共用接口 | 各产品 |
| 邮件 | platform-mail | platform-mail | platform-mail | 共用实现 | 平台 |
| 支付 | platform-pay | platform-pay | platform-pay | 共用实现 | 平台 |
| 合规 | platform-compliance | platform-compliance | platform-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 系统边界与数据所有权总表 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。