《互联网产品的服务端架构·交易、增长与平台化》11.2 数据驻留与合规

EU 用户的姓名与收货地址,被一份周报 ETL 同步到 cn 区做汇总,审计时才发现违反数据驻留要求。文章用一个跨境电商的合规事故,说明驻留是法律约束而非性能优化、个人数据与日志的等级差异、跨境开关为什么要单独设计,并给出 Allowed 与 Check 的 Go 实现、等级矩阵测试和真实运行输出。

一份周报把 EU 用户的地址同步到了 cn 区

「刚好」商城在欧洲上线半年,订单、支付、用户资料分别落在 eu 和 cn 两个区域。数据团队为了做一张全球 GMV 周报,在 cn 区建了一个汇总库,用夜间 ETL 把各区的订单和用户表拉过来。任务跑了三个月,没人觉得有问题,直到一次外部审计要求说明“EU 用户的个人数据存放在哪里”。

审计员拿到数据流图后指出:EU 用户的姓名、邮箱、收货地址在每天凌晨被复制到 cn 区,保留七天。这个行为没有经过任何合规评估,也没有对用户披露。更麻烦的是,汇总库的访问权限比生产库宽松,数据分析师和外包报表团队都能查到明细。订单金额本身可以跨境汇总,个人身份信息不行,但 ETL 任务只按表同步,不区分列的含义。

事故的时间线也值得记录。任务是三月上线的,四月新增了 user_address 表,五月法务更新了 EU 用户的数据处理登记,六月审计入场。四个月里,没有任何一个环节把“新增表”和“跨境策略”联系起来。工程团队认为数据团队知道边界,数据团队认为应用层已经做过过滤,法务以为技术控制已经生效。三方都以为别人在管,这就是最典型的合规缺口。

审计还问了第二个问题:这些数据被哪些下游读取过?汇总库没有列级血缘,报表、导出、模型训练都从同一张宽表取数,无法判断个人数据到底扩散到了哪里。团队只能按最坏情况处理:通知所有能访问汇总库的团队,暂停相关报表,逐表确认字段。

团队的第一反应是“把汇总库加上访问控制”。审计员否掉了:访问控制解决“谁能看”,解决不了“数据放在哪”。数据驻留约束的是存储位置和跨境传输本身,不是权限配置。第二个方案是“只同步脱敏后的字段”,这方向对了,但需要先回答一个更基础的问题:哪些字段属于个人数据,哪些属于支付数据,哪些只是日志?不同等级适用的规则并不一样。

驻留是法律约束,不是性能优化

很多团队把数据驻留当成“多区域部署”的一部分,和就近接入、容灾放在一起讨论。这会带来一个危险的默认值:既然 cn 区离用户近、延迟低,那就把数据都放过去。驻留的第一性原理不是延迟,而是法律和合同:某类数据在某个区域收集,就必须在允许的区域存储和处理,跨境需要单独依据。

数据等级cn 区eu 区us 区判断依据
personal(姓名、邮箱、地址)允许允许不允许本案例假设 cn/eu 已完成评估,us 未覆盖
payment(支付凭证、账单)允许允许不允许支付数据按更严格的合同约束处理
log(脱敏访问日志、指标)允许允许允许不含个人标识,可用于全球聚合
未知等级拒绝拒绝拒绝不默认放行,避免新字段绕过策略

数据分类不是一次性工作。新字段上线、供应商接入、并购带来的系统迁移,都会改变数据的流向和等级。一个可持续的做法是:每个字段在 schema 里标注等级和允许区域,CI 在合并前检查新增字段是否有标注,数据目录定期和实际存储做比对。没有标注的字段按最高等级处理,直到有人补全。

这张表是本章的“案例假设值”,不是法律意见。真实项目里每一行都要有法务确认、数据分类清单和跨境传输记录。代码能做的,是把已经确认的策略固化成可执行检查,让新加的表和字段不能悄悄绕过。

