《互联网产品的服务端架构·产品、流量与基础能力》4.3 注销、导出与数据留存

从「回声」用户注销后仍能在备份里找回账号的事故说起,说明 pending、deleted、purged 三态注销机如何约束导出、删除、备份清除和审计留痕的时序,给出拒绝提前清除、校验备份窗口的 Go 实现与状态测试,并讨论日志、备份和审计记录里残留个人信息该怎么收尾。

注销按钮背后的十四天

「回声」的注销入口藏在设置页最底部。用户点击后弹出一个确认框,文案写着“注销后账号数据将永久删除”。确认之后,前端调用 DELETE /me,服务端把 users.deleted_at 填上当前时间,接口返回 204。从用户视角,事情结束了。

十四天后,同一个手机号重新注册,登录后看到了旧账号的昵称和一部分历史订单。用户把截图发到社区,配文是“说好的永久删除呢”。团队排查后发现,删除逻辑只做了一件事:把用户表标记为已删除。订单表、消息表、搜索索引、推荐特征、日志、备份都没有动。

更麻烦的是恢复流程。一次数据库误操作后,团队从备份恢复了一份三天前的快照,把已经注销的账号也带了回来。清理任务只扫描 deleted_at IS NOT NULL 的用户表,恢复后的订单和消息变成了没有 owner 的孤儿数据,部分还被重新关联到新注册的账号上。

这起事故的根因不是某个删除语句写漏了,而是“注销”被实现成了一个布尔值。真实世界的注销至少包含四个动作:导出、删除、清除备份、留下审计记录。它们有先后顺序,也有各自的失败模式。把四个动作压成一个 deleted_at,就等于把顺序和边界全部丢掉。

注销是状态机,四个动作有先后

「回声」后来把注销拆成三个状态。状态回答“现在处于哪一步”,动作回答“这一步允许做什么”。两者分开,清理任务才不会跳过导出,也不会在备份还没到期时提前清除。

状态含义用户可见行为允许的下一步
pending已受理,等待冷静期或人工确认账号冻结,可撤销注销deleted
deleted业务数据已删除或匿名化,备份仍保留无法登录,不能重新注册同标识purged
purged备份窗口结束,副本已清除仅保留最小审计记录无

状态之外,四个动作的时序也要写死:

动作发生时机输入失败后的处理
导出pending 阶段,删除之前用户可导出的字段清单导出失败不进入 deleted,允许重试
删除pending → deleted用户 ID、删除范围部分失败保持 pending,记录待重试项
清除备份deleted 且备份到期BackupUntil、备份批次未到期拒绝执行,到期后重试
审计留痕每个状态转换请求 ID、操作者、时间、结果审计写入失败阻断状态转换

顺序的核心是两条:导出必须在删除之前完成,否则用户拿不到自己的数据;备份清除必须在备份到期之后,否则恢复流程会把已删数据带回来。两条都不是“最佳实践”,而是状态机的不变式。

还有一条容易被忽略:审计记录本身也要最小化。它可以记录“谁在什么时候把哪个请求推进到哪个状态”,但不应该复制手机号、邮箱、证件号。审计日志保留时间通常比业务数据长,如果它包含完整个人信息,注销就永远无法真正完成。

冷静期不是拖延

pending 状态可以承载冷静期,但冷静期的目的是让用户反悔,不是给团队争取清理时间。冷静期内账号应该冻结:不能发内容、不能下单、不能被推荐,但用户可以撤销注销。冷静期结束后,系统自动进入 deleted,而不是等客服手动点按钮。

如果业务需要人工审核(例如涉及未结清订单),pending 可以延长,但必须记录延长原因和新的截止时间。没有截止时间的 pending 会变成永久挂起,用户既不能用账号,也不能重新注册。

deleted 不等于数据消失

deleted 表示业务系统已经不再使用这份数据,但备份、日志、消息队列里可能仍有副本。这个状态的存在,是为了让“业务删除”和“副本清除”解耦:前者可以很快完成,后者要等备份周期结束。

