《互联网产品的服务端架构·产品、流量与基础能力》2.2 领域模型与数据所有权

以一次账号注销后内容、私信和订单里仍残留用户信息的事故为引子,把用户、内容、关系、订单四类数据的所有权列成表,解释为什么每个数据集只能有一个写者、多个写者为何必须显式登记,并给出可校验重复数据集、空 owner 与越权写者的 Go 实现和表驱动测试。

一个注销请求留下的四条残影

「回声」有一位用户申请注销账号。身份服务按流程删除了用户行,标记会话失效,并发出了一条“用户已注销”的事件。工单在系统里显示“已完成”。

三十天后,客服收到另一名用户的举报:一个已经注销的账号还在发布内容,头像和昵称都还能看到。排查发现,身份服务确实删干净了自己库里的用户行,但另外三个地方还留着这个人的痕迹:

  • 内容服务在文章表里存了一份 author_name 和 author_avatar,用于列表页直接渲染,注销时没有清理。
  • 私信服务在会话表里存了对方的昵称快照,历史消息仍然可以点进主页。
  • 订单服务保存了下单时的收货地址和手机号,用于售后和发票,注销流程没有覆盖。

更麻烦的是搜索索引。内容服务为了“读得快”,曾经直接写过用户表的一个冗余字段;身份服务删除用户时,并不知道内容服务也在写这张表,清理脚本自然也没有包含搜索索引。案例假设这次清理涉及 3 个库、2 个搜索索引和 1 个缓存集群,人工处理花了两周。

这不是一个“删除逻辑没写全”的普通 bug。根因是数据所有权没有写清楚:谁拥有用户数据、谁只能读、谁在什么条件下可以写,全靠口头约定。口头约定在正常迭代里看不出问题,一到注销、审计、迁移和排障就会集中爆发。

四类数据,四个 owner

把「回声」的数据按语义分成四类,每类指定一个 owner,再列清楚允许的写者和读者,注销流程才有可能被完整设计。

数据集owner允许的写者主要读者注销/删除方式一致性要求
用户与身份identityidentitycontent、messaging、orderowner 删除主体,发事件通知下游强一致
内容与 Feedcontentcontent、editorfeed、search、recommendation按作者 ID 批量下线,保留审计副本读多写少,最终一致
关系(关注、拉黑)socialsocialfeed、messaging、recommendation随主体删除,双向清理强一致
订单与履约orderorderpayment、support、finance保留法定年限,匿名化而非物理删除强一致

表里的写者与读者是案例假设值,重点在两条规则。

第一条:每个数据集只有一个 owner。owner 不一定自己实现所有写入,但它对“什么算合法写入”负责,包括字段含义、状态机、删除语义和审计要求。其他边界只能通过 owner 公开的接口或事件修改数据。

第二条:owner 默认是唯一写者。出现第二个写者时,必须显式登记,并说明它写哪些字段、在什么条件下写、冲突时谁赢。显式登记不是为了形式,而是为了在注销、迁移和故障排查时能找到一个完整的写者清单。

为什么单写者这么重要?因为三件事都依赖它:

  • 注销与合规。只有知道所有写者,才能保证删除或匿名化覆盖全部副本;漏掉一个写者,就会留下“已注销但还能被搜到”的残影。
  • 审计与追责。同一份数据被多个服务随意写,出现脏数据时无法判断是哪个服务、哪次发布、哪条规则造成的。
  • 模型演进。owner 可以独立修改表结构和字段语义;多写者场景下,每次变更都要协调所有写者,最终没人敢改。

单写者不是单实例

单写者描述的是逻辑角色,不是物理进程。identity 可以是一个多副本集群,也可以由多个内部模块组成,只要对外只有一套写入语义、一个发布节奏、一份审计口径,它就仍然是一个写者。

反过来,一个服务即使只部署了一个实例,只要它和另一个服务各自维护一套写入逻辑,它就是两个写者。判断标准不是进程数量,而是“是否存在两条独立的代码路径在改同一份数据”。

内容服务直接写用户表的那半年

「回声」的内容服务曾经遇到一个很典型的性能问题:Feed 列表需要展示作者昵称和头像,每次渲染都调用身份服务,假设首页 QPS 2000、每次返回 20 条内容(案例假设值),身份服务要承受每秒 4 万次批量查询。为了省掉这次调用,内容服务做了一个“临时优化”:在内容库里冗余作者信息,并在作者修改资料时直接写用户表。

