《游戏服务端架构·规模化、全球化与成本》10.2 隐私、数据出境与删除权

《群岛纪元》的一张客服工单同时牵出身份、支付、日志和战斗回放:它们对驻留、最小化和删除的要求并不相同。本文按数据类别而不是按表定义规则,比较区域内闭环、白名单传输与去标识化汇聚,拆解删除请求在主数据、缓存、日志、备份和第三方之间的级联与完成证明,并附 internal/privacydeletion 的 Go 实现与六组表驱动测试。

一张工单,五类数据

《群岛纪元》进入第二个运营年时,欧洲客服接到一张工单:一名在亚洲创建角色的玩家,希望查询并删除自己的数据。客服工具按“账号”拉取关联行,导出的压缩包里同时出现账号绑定、支付流水、登录日志、联盟聊天和一场战斗回放。

数据团队拒绝了这张工单,理由不是“不能删”,而是“不能这样删”。账号绑定要留墓碑防止 ID 复用,支付流水按财务口径有保留要求,登录日志可以匿名化,聊天和回放应当删除,备份里的副本不能原地改写。五类数据混在一次导出、一次删除里,任何动作都会同时违反另一类数据的规则。

同一周还有另外三个请求:支付团队要跨区对账,反作弊团队要集中分析跨区回放,运营团队只想要不含身份信息的分地区活跃曲线。它们都叫“数据出境”,但适用的机制完全不同。

这一节讨论的是工程落点,不构成法律意见。哪些字段可以收集、哪些区域可以传输、每类数据保留多久,由法务、财务和安全团队给出参数;服务端负责让这些参数可执行、可审计、可证明。

数据分类与最小化

分类依据不是表名,也不是存储引擎,而是三个问题:这条数据回答什么、泄露后影响谁、谁有权读取。北辰把玩家相关数据分成七类,每类有独立的驻留、最小化和删除规则。

类别典型内容是否必需默认处理
account_identity账号 ID、绑定关系、设备凭据登录必需最小化收集,删除留墓碑
payment订单、渠道流水、退款状态交易必需按财务参数保留
behavior_log登录、点击、异常行为安全分析需要可匿名化
battle_replay输入序列、对局快照反作弊与回放需要到期删除
user_content聊天、昵称、UGC产品功能需要删除或匿名化
backup快照、归档灾备需要不原地改写,按周期淘汰
third_party分析、推送、客服供应商视合同而定通知删除并留凭证

最小化不是“少存字段”这么简单,它要落到三个具体动作上。

第一,默认不收集。设备指纹、通讯录、精确定位这类字段,如果没有明确的产品用途,就不进入采集清单;采集清单本身要版本化,新增字段需要评审。

第二,能派生就不存原值。年龄只需要年龄档,不需要出生日期;地区只需要策略区域,不需要精确地址;支付对账只需要渠道订单号,不需要完整卡信息。

第三,敏感字段不进入日志和宽表。身份证明文、完整设备号、聊天原文一旦被复制进日志,删除请求的级联范围就会从一张主表扩散到整个数据湖。

分类的结果要能被程序读取。北辰的做法是给每个存储目标打一个 DataClass 标签,删除计划、驻留规则和导出审批都读同一个标签;标签缺失的存储不允许上线,避免“新加一个 Redis 就没人知道它该不该删”。

出境的三种机制

跨境不是“允许”与“禁止”的二选一。工程上常用三种机制,它们的代价不同,适用类别也不同。区域选型与延迟预算已在 3.2 数据驻留与跨境合规 讨论过,这里只补充与删除、导出的衔接。

机制做法适用类别主要代价
区域内闭环数据不出区域,跨区只传结果身份、支付跨区客服与对账需要额外工具
白名单传输仅对批准的目的地和接收方传输特定客服、争议处理审批、审计、到期撤销
去标识化汇聚去掉直接标识后汇总到中心行为日志、聚合统计脱敏不彻底会留下准标识

三种机制可以组合,但不能互相替代。只读副本不是“没有出境”,它仍然是存储;脱敏后的汇聚也不是“随便传”,聚合粒度、准标识组合和接收方范围都要写进规则。

与删除权的衔接点在两处。第一,白名单传输必须带到期时间,授权到期后接收方要删除或返还,并给出回执;第二,去标识化汇聚的数据如果仍能重新识别到个人,删除请求到达时也要能被定位和清理。做不到定位的汇聚数据,不应包含可回溯的账号键。

删除权:级联范围与完成证明

删除请求最容易犯的错误,是把它当成一条 DELETE FROM accounts WHERE id = ?。真实的范围至少包括五层。

层级典型目标动作难点
主数据账号、角色、背包、好友关系删除或墓碑外键与引用完整性
缓存会话、资料、排行榜快照删除删除后可能被回源重建
日志登录、行为、客服记录匿名化或删除已进入冷存储与索引
备份快照、归档、导出文件标记失效、按周期淘汰不可原地改写
第三方分析、推送、客服工单通知删除并留回执无统一接口,回执不一致