因此 deleted 状态下,用户重新注册同一手机号时,系统应该允许还是拒绝?「回声」选择拒绝,直到 purged。原因是旧数据可能仍在备份中,允许重新注册会让新旧账号的标识冲突,也会让恢复流程难以判断归属。这个选择不唯一,但必须显式写进产品规则。

失败场景:用户表删了,日志和备份还在

回到那次“十四天后复活”。真正的链路比一个删除语句复杂得多:

  • 用户表被标记删除,但订单表通过外键仍然保留用户 ID。
  • 搜索索引没有删除文档,用户还能搜到旧昵称。
  • 推荐特征表按设备 ID 保留行为序列,设备 ID 没有变。
  • 应用日志里保留了注册和登录时的手机号,日志保留期是 180 天。
  • 备份策略是每日全量、保留 30 天,恢复时不会区分已注销用户。
  • 审计表只记录了“删除成功”,没有记录删除范围、操作者和请求 ID。

六个副本里,只有第一个被清理了。用户重新注册后,推荐系统按设备 ID 把旧行为接上,搜索索引把旧昵称带出来,备份恢复则让旧账号整体复活。

后果不只是用户投诉。团队无法回答“到底删了哪些数据”,因为删除动作没有范围清单;也无法证明“备份里的数据不会再被使用”,因为恢复流程没有注销过滤。面对监管问询,这两点都是硬伤。

修复时,团队做了三件事。第一,为每个存储建立数据清单,标明字段、用途、保留期和删除方式。第二,把删除动作改成按清单执行,部分失败不回滚,而是记录待重试项。第三,恢复流程增加过滤:恢复后先重放注销状态,再对外提供服务。

第三件事最容易被忽略。备份恢复通常被当成灾难恢复演练的一部分,演练只验证“数据能不能回来”,不验证“已注销的数据会不会一起回来”。如果恢复后直接切流量,注销承诺就被一次演练推翻了。

用 Advance 和 Exportable 固定时序

状态机的规则如果只写在运维手册里,下一次赶工就会被绕过。把它写成纯函数,输入当前状态和当前时间,输出下一个状态或错误,才能让清理任务、接口和测试共用同一套判断。

下面这段代码来自 webapp-lab。Advance 只允许 pending → deleted → purged,deleted 到 purged 之间必须等备份到期;Exportable 给出用户可导出的字段清单,内部风控和运营备注不进入导出。

// file: internal/profile/deletion.go
// tier: L1
// verify: go test ./internal/profile/

package profile

import (
	"fmt"
	"sort"
	"time"
)

// State 是注销流程的状态。注销不是布尔值,不能一次调用删干净。
type State string

const (
	StatePending State = "pending"
	StateDeleted State = "deleted"
	StatePurged  State = "purged"
)

// Deletion 记录一次注销请求。BackupUntil 是备份的最晚保留时间,
// 在此之前不能清除备份副本。
type Deletion struct {
	State       State
	RequestedAt time.Time
	BackupUntil time.Time
}

// Advance 把注销推进到下一个状态。pending 只能到 deleted;
// deleted 在备份到期后才能到 purged;purged 和未知状态都报错。
func Advance(d Deletion, now time.Time) (Deletion, error) {
	switch d.State {
	case StatePending:
		if d.RequestedAt.IsZero() {
			return d, fmt.Errorf("profile: deletion has no request time")
		}
		d.State = StateDeleted
		return d, nil
	case StateDeleted:
		if d.RequestedAt.IsZero() {
			return d, fmt.Errorf("profile: deletion has no request time")
		}
		if now.Before(d.BackupUntil) {
			return d, fmt.Errorf("profile: backup retained until %s", d.BackupUntil.Format(time.RFC3339))
		}
		d.State = StatePurged
		return d, nil
	case StatePurged:
		return d, fmt.Errorf("profile: deletion already purged")
	default:
		return d, fmt.Errorf("profile: unknown deletion state %q", d.State)
	}
}

// Exportable 返回可交付给用户的数据字段名,按名称排序。
// 公开和敏感字段属于用户本人;内部风控、运营备注不进入导出。
func Exportable(fields []Field) []string {
	names := make([]string, 0, len(fields))
	for _, f := range fields {
		switch f.Class {
		case ClassPublic, ClassSensitive:
			names = append(names, f.Name)
		}
	}
	sort.Strings(names)
	return names
}