这个优化在上线后的半年里看起来非常成功:Feed 渲染不再依赖身份服务,P99 降了,身份服务的压力也小了。但它同时制造了三个隐患。

第一个隐患是写入冲突。身份服务和内容服务都能改用户表,作者改昵称时,两个服务可能用不同的时间戳和字段覆盖彼此。曾经出现过一次昵称被回滚到三天前的值,原因是内容服务的异步任务用旧快照覆盖了新值。

第二个隐患是注销遗漏。身份服务的注销流程只清理自己拥有的表;内容服务既然“也能写用户表”,身份服务就默认它会自己处理。结果两边都没有清理搜索索引和缓存,注销用户的昵称继续出现在推荐结果里。

第三个隐患是迁移困难。半年后身份服务要拆分用户表,发现内容服务直接依赖了其中五个字段,其中两个字段还是内容服务自己写进去的。拆分方案被迫增加一个兼容层,发布时间从两周拖到一个季度。

修复时,团队做了四件事:

  1. 内容服务停止直接写用户表,改为订阅“用户资料变更”事件,只更新自己库里的只读投影。
  2. 身份服务成为用户数据的唯一写者,注销时按写者清单逐个通知下游清理。
  3. 内容服务的投影表增加 source_version,旧事件可以被识别并丢弃,避免异步覆盖新值。
  4. 把“谁写哪些字段”写进所有权表,并在发布检查里校验。

这个案例的教训不是“不要冗余”,而是“冗余可以,但不能因此获得写权限”。读优化和写所有权是两件事,混在一起就会同时失去性能和可治理性。

把所有权写成可校验的表

所有权如果只写在 wiki 里,下一次“临时优化”仍然会绕过去。把数据集、owner、写者和读者写成结构体,再用一个函数校验单写者规则,至少能让“多写者未登记”和“重复数据集”在代码评审时被发现。

下面的代码来自 webapp-lab。Ownership 返回“数据集 -> owner”的映射,同时检查空名、空 owner、重复数据集、空写者/读者和重复写者。它允许一个数据集有多个写者,但要求 owner 必须出现在显式写者列表里:这表示 owner 知情并授权,而不是被其他服务绕过。

// file: internal/platform/design/ownership.go
// tier: L1
// verify: go test ./internal/platform/design/

package design

import (
	"fmt"
	"strings"
)

// DataSet 是一份有明确归属的数据。Owner 对写入语义负责,
// Writers 是显式登记的写者,Readers 是允许读的边界。
type DataSet struct {
	Name    string
	Owner   string
	Writers []string
	Readers []string
}

// Ownership 返回“数据集 -> owner”的映射,并校验单写者规则。
// owner 默认是唯一写者;当 Writers 显式登记了多个写者时,
// owner 必须出现在列表中,表示授权而不是被绕过。
func Ownership(rows []DataSet) (map[string]string, error) {
	owners := make(map[string]string, len(rows))
	for _, row := range rows {
		name := strings.TrimSpace(row.Name)
		owner := strings.TrimSpace(row.Owner)
		if name == "" {
			return nil, fmt.Errorf("design: data set name is empty")
		}
		if owner == "" {
			return nil, fmt.Errorf("design: data set %q has no owner", name)
		}
		if _, dup := owners[name]; dup {
			return nil, fmt.Errorf("design: duplicate data set %q", name)
		}
		if err := checkNames("writer", name, row.Writers); err != nil {
			return nil, err
		}
		if err := checkNames("reader", name, row.Readers); err != nil {
			return nil, err
		}
		if len(row.Writers) > 1 && !containsName(row.Writers, owner) {
			return nil, fmt.Errorf("design: data set %q has multiple writers but owner %q is not registered", name, owner)
		}
		owners[name] = owner
	}
	return owners, nil
}

// WritersOf 返回一个数据集的显式写者。没有登记写者时,owner 是唯一写者。
func WritersOf(d DataSet) []string {
	owner := strings.TrimSpace(d.Owner)
	if len(d.Writers) == 0 {
		return []string{owner}
	}
	out := make([]string, 0, len(d.Writers))
	seen := make(map[string]bool, len(d.Writers))
	for _, writer := range d.Writers {
		writer = strings.TrimSpace(writer)
		if writer == "" || seen[writer] {
			continue
		}
		seen[writer] = true
		out = append(out, writer)
	}
	return out
}