级联有顺序。缓存必须在主数据之后失效,否则下一次读会把旧值回填;第三方通知要在主数据删除后发出,否则供应商可能按旧账号键重新拉取;备份不能假装已删除,只能记录“该快照包含待淘汰数据”,并在淘汰周期到达时优先清理。

完成证明不是一句“已删除”。北辰给每次请求生成一个 Proof:请求号、账号键、完成时间、每个目标的动作与证据(影响行数、对象版本、供应商回执号),再对整份证明做摘要。摘要让支持团队可以验证证明没有被补写或裁剪,也让审计可以只保存摘要而不保存明文证据。

删除之后还要做残留检测。按主键删除只能覆盖直接标识;同一个账号键可能被复制进日志字段、客服工单正文或第三方导出文件。残留扫描的结论必须带范围:扫了哪些存储、抽样多少、命中哪些字段。没有范围的“未发现残留”没有意义。

把级联做成计划:privacydeletion

下面的实现把存储目标做成目录,先校验请求与依赖,再做确定性的拓扑排序;执行阶段按计划顺序调用目标,任一失败就停止,不返回半份证明。

// file: internal/privacydeletion/deletion.go
// tier: L1
// verify: go test ./internal/privacydeletion/
package privacydeletion

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

// DataClass groups stores by the obligation that governs them, not by the
// database engine. A single account deletion touches identity, payment,
// logs, backups and third parties, and each class can end in a different
// action.
type DataClass string

const (
	ClassIdentity   DataClass = "account_identity"
	ClassPayment    DataClass = "payment"
	ClassBehavior   DataClass = "behavior_log"
	ClassReplay     DataClass = "battle_replay"
	ClassContent    DataClass = "user_content"
	ClassBackup     DataClass = "backup"
	ClassThirdParty DataClass = "third_party"
)

// Action is what the plan does to one target. RetainHold is produced by the
// planner, never by the catalog: a legal hold overrides a delete or anonymize
// instruction and must remain visible in the completion proof.
type Action string

const (
	ActionDelete     Action = "delete"
	ActionAnonymize  Action = "anonymize"
	ActionTombstone  Action = "tombstone"
	ActionRetainHold Action = "retain_legal_hold"
)

func actionValid(a Action) bool {
	switch a {
	case ActionDelete, ActionAnonymize, ActionTombstone:
		return true
	default:
		return false
	}
}

// Target describes one store that must be touched. DependsOn orders the
// cascade: a cache invalidation that runs before the primary row is deleted
// will simply be repopulated by the next read.
type Target struct {
	Name         string
	Class        DataClass
	Region       string
	Action       Action
	RetainOnHold bool
	DependsOn    []string
}

// Request is a verified deletion request. Verified is explicit because an
// unverified request is an account-takeover vector, not a data subject right.
type Request struct {
	RequestID   string
	AccountID   string
	RequestedAt time.Time
	Verified    bool
	LegalHold   bool
}

// Step is one target with the action the planner selected after applying the
// hold policy. Execute consumes Steps, not Targets, so a hold cannot be
// silently dropped by a later code path.
type Step struct {
	Target Target
	Action Action
}

type Plan struct {
	RequestID string
	AccountID string
	CreatedAt time.Time
	Steps     []Step
}

// Receipt is evidence that one target completed. Evidence is target-specific
// (row count, object version, webhook id) and is covered by the proof digest.

var (
	ErrInvalidRequest    = errors.New("privacydeletion: request id and account id are required")
	ErrNotVerified       = errors.New("privacydeletion: request identity is not verified")
	ErrEmptyTarget       = errors.New("privacydeletion: empty target name")
	ErrDuplicateTarget   = errors.New("privacydeletion: duplicate target name")
	ErrBadAction         = errors.New("privacydeletion: unsupported catalog action")
	ErrUnknownDependency = errors.New("privacydeletion: unknown dependency")
	ErrCycle             = errors.New("privacydeletion: dependency cycle")
	ErrApply             = errors.New("privacydeletion: apply failed")
)

// BuildPlan validates the request and catalog, then orders targets with a
// deterministic topological sort. Ready targets are sorted by name so two
// runs over the same input produce the same proof digest.
func BuildPlan(catalog []Target, req Request) (Plan, error) {
	if req.RequestID == "" || req.AccountID == "" {
		return Plan{}, ErrInvalidRequest
	}
	if !req.Verified {
		return Plan{}, ErrNotVerified
	}
	byName := make(map[string]Target, len(catalog))
	for _, t := range catalog {
		if t.Name == "" {
			return Plan{}, ErrEmptyTarget
		}
		if _, ok := byName[t.Name]; ok {
			return Plan{}, fmt.Errorf("%w: %s", ErrDuplicateTarget, t.Name)
		}
		if !actionValid(t.Action) {
			return Plan{}, fmt.Errorf("%w: %s", ErrBadAction, t.Action)
		}
		byName[t.Name] = t
	}
	order, err := orderTargets(byName)
	if err != nil {
		return Plan{}, err
	}
	plan := Plan{
		RequestID: req.RequestID,
		AccountID: req.AccountID,
		CreatedAt: req.RequestedAt,
		Steps:     make([]Step, 0, len(order)),
	}
	for _, name := range order {
		t := byName[name]
		action := t.Action
		if req.LegalHold && t.RetainOnHold {
			action = ActionRetainHold
		}
		plan.Steps = append(plan.Steps, Step{Target: t, Action: action})
	}
	return plan, nil
}

