《游戏服务端架构·核心系统与契约》13.2 系统边界与数据所有权总表

把账号、对局、经济、社交、榜单、支付等核心系统的数据归属收进一张总表:谁拥有、谁可读、保留多久、由谁删除;给出越权写入的禁止清单与事件、API、对账三种跨系统通道,讲解保留期起算与删除传导,并附一个检测直写、未登记通道和拓扑漂移的可运行 Go 包。

所有权不是“谁在用”,而是“谁能写”

一个系统出了故障,最常见的争吵不是“怎么修”,而是“这数据归谁”。账号服务说实名信息在合规库,合规库说游戏侧也在写;钱包说订单状态由支付服务维护,支付服务说发货结果由游戏回写。只要两份代码都能写同一张表,讨论就没有终点。

13.1 的契约矩阵解决横向使用关系,本节解决纵向数据归属。规则只有一条:每份数据有且只有一个所有者,只有所有者可以写。其他系统可以读,可以通过事件、API 或对账请求变更,但不能直接落库。

这条规则听起来很硬,实际是在保护故障边界。所有者明确,才有唯一的写入路径、唯一的幂等键、唯一的回滚责任;否则一致性只能靠“大家小心一点”。

所有权表必须有六列

列回答的问题缺失后的后果
数据这份数据的名字和粒度讨论对象漂移,各说各的表
所有者谁可以写、谁对正确性负责故障时无人认领
可读方谁依赖它、按什么口径读下游偷偷直连,schema 变更即事故
保留期数据保留多久、按什么事件起算无限增长或提前删除
删除责任谁执行删除、谁验证删除注销后仍有残留,合规风险
跨系统通道非所有者可以走哪条路请求变更需求一来就开直写口子

“可写方”这一列故意不存在。写者永远只有所有者一个;如果业务上真的需要另一个系统修改,那不是所有权问题,而是所有者提供的 API 不够用。

核心系统数据所有权总表

下表是北辰三款产品共用平台后的逻辑所有权。保留期和通道都是示例值,接入前要按实际合规要求和存储成本调整。

数据所有者可读方保留期(示例值)删除责任允许通道
账号资料accountlogin、mail、payment、compliance账号存续期accountapi
登录票据与会话logingateway、ops30 天loginevent
房间与匹配记录roombattle、ops、rank7 天roomevent
世界与 AOI 快照worldbattle、ops1 天worldapi
战斗日志与回放battlerank、audit180 天battleapi
货币余额与流水walletrank、mail、payment、ops5 年wallet 与 complianceevent、reconcile
成长与任务进度growthquest、rank账号存续期growthevent
好友与公会关系socialchat、mail、rank账号存续期socialapi
聊天记录chatcompliance、ops30 天chatapi
榜单与赛季快照rankmail、ops赛季结束后 90 天rankevent
邮件正文与附件mailwallet、growth、ops过期后 90 天mailapi
支付订单与退款paymentwallet、compliance、ops5 年payment 与 complianceevent、reconcile
实名与防沉迷记录complianceaccount、login按监管要求complianceapi

这张表有三处容易误读,需要说明。

第一,wallet 的流水保留 5 年,不等于玩家注销后还保留 5 年。账号注销触发的是合规删除流程,财务归档只保留去标识化后的必要字段,两者由 wallet 与 compliance 共同确认。

第二,world 的 AOI 快照保留 1 天,是因为它是可重建的运行时状态,不是权威数据。重建能力本身就是所有权的一部分:所有者必须能说清楚“丢了怎么恢复”。

第三,chat 的 30 天不是产品偏好,而是合规与成本的折中。超过保留期的消息必须真的删除,不能因为“以后可能有用”而无限延期;留存越久,泄露的代价越大。

越权写入的禁止清单

下面这些写法在任何产品里都不允许,评审时看到就应打回:

  • 战斗服直接扣钱包余额,绕过结算事件;
  • 邮件服务直接往背包表插道具,跳过附件领取状态机;
  • 榜单服务在赛季结束时直接给钱包加钱;
  • 运营工具直接改任意业务的表;
  • 产品 A 的库直接读写产品 B 的表;
  • 数据分析任务回写业务表,即使只改一个标记位。

禁止直写的理由不是“洁癖”,而是这些路径都绕过了所有者的事务边界、幂等键和审计日志。一旦出事,无法判断哪次写入是合法的,也无法安全重放。