func checkNames(kind, dataSet string, names []string) error {
	seen := make(map[string]bool, len(names))
	for _, name := range names {
		name = strings.TrimSpace(name)
		if name == "" {
			return fmt.Errorf("design: data set %q has an empty %s", dataSet, kind)
		}
		if seen[name] {
			return fmt.Errorf("design: data set %q has duplicate %s %q", dataSet, kind, name)
		}
		seen[name] = true
	}
	return nil
}

func containsName(names []string, want string) bool {
	for _, name := range names {
		if strings.TrimSpace(name) == want {
			return true
		}
	}
	return false
}

这段代码有几个明确的边界:

  • WritersOf 在没有显式写者时返回 owner,而不是空列表。调用方不需要在每次读取时判断“是不是默认情况”,减少漏判。
  • 多个写者时要求 owner 在列表里,是一个最小化的授权检查。它不能证明每个写者真的只写约定字段,但能防止“owner 完全不知道还有别人在写”。
  • 校验只覆盖声明,不覆盖数据库权限。一个服务仍然可以直接连别人的库,真正要堵住这条路,需要数据库账号、网络策略和发布审计一起配合。

还要注意,所有权表不是越细越好。把每个字段都拆成独立数据集,会带来大量微小 owner 和跨表事务;按语义聚合、把强关联字段放在同一个数据集里,通常更容易维护。判断粒度是否合适,可以问:这份数据能否独立定义生命周期、删除语义和审计要求?能,就适合成为一个数据集。

测试:重复、越权与默认写者

所有权校验最容易在两类地方出错:重复登记被静默接受,以及“多个写者但 owner 不在其中”被当成合法配置。下面的表驱动测试把这两类情况都固定住,同时验证没有显式写者时 owner 是默认写者。

// file: internal/platform/design/ownership_test.go
// tier: L1
// verify: go test ./internal/platform/design/

package design

import "testing"

func TestOwnershipMapsOwner(t *testing.T) {
	rows := []DataSet{
		{Name: "user", Owner: "identity", Readers: []string{"content", "order"}},
		{Name: "content", Owner: "content", Writers: []string{"content", "editor"}, Readers: []string{"feed"}},
		{Name: "order", Owner: "order", Writers: []string{"order"}},
	}
	got, err := Ownership(rows)
	if err != nil {
		t.Fatal(err)
	}
	want := map[string]string{"user": "identity", "content": "content", "order": "order"}
	for name, owner := range want {
		if got[name] != owner {
			t.Fatalf("%s owner = %q, want %q", name, got[name], owner)
		}
	}
	if len(got) != len(want) {
		t.Fatalf("owners = %v, want %v", got, want)
	}
}

func TestOwnershipRejectsBrokenRows(t *testing.T) {
	cases := []struct {
		name string
		rows []DataSet
	}{
		{"empty-name", []DataSet{{Name: " ", Owner: "identity"}}},
		{"missing-owner", []DataSet{{Name: "user"}}},
		{
			name: "duplicate-data-set",
			rows: []DataSet{
				{Name: "user", Owner: "identity"},
				{Name: "user", Owner: "profile"},
			},
		},
		{
			name: "duplicate-writer",
			rows: []DataSet{{Name: "content", Owner: "content", Writers: []string{"content", "content"}}},
		},
		{
			name: "empty-writer",
			rows: []DataSet{{Name: "content", Owner: "content", Writers: []string{" "}}},
		},
		{
			name: "multiple-writers-without-owner",
			rows: []DataSet{{Name: "content", Owner: "content", Writers: []string{"editor", "crawler"}}},
		},
		{
			name: "empty-reader",
			rows: []DataSet{{Name: "content", Owner: "content", Readers: []string{""}}},
		},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if _, err := Ownership(c.rows); err == nil {
				t.Fatal("want error for broken ownership rows")
			}
		})
	}
}

func TestWritersOfDefaultsToOwner(t *testing.T) {
	got := WritersOf(DataSet{Name: "user", Owner: "identity"})
	if len(got) != 1 || got[0] != "identity" {
		t.Fatalf("writers = %v, want [identity]", got)
	}
}

func TestWritersOfDeduplicates(t *testing.T) {
	got := WritersOf(DataSet{
		Name:    "content",
		Owner:   "content",
		Writers: []string{"content", " editor ", "content", ""},
	})
	if len(got) != 2 || got[0] != "content" || got[1] != "editor" {
		t.Fatalf("writers = %v, want [content editor]", got)
	}
}