这段代码有几个刻意的限制。

  • Advance 是纯函数,不直接写数据库。调用方拿到新状态后再决定如何持久化,测试可以在不启动数据库的情况下覆盖所有转换。
  • pending 和 deleted 都检查 RequestedAt。没有请求时间的注销无法计算冷静期和备份窗口,应该尽早失败,而不是用零值时间默默继续。
  • deleted 到 purged 用 now.Before(BackupUntil) 判断,等于备份到期时刻才允许清除。边界取“到期即允许”,因为备份保留承诺的是“不早于这个时间删除”,不是“必须晚于”。
  • purged 和未知状态都返回错误。重复执行清理任务时,第二次会得到明确的“已经清除”,而不是静默成功;未知状态则说明数据被写坏或版本不兼容,应该报警。
  • Exportable 只返回 public 和 sensitive,internal 字段不进入用户导出。导出是给用户的数据副本,不是内部风控报表。

代码没有覆盖“删除范围”本身。它只推进状态,不知道订单、消息、搜索索引和日志分别该怎么删。删除范围应该来自上一节的数据清单,并按存储分别执行、分别重试。

测试:状态转换和备份窗口都要有边界

状态机最容易出错的地方不是正常路径,而是边界:备份到期前一秒、到期时刻、重复执行、未知状态、缺失请求时间。测试要把这些情况都固定下来。

// file: internal/profile/deletion_test.go
// tier: L1
// verify: go test ./internal/profile/

package profile

import (
	"reflect"
	"strings"
	"testing"
	"time"
)

func at(day int) time.Time {
	return time.Date(2026, 11, day, 9, 0, 0, 0, time.UTC)
}

func TestAdvanceFollowsStateMachine(t *testing.T) {
	d := Deletion{State: StatePending, RequestedAt: at(1), BackupUntil: at(30)}

	deleted, err := Advance(d, at(2))
	if err != nil {
		t.Fatal(err)
	}
	if deleted.State != StateDeleted {
		t.Fatalf("state = %s, want deleted", deleted.State)
	}
	if !deleted.RequestedAt.Equal(d.RequestedAt) || !deleted.BackupUntil.Equal(d.BackupUntil) {
		t.Fatalf("timestamps changed: %+v", deleted)
	}

	purged, err := Advance(deleted, at(30))
	if err != nil {
		t.Fatal(err)
	}
	if purged.State != StatePurged {
		t.Fatalf("state = %s, want purged", purged.State)
	}
}

func TestAdvanceWaitsForBackupWindow(t *testing.T) {
	d := Deletion{State: StateDeleted, RequestedAt: at(1), BackupUntil: at(30)}

	got, err := Advance(d, at(29))
	if err == nil {
		t.Fatal("want error before backup expiry")
	}
	if got.State != StateDeleted {
		t.Fatalf("state = %s, want unchanged deleted", got.State)
	}
	if !strings.Contains(err.Error(), "backup retained") {
		t.Fatalf("error = %v, want backup message", err)
	}
}

func TestAdvanceRejectsInvalidTransitions(t *testing.T) {
	if _, err := Advance(Deletion{State: StatePurged}, at(1)); err == nil {
		t.Fatal("want error for purged state")
	}
	if _, err := Advance(Deletion{State: State("archived"), RequestedAt: at(1)}, at(2)); err == nil {
		t.Fatal("want error for unknown state")
	}
	if _, err := Advance(Deletion{State: StatePending}, at(2)); err == nil {
		t.Fatal("want error for missing request time")
	}
	if _, err := Advance(Deletion{State: StateDeleted, BackupUntil: at(30)}, at(31)); err == nil {
		t.Fatal("want error for missing request time")
	}
}