必须跨系统时走什么通道

所有权不等于“老死不相往来”。跨系统变更只有三条通道,而且都必须由所有者执行最终写入:

通道语义适用约束
event异步事实通知,至少一次结算发奖、订单支付成功事件带幂等键,消费者去重
api同步命令,所有者校验后执行领取附件、冻结资产、注销账号调用方不持有写权限
reconcile周期性比对,差异生成工单支付对账、钱包与订单核对只报告差异,不直接修数据

选择顺序通常是 event 优先。结算、支付回调这类“已经发生的事实”适合事件;需要立即知道结果的用户操作适合 API;跨系统长期一致性靠对账兜底。三条通道可以叠加:支付成功先发事件,发货失败走对账,人工处理再通过 API 补单。

通道必须写进所有权表,而不是临时协商。没有登记的通道默认拒绝;新增通道要走变更评审,因为它会扩大所有者的输入面。

保留期与删除责任

保留期从什么时刻起算,必须写清楚:从创建、从最后活跃、从赛季结束还是从订单完成。起算点不同,清理任务的结果会差出几个数量级。

删除责任也要拆成两个动作:谁执行删除、谁验证删除。执行者通常是所有者,验证者通常是合规或独立审计。只有执行没有验证,注销用户的残留数据不会被发现;只有验证没有执行,报告会长期挂着“待清理”。

删除还必须能传导。账号注销后,好友关系、邮件、榜单昵称、聊天记录都要按各自保留期处理;11.3 已经把这套传导写进合规流程,本节的总表只是把它落到每一行数据上。

与卷一拓扑基线对照

卷一给过一套拓扑基线:网关、登录、房间、世界、战斗、经济、社交、榜单、邮件、支付、合规各自是逻辑系统。部署时可以合并进程、拆分子集群、换分片键,但逻辑所有权不应随之改变。

拓扑变化后要检查三件事:

  1. 每个数据的所有者是否仍然存在;
  2. 每个可读方是否仍然存在,读口径是否变化;
  3. 删除责任方是否仍然存在,注销流程是否仍然可达。

如果某个系统被合并进另一个进程,所有权要显式转移并更新总表,不能因为“代码在同一个仓库”就默认合并。反过来,拆服务也不能把一份数据拆成两个所有者;需要拆分时,先拆数据边界,再拆部署。

可运行片段:所有权表与越权检测

下面的 internal/ownershipmap 把总表、直写检测、跨系统请求校验和拓扑基线对照写成一个包。它不连接数据库:CheckDirectWrite 在代码评审和运行时都能用,CompareBaseline 用于部署前确认没有系统被悄悄移除。

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

import (
	"errors"
	"fmt"
	"sort"
)

// Channel is the only way a non-owner may influence a dataset. None of these
// channels is a direct write: the owning system still performs the write.
type Channel string

const (
	ChannelEvent     Channel = "event"
	ChannelAPI       Channel = "api"
	ChannelReconcile Channel = "reconcile"
)

var (
	ErrUnknownSystem  = errors.New("ownershipmap: unknown system")
	ErrUnknownDataset = errors.New("ownershipmap: unknown dataset")
	ErrDuplicate      = errors.New("ownershipmap: duplicate dataset")
	ErrOwner          = errors.New("ownershipmap: owner is not a registered system")
	ErrDeleteOwner    = errors.New("ownershipmap: delete owner is not a registered system")
	ErrRetention      = errors.New("ownershipmap: retention must not be negative")
	ErrNoReader       = errors.New("ownershipmap: dataset needs at least one reader")
	ErrReader         = errors.New("ownershipmap: reader is not a registered system")
	ErrChannel        = errors.New("ownershipmap: channel is not valid")
	ErrChannelDenied  = errors.New("ownershipmap: channel is not approved for this dataset")
	ErrCrossWrite     = errors.New("ownershipmap: direct write by a non-owner")
	ErrOwnerRequest   = errors.New("ownershipmap: the owner does not need a request")
)

// Dataset is one row of the ownership table. Writers is deliberately absent:
// the owner is the only writer. Cross-system influence is expressed through
// Channels, and the owner still applies the change.
type Dataset struct {
	Name          string
	Owner         string
	Readers       []string
	RetentionDays int // 0 means until account deletion
	DeleteOwner   string
	Channels      []Channel
}

