《游戏服务端架构·规模化、全球化与成本》3.2 数据驻留与跨境合规

《群岛纪元》的全球客服与支付对账,把同一张玩家表里的身份、支付、日志和回放推到了不同区域的合规边界。本文按数据类别而不是按表定义驻留规则,比较区域内闭环、脱敏汇聚与授权传输三种机制,说明只读副本、跨区注销与墓碑保留的边界,并附 internal/dataregion 的 Go 实现与六组表驱动测试。

一张客服导出请求,四种数据

《群岛纪元》进入第二个运营年时,欧洲客服团队接到一张工单:一名在亚洲创建角色的玩家,希望查询自己过去半年的充值记录和联盟聊天。客服工具默认把所有相关行导出成一个压缩包,里面有账号标识、支付流水、登录日志、战斗回放和一段聊天文本。数据工程团队看了一眼就拒绝了:这些行来自同一张宽表,但它们的驻留要求完全不同。

同一周,支付团队要做跨区对账,需要把两个区域的订单汇总到总部;反作弊团队希望把跨区战斗回放集中分析;而运营团队只想要一份不含身份信息的分地区活跃曲线。四个请求都叫“导出数据”,却没有一个可以套用同一套规则。

问题不在于团队不守规矩,而在于系统把不同类别的数据放在了一起。只要表结构还假设“一行玩家数据整体属于一个区域”,任何跨区请求都只能靠人工判断。要把它变成可执行的架构,第一步是把数据按类别拆开,再为每个类别写一条驻留规则。

数据分类:按类别而不是按表

北辰把玩家相关数据分成五类。分类的依据不是存储引擎,也不是表名,而是“这条数据回答什么问题、泄露后影响谁、谁有权读取”。

类别典型内容写入归属跨区默认删除时
account_identity 账号身份账号 ID、绑定关系、设备凭据账号主区域默认拒绝,删除元数据除外保留墓碑
payment 支付订单、渠道流水、退款状态支付主区域默认拒绝按财务口径保留
behavior_log 行为日志登录、点击、异常行为玩家所在区域可脱敏汇聚删除或匿名化
battle_replay 战斗回放输入序列、对局快照对局所在区域需授权或脱敏删除
aggregate_stats 聚合统计分地区 DAU、留存、时长各区域可聚合导出保留

这张表有三条设计意图。

第一,驻留要求按类别生效,不按表生效。一张表可以同时存身份列和聚合列,但导出时必须按列分类;如果做不到,就把表拆开。按表打标签的系统,最后一定会出现“整表禁止导出”,然后业务用截图绕过。

第二,写归属只有一个区域。账号身份归账号主区域,支付归支付主区域,回放归对局区域;跨区请求只能读副本或走受控传输,不能直接写对方的主库。这条规则和 3.1 的延迟预算共用同一个原则:需要串行化的状态只有一个写入点。

第三,删除动作不等于所有类别同时物理删除。身份留墓碑防止 ID 复用,支付按财务口径保留,日志和回放可以删除或匿名化。把“删除”理解成“全区域全表 DELETE”,既做不到,也会破坏对账和反作弊。

三种跨境机制与各自代价

跨境不是只有“允许”和“禁止”两个选项。工程上常用三种机制,它们的代价和适用类别不同。

机制做法适用类别主要代价
区域内闭环数据不出区域,跨区只传结果身份、支付跨区客服与对账需要额外工具
脱敏汇聚去掉直接标识后汇总到中心行为日志、聚合统计脱敏不彻底会留下准标识
授权传输在明确目的和授权下传输原始数据回放、特定客服场景审计、授权撤销与到期删除

区域内闭环最容易被低估。它意味着欧洲客服不能直接打开亚洲玩家的工单详情,只能调用亚洲区域提供的受控接口,接口返回什么由亚洲区域决定。好处是边界清晰,坏处是每个跨区场景都要做一套接口,不能靠数据库账号互通。

脱敏汇聚的关键不是“删掉姓名”,而是处理准标识。设备型号、登录时间、角色 ID 的组合,即使不含姓名,也可能重新识别到个人。因此脱敏必须和聚合粒度一起设计:能出日报就不出明细,能出分桶就不出精确值。

授权传输适合回放和特定客服场景,但必须带目的、范围和到期时间。没有到期时间的授权,实际上等于永久开放;没有审计的传输,事后无法回答“谁在什么时候把什么数据发到了哪里”。

只读副本能做什么,不能做什么

只读副本是全球化系统里最常用的折中:数据留在主区域,其他区域保留一份副本,读请求就近处理。它解决了延迟,但不改变驻留归属。

边界有四条。

第一,副本只能读。任何写入都必须回到主区域,包括“只改一个字段”的修补。允许副本写会让两个区域同时拥有写权限,最终只能靠冲突合并,而冲突合并无法向合规主体解释哪一份是权威。

第二,副本的新鲜度要写进契约。账号封禁、支付状态、角色删除这类数据,副本延迟不能太长;排行榜、活跃统计可以接受更长窗口。新鲜度窗口应当由数据类别决定,而不是由缓存组件的默认 TTL 决定。

第三,副本不能成为跨境传输的旁路。把主区域数据复制到另一个区域,本质上仍是一次跨境存储;如果类别默认拒绝跨境,副本也不应存在,除非规则明确把它列为只读副本区域。

第四,删除要传播到副本。副本上的旧行如果继续可读,删除请求就没有真正完成。工程上通常用墓碑加版本号:主区域先写墓碑,副本按版本应用删除,查询层过滤已删除行。

与账号体系的关系:全球账号加区域角色

北辰的账号体系是“全球账号 + 区域角色”。账号回答“谁拥有这些数据”,角色回答“玩家在这个世界里是谁”。一个账号可以在多个区域拥有角色,但账号身份本身只有一个主区域。

这个模型让注销变成一次扇出,而不是一次单库删除。流程可以写成四步:

  1. 账号主区域写入删除墓碑,立即禁止新登录和新写入。
  2. 枚举该账号在所有区域的角色,按角色所在区域生成删除任务。
  3. 各区域删除行为日志和回放,保留支付与聚合数据的受控副本。
  4. 所有区域回报完成,账号进入已删除状态;墓碑保留,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 全球同服的状态归属 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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