func TestExportableSkipsInternalFields(t *testing.T) {
	fields := []Field{
		{Name: "phone", Class: ClassSensitive},
		{Name: "nickname", Class: ClassPublic},
		{Name: "risk_score", Class: ClassInternal},
		{Name: "avatar", Class: ClassPublic},
	}
	got := Exportable(fields)
	want := []string{"avatar", "nickname", "phone"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("exportable = %v, want %v", got, want)
	}

	empty := Exportable(nil)
	if empty == nil || len(empty) != 0 {
		t.Fatalf("nil input = %#v, want non-nil empty slice", empty)
	}
}

TestAdvanceFollowsStateMachine 走完 pending → deleted → purged,并断言两个时间戳在转换中不变。时间戳一旦被覆盖,冷静期和备份窗口都会失去依据。

TestAdvanceWaitsForBackupWindow 用第 29 天访问第 30 天到期的备份,期望报错且状态保持 deleted。另一个隐含边界在第 30 天:now.Before(BackupUntil) 为 false,所以到期当天就允许清除。测试的零值用例则覆盖“没有请求时间”和“没有备份时间”这两种数据损坏。

TestExportableSkipsInternalFields 断言风险分不进入导出。这个用例防的是“导出时复用完整对象序列化”的老问题:内部字段一旦进入用户下载包,就等于主动泄露。

测试没有覆盖真实备份系统的批次、跨区域复制和恢复演练。这些需要集成测试和定期演练,单元测试只能保证状态机本身不倒退。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/profile/ -run 'TestAdvance|TestExportable' -count=1 -v

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

=== RUN   TestAdvanceFollowsStateMachine
--- PASS: TestAdvanceFollowsStateMachine (0.00s)
=== RUN   TestAdvanceWaitsForBackupWindow
--- PASS: TestAdvanceWaitsForBackupWindow (0.00s)
=== RUN   TestAdvanceRejectsInvalidTransitions
--- PASS: TestAdvanceRejectsInvalidTransitions (0.00s)
=== RUN   TestExportableSkipsInternalFields
--- PASS: TestExportableSkipsInternalFields (0.00s)
PASS
ok  	webapp/internal/profile	0.271s

输出只覆盖状态机和导出清单,不包含真实存储的删除结果。上线前还需要在预发环境跑一次完整链路:导出、删除、等待备份窗口、清除、恢复演练,并确认恢复后已注销账号不会重新出现。

小结

注销不是布尔值,而是一个有顺序的状态机。pending 让用户反悔,deleted 表示业务数据已经删除但副本仍在,purged 表示备份窗口结束、副本已清除。导出必须在删除之前,备份清除必须在到期之后,审计留痕必须贯穿每一次状态转换且不复制个人信息。

只看用户表有没有 deleted_at,无法回答“数据到底删干净没有”。真正的答案是数据清单:每个存储、每个字段、每个副本分别怎么删、谁来重试、失败如何记录。状态机负责顺序,清单负责范围,两者缺一不可。

下一章会从资料与隐私转向多端产品:同一个服务端,面对 App、Web 和小程序时,接口、缓存和发布节奏会有什么不同。

阅读导航:上一节:4.2 个人信息分级与最小化 · 下一节:5.1 App、Web 与小程序的差异 。

常见错误与边界

  • 用一个布尔字段表示注销。deleted_at 只能表示“业务表标记过”,无法表达导出、备份清除和审计的状态。
  • 先删数据再让用户导出。导出必须在 pending 阶段完成,删除后用户拿不到自己的数据,客服也无法补救。
  • 备份未到期就清除。恢复流程会把已删数据带回来,注销承诺被一次恢复演练推翻。
  • 恢复后不重放注销状态。备份恢复只验证“数据能回来”,没验证“已注销的数据不会回来”。
  • 审计日志复制完整个人信息。审计保留期通常更长,复制手机号和证件号会让注销永远无法真正完成。
  • 清理任务重复执行时静默成功。purged 状态应该返回明确错误或幂等结果,未知状态应该报警。
  • 只删用户表。订单、消息、搜索索引、推荐特征、日志、第三方 SDK 都可能有副本,需要按清单逐项处理。
  • pending 没有截止时间。没有截止时间的冷静期会变成永久挂起,用户既不能用账号也不能重新注册。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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