还有一条常被忽略:驻留约束跟着数据走,不跟着请求走。用户从 eu 出差到 cn,用 cn 的网络登录,请求落在 cn 区,但他的个人数据仍然受 eu 的规则约束。按请求来源判断“该用哪套规则”会把位置和管辖混淆,正确做法是先确定数据主体属于哪个区域,再决定数据可以放在哪里。

关键判断有三条。第一,驻留按数据等级而不是按服务划分:同一个订单服务里,订单号可以全球汇总,收货地址不行。第二,跨境要有独立开关,不能因为“同一张表”就默认一起走。第三,未知等级必须拒绝,因为默认放行会让新增字段在没人注意时突破边界。

日志常被当成“没有个人数据”,这不成立。访问日志里的 IP、User-Agent、请求参数都可能指向个人;只有经过脱敏、聚合,去掉了可识别性,才能按低等级处理。把原始日志直接同步到另一个区域,和同步用户表没有本质区别。

跨境同步在工程上有四种常见做法,合规强度依次提高:

做法数据是否出境可审计性典型问题
各区域独立汇总,只交换聚合指标否高需要提前定义指标口径
跨境只同步脱敏后的派生表部分中脱敏规则可能被绕过
跨境同步明细,但走审批开关是中开关配置错误会静默越境
默认全量同步,事后审计是低发现问题时数据已经复制多份

本章的策略表对应第二、三种之间:个人与支付数据不跨境,日志可以全球流动。真实选择取决于业务需要的明细程度,但无论选哪种,都要能回答“哪些字段、什么时候、依据什么审批、流到了哪里”。

失败场景:ETL 绕过应用层检查

开头的事故里,应用层的订单服务其实有区域判断:创建订单时按用户区域写入对应的库。问题出在 ETL。数据团队用的是数据库账号直连,绕过了服务层,也绕过了任何 Allowed 检查。同步任务只关心“表存不存在、列能不能映射”,不关心列的业务含义。

第二个问题是跨境开关缺失。汇总任务配置了一个 sync_all=true,本意是“同步所有表”,实际效果是“无视区域边界”。当 eu 区新增一张 user_address 表时,任务自动把它纳入同步范围,没有任何人审批。三个月里,这张表的每一行都被复制到了 cn 区。

修复之后,团队补了一次数据主体权利演练:如果一位 EU 用户要求删除数据,能不能在 cn 区准确定位并删除?答案是之前不能,因为缺少来源区域和主体标识的关联。加上 source_region 和主体哈希之后,删除请求可以按区域路由到对应的存储,再汇总执行结果。这个演练比任何文档都更能暴露设计缺口。

第三个问题是审计日志不完整。团队能查到“谁访问了汇总库”,但查不到“哪些数据从哪个区流到了哪个区”。出事后只能靠 ETL 代码和数据库 binlog 反推,花了整整两天才拼出完整链路。合规事件里,“证明没有发生”往往比“证明发生了什么”更难。

出事后团队还发现一个细节:汇总库里没有保留来源区域字段。同一张 user_address 表里,eu 和 cn 的记录混在一起,无法按区域删除或召回。修复时加了一列 source_region,并在写入时强制填写。这个字段看起来只是元数据,却决定了事故发生后能不能把越境数据准确地清理掉。

修复分三层。应用层用 Check 在写入前拦截不合规的区域与等级组合;数据层给跨境同步单独建开关,默认关闭,开启需要记录审批单号;审计层记录每次跨区的数据集、字段清单、行数和操作人。三层里任何一层单独存在都不够:只靠应用层,ETL 能绕过;只靠开关,配置错了没人发现;只靠审计,损失已经发生。

写代码之前先明确职责边界。这个包只回答“某区域是否允许某等级”,不负责识别字段等级、不负责执行删除、也不负责生成合规报告。把职责收窄的好处是测试稳定:策略表是输入,布尔值是输出,没有隐藏的全局状态。