测试里最关键的是 multiple-writers-without-owner:内容数据集声明了 editor 和 crawler 两个写者,但 owner content 不在列表里,函数必须报错。这个用例防的不是技术错误,而是组织上的“先斩后奏”。

WritersOfDeduplicates 验证的是读取侧的健壮性:即使上游传入了重复、带空格和空字符串,返回值也应该去重并保持稳定。它不能替代写入侧的校验,但能让日志和审计输出更可读。

测试没有覆盖的部分包括:写者是否真的只写约定字段、owner 注销时是否真的通知了所有下游、搜索索引和缓存是否被包含在清理范围内。这些需要集成测试、演练和合规审计来回答。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/platform/design/ -run 'TestOwnership|TestWritersOf' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestOwnershipMapsOwner
--- PASS: TestOwnershipMapsOwner (0.00s)
=== RUN   TestOwnershipRejectsBrokenRows
=== RUN   TestOwnershipRejectsBrokenRows/empty-name
=== RUN   TestOwnershipRejectsBrokenRows/missing-owner
=== RUN   TestOwnershipRejectsBrokenRows/duplicate-data-set
=== RUN   TestOwnershipRejectsBrokenRows/duplicate-writer
=== RUN   TestOwnershipRejectsBrokenRows/empty-writer
=== RUN   TestOwnershipRejectsBrokenRows/multiple-writers-without-owner
=== RUN   TestOwnershipRejectsBrokenRows/empty-reader
--- PASS: TestOwnershipRejectsBrokenRows (0.00s)
    --- PASS: TestOwnershipRejectsBrokenRows/empty-name (0.00s)
    --- PASS: TestOwnershipRejectsBrokenRows/missing-owner (0.00s)
    --- PASS: TestOwnershipRejectsBrokenRows/duplicate-data-set (0.00s)
    --- PASS: TestOwnershipRejectsBrokenRows/duplicate-writer (0.00s)
    --- PASS: TestOwnershipRejectsBrokenRows/empty-writer (0.00s)
    --- PASS: TestOwnershipRejectsBrokenRows/multiple-writers-without-owner (0.00s)
    --- PASS: TestOwnershipRejectsBrokenRows/empty-reader (0.00s)
=== RUN   TestWritersOfDefaultsToOwner
--- PASS: TestWritersOfDefaultsToOwner (0.00s)
=== RUN   TestWritersOfDeduplicates
--- PASS: TestWritersOfDeduplicates (0.00s)
PASS
ok  	webapp/internal/platform/design	0.231s

PASS 和退出码 0 是判定标准。-run 只筛选用例,不会改变校验逻辑;CI 里应该跑整个包,避免新增测试被过滤掉。

小结

数据所有权不是一张组织分工表,而是服务端能否安全演进的前提。每个数据集一个 owner,owner 默认是唯一写者,其他写者必须显式登记,这三条规则同时服务于注销、审计、模型演进和故障排查。

冗余可以做,但冗余不等于写权限。内容服务可以保存作者昵称的只读投影,却不应该直接改用户表;一旦它获得了写权限,注销流程就会出现没人负责的盲区。

下一节会把前两节的用例、边界和所有权收拢成一份可评审的架构决策记录,看看一次 Feed 推拉模式的取舍应该怎样写,才能在半年后仍然被后来者读懂。

阅读导航:上一节:2.1 用例、用户旅程与系统边界 · 下一节:2.3 一份可评审的架构决策记录 。

常见错误与边界

  • 把 owner 理解成“最熟悉这张表的人”。owner 是对写入语义和生命周期负责的角色,不是知识最多的个人。
  • 允许“只读副本”反向写入。副本一旦可写,它就不再是副本,而是第二个写者,必须显式登记。
  • 用共享数据库代替接口。共享库让依赖看不见,注销、迁移和故障隔离都会失去边界。
  • 多写者只写在文档里。没有校验和发布检查,文档会在下一次紧急需求里被绕过。
  • 把注销当成删除一行。注销要覆盖投影、搜索索引、缓存、消息快照和法定留存数据,必须按写者清单执行。
  • 认为最终一致可以没有 owner。最终一致只改变同步方式,不改变“谁定义正确结果”的责任。
  • 数据集切得过细。每个字段一个 owner 会制造大量跨表事务;应按生命周期和删除语义聚合。
  • 忽略物理权限。结构校验通过不代表服务真的只能读,数据库账号和网络策略必须同步收紧。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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