一份周报把 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、请求参数都可能识别到个人,脱敏和聚合之后才能降级。
- 未知等级默认放行。新增字段会在没人注意时突破边界,正确做法是拒绝并让它先登记。
- 只做访问控制,不做流向审计。权限解决“谁能看”,解决不了“数据曾经去了哪”。
- 策略表改了但没有测试和审批记录。合规检查需要能回答“当时为什么这样配置”。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。