落地上有四个检查点,缺一不可:

  • 应用写入前调用 Check,把区域和字段等级一起传入。
  • 数据同步任务在批次级别调用 Check,拒绝包含高风险等级的跨境批次。
  • 存储层按区域和等级分区,避免不同来源的数据混在同一张表里。
  • 审计层记录每次跨区的数据集、字段、行数、审批单号和操作人。

前两个检查点拦截新增流量,后两个保证事后可追溯。只做前两个,历史数据仍然说不清;只做后两个,新的越境行为会持续发生。

用 Allowed 与 Check 把策略写成代码

Allowed 是纯查询,回答“这个区域能不能放这个等级”;Check 按顺序检查一组等级,返回第一个违规项。把两者分开,是因为调用方有时只需要判断,有时需要在写入前拿到具体是哪一类数据越界。

// file: internal/i18n/residency.go
// tier: L1
// verify: go test ./internal/i18n/

package i18n

import (
	"errors"
	"fmt"
)

// Region 是数据实际落盘的地理区域。
type Region string

const (
	RegionCN Region = "cn"
	RegionEU Region = "eu"
	RegionUS Region = "us"
)

// Class 是数据等级。不同等级适用的跨境规则不同。
type Class string

const (
	ClassPersonal Class = "personal" // 个人身份、联系方式、地址。
	ClassPayment  Class = "payment"  // 支付凭证、账单、交易流水。
	ClassLog      Class = "log"      // 脱敏后的访问日志与指标。
)

// ErrNotAllowed 表示某个数据等级不允许落在指定区域。
var ErrNotAllowed = errors.New("i18n: data class not allowed in region")

// allowed 是驻留策略的唯一事实来源。personal 与 payment 只能落在 cn/eu,
// 因为这两类区域在本案例假设中已有对应合规评估;log 允许全部区域。
var allowed = map[Region]map[Class]bool{
	RegionCN: {
		ClassPersonal: true,
		ClassPayment:  true,
		ClassLog:      true,
	},
	RegionEU: {
		ClassPersonal: true,
		ClassPayment:  true,
		ClassLog:      true,
	},
	RegionUS: {
		ClassLog: true,
	},
}

// Allowed 判断某个区域是否允许存放某等级的数据。
// 未知区域或未知等级一律返回 false,不做默认放行。
func Allowed(region Region, class Class) bool {
	classes, ok := allowed[region]
	if !ok {
		return false
	}
	return classes[class]
}

// Check 按输入顺序检查一组数据等级,返回第一个违规项。
// 返回 nil 表示这组数据都可以落在该区域。
func Check(region Region, classes []Class) error {
	for _, class := range classes {
		if !Allowed(region, class) {
			return fmt.Errorf("%w: region=%s class=%s", ErrNotAllowed, region, class)
		}
	}
	return nil
}

实现里有几个值得说明的点:

  • Region 和 Class 都是自定义字符串类型,调用方不能把任意字符串混进来。未知值在 Allowed 里查不到,直接返回 false。
  • 策略表 allowed 是唯一事实来源。新增区域或调整等级时只改这一处,不在多个 if 里散落规则。
  • Allowed 对未知区域和未知等级都返回 false。这里没有“默认允许”的分支,新增字段必须先登记等级。
  • Check 按输入顺序返回第一个违规项,而不是收集全部错误。调用方通常只需要知道“先修哪一个”,完整清单可以由上层遍历生成。
  • Check 返回的错误用 %w 包装 ErrNotAllowed,调用方可以用 errors.Is 判断类别,同时从错误信息里拿到区域和等级。
  • 空切片返回 nil。没有数据要检查时不应该报错,这一点在批量任务里很常见。

策略的变更流程同样重要。allowed 表应该由法务、安全和数据团队共同 review,代码仓库里保留变更记录和对应审批单号。工程团队可以改实现,但不能在没有审批的情况下改策略。把策略放在代码里不是为了绕过流程,而是为了让流程的结论可以被执行和验证。