func channelValid(c Channel) bool {
	switch c {
	case ChannelEvent, ChannelAPI, ChannelReconcile:
		return true
	}
	return false
}

// Map is the complete ownership table for one topology.
type Map struct {
	systems  map[string]bool
	datasets map[string]Dataset
}

// NewMap registers the known systems. Registering a system here does not give
// it any write permission; permission comes only from Dataset.Owner.
func NewMap(systems []string) (*Map, error) {
	if len(systems) == 0 {
		return nil, fmt.Errorf("ownershipmap: at least one system is required")
	}
	m := &Map{systems: make(map[string]bool, len(systems)), datasets: make(map[string]Dataset)}
	for _, s := range systems {
		if s == "" {
			return nil, fmt.Errorf("ownershipmap: empty system name")
		}
		if m.systems[s] {
			return nil, fmt.Errorf("ownershipmap: duplicate system %q", s)
		}
		m.systems[s] = true
	}
	return m, nil
}

func (m *Map) validateDataset(d Dataset) error {
	if d.Name == "" {
		return fmt.Errorf("ownershipmap: empty dataset name")
	}
	if !m.systems[d.Owner] {
		return fmt.Errorf("%w: dataset %q names %q", ErrOwner, d.Name, d.Owner)
	}
	if !m.systems[d.DeleteOwner] {
		return fmt.Errorf("%w: dataset %q names %q", ErrDeleteOwner, d.Name, d.DeleteOwner)
	}
	if d.RetentionDays < 0 {
		return fmt.Errorf("%w: dataset %q has %d days", ErrRetention, d.Name, d.RetentionDays)
	}
	if len(d.Readers) == 0 {
		return fmt.Errorf("%w: dataset %q", ErrNoReader, d.Name)
	}
	seen := make(map[string]bool, len(d.Readers))
	for _, r := range d.Readers {
		if !m.systems[r] {
			return fmt.Errorf("%w: dataset %q names %q", ErrReader, d.Name, r)
		}
		if seen[r] {
			return fmt.Errorf("ownershipmap: dataset %q lists reader %q twice", d.Name, r)
		}
		seen[r] = true
	}
	for _, c := range d.Channels {
		if !channelValid(c) {
			return fmt.Errorf("%w: %q on dataset %q", ErrChannel, c, d.Name)
		}
	}
	return nil
}

// Add inserts one dataset after validating every reference. Validation is
// repeated in Validate so a table loaded from a file can be audited as a
// whole before it is trusted.
func (m *Map) Add(d Dataset) error {
	if _, ok := m.datasets[d.Name]; ok {
		return fmt.Errorf("%w: %q", ErrDuplicate, d.Name)
	}
	if err := m.validateDataset(d); err != nil {
		return err
	}
	m.datasets[d.Name] = cloneDataset(d)
	return nil
}

func cloneDataset(d Dataset) Dataset {
	out := d
	out.Readers = append([]string(nil), d.Readers...)
	out.Channels = append([]Channel(nil), d.Channels...)
	return out
}

// Dataset returns a copy of one row.
func (m *Map) Dataset(name string) (Dataset, bool) {
	d, ok := m.datasets[name]
	if !ok {
		return Dataset{}, false
	}
	return cloneDataset(d), true
}

// Validate re-checks every row in deterministic order.
func (m *Map) Validate() []error {
	var errs []error
	for _, name := range m.Datasets() {
		if err := m.validateDataset(m.datasets[name]); err != nil {
			errs = append(errs, err)
		}
	}
	return errs
}

// Datasets returns dataset names in sorted order.
func (m *Map) Datasets() []string {
	out := make([]string, 0, len(m.datasets))
	for name := range m.datasets {
		out = append(out, name)
	}
	sort.Strings(out)
	return out
}

// CheckDirectWrite rejects every writer that is not the owner. This is the
// forbidden list: there are no exceptions and no "temporary" direct writes.
func (m *Map) CheckDirectWrite(dataset, actor string) error {
	d, ok := m.datasets[dataset]
	if !ok {
		return fmt.Errorf("%w: %q", ErrUnknownDataset, dataset)
	}
	if !m.systems[actor] {
		return fmt.Errorf("%w: %q", ErrUnknownSystem, actor)
	}
	if actor == d.Owner {
		return nil
	}
	return fmt.Errorf("%w: %s must not write %s (owner %s)", ErrCrossWrite, actor, dataset, d.Owner)
}

