一个注销请求留下的四条残影
「回声」有一位用户申请注销账号。身份服务按流程删除了用户行,标记会话失效,并发出了一条“用户已注销”的事件。工单在系统里显示“已完成”。
三十天后,客服收到另一名用户的举报:一个已经注销的账号还在发布内容,头像和昵称都还能看到。排查发现,身份服务确实删干净了自己库里的用户行,但另外三个地方还留着这个人的痕迹:
- 内容服务在文章表里存了一份
author_name和author_avatar,用于列表页直接渲染,注销时没有清理。 - 私信服务在会话表里存了对方的昵称快照,历史消息仍然可以点进主页。
- 订单服务保存了下单时的收货地址和手机号,用于售后和发票,注销流程没有覆盖。
更麻烦的是搜索索引。内容服务为了“读得快”,曾经直接写过用户表的一个冗余字段;身份服务删除用户时,并不知道内容服务也在写这张表,清理脚本自然也没有包含搜索索引。案例假设这次清理涉及 3 个库、2 个搜索索引和 1 个缓存集群,人工处理花了两周。
这不是一个“删除逻辑没写全”的普通 bug。根因是数据所有权没有写清楚:谁拥有用户数据、谁只能读、谁在什么条件下可以写,全靠口头约定。口头约定在正常迭代里看不出问题,一到注销、审计、迁移和排障就会集中爆发。
四类数据,四个 owner
把「回声」的数据按语义分成四类,每类指定一个 owner,再列清楚允许的写者和读者,注销流程才有可能被完整设计。
| 数据集 | owner | 允许的写者 | 主要读者 | 注销/删除方式 | 一致性要求 |
|---|---|---|---|---|---|
| 用户与身份 | identity | identity | content、messaging、order | owner 删除主体,发事件通知下游 | 强一致 |
| 内容与 Feed | content | content、editor | feed、search、recommendation | 按作者 ID 批量下线,保留审计副本 | 读多写少,最终一致 |
| 关系(关注、拉黑) | social | social | feed、messaging、recommendation | 随主体删除,双向清理 | 强一致 |
| 订单与履约 | order | order | payment、support、finance | 保留法定年限,匿名化而非物理删除 | 强一致 |
表里的写者与读者是案例假设值,重点在两条规则。
第一条:每个数据集只有一个 owner。owner 不一定自己实现所有写入,但它对“什么算合法写入”负责,包括字段含义、状态机、删除语义和审计要求。其他边界只能通过 owner 公开的接口或事件修改数据。
第二条:owner 默认是唯一写者。出现第二个写者时,必须显式登记,并说明它写哪些字段、在什么条件下写、冲突时谁赢。显式登记不是为了形式,而是为了在注销、迁移和故障排查时能找到一个完整的写者清单。
为什么单写者这么重要?因为三件事都依赖它:
- 注销与合规。只有知道所有写者,才能保证删除或匿名化覆盖全部副本;漏掉一个写者,就会留下“已注销但还能被搜到”的残影。
- 审计与追责。同一份数据被多个服务随意写,出现脏数据时无法判断是哪个服务、哪次发布、哪条规则造成的。
- 模型演进。owner 可以独立修改表结构和字段语义;多写者场景下,每次变更都要协调所有写者,最终没人敢改。
单写者不是单实例
单写者描述的是逻辑角色,不是物理进程。identity 可以是一个多副本集群,也可以由多个内部模块组成,只要对外只有一套写入语义、一个发布节奏、一份审计口径,它就仍然是一个写者。
反过来,一个服务即使只部署了一个实例,只要它和另一个服务各自维护一套写入逻辑,它就是两个写者。判断标准不是进程数量,而是“是否存在两条独立的代码路径在改同一份数据”。
内容服务直接写用户表的那半年
「回声」的内容服务曾经遇到一个很典型的性能问题:Feed 列表需要展示作者昵称和头像,每次渲染都调用身份服务,假设首页 QPS 2000、每次返回 20 条内容(案例假设值),身份服务要承受每秒 4 万次批量查询。为了省掉这次调用,内容服务做了一个“临时优化”:在内容库里冗余作者信息,并在作者修改资料时直接写用户表。
这个优化在上线后的半年里看起来非常成功:Feed 渲染不再依赖身份服务,P99 降了,身份服务的压力也小了。但它同时制造了三个隐患。
第一个隐患是写入冲突。身份服务和内容服务都能改用户表,作者改昵称时,两个服务可能用不同的时间戳和字段覆盖彼此。曾经出现过一次昵称被回滚到三天前的值,原因是内容服务的异步任务用旧快照覆盖了新值。
第二个隐患是注销遗漏。身份服务的注销流程只清理自己拥有的表;内容服务既然“也能写用户表”,身份服务就默认它会自己处理。结果两边都没有清理搜索索引和缓存,注销用户的昵称继续出现在推荐结果里。
第三个隐患是迁移困难。半年后身份服务要拆分用户表,发现内容服务直接依赖了其中五个字段,其中两个字段还是内容服务自己写进去的。拆分方案被迫增加一个兼容层,发布时间从两周拖到一个季度。
修复时,团队做了四件事:
- 内容服务停止直接写用户表,改为订阅“用户资料变更”事件,只更新自己库里的只读投影。
- 身份服务成为用户数据的唯一写者,注销时按写者清单逐个通知下游清理。
- 内容服务的投影表增加
source_version,旧事件可以被识别并丢弃,避免异步覆盖新值。 - 把“谁写哪些字段”写进所有权表,并在发布检查里校验。
这个案例的教训不是“不要冗余”,而是“冗余可以,但不能因此获得写权限”。读优化和写所有权是两件事,混在一起就会同时失去性能和可治理性。
把所有权写成可校验的表
所有权如果只写在 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 会制造大量跨表事务;应按生命周期和删除语义聚合。
- 忽略物理权限。结构校验通过不代表服务真的只能读,数据库账号和网络策略必须同步收紧。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。