针对 Anthropic 数据基础设施问题的完整解答:数据摄取、模式演进、幂等处理、湖仓存储、带血缘的敏感度标签、行级与列级访问控制、回填与删除。
作者:前沿部署编辑部已审阅
本文属于 Anthropic 系统设计题库。该题目对面试轮次具有代表性。分析与解答为本站原创。
问题描述
该平台从产品和服务中收集事件,安全落地,清洗并关联数据,然后提供给分析师、ML 团队以及少量在线服务。每个数据集都带有敏感度级别,访问权限随之而定。不需要专门的 ML 知识。
澄清问题
- 数据源有哪些? 来自应用的产品事件、服务日志,以及生产数据库的每夜导出。
- 数据需要多新鲜? 运营仪表盘需要分钟级,大多数分析一天即可。
- 谁在读,怎么读? 分析师使用 SQL,ML 团队批量读取大文件,还有少量服务需要对派生特征进行毫秒级查找。
- 存在哪些敏感度级别? 练习设定:公开、内部、个人、受限,其中受限包含客户内容。
- 适用哪些法规? 删除请求必须在规定时间内(如 30 天)在全球范围内得到执行。
- 数据量多大? 练习设定:每秒 20 万条事件,每条 1 KB。
数据平台难在哪里
收集数据很容易。难在以下三点。
数据的形态会变。生产方会新增字段、重命名字段,还会发送格式错误的记录。平台必须能够接受变化,而不破坏所有下游作业。
数据有时是错的。一个有 bug 的生产方或一次糟糕的转换会写入垃圾数据,而基于它构建的每张表都会继承这些损坏。平台必须能够发现损坏、控制影响范围并重建。
而且数据是敏感的。一个个人字段通过 join 被复制到新表后,即使没人记得它来自哪里,它依然保持敏感。只保护原始表的访问控制会通过派生表泄露。
因此,核心矛盾是开放与管控。团队需要广泛、快速的访问来完成工作,而每个敏感字段必须在每一次复制和转换中都受到保护。
flowchart LR S[Sources]:::user --> I[Ingest]:::svc --> RZ[(Raw zone)]:::store RZ --> CZ[(Clean zone<br/>labeled)]:::store --> CUR[(Curated zone)]:::store CUR --> Q[SQL / files / serving]:::svc P[Policy layer<br/>labels + rules + audit]:::bad -.-> CZ P -.-> CUR P -.-> Q classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717; classDef bad fill:#fbe9e4,stroke:#c4492d,color:#171717;
核心思想。 将敏感度视为随数据流动的数据。在数据摄取时分配的标签必须在每次连接和复制后依然保留,否则访问控制就会泄露。
关键概念
分区
数据流经质量逐步提升、敏感度逐步下降的分区。原始分区保存到达的原始数据,只允许追加且访问受到严格限制。清洗分区保存经过验证、定义了类型并打上标签的表。精选分区保存为特定团队构建的表,其中的敏感字段通常已被掩码。
批处理与流处理
批处理针对有界的数据块运行,例如一天的数据。流处理则在记录到达时持续处理。经典的 Lambda 架构同时运行两条路径并进行合并。Kappa 架构将一切都作为流来运行,并通过重放日志来重新计算。现代引擎可以在两种模式下运行相同的代码,从而消除了让 Lambda 架构痛苦不堪的重复逻辑。
湖仓一体
湖仓一体将表以列式文件的形式存储在对象存储中,并通过表格式提供事务、模式演进和时间旅行等能力。它以对象存储的成本提供类似数据仓库的表,而且多种引擎都可以读取相同的数据。
访问控制模型
基于角色的访问控制(RBAC)将权限授予角色,例如“分析团队可以读取销售领域的表”。基于属性的访问控制(ABAC)根据用户、数据和请求的属性来做决策,例如“EU 地区的用户可以读取 region 为 EU 的行,且只有在获得目的授权时才能读取个人数据列”。ABAC 能够表达 RBAC 无法表达的敏感度规则。
血缘
血缘记录每个输出由哪些输入产生,精确到列。它回答平台不可或缺的两个问题:这个坏输入影响了哪些数据?这个个人字段最终流向了哪里?
核心思想。 分区区分信任级别,一个引擎同时处理批和流,湖仓一体以低成本存储数据,ABAC 表达敏感度规则,而血缘则让故障遏制与合规都成为可能。
需求
在继续阅读之前。 列出各项需求,然后指出你会最先保护的属性,以及塑造整体设计的关键约束。
1.1 功能需求
- 摄取流式事件和批量导出数据。
- 根据模式验证记录,并将失败的数据分流出去。
- 按计划并持续地将数据处理为清洗表和精选表。
- 提供 SQL、批量文件访问和低延迟查询服务。
- 为每一列标注敏感度级别,并据此实施访问控制。
- 在每一份数据副本上执行删除请求。
- 记录血缘,并对每一次敏感数据读取保留审计日志。
1.2 非功能需求
- 新鲜度。 流式表为分钟级,批处理为一天。
- 正确性。 重跑产生完全相同的结果;迟到数据按照明确的规则处理。
- 成本。 存储和计算随使用量扩展,冷数据存放在廉价的存储中。
- 隔离。 一次繁重的回填任务不能让日常作业被饿死。
1.3 约束与属性
保护敏感数据是属性。 个人数据或客户数据的泄露是平台无法挽回的唯一失败。数据量是约束。 每天有 17 TB 的原始输入,关于存储格式、分区和计算的每一个设计选择都有代价。
粗略估算
2.1 摄取
每秒 200,000 个事件 × 1 KB = 200 MB/s。一天下来:200 MB/s × 86,400 秒 ≈ 17 TB 原始数据。
2.2 存储
列式格式能很好地压缩事件数据。假设压缩 5 倍,那么干净区每天约 3.5 TB,在考虑副本之前一年约 1.3 PB。对象存储在内部进行复制,这就是需要预算的数字。
2.3 热数据与冷数据
如果 90 天是热数据,其余是冷数据,热数据约为 90 × 3.5 TB = 315 TB。一年中剩余的部分约 960 TB,存放在每字节成本低得多的更冷的存储层级中。
2.4 查询成本
扫描某个事件表一天数据的查询最多读取 3.5 TB,如果按分区列过滤且只读取少数几列,读取量会更少。先按日期分区,再按常见过滤条件(如产品或地区)分区。忘记日期过滤条件的查询会扫描一整年的数据:在查询引擎中加入防护,拒绝在大表上进行无边界扫描。
核心要点。 每天原始数据 17 TB,存储后 3.5 TB,一年 1.3 PB。分区和列式读取决定了查询的成本。
API 设计
平台的接口与其说是端点,不如说是契约。
3.1 生产者契约
POST /v1/events (batched)
[{event_id, event_type, schema_version, occurred_at, producer, payload}]
-> 202 {accepted, rejected: [{event_id, reason}]}
schema registry
PUT /v1/schemas/:event_type/versions {schema, compatibility: backward}
-> 409 if the new schema breaks backward compatibility3.2 访问请求
POST /v1/access-requests {dataset, columns?, purpose, duration}
-> pending approval by the data owner
GET /v1/datasets/:name -> {owner, schema, column_labels, lineage_upstream, freshness}3.3 删除
POST /v1/deletion-requests {subject_id, reason}
-> {request_id, due_by}
GET /v1/deletion-requests/:id -> {status per dataset}数据模型
平台自身的元数据与数据本身同等重要。
dataset (name, zone, owner_team, format, partition_spec, retention)
column (dataset, name, type, sensitivity: public|internal|personal|restricted,
tags: [email, phone, customer_content, ...])
lineage_edge (output_dataset, output_column, input_dataset, input_column, job_id)
policy (id, subject: role|attribute expr, dataset pattern,
action: read|write, row_filter?, column_rule: allow|mask|hash|deny)
grant (user, dataset, purpose, approved_by, expires_at)
audit_log (ts, user, dataset, columns_read, row_filter_applied, query_id)
job_run (job_id, partition, input_versions, output_version, status, checks)input_versions
在每次作业运行时记录每个分区是由每个输入的哪个快照构建的。借助表的时间旅行功能,任何分区都可以复现。
高层设计
5.1 所有人都读取生产数据库
flowchart LR A[Analysts]:::user --> PDB[(Production DB)]:::bad M[ML team]:::user --> PDB classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef bad fill:#fbe9e4,stroke:#c4492d,color:#171717;
繁重的查询拖慢产品,没有人能看到产品保留范围之外的历史,而且每个分析师都能读取每一列。它在负载、历史和访问控制三方面都不合格。
5.2 修复方案 1:由摄取服务供数的原始区
事件通过摄取服务进入持久化日志,加载器将它们写入对象存储中的只追加文件。数据库导出的数据也存放在那里。生产数据库不再被直接查询。
flowchart LR P[Products]:::user --> IN[Ingest API]:::new --> LOG[[Durable log]]:::new --> RAW[(Raw zone)]:::new DB[(Prod DB exports)]:::store --> RAW classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717; classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;
5.3 修复 2:校验、定型并打标,写入干净区
一个处理作业读取原始区,根据已注册的 schema 校验每条记录,将失败记录连同原因路由到死信区,并写入带类型的表。一个分类器与 schema 自身的标签为每一列分配敏感度标签。
5.4 修复 3:精选表与策略层
面向团队的表由干净表构建。每个查询引擎在执行查询前都要询问同一个策略服务:哪些行、哪些列、脱敏还是明文。每次读取个人或受限数据都会被记录。
flowchart LR U([User]):::user --> QE[Query engine]:::svc QE -->|who, dataset, columns, purpose| PS[Policy service]:::new PS -->|row filter + column rules| QE QE --> T[(Tables)]:::store QE -->|audit record| AL[(Audit log)]:::new classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717; classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;
5.5 修复 4:血缘与标签传播
每个作业都会上报其列级的输入与输出。目录利用这些信息构建血缘,并将每个输出列的标签计算为其输入中的最高标签,除非该转换声明会对字段进行脱敏或聚合。由邮箱地址连接生成的列会一直保持个人级别,直到某个作业显式地对其进行哈希处理。
5.6 组合后的设计
flowchart TB SRC[Sources]:::user --> IN[Ingest + schema registry]:::svc --> LOG[[Log]]:::svc --> RAW[(Raw: append only, restricted)]:::store RAW --> VAL[Validate, type, classify]:::svc --> CLEAN[(Clean: labeled tables)]:::store VAL --> DLQ[(Dead letters)]:::bad CLEAN --> TR[Idempotent transforms]:::svc --> CUR[(Curated: team tables)]:::store CUR --> SQL[SQL engine]:::svc CUR --> FILES[Bulk file access]:::svc CUR --> FS[(Feature store / cache)]:::store CAT[Catalog: labels, lineage, policies]:::svc -.-> VAL CAT -.-> TR CAT -.-> SQL CAT -.-> FILES classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717; classDef bad fill:#fbe9e4,stroke:#c4492d,color:#171717;
核心思想。 安全地落地原始数据,一次性完成校验与打标,以幂等方式进行转换,并让所有读取都经过同一个由带血缘的目录支撑的策略层。
深入探讨
6.1 不破坏系统的 schema 演进
阅读前思考。 一个生产者将 user_id 重命名为 account_id,并在周五部署上线。是什么阻止了所有下游作业失败?
模式注册表(schema registry)。每种事件类型都有带版本的模式,并遵循兼容性规则,通常是向后兼容:新的读取方可以读取旧数据,新字段必须是可选的。重命名是破坏性变更,因此注册表会在生产者的构建步骤中拒绝它。生产者改为新增
account_id
,并在弃用期内保留
user_id
,让消费者逐步迁移。
即使如此,如果某个生产者绕过了注册表,其未通过校验的记录会连同原因和模式版本一起进入死信区。系统向负责团队触发告警。流水线对其他所有人继续正常运行。
区分答案的关键:模式变更
WeakHopes 生产者会正常工作
没有注册表,因此重命名会破坏所有下游作业。
Good带兼容性检查的注册表
在生产者端拒绝破坏性变更,并将坏记录写入死信区。
Strong将模式视为有明确负责人的契约
在 CI 中强制执行兼容性检查,重命名设有弃用期,死信告警路由到负责团队,下游作业固定使用特定模式版本。
6.2 幂等处理与迟到数据
阅读前思考。 昨天的每日作业因为重试运行了两次。现在数字翻倍了吗?
绝不能翻倍。让每个转换按分区写入输出,并采用覆盖语义:重跑 D 日的作业会完全替换 D 日的分区。由于 D 日的输入没有变化,结果完全相同。用追加而不是覆盖,正是数字翻倍的原因。
迟到数据需要规则。事件带有
occurred_at
,而移动客户端可能今天才发送昨天的事件。流式作业使用水位线(watermark):在将某个窗口视为完成之前,先等待一段设定时间,例如一小时。批处理作业每晚重新处理最近几天的分区,以吸收迟到的数据。明确说明修正窗口以及晚于该窗口的事件如何处理:它们要么带标记落入当前分区,要么被丢弃并计数。
在干净区按
event_id
去重,因为生产者会重试,而日志至少投递一次。
6.3 能经受连接考验的访问控制
阅读前思考。 一位没有个人数据访问权限的分析师将订单表与用户表进行连接。用户表有一个 email 列。这位分析师会看到什么?
分析师看到的 email 列是脱敏的,或者针对该列的查询被拒绝,具体取决于策略。策略服务评估查询读取的每一列,而不仅仅是每张表。
更棘手的问题是派生表。如果某个作业写入的精选表包含 email,那么该表必须被标记为个人数据。通过血缘进行标签传播可以解决这个问题:输出列继承输入中最高的标签。对 email 进行哈希处理的作业会声明该转换,其输出列随之变为内部数据。
行级规则纳入同一策略。区域团队会由引擎自动应用行过滤器。
region = 'EU'
用途授权处理例外情况:调查员以声明的用途和期限申请访问受限数据,数据所有者批准后,每次读取都会针对该授权进行记录。
flowchart LR E[users.email<br/>personal]:::bad --> J[join job]:::svc --> O[curated.orders_enriched.email<br/>personal, inherited]:::bad E --> H[hash job<br/>declares masking]:::svc --> O2[curated.orders.user_hash<br/>internal]:::store classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717; classDef bad fill:#fbe9e4,stroke:#c4492d,color:#171717;
区分答案的关键:敏感数据
差表级权限而已
将整张表授权给团队,导致个人字段通过连接和派生表泄露。
好列标签与脱敏
为列打标签,对未获授权的用户脱敏个人字段,并记录读取行为。
强跟随数据的标签
通过血缘传播标签,支持行过滤器和用途授权,审计每一次敏感读取,并说明删除如何触达派生副本。
6.4 坏数据与影响范围
在继续阅读之前。 某个生产方连续六小时以“分”而非“元”发送价格。营收仪表盘翻了三倍。你如何找出受影响的范围并修复?
预防优先。在每个阶段之后运行数据检查:行数在预期范围内、空值率、取值范围,以及与上周相比的分布变化。检查失败会阻断该分区的下游作业,从而将损害限制在一张表内。
当损害已经扩散时,血缘回答范围问题:列出这些小时内坏输入下游的每张表和每个列。修复输入,方法是更正原始记录,或在清洗层中添加修正。然后按依赖顺序重跑受影响的分区。幂等覆盖使重跑安全。通过数据目录通知受影响表的所有者,并附上时间范围。
6.5 回填与隔离
回填会重建数月的分区,可能占用整个集群。应在单独的计算池中以较低优先级运行,并配以独立的配额。日常作业保留自己的池。与日常运行争抢资源的回填会延误全公司的每一块仪表盘。
6.6 删除请求
删除请求指明一个主体。血缘列出持有该主体数据的每个数据集。对每个数据集,删除作业会重写受影响的分区并剔除该主体的行;lakehouse 表格式支持以事务方式执行行级删除。原始区中个人数据的保留期很短,例如 30 天,因此原始副本会在期限内自动过期。用于时间旅行的快照也必须在期限内过期,否则它们会让已删除的数据继续存活。
6.7 服务三类读者
阅读前思考。分析师运行 SQL,机器学习团队读取数 TB 的文件,而定价服务需要在 5 毫秒内获取用户的特征。一个存储层能否同时服务这三者?
一份数据可以,但需要三条访问路径。
分析师通过湖仓表之上的分布式 SQL 引擎进行查询。查询会裁剪分区,只读取所需的列。热门仪表盘运行在每小时刷新的小型预聚合表上,因此无需重新扫描原始事件。在前面加一道查询成本防线:拒绝在没有日期过滤的情况下查询大表,并按角色限制每次查询的扫描字节数。
机器学习团队直接读取文件:通过表格式的读取器读取同样的列式文件,以对象存储的完整吞吐量。他们可以按版本获取快照,因此一次训练运行可以固定它所读取的确切数据。策略层依然适用:导出需经过一个作业,在写入团队可读的文件之前应用列规则。
在线服务无法在 5 毫秒内查询湖仓。一个流式作业计算它们所需的特征,并写入以用户或实体为键的键值特征存储。同一特征定义也在批处理中运行以生成训练数据,因此训练与服务之间的数值保持一致。
flowchart LR LH[(Lakehouse tables)]:::store --> SQL[SQL engine<br/>analysts]:::svc LH --> FILES[Snapshot reads<br/>ML training]:::svc STREAM[Streaming feature job]:::svc --> FS[(Feature store<br/>ms lookups)]:::store LOG[[Event log]]:::svc --> STREAM LOG --> LH POL[Policy layer]:::bad -.-> SQL POL -.-> FILES POL -.-> FS classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717; classDef bad fill:#fbe9e4,stroke:#c4492d,color:#171717;
6.8 成本控制
每年 1.3 PB 的数据量,存储和计算成本需要主动管理。
- 存储分层。热分区放在标准对象存储上,较旧的分区放在低频访问层级,原始数据按计划过期。压缩合并将小文件合并成大文件,既降低存储开销,也缩短查询时间。
- 计算配额。每个团队都有用于查询和作业的计算预算,并有报告显示每次查询和每个定时作业的成本。最昂贵的五个作业通常占账单的很大一部分,而且很容易修复。
- 明智地物化。一个每五分钟扫描一周原始事件的仪表盘,其成本远高于改为读取每小时聚合表。
- 淘汰不再使用的表。目录记录每张表的最后读取时间。90 天未被读取的表会发给其所有者进行删除,这也缩小了删除请求的攻击面。
练习:如果一个热门仪表盘每小时扫描 25 TB(一周的清洗后数据)12 次,那就是每小时 300 TB 的扫描量。而一个几 GB 的每小时聚合表就能服务同一个仪表盘。展示这类算术;这正是数据平台真正省钱的方式。
区分答案优劣的关键:服务与成本
WeakOne 万能仓库
期望仓库提供毫秒级查询,且从不提及成本。
Good 为不同读取方提供独立路径
分析师用 SQL,机器学习用快照文件,在线服务用特征存储。
Strong 路径加成本纪律
增加查询防护、按团队预算、热门看板的聚合表、存储分层以及闲置表的下线,并为每个决策提供数据支撑。
变体
7.1 在线服务的实时特征
某服务需要在毫秒内获取用户最近一小时的购买次数。在流式作业中计算并写入以用户为键的键值特征存储。同一口径在批处理中运行以生成训练数据,从而保证在线与离线数值一致。
7.2 多区域数据驻留
欧盟数据留在欧盟。每个区域独立运行摄取和存储。跨区域查询只能看到策略允许离开该区域的聚合或匿名化输出。
7.3 机器学习的训练数据
机器学习团队需要大规模的精选数据快照。为他们提供带版本号的快照,并在导出时应用策略,同时在每次训练运行中记录快照 ID,这样后续的删除请求就能查明哪些模型使用了该主体的数据。
7.4 数据量放大十倍时
在每秒 200 万事件的情况下,摄取转向多个日志分区和多个加载工作进程,但架构依然成立。有三件事变得更难。目录和血缘图增长到数百万列,因此血缘查询需要自己的索引存储。删除请求涉及的分区大幅增加,因此按数据集每日批量处理。此外每日批处理窗口收紧,因此更多管道转向只处理新分区的增量处理。
可迁移的模式
数据平台是一条信任区管道,元数据随数据流动。数据从不可信区流向精选区,其标签、模式和血缘随之移动。同样的模式适用于任何副本会增多的系统:备份、用户数据缓存、搜索索引和训练集。凡是数据被复制的地方,都要问:哪些元数据必须随之复制。
回顾:30 秒答案
- 原始、干净、精选三层区。 只追加的原始数据、经过验证并打标的干净表、面向团队的精选表。
- 模式注册表。 破坏性变更在生产端被拒绝;坏记录进入死信队列。
- 幂等的分区覆盖。 重跑和回填都是安全的。
- 标签跟随血缘。 单一策略层应用行过滤和列掩码,并审计敏感读取。
- 校验与影响范围。 分级校验拦截坏数据;血缘界定范围,重跑完成修复。
测验
+为什么把转换写成分区覆盖?
因为重跑时分区会被相同结果替换。若采用追加方式,每次重试或回填都会使数据翻倍。
+个人字段在经过 join 之后如何保持受保护?
血缘记录表明输出列来自某个个人输入列,输出会继承最高级别的输入标签。访问控制随后像对源表一样作用于派生表,除非转换显式地对该字段进行脱敏。
+模式注册表能防止什么?
防止破坏性变更波及消费者。它在生产者的构建阶段根据兼容性规则检查每个新的模式版本,因此重命名和类型变更会在发布前被拒绝。
+如何找出受六小时坏输入影响的所有内容?
在血缘图中查询坏输入下游的每个表和列,并限定在受影响的时间分区内。然后修复输入,并按依赖顺序重新运行这些分区。
+为什么时间旅行快照必须过期,删除才能生效?
旧快照仍然包含已删除的行。如果它们的存续时间超过删除期限,那么在平台声称数据已删除之后,该主体的数据仍然可以被恢复。
+为什么湖仓无法提供 5 毫秒的特征查询?
它将列式文件存储在对象存储中,专为扫描大范围数据而设计,而非单键查询。一个流式作业将所需的特征写入为快速点读取而构建的键值存储。
+对于一个会重复扫描原始事件的高成本仪表盘,最廉价的修复方法是什么?
按计划(例如每小时)构建一个小的聚合表,并将仪表盘指向它。这样读取的是千兆字节而不是太字节的数据。
来源与延伸阅读
- Apache Iceberg 记录了一种开放表格式,支持模式演进、行级删除和时间旅行。
- Jay Kreps 的 Questioning the Lambda Architecture 介绍了从日志重新处理的 Kappa 方法。
- OpenLineage 是一个用于从作业中收集列级血缘的开放标准。
- Security and compliance 涵盖客户数据的分类、访问和保留。