func orderTargets(targets map[string]Target) ([]string, error) {
	indegree := make(map[string]int, len(targets))
	dependents := make(map[string][]string, len(targets))
	for name := range targets {
		indegree[name] = 0
	}
	for name, t := range targets {
		for _, dep := range t.DependsOn {
			if _, ok := targets[dep]; !ok {
				return nil, fmt.Errorf("%w: %s -> %s", ErrUnknownDependency, name, dep)
			}
			indegree[name]++
			dependents[dep] = append(dependents[dep], name)
		}
	}
	ready := make([]string, 0, len(targets))
	for name, n := range indegree {
		if n == 0 {
			ready = append(ready, name)
		}
	}
	sort.Strings(ready)
	order := make([]string, 0, len(targets))
	for len(ready) > 0 {
		name := ready[0]
		ready = ready[1:]
		order = append(order, name)
		for _, next := range dependents[name] {
			indegree[next]--
			if indegree[next] == 0 {
				ready = append(ready, next)
				sort.Strings(ready)
			}
		}
	}
	if len(order) != len(targets) {
		return nil, ErrCycle
	}
	return order, nil
}

// Execute runs steps in plan order and stops at the first failure. A failure
// returns no proof: a proof with missing receipts would let an incomplete
// cascade look finished. The caller persists progress separately and retries
// the remaining steps with the same request id.

这段代码刻意把“计划”和“执行”分开。

第一,BuildPlan 只依赖目录和请求,不触碰存储,因此可以在工单受理时先算出影响范围,供客服确认。依赖关系让缓存失效排到主数据删除之后,避免回源重建。

第二,拓扑排序对就绪节点按名称排序,同样的输入总是得到同样的顺序和同样的证明摘要。审计比对两次执行时,不会因为 map 遍历顺序不同而误报。

第三,法律保留由 RetainOnHold 标记,规划阶段把动作改成 retain_legal_hold,而不是在执行阶段临时跳过。跳过不会留下记录,改写动作会进入证明。

第四,Execute 在第一个失败处停止并返回错误,不生成证明。未完成的级联不能看起来像已完成;调用方用同一个请求号持久化进度,重试剩余目标。

同包的 proof.go 负责摘要与验证,residual.go 负责残留扫描和北辰的默认目标目录。三部分分开后,规划逻辑可以脱离存储单测,证明格式也可以独立演进。

失败模式与兜底

失败直接后果兜底
缓存先于主数据删除旧资料被回源重建依赖排序 + 删除后二次失效
第三方无回执证明缺少一层回执号必填,超时进入人工工单
备份被当作已删除实际仍有副本标记失效 + 淘汰周期 + 残留扫描
残留扫描只扫主库日志字段漏网按类别列出扫描范围与抽样比例
证明只存明文审计文件本身成为泄露源只存摘要,证据分权限保存
同一请求重复执行重复通知、重复计数请求号幂等 + 目标级幂等键

兜底的目标不是“让删除看起来成功”,而是“让未完成的部分可见”。一张显示“第三方待回执”的工单,比一张显示“全部完成”但实际留有副本的工单更安全。

常见错误

第一,按表打驻留标签。宽表里混着身份和统计,最后只能整表禁止,业务绕道截图或手工导出。

第二,把只读副本当成“没有出境”。副本仍是存储,仍受类别规则约束。

第三,删除时所有类别一起物理删除。支付对账需要保留,身份需要墓碑,日志和回放才适合删除。

第四,缓存删除排在主数据之前。下一次读会把旧值写回,删除等于没做。

第五,证明只有一句“已完成”。没有目标、动作、证据和摘要,事后无法复核。

第六,残留检测没有范围。扫了一个库就说“无残留”,把未知当成干净。

小结

隐私治理的落点不是一张合规清单,而是三个可执行的东西:每个存储目标有类别标签,每次跨境命中一种明确机制,每次删除生成可验证的级联计划与证明。身份、支付、日志、回放、内容和备份的规则不同,不能用一张表的标签概括;只读副本解决延迟,不改变归属;删除的难点不在主键,而在缓存、日志、备份和第三方。

数据能删、能证明删干净之后,下一个问题是:那些不能立刻删的日志要留多久、放在哪一层、怎样防止被改写,又怎样在需要取证时按人和订单导出来。这会把讨论从“删除”推进到“留存与取证”。

阅读导航:上一节:10.1 版号、实名、防沉迷的架构落点 · 下一节:10.3 日志留存、审计与取证 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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