// CheckRequest verifies the sanctioned path for a non-owner: a declared event,
// API or reconciliation channel. The request is not a write; the owner applies
// it and remains the only writer.
func (m *Map) CheckRequest(dataset, from string, ch Channel) error {
	d, ok := m.datasets[dataset]
	if !ok {
		return fmt.Errorf("%w: %q", ErrUnknownDataset, dataset)
	}
	if !m.systems[from] {
		return fmt.Errorf("%w: %q", ErrUnknownSystem, from)
	}
	if from == d.Owner {
		return fmt.Errorf("%w: %s owns %s", ErrOwnerRequest, from, dataset)
	}
	if !channelValid(ch) {
		return fmt.Errorf("%w: %q", ErrChannel, ch)
	}
	for _, allowed := range d.Channels {
		if allowed == ch {
			return nil
		}
	}
	return fmt.Errorf("%w: %s -> %s via %s", ErrChannelDenied, from, dataset, ch)
}

// CompareBaseline checks that a topology change did not orphan an owner, a
// delete owner or a reader. Ownership survives scale changes only while every
// named system still exists.
func (m *Map) CompareBaseline(baseline []string) []error {
	known := make(map[string]bool, len(baseline))
	for _, s := range baseline {
		known[s] = true
	}
	var errs []error
	for _, name := range m.Datasets() {
		d := m.datasets[name]
		if !known[d.Owner] {
			errs = append(errs, fmt.Errorf("dataset %q: owner %q is missing from the baseline topology", name, d.Owner))
		}
		if !known[d.DeleteOwner] {
			errs = append(errs, fmt.Errorf("dataset %q: delete owner %q is missing from the baseline topology", name, d.DeleteOwner))
		}
		for _, r := range d.Readers {
			if !known[r] {
				errs = append(errs, fmt.Errorf("dataset %q: reader %q is missing from the baseline topology", name, r))
			}
		}
	}
	return errs
}

几个实现取舍值得说明:

  • Dataset 没有 Writers 字段,从类型上就不给“多写者”留位置。跨系统影响只能通过 Channels 表达。
  • CheckDirectWrite 对所有非 owner 返回同一个 ErrCrossWrite,不区分“读方”还是“无关系统”。越权就是越权,不需要更细的分类。
  • CheckRequest 要求通道预先登记。临时答应的“这次先这样”在代码里没有入口。
  • CompareBaseline 只看参与者是否存在,不比较部署形态。进程合并、分片调整不影响逻辑所有权,系统消失才会报错。

失败模式与兜底

失败表现兜底
两个系统都能写数据冲突时无法判定权威值总表只允许一个 owner,CI 校验
读方直连数据库schema 变更导致下游事故读方登记 + 只读视图或 API
跨系统通道临时开出事后无法重放和审计通道必须登记,未登记默认拒绝
保留期起算点含糊清理任务误删或长期堆积每行写清起算事件
注销后残留合规审计不通过执行者与验证者分离,删除可传导
拓扑调整后 owner 消失数据无人负责部署前跑基线对照

常见错误

第一,把“读得多”当成“可以写”。读方越多,越应该通过 API 或事件变更,而不是共享写权限。

第二,用数据库账号控制权限。库权限只能防住一部分误操作,防不住服务内部的错误 SQL;所有权要在代码和评审里表达。

第三,删除只做软删。软删是恢复手段,不是删除;保留期到了必须有真正的物理删除或不可逆脱敏。

第四,拓扑一变就重画所有权。部署形态可以变,逻辑所有权不应频繁变;变的是实现位置,不是责任。

第五,对账直接修数据。对账的职责是发现差异并生成工单,修复仍然走所有者的 API 或人工流程。

小结

数据所有权总表把“系统边界”从架构图上的框,变成每一行数据的责任。只有 owner 能写,其他系统通过事件、API、对账请求变更;保留期和删除责任写清楚;拓扑调整后对照基线,确认没有系统被悄悄移除。

横向的契约矩阵和纵向的所有权总表都收齐之后,剩下的是交接:卷二哪些问题没有答案,进入卷三之前必须先补哪些测量。

阅读导航:上一节:13.1 三款产品共用平台的契约矩阵 · 下一节:13.3 进入卷三:规模与全球化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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