这个包不负责“发现数据属于哪个等级”。字段分类是元数据问题,应该由数据目录或 schema 注解提供,代码只消费分类结果。把分类逻辑写进 Allowed 会让策略和业务耦合,最后没人敢改。

测试的边界也要说清楚。它验证的是“代码与当前策略表一致”,不验证“策略表符合某部法律”。后者需要法务判断,前者需要工程保证。两者混在一起,测试就会变成一种虚假的安全感。

表驱动测试的价值在于组合完整。区域有三个、等级有三个,九种组合必须逐一断言,不能只测“允许”的几种。遗漏的组合往往就是策略变更时最先出错的地方。

测试:等级矩阵与首个违规

测试要覆盖三件事:九种区域与等级的组合是否符合策略表、未知值是否被拒绝、Check 是否在第一个违规处停下。

// file: internal/i18n/residency_test.go
// tier: L1
// verify: go test ./internal/i18n/

package i18n

import (
	"errors"
	"strings"
	"testing"
)

func TestAllowed(t *testing.T) {
	cases := []struct {
		region Region
		class  Class
		want   bool
	}{
		{RegionCN, ClassPersonal, true},
		{RegionEU, ClassPersonal, true},
		{RegionUS, ClassPersonal, false},
		{RegionCN, ClassPayment, true},
		{RegionEU, ClassPayment, true},
		{RegionUS, ClassPayment, false},
		{RegionCN, ClassLog, true},
		{RegionEU, ClassLog, true},
		{RegionUS, ClassLog, true},
		{Region("ap"), ClassLog, false},
		{RegionCN, Class("profile"), false},
	}
	for _, c := range cases {
		if got := Allowed(c.region, c.class); got != c.want {
			t.Fatalf("Allowed(%s, %s) = %v, want %v", c.region, c.class, got, c.want)
		}
	}
}

func TestCheckReturnsFirstViolation(t *testing.T) {
	err := Check(RegionUS, []Class{ClassLog, ClassPersonal, ClassPayment})
	if !errors.Is(err, ErrNotAllowed) {
		t.Fatalf("error = %v, want ErrNotAllowed", err)
	}
	if !strings.Contains(err.Error(), "personal") {
		t.Fatalf("error = %q, want first violating class personal", err.Error())
	}
	if strings.Contains(err.Error(), "payment") {
		t.Fatalf("error = %q, must stop at the first violation", err.Error())
	}
}

func TestCheckAllowsValidSets(t *testing.T) {
	if err := Check(RegionEU, []Class{ClassPersonal, ClassPayment, ClassLog}); err != nil {
		t.Fatalf("Check(EU) = %v, want nil", err)
	}
	if err := Check(RegionCN, []Class{ClassPayment}); err != nil {
		t.Fatalf("Check(CN) = %v, want nil", err)
	}
	if err := Check(RegionUS, []Class{ClassLog}); err != nil {
		t.Fatalf("Check(US log) = %v, want nil", err)
	}
	if err := Check(RegionCN, nil); err != nil {
		t.Fatalf("Check(empty) = %v, want nil", err)
	}
}

func TestCheckRejectsUnknownRegion(t *testing.T) {
	err := Check(Region("ap"), []Class{ClassLog})
	if !errors.Is(err, ErrNotAllowed) {
		t.Fatalf("error = %v, want ErrNotAllowed", err)
	}
	if !strings.Contains(err.Error(), "region=ap") {
		t.Fatalf("error = %q, want region=ap", err.Error())
	}
}

TestAllowed 用表驱动覆盖 cn/eu/us 与 personal/payment/log 的全部组合,再补两个未知值。TestCheckReturnsFirstViolation 传入 [log, personal, payment],在 us 区应当停在 personal,错误信息里不能出现 payment。TestCheckAllowsValidSets 验证合法组合与空切片都返回 nil。TestCheckRejectsUnknownRegion 确认未知区域不会被当成“没有限制”。

