一张工单,五类数据
《群岛纪元》进入第二个运营年时,欧洲客服接到一张工单:一名在亚洲创建角色的玩家,希望查询并删除自己的数据。客服工具按“账号”拉取关联行,导出的压缩包里同时出现账号绑定、支付流水、登录日志、联盟聊天和一场战斗回放。
数据团队拒绝了这张工单,理由不是“不能删”,而是“不能这样删”。账号绑定要留墓碑防止 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 日志留存、审计与取证 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。