所有权不是“谁在用”,而是“谁能写”
一个系统出了故障,最常见的争吵不是“怎么修”,而是“这数据归谁”。账号服务说实名信息在合规库,合规库说游戏侧也在写;钱包说订单状态由支付服务维护,支付服务说发货结果由游戏回写。只要两份代码都能写同一张表,讨论就没有终点。
13.1 的契约矩阵解决横向使用关系,本节解决纵向数据归属。规则只有一条:每份数据有且只有一个所有者,只有所有者可以写。其他系统可以读,可以通过事件、API 或对账请求变更,但不能直接落库。
这条规则听起来很硬,实际是在保护故障边界。所有者明确,才有唯一的写入路径、唯一的幂等键、唯一的回滚责任;否则一致性只能靠“大家小心一点”。
所有权表必须有六列
| 列 | 回答的问题 | 缺失后的后果 |
|---|---|---|
| 数据 | 这份数据的名字和粒度 | 讨论对象漂移,各说各的表 |
| 所有者 | 谁可以写、谁对正确性负责 | 故障时无人认领 |
| 可读方 | 谁依赖它、按什么口径读 | 下游偷偷直连,schema 变更即事故 |
| 保留期 | 数据保留多久、按什么事件起算 | 无限增长或提前删除 |
| 删除责任 | 谁执行删除、谁验证删除 | 注销后仍有残留,合规风险 |
| 跨系统通道 | 非所有者可以走哪条路请求变更 | 需求一来就开直写口子 |
“可写方”这一列故意不存在。写者永远只有所有者一个;如果业务上真的需要另一个系统修改,那不是所有权问题,而是所有者提供的 API 不够用。
核心系统数据所有权总表
下表是北辰三款产品共用平台后的逻辑所有权。保留期和通道都是示例值,接入前要按实际合规要求和存储成本调整。
| 数据 | 所有者 | 可读方 | 保留期(示例值) | 删除责任 | 允许通道 |
|---|---|---|---|---|---|
| 账号资料 | account | login、mail、payment、compliance | 账号存续期 | account | api |
| 登录票据与会话 | login | gateway、ops | 30 天 | login | event |
| 房间与匹配记录 | room | battle、ops、rank | 7 天 | room | event |
| 世界与 AOI 快照 | world | battle、ops | 1 天 | world | api |
| 战斗日志与回放 | battle | rank、audit | 180 天 | battle | api |
| 货币余额与流水 | wallet | rank、mail、payment、ops | 5 年 | wallet 与 compliance | event、reconcile |
| 成长与任务进度 | growth | quest、rank | 账号存续期 | growth | event |
| 好友与公会关系 | social | chat、mail、rank | 账号存续期 | social | api |
| 聊天记录 | chat | compliance、ops | 30 天 | chat | api |
| 榜单与赛季快照 | rank | mail、ops | 赛季结束后 90 天 | rank | event |
| 邮件正文与附件 | wallet、growth、ops | 过期后 90 天 | api | ||
| 支付订单与退款 | payment | wallet、compliance、ops | 5 年 | payment 与 compliance | event、reconcile |
| 实名与防沉迷记录 | compliance | account、login | 按监管要求 | compliance | api |
这张表有三处容易误读,需要说明。
第一,wallet 的流水保留 5 年,不等于玩家注销后还保留 5 年。账号注销触发的是合规删除流程,财务归档只保留去标识化后的必要字段,两者由 wallet 与 compliance 共同确认。
第二,world 的 AOI 快照保留 1 天,是因为它是可重建的运行时状态,不是权威数据。重建能力本身就是所有权的一部分:所有者必须能说清楚“丢了怎么恢复”。
第三,chat 的 30 天不是产品偏好,而是合规与成本的折中。超过保留期的消息必须真的删除,不能因为“以后可能有用”而无限延期;留存越久,泄露的代价越大。
越权写入的禁止清单
下面这些写法在任何产品里都不允许,评审时看到就应打回:
- 战斗服直接扣钱包余额,绕过结算事件;
- 邮件服务直接往背包表插道具,跳过附件领取状态机;
- 榜单服务在赛季结束时直接给钱包加钱;
- 运营工具直接改任意业务的表;
- 产品 A 的库直接读写产品 B 的表;
- 数据分析任务回写业务表,即使只改一个标记位。
禁止直写的理由不是“洁癖”,而是这些路径都绕过了所有者的事务边界、幂等键和审计日志。一旦出事,无法判断哪次写入是合法的,也无法安全重放。
必须跨系统时走什么通道
所有权不等于“老死不相往来”。跨系统变更只有三条通道,而且都必须由所有者执行最终写入:
| 通道 | 语义 | 适用 | 约束 |
|---|---|---|---|
| event | 异步事实通知,至少一次 | 结算发奖、订单支付成功 | 事件带幂等键,消费者去重 |
| api | 同步命令,所有者校验后执行 | 领取附件、冻结资产、注销账号 | 调用方不持有写权限 |
| reconcile | 周期性比对,差异生成工单 | 支付对账、钱包与订单核对 | 只报告差异,不直接修数据 |
选择顺序通常是 event 优先。结算、支付回调这类“已经发生的事实”适合事件;需要立即知道结果的用户操作适合 API;跨系统长期一致性靠对账兜底。三条通道可以叠加:支付成功先发事件,发货失败走对账,人工处理再通过 API 补单。
通道必须写进所有权表,而不是临时协商。没有登记的通道默认拒绝;新增通道要走变更评审,因为它会扩大所有者的输入面。
保留期与删除责任
保留期从什么时刻起算,必须写清楚:从创建、从最后活跃、从赛季结束还是从订单完成。起算点不同,清理任务的结果会差出几个数量级。
删除责任也要拆成两个动作:谁执行删除、谁验证删除。执行者通常是所有者,验证者通常是合规或独立审计。只有执行没有验证,注销用户的残留数据不会被发现;只有验证没有执行,报告会长期挂着“待清理”。
删除还必须能传导。账号注销后,好友关系、邮件、榜单昵称、聊天记录都要按各自保留期处理;11.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 进入卷三:规模与全球化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。