新增区域时,测试应该先失败,提醒作者补全矩阵;补全后通过,才说明新区域真的被纳入了策略。如果新增区域后测试仍然全绿,通常意味着代码里出现了默认放行,需要立刻检查。

如果未来新增 RegionAP,作者必须先把它加入 Region 常量、策略表和测试矩阵。编译器不会提醒漏掉策略表,但测试会。把这种提醒放在 CI 里,比放在上线后的审计里便宜得多。

这些测试是策略的回归保护。真实项目里策略表会随法务评估变化,每次改动都应该有对应的测试用例和变更记录。测试通过不代表合规,只代表代码与当前策略一致;策略本身是否正确,仍然需要人 review。

命令只跑与驻留相关的四个用例,输出里每一项对应矩阵的一组断言。-count=1 关闭测试结果缓存,确保看到的是本次执行。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/i18n/ -run 'TestAllowed|TestCheck' -count=1 -v

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

=== RUN   TestAllowed
--- PASS: TestAllowed (0.00s)
=== RUN   TestCheckReturnsFirstViolation
--- PASS: TestCheckReturnsFirstViolation (0.00s)
=== RUN   TestCheckAllowsValidSets
--- PASS: TestCheckAllowsValidSets (0.00s)
=== RUN   TestCheckRejectsUnknownRegion
--- PASS: TestCheckRejectsUnknownRegion (0.00s)
PASS
ok  	webapp/internal/i18n	0.221s

输出里的耗时是测试进程启动和执行的合计,不代表生产环境的检查开销。Check 本身只做几次 map 查找,成本可以忽略;真正昂贵的是跨区数据同步和审计日志写入。

PASS 表示 4 个驻留策略用例全部通过。它们证明的是这张策略表被正确执行,不证明策略本身满足某个司法辖区的全部要求。

驻留策略的落地顺序是:先分类,再评估,再写代码,最后做审计。顺序颠倒会得到一套“看起来能跑、但说不清依据”的系统。代码只能固化结论,不能替代判断。

小结

数据驻留是法律约束,先有分类和评估,再有代码。个人数据与支付数据只在已评估的区域落盘,日志只有脱敏后才能全球流动,跨境同步必须有独立开关和审计记录。Allowed 与 Check 把策略固化成可执行检查,未知值一律拒绝。下一节讨论区域之间如何切换流量,以及切换时数据延迟会带来什么。

下一节把视角从“数据放在哪”转到“请求发到哪”。区域路由看起来只是 DNS 和健康检查,实际会同时牵动数据可用性、会话一致性和故障切换顺序。

阅读导航:上一节:11.1 语言、时区与货币 · 下一节:11.3 多区域部署与就近接入 。

  • 只给新表加检查,不回溯历史数据。策略上线前已经越境的数据仍然存在,需要单独盘点。

常见错误与边界

  • 把驻留当成性能优化。延迟低不等于可以存,先把法律依据和数据分类确认清楚。
  • 按服务而不是按数据等级划分。同一个订单服务里,订单号可以跨境,收货地址不行。
  • ETL 用数据库账号直连,绕过应用层检查。服务层的 Check 对离线任务无效,必须在数据层再拦一次。
  • 跨境开关默认打开。sync_all=true 这类配置会让新表自动越境,默认值必须是关闭。
  • 把原始日志当低等级数据。IP、User-Agent、请求参数都可能识别到个人,脱敏和聚合之后才能降级。
  • 未知等级默认放行。新增字段会在没人注意时突破边界,正确做法是拒绝并让它先登记。
  • 只做访问控制,不做流向审计。权限解决“谁能看”,解决不了“数据曾经去了哪”。
  • 策略表改了但没有测试和审批记录。合规检查需要能回答“当时为什么这样配置”。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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