一张客服导出请求,四种数据
《群岛纪元》进入第二个运营年时,欧洲客服团队接到一张工单:一名在亚洲创建角色的玩家,希望查询自己过去半年的充值记录和联盟聊天。客服工具默认把所有相关行导出成一个压缩包,里面有账号标识、支付流水、登录日志、战斗回放和一段聊天文本。数据工程团队看了一眼就拒绝了:这些行来自同一张宽表,但它们的驻留要求完全不同。
同一周,支付团队要做跨区对账,需要把两个区域的订单汇总到总部;反作弊团队希望把跨区战斗回放集中分析;而运营团队只想要一份不含身份信息的分地区活跃曲线。四个请求都叫“导出数据”,却没有一个可以套用同一套规则。
问题不在于团队不守规矩,而在于系统把不同类别的数据放在了一起。只要表结构还假设“一行玩家数据整体属于一个区域”,任何跨区请求都只能靠人工判断。要把它变成可执行的架构,第一步是把数据按类别拆开,再为每个类别写一条驻留规则。
数据分类:按类别而不是按表
北辰把玩家相关数据分成五类。分类的依据不是存储引擎,也不是表名,而是“这条数据回答什么问题、泄露后影响谁、谁有权读取”。
| 类别 | 典型内容 | 写入归属 | 跨区默认 | 删除时 |
|---|---|---|---|---|
| account_identity 账号身份 | 账号 ID、绑定关系、设备凭据 | 账号主区域 | 默认拒绝,删除元数据除外 | 保留墓碑 |
| payment 支付 | 订单、渠道流水、退款状态 | 支付主区域 | 默认拒绝 | 按财务口径保留 |
| behavior_log 行为日志 | 登录、点击、异常行为 | 玩家所在区域 | 可脱敏汇聚 | 删除或匿名化 |
| battle_replay 战斗回放 | 输入序列、对局快照 | 对局所在区域 | 需授权或脱敏 | 删除 |
| aggregate_stats 聚合统计 | 分地区 DAU、留存、时长 | 各区域 | 可聚合导出 | 保留 |
这张表有三条设计意图。
第一,驻留要求按类别生效,不按表生效。一张表可以同时存身份列和聚合列,但导出时必须按列分类;如果做不到,就把表拆开。按表打标签的系统,最后一定会出现“整表禁止导出”,然后业务用截图绕过。
第二,写归属只有一个区域。账号身份归账号主区域,支付归支付主区域,回放归对局区域;跨区请求只能读副本或走受控传输,不能直接写对方的主库。这条规则和 3.1 的延迟预算共用同一个原则:需要串行化的状态只有一个写入点。
第三,删除动作不等于所有类别同时物理删除。身份留墓碑防止 ID 复用,支付按财务口径保留,日志和回放可以删除或匿名化。把“删除”理解成“全区域全表 DELETE”,既做不到,也会破坏对账和反作弊。
三种跨境机制与各自代价
跨境不是只有“允许”和“禁止”两个选项。工程上常用三种机制,它们的代价和适用类别不同。
| 机制 | 做法 | 适用类别 | 主要代价 |
|---|---|---|---|
| 区域内闭环 | 数据不出区域,跨区只传结果 | 身份、支付 | 跨区客服与对账需要额外工具 |
| 脱敏汇聚 | 去掉直接标识后汇总到中心 | 行为日志、聚合统计 | 脱敏不彻底会留下准标识 |
| 授权传输 | 在明确目的和授权下传输原始数据 | 回放、特定客服场景 | 审计、授权撤销与到期删除 |
区域内闭环最容易被低估。它意味着欧洲客服不能直接打开亚洲玩家的工单详情,只能调用亚洲区域提供的受控接口,接口返回什么由亚洲区域决定。好处是边界清晰,坏处是每个跨区场景都要做一套接口,不能靠数据库账号互通。
脱敏汇聚的关键不是“删掉姓名”,而是处理准标识。设备型号、登录时间、角色 ID 的组合,即使不含姓名,也可能重新识别到个人。因此脱敏必须和聚合粒度一起设计:能出日报就不出明细,能出分桶就不出精确值。
授权传输适合回放和特定客服场景,但必须带目的、范围和到期时间。没有到期时间的授权,实际上等于永久开放;没有审计的传输,事后无法回答“谁在什么时候把什么数据发到了哪里”。
只读副本能做什么,不能做什么
只读副本是全球化系统里最常用的折中:数据留在主区域,其他区域保留一份副本,读请求就近处理。它解决了延迟,但不改变驻留归属。
边界有四条。
第一,副本只能读。任何写入都必须回到主区域,包括“只改一个字段”的修补。允许副本写会让两个区域同时拥有写权限,最终只能靠冲突合并,而冲突合并无法向合规主体解释哪一份是权威。
第二,副本的新鲜度要写进契约。账号封禁、支付状态、角色删除这类数据,副本延迟不能太长;排行榜、活跃统计可以接受更长窗口。新鲜度窗口应当由数据类别决定,而不是由缓存组件的默认 TTL 决定。
第三,副本不能成为跨境传输的旁路。把主区域数据复制到另一个区域,本质上仍是一次跨境存储;如果类别默认拒绝跨境,副本也不应存在,除非规则明确把它列为只读副本区域。
第四,删除要传播到副本。副本上的旧行如果继续可读,删除请求就没有真正完成。工程上通常用墓碑加版本号:主区域先写墓碑,副本按版本应用删除,查询层过滤已删除行。
与账号体系的关系:全球账号加区域角色
北辰的账号体系是“全球账号 + 区域角色”。账号回答“谁拥有这些数据”,角色回答“玩家在这个世界里是谁”。一个账号可以在多个区域拥有角色,但账号身份本身只有一个主区域。
这个模型让注销变成一次扇出,而不是一次单库删除。流程可以写成四步:
- 账号主区域写入删除墓碑,立即禁止新登录和新写入。
- 枚举该账号在所有区域的角色,按角色所在区域生成删除任务。
- 各区域删除行为日志和回放,保留支付与聚合数据的受控副本。
- 所有区域回报完成,账号进入已删除状态;墓碑保留,ID 不复用。
这里最容易出错的是“部分完成”。某个区域的任务失败,账号不能回到正常状态,也不能假装已经删除;正确做法是保持删除中,重试并告警。另一个错误是把支付数据一起物理删除,导致退款和对账无法完成;保留不等于可读,保留行应当去掉直接标识,只留下财务需要的字段。
选定方案与边界
北辰最终把规则收敛为五条。
第一,规则按类别声明,默认拒绝。每个类别必须显式写出主区域、只读副本和跨境模式;没有声明的类别不能被导出,也不能被复制。
第二,同区域访问永远允许,跨区域访问必须命中一种机制。代码里的 Decide 先判断是否同区,再判断副本,再按类别模式裁决;删除元数据是唯一可以穿过“默认拒绝”的载荷。
第三,副本写入一律拒绝。即使某个类别的跨境模式允许授权传输,副本区域也不能直接接收写请求;写只能回主区域。
第四,删除请求单独建模。PlanDeletion 不执行删除,只枚举区域、要删除的类别、要保留的类别和墓碑位置;执行器按计划逐区域调用,失败可重试。
第五,审计以决策为单位。每次放行都记录类别、来源区域、目标区域、机制和脱敏字段;只记录“允许”而不记录“为什么允许”,事后无法复现判断。
代码:internal/dataregion
下面的 policy.go 定义类别、区域、操作与传输模式,并把裁决写成纯函数。deletion.go 负责生成跨区域注销计划,测试覆盖了同区、副本、授权、聚合与删除扇出。
// file: internal/dataregion/policy.go
// tier: L1
// verify: go test ./internal/dataregion/
package dataregion
import (
"errors"
"fmt"
"sort"
)
// Category is a class of data with one residency contract. Rules are written
// per category, not per table: a single table often mixes identifiers with
// analytics rows, and a per-table rule would force one of them to be wrong.
type Category string
const (
CategoryAccountIdentity Category = "account_identity"
CategoryPayment Category = "payment"
CategoryBehaviorLog Category = "behavior_log"
CategoryBattleReplay Category = "battle_replay"
CategoryAggregate Category = "aggregate_stats"
)
// Region is where data is stored and processed, not where the player lives.
type Region string
// Operation separates a read replica from a writable home. A copy that is
// allowed to serve reads is not thereby allowed to accept writes.
type Operation string
const (
OpRead Operation = "READ"
OpWrite Operation = "WRITE"
)
// Purpose is why the data crosses a boundary. Support access and analytics
// export carry different obligations even when the bytes look identical.
type Purpose string
const (
PurposeService Purpose = "SERVICE"
PurposeAnalytics Purpose = "ANALYTICS"
PurposeSupport Purpose = "SUPPORT"
PurposeDeletion Purpose = "DELETION"
)
// TransferMode is the strongest mechanism the rule permits. Deny is the
// default; every other mode must be proven by the request.
type TransferMode string
const (
TransferDeny TransferMode = "DENY"
TransferPseudonymize TransferMode = "PSEUDONYMIZE"
TransferConsent TransferMode = "CONSENT"
TransferAggregate TransferMode = "AGGREGATE_ONLY"
)
var (
ErrBadRule = errors.New("dataregion: invalid rule")
ErrUnknown = errors.New("dataregion: unknown category")
ErrTransferDenied = errors.New("dataregion: transfer denied")
)
// Rule is the residency contract for one category. Home is the only region
// that may accept writes; Replicas may serve reads. CrossRegion applies to
// both reads outside the replica list and writes outside Home.
type Rule struct {
Category Category
Home Region
Replicas []Region
CrossRegion TransferMode
}
// Validate rejects ambiguous rules. A category may appear only once, Home may
// not also be a replica, and every replica must be a non-empty region.
func Validate(rules []Rule) error {
seen := map[Category]bool{}
for _, r := range rules {
if r.Category == "" {
return fmt.Errorf("%w: empty category", ErrBadRule)
}
if seen[r.Category] {
return fmt.Errorf("%w: duplicate category %q", ErrBadRule, r.Category)
}
seen[r.Category] = true
if r.Home == "" {
return fmt.Errorf("%w: %q has no home", ErrBadRule, r.Category)
}
if !validMode(r.CrossRegion) {
return fmt.Errorf("%w: %q has unknown mode %q", ErrBadRule, r.Category, r.CrossRegion)
}
replicas := map[Region]bool{}
for _, region := range r.Replicas {
if region == "" {
return fmt.Errorf("%w: %q has empty replica", ErrBadRule, r.Category)
}
if region == r.Home {
return fmt.Errorf("%w: %q lists home as replica", ErrBadRule, r.Category)
}
if replicas[region] {
return fmt.Errorf("%w: %q repeats replica %q", ErrBadRule, r.Category, region)
}
replicas[region] = true
}
}
return nil
}
func validMode(m TransferMode) bool {
switch m {
case TransferDeny, TransferPseudonymize, TransferConsent, TransferAggregate:
return true
default:
return false
}
}
// Request is one read or write that may cross a region boundary. The caller
// must state whether identifiers were removed and whether consent exists;
// the decision function never infers either from the purpose.
type Request struct {
Category Category
From Region
To Region
Operation Operation
Purpose Purpose
HasConsent bool
Pseudonymized bool
}
// Decision records not only allow/deny but the mechanism that made it legal.
// Redactions lists the fields that must be stripped before the bytes leave.
type Decision struct {
Allowed bool
Mode TransferMode
ReadOnly bool
Redactions []string
Reason string
}
// Decide applies one category rule to one request. Same-region traffic is
// always allowed because no boundary is crossed. A replica read is allowed
// with no payload mutation, but a replica write is denied even when the
// category's CrossRegion mode would otherwise permit transfer.
func Decide(rules []Rule, req Request) (Decision, error) {
if err := Validate(rules); err != nil {
return Decision{}, err
}
var rule *Rule
for i := range rules {
if rules[i].Category == req.Category {
rule = &rules[i]
break
}
}
if rule == nil {
return Decision{}, fmt.Errorf("%w: %q", ErrUnknown, req.Category)
}
if req.From == "" || req.To == "" {
return Decision{}, fmt.Errorf("%w: from and to are required", ErrBadRule)
}
if req.Operation != OpRead && req.Operation != OpWrite {
return Decision{}, fmt.Errorf("%w: unknown operation %q", ErrBadRule, req.Operation)
}
if req.From == req.To {
return Decision{Allowed: true, Mode: TransferDeny, Reason: "same region"}, nil
}
if isReplica(*rule, req.To) {
if req.Operation == OpRead {
return Decision{Allowed: true, Mode: TransferDeny, ReadOnly: true, Reason: "read replica"}, nil
}
return Decision{Allowed: false, Mode: TransferDeny, Reason: "replica does not accept writes"}, nil
}
if req.Purpose == PurposeDeletion {
return Decision{
Allowed: true,
Mode: TransferPseudonymize,
Redactions: []string{"display_name", "device_id", "free_text"},
Reason: "deletion tombstone carries no payload",
}, nil
}
switch rule.CrossRegion {
case TransferPseudonymize:
if !req.Pseudonymized {
return Decision{Allowed: false, Mode: TransferPseudonymize, Reason: "identifiers were not removed"}, nil
}
return Decision{Allowed: true, Mode: TransferPseudonymize, Redactions: []string{"device_id", "ip_address"}}, nil
case TransferConsent:
if !req.HasConsent {
return Decision{Allowed: false, Mode: TransferConsent, Reason: "consent missing"}, nil
}
return Decision{Allowed: true, Mode: TransferConsent, Redactions: []string{"device_id"}}, nil
case TransferAggregate:
if req.Purpose != PurposeAnalytics || !req.Pseudonymized {
return Decision{Allowed: false, Mode: TransferAggregate, Reason: "aggregate export needs analytics purpose and pseudonymized rows"}, nil
}
return Decision{Allowed: true, Mode: TransferAggregate, Redactions: []string{"account_id", "role_id"}}, nil
default:
return Decision{Allowed: false, Mode: TransferDeny, Reason: "category is region-locked"}, nil
}
}
func isReplica(r Rule, region Region) bool {
for _, candidate := range r.Replicas {
if candidate == region {
return true
}
}
return false
}
// SortedRegions returns the distinct regions named by rules in lexical order.
// Deterministic order keeps generated audit reports easy to diff.
func SortedRegions(rules []Rule) []Region {
seen := map[Region]bool{}
var out []Region
for _, r := range rules {
for _, region := range append([]Region{r.Home}, r.Replicas...) {
if !seen[region] {
seen[region] = true
out = append(out, region)
}
}
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
Decide 有一个刻意的设计:它返回 Mode 和 Redactions,而不是只返回布尔值。放行时必须说明“靠什么机制放行、删掉了哪些字段”,否则审计日志里只剩一个 allowed=true,无法区分“同区访问”和“授权传输”。PlanDeletion 则坚持把每个角色区域都列出来,即使某个区域当前没有可删的数据;漏掉一个区域,删除请求就永远无法收敛。
失败模式与兜底
| 失败 | 现象 | 兜底 |
|---|---|---|
| 规则缺失 | 新类别默认被导出 | 默认拒绝;启动时校验每个类别都有规则 |
| 脱敏不彻底 | 聚合数据可反推到个人 | 最小粒度约束;发布前做准标识检查 |
| 副本越权写 | 两个区域同时改同一行 | 主区域拒绝非归属写;副本只读 |
| 删除扇出不完整 | 账号长期停在删除中 | 任务持久化、重试、按区域回报收敛 |
| 授权过期未撤销 | 旧授权继续可读 | 授权带到期时间;访问时实时校验 |
| 审计缺失 | 无法回答数据流向 | 每次决策落审计;按请求 ID 关联 |
前两项是规则设计问题,后四项是执行问题。它们的共同点是:不能靠“大家自觉”。规则要在代码里默认拒绝,执行要有持久化任务和可查询的审计,否则一次跨区导出就可能变成无法回溯的事件。
常见错误与边界
第一,按表打驻留标签。宽表里混着身份和统计,最后只能整表禁止,业务绕道截图或手工导出。
第二,把只读副本当成“没有跨境”。副本仍是存储,仍受类别规则约束。
第三,删除时全类别物理删除。支付和对账数据需要保留,身份需要墓碑,日志和回放才适合删除。
第四,授权没有到期时间。永久授权等于永久开口,事后无法证明数据已经被清理。
第五,把脱敏理解成删姓名。准标识组合仍然可以重新识别,脱敏必须和聚合粒度一起设计。
第六,删除任务只发一次。跨区域任务会失败,必须有持久化、重试和完成回报。
小结
数据驻留的落点不是一张合规清单,而是“每个类别只有一个写归属、跨区访问必须命中一种机制、删除必须扇出并可收敛”。身份、支付、日志、回放和聚合数据的规则不同,不能用一张表的标签概括;只读副本解决延迟,不改变归属;全球账号加区域角色让注销变成可枚举的任务,而不是一次不可控的全库操作。
数据归属确定之后,下一个问题是:全球同服的世界状态到底放在哪里,玩家跨区看到的等级、联盟和排行榜可以旧多久。这会把讨论从“数据能不能过去”推进到“过去之后谁有权写”。
阅读导航:上一节:3.1 全球延迟预算与路由 · 下一节:3.3 全球同服的状态归属 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。