60.1 数据平台分层

一句话

数据平台的分层不是把组件摆成五排,而是把「谁生产数据、谁消费数据、谁担保 SLA、谁付存储账单」切成四份可执行的契约;层的边界由元数据 + 依赖规则定义,产品名只是实现。

先破除五个误区

误区 现实
平台 = 组件堆叠(Kafka + Spark + 数仓) 组件是能力,层是责任边界;没有 owner 和 SLA 的组件集合叫「一堆中间件」
分层越多越专业 每多一层就多一次延迟、一份存储、一个故障域;层的价值是复用与隔离,不是数量
上云 = 有数据平台 托管服务只替你运维组件,不替你定义口径、权限与质量门禁
数据湖 = 数据平台 湖只是存储形态;没有 catalog、表格式与治理,湖会退化成「更贵的网盘」
建平台 = 建中台 中台是组织与复用问题;平台是技术契约问题,两者不能互相替代

🔑 判断一个「层」是否成立,只问三件事:它有没有独立的输入输出?有没有独立的 SLA?有没有明确的 owner? 三问缺一,这一层就是架构图上的装饰。

五层:每层只回答一个问题

层 回答什么 输入 → 输出 关键指标 典型产品 典型失败
接入 数据怎么进来 业务库 / 日志 / API / 文件 → 原始数据 端到端延迟、丢数率、重复率 Kafka、Debezium、Flink CDC、Airbyte、DataX、Filebeat 漏采、重复投递、schema 漂移
存储 放哪、怎么组织 原始 / 明细 / 汇总 → 可查询数据集 单位 GB 成本、持久性、可用性 S3 / OSS、HDFS、Iceberg / Delta / Hudi、ClickHouse 小文件、元数据瓶颈、冷热错配
计算 怎么算 数据集 → 数据集 任务成功率、SLA 达成率、单位计算成本 Spark、Flink、Trino、DuckDB、dbt 数据倾斜、失败重跑、资源争抢
服务 怎么给出去 数据集 → API / 报表 / 特征 P99、并发、口径一致率 Trino / Presto、ClickHouse、BI、特征平台 慢查询拖垮集群、同名指标两个值
治理 谁负责、能不能信 元数据 / 规则 → 策略与账本 覆盖率、违规数、成本归因率 DataHub、OpenMetadata、Great Expectations、Ranger 元数据过期、规则形同虚设

这张表最重要的不是产品名,而是每层的失败模式不同:接入层怕丢和重,存储层怕小文件和成本,计算层怕倾斜和排队,服务层怕慢查询和口径,治理层怕「有规则、没执行」。用一套告警去覆盖五层,等于没有告警。

控制面与数据面:必须分开

控制面 数据面
组成 调度、元数据、权限、质量规则、成本配额 存储、计算、网络、查询执行
流量特征 低频写、高频读、强一致 高频读写、可容忍短暂不一致
故障影响 不能调度新任务 / 新策略不生效,已跑任务可继续 业务直接查不到数
变更方式 审计、灰度、可回滚 限流、降级、隔离
部署要求 多副本 + 客户端缓存 + 变更通知 资源池隔离 + 熔断

把元数据库和查询集群混在一套实例里,最常见的结局是:一次元数据全量扫描把查询集群的 IO 打满,所有看板同时超时。控制面故障应该只影响「变更」,不该影响「已经能跑的东西」。

🔑 控制面是变更的来源,必须可审计、可灰度、可回滚;数据面是流量的通道,必须可限流、可降级、可隔离。两者的 SLO 要分开定,事故也要分开复盘。

一条订单数据的旅程:每层的时间预算

跳 层 动作 示例预算(P99) 失败后多久可见
1 接入 业务库 CDC → ODS 5 min 延迟告警 10 min 内
2 计算 ODS → DWD(清洗、去重、维表关联) 15 min 任务失败告警
3 计算 DWD → DWS(按主题汇总) 30 min SLA 看板变红
4 计算 DWS → ADS(指标口径) 30 min 指标对账差异
5 服务 ADS → BI / API 5 min 页面/接口错误率

把这条链的预算加起来是 85 分钟——它不是「技术上限」,而是从业务问题倒推出来的:运营早上 9 点要看昨日 GMV,T+1 批处理完全够;大促要分钟级,就得把 2–4 跳改成流式(见 62.6 实时数仓)。

⚠️ 延迟预算必须按 P99 + 排队时间 定,不能按平均耗时。晚高峰的排队时间往往比计算时间长一个数量级,而平均值会把它抹平。

分层契约:每个数据集必须登记的字段

字段 缺了会怎样 谁维护
owner 口径错了没人认领,告警没人接 数据开发团队
layer 依赖方向无法校验,越层无人发现 平台(从命名/注册信息生成)
sla(新鲜度 + 产出时间) 无法判断「晚到」是故障还是正常 数据开发 + 业务方
classification(L1–L4) 权限与脱敏无法执行(见 38.5 数据分级与脱敏) 安全 + 数据 owner
retention 成本无限增长,合规删除无依据(见 46.6 数据生命周期) 数据 owner + 合规
upstream / consumers 影响面算不出来,改表靠「谁记得」 平台自动采集

🔑 分层的可执行形态是「数据集清单 + 依赖规则 + CI 检查」,不是一张架构图。图会过期,CI 不会。

代码:把分层规则做成 CI 门禁

下面这段程序把四条规则变成可执行检查:R1 依赖只能从上游指向下游;R2 ODS 不能跳过 DWD 直写 DWS/ADS;R3 数据集四项元数据必须齐全;R4 APP 是终端,不能被当作输入。

#!/usr/bin/env python3
from dataclasses import dataclass

RANK = {"ODS": 0, "DWD": 1, "DWS": 2, "ADS": 3, "APP": 4}


@dataclass
class Dataset:
    name: str
    layer: str
    owner: str = ""
    sla_minutes: int = 0
    classification: str = ""
    retention_days: int = 0
    exception: str = ""       # 越层审批工单号


@dataclass
class Pipeline:
    name: str
    inputs: list[str]
    output: str
    owner: str = ""


DATASETS = {d.name: d for d in [
    Dataset("ods_orders",   "ODS", "data-eng", 5,   "L3", 365),
    Dataset("ods_users",    "ODS", "data-eng", 30,  "L3", 365),
    Dataset("ods_payments", "ODS", "pay-eng",  0,   "L4", 0),      # 缺 SLA + 留存
    Dataset("dwd_trade_order_di", "DWD", "dw-trade", 15, "L3", 1095),
    Dataset("dwd_user_profile",   "DWD", "dw-user",  60, "L3", 1095),
    Dataset("dws_trade_sales_1d", "DWS", "dw-trade", 30, "L2", 1095),
    Dataset("dws_user_value_1d",  "DWS", "",         60, "L3", 730),  # 缺 owner
    Dataset("ads_gmv_dashboard",  "ADS", "bi-team",  30, "L2", 365),
    Dataset("ads_finance_report", "ADS", "fin-team", 120, "L3", 1825),
    Dataset("ml_churn_feature",   "APP", "ml-team",  60, "L3", 365),
]}

PIPELINES = [
    Pipeline("sync_orders",   ["ods_orders"], "dwd_trade_order_di", "dw-trade"),
    Pipeline("sync_users",    ["ods_users"],  "dwd_user_profile",   "dw-user"),
    Pipeline("sales_daily",   ["dwd_trade_order_di"], "dws_trade_sales_1d", "dw-trade"),
    Pipeline("user_value",    ["dwd_user_profile", "dws_trade_sales_1d"], "dws_user_value_1d", "dw-user"),
    Pipeline("gmv_dashboard", ["dws_trade_sales_1d"], "ads_gmv_dashboard", "bi-team"),
    Pipeline("finance_report", ["ods_payments"], "ads_finance_report", "fin-team"),   # 越层
    Pipeline("churn_feature", ["ads_finance_report"], "ml_churn_feature", "ml-team"),  # 合法
    Pipeline("ops_backfill",  ["ads_gmv_dashboard"], "dwd_user_profile"),             # 反向
    Pipeline("share_feature", ["ml_churn_feature"], "ads_finance_report", "bi-team"),  # APP 被消费
]


def check():
    errs, warns = [], []
    for p in PIPELINES:
        if not p.owner:
            warns.append(f"[W] 任务 {p.name} 没有 owner")
        out = DATASETS.get(p.output)
        if out is None:
            errs.append(f"[R0] {p.name} 的输出数据集 {p.output} 未登记")
            continue
        for src in p.inputs:
            s = DATASETS.get(src)
            if s is None:
                errs.append(f"[R0] {p.name} 的输入数据集 {src} 未登记")
                continue
            if RANK[s.layer] > RANK[out.layer]:
                errs.append(f"[R1] {p.name}: {src}({s.layer}) -> {p.output}({out.layer}) 反向依赖")
            if s.layer == "ODS" and out.layer in ("DWS", "ADS") and not out.exception:
                errs.append(f"[R2] {p.name}: ODS 跳过 DWD 直写 {out.layer}(无例外工单)")
            if s.layer == "APP":
                errs.append(f"[R4] {p.name}: APP 数据集 {src} 被当作输入")

    for d in DATASETS.values():
        missing = []
        if not d.owner:
            missing.append("owner")
        if d.sla_minutes <= 0:
            missing.append("SLA")
        if not d.classification:
            missing.append("分级")
        if d.retention_days <= 0:
            missing.append("留存期")
        if missing:
            errs.append(f"[R3] {d.name}({d.layer}) 缺:{', '.join(missing)}")
    return errs, warns


def main():
    errs, warns = check()
    checks = len(DATASETS) * 4 + len(PIPELINES)
    score = max(0, 100 - 4 * len(errs) - 2 * len(warns))
    print(f"数据集 {len(DATASETS)} 个,任务 {len(PIPELINES)} 个,检查项 {checks} 条")
    print(f"硬错误 {len(errs)} 条,告警 {len(warns)} 条,平台分层健康分 {score}\n")
    for e in errs:
        print("  ✗", e)
    for w in warns:
        print("  ⚠", w)

    layers = ["ODS", "DWD", "DWS", "ADS", "APP"]
    counts = {(a, b): 0 for a in layers for b in layers}
    for p in PIPELINES:
        for src in p.inputs:
            if src in DATASETS and p.output in DATASETS:
                counts[(DATASETS[src].layer, DATASETS[p.output].layer)] += 1
    print("\n分层依赖矩阵(行=输入层,列=输出层):")
    print("        " + "".join(f"{x:>6}" for x in layers))
    for a in layers:
        print(f"  {a:<4}" + "".join(f"{counts[(a, b)]:>6}" for b in layers))

    print("\n按层统计:")
    for layer in layers:
        ds = [d for d in DATASETS.values() if d.layer == layer]
        ready = sum(1 for d in ds if d.owner and d.sla_minutes and d.classification and d.retention_days)
        print(f"  {layer:<4} 数据集 {len(ds)} 个,元数据完整 {ready}/{len(ds)}")


main()

本机实测(macOS,Apple M1,Python 3.14.8):

数据集 10 个,任务 9 个,检查项 49 条
硬错误 6 条,告警 1 条,平台分层健康分 74

  ✗ [R2] finance_report: ODS 跳过 DWD 直写 ADS(无例外工单)
  ✗ [R1] ops_backfill: ads_gmv_dashboard(ADS) -> dwd_user_profile(DWD) 反向依赖
  ✗ [R1] share_feature: ml_churn_feature(APP) -> ads_finance_report(ADS) 反向依赖
  ✗ [R4] share_feature: APP 数据集 ml_churn_feature 被当作输入
  ✗ [R3] ods_payments(ODS) 缺:SLA, 留存期
  ✗ [R3] dws_user_value_1d(DWS) 缺:owner
  ⚠ [W] 任务 ops_backfill 没有 owner

分层依赖矩阵(行=输入层,列=输出层):
           ODS   DWD   DWS   ADS   APP
  ODS      0     2     0     1     0
  DWD      0     0     2     0     0
  DWS      0     0     1     1     0
  ADS      0     1     0     0     1
  APP      0     0     0     1     0

按层统计:
  ODS  数据集 3 个,元数据完整 2/3
  DWD  数据集 2 个,元数据完整 2/2
  DWS  数据集 2 个,元数据完整 1/2
  ADS  数据集 2 个,元数据完整 2/2
  APP  数据集 1 个,元数据完整 1/1

读数:

信号 说明 修法
ODS → ADS 一条 支付报表绕开 DWD,等于把清洗逻辑复制到报表里 补 DWD 支付明细表;申请例外必须有工单 + 到期日
反向依赖两条 ADS 被回灌成 DWD 的输入、APP 被当作输入 改依赖,或把这些任务降级为「临时任务」不进生产 DAG
元数据缺口三处 缺 owner / SLA / 留存,故障时无人认领 注册时强校验;存量按周清理
矩阵对角线 DWS→DWS 有一条(用户价值汇总) 同层依赖允许,但要限制层数,避免 DAG 变成蜘蛛网

⚠️ 门禁最容易死在「存量全红」。上线分三步:① 只报告不阻断,跑两周把存量清单交给 owner;② 只阻断本次变更新增的违规(存量加豁免期);③ 豁免到期前清理,再全量阻断。一步到位只会逼团队加白名单,最后规则失效。

平台分层怎么落到组织

角色 负责 不负责 判据
平台团队 存储、计算、调度、catalog、权限基础设施 指标口径、业务模型 平台 SLO(调度成功率、查询可用性)
数据开发 DWD / DWS 模型、SLA、数据质量 业务指标定义 数据集 owner 与 on-call
分析 / BI ADS、报表、指标口径 底层链路运维 指标字典签字
安全 / 合规 分级、授权、审计、删除 业务建模 审计通过率
业务方 指标定义、验收、优先级 技术实现 口径变更发起人

🔑 谁 on-call,谁拥有 SLA;谁定义指标,谁签字口径。两者不能都推给「平台团队」。

选型:三种典型形态

形态 适用 组件组合 什么时候别用
托管 ELT < 10 名数据工程师、< 10 TB、无强实时 Fivetran / Airbyte + BigQuery / Snowflake + dbt + Looker / Superset 数据不能出域、成本按扫描量不可控
自建湖仓 数据量大、要多引擎、要开放格式 Kafka + 对象存储 + Iceberg + Spark / Flink + Trino + Airflow + DataHub + GE + Ranger 团队 < 3 人:控制面运维成本会吃掉全部收益
实时优先 风控、推荐、实时大屏 Kafka + Flink + ClickHouse / Doris / StarRocks + 轻量调度 业务只要 T+1:流式带来的状态与运维复杂度不划算

💡 我的判断:先买「控制面」,再自建「数据面」。元数据、调度、权限这些控制面组件自研的收益极低、维护成本极高;而存储与计算直接决定成本与性能,值得按业务自建或深度调优。

生产落地:指名 → 可抄 → 可验 → 可退

指名:元数据用 DataHub(替代 OpenMetadata / Atlas);质量用 Great Expectations(替代 Soda、dbt tests 视栈而定);湖表格式用 Apache Iceberg(替代 Delta Lake / Hudi,按引擎生态选);权限用 Apache Ranger(云上用 Lake Formation / Unity Catalog)。

可抄(未验证:本机未安装 Docker / DataHub,版本 0.15.x,GMS 默认 8080、前端 9002):

# 1) 启动单机 DataHub(需要 Docker,建议 ≥ 8 GB 内存)
python3 -m pip install --upgrade 'acryl-datahub'
datahub docker quickstart

# 2) 采集 PostgreSQL 元数据(recipe.yml 见下)
datahub ingest -c recipe.yml
# recipe.yml:只读账号;不要用业务写账号
source:
  type: postgres
  config:
    host: localhost
    port: 5432
    username: meta_reader
    password: ${PG_PASSWORD}
    database: shop
    include_tables: true
sink:
  type: datahub-rest
  config:
    server: "http://localhost:8080"

可验:本节脚本可直接跑,输出 6 条硬错误即为门禁生效;产品侧验证用 curl -s localhost:8080/health(预期 {"status":"UP"},未验证)与前端搜索数据集名。可退:采集器是只读旁路,停掉不影响业务;门禁先以 --warn-only 运行,确认误报率后切阻断;权限策略用 shadow 模式(见 49.3 权限中心与权限矩阵)。

反模式

反模式 后果
按产品名分层(「Kafka 层 / Spark 层」) 换一个组件就要重画架构,层失去稳定含义
平台团队同时管基础设施与指标口径 出事互相甩锅,口径变更无人签字
所有数据先入湖、不登记 owner / SLA 三个月后变成数据沼泽,谁也不敢删
ADS 直读 ODS 清洗逻辑在报表里复制 N 份,上游改字段全站爆炸
控制面与数据面共用一套集群 元数据扫描 / 权限服务重启拖垮全部查询
用架构图代替依赖规则 图在评审时正确,三个月后与真实链路脱节
只建平台不建成本账 存储与计算成本随数据量线性涨,第二年预算翻倍
例外没有到期日 「临时越层」永久化,门禁形同虚设

自检

Q1 判断一个「层」是否成立,用哪三个问题?为什么产品名不能作为判据?

▸ 答案:三问是「有没有独立输入输出、有没有独立 SLA、有没有明确 owner」。产品名不能作判据,因为同一层可以用不同产品实现(对象存储可以是 S3、OSS、HDFS),而同一产品也可能横跨多层(Spark 既能做接入后的清洗,也能做服务层查询)。层的本质是责任与契约的切分,产品只是实现手段;按产品分层,换组件就会导致架构返工。

Q2 控制面与数据面分开,具体防住哪类故障?举一个反例。

▸ 答案:防的是「控制面故障放大成数据面全站不可用」。例如元数据库与查询集群共用实例,一次元数据全量扫描把 IO 打满,所有看板查询超时——业务并没有算错,只是「查不到」。分开后,元数据服务故障只影响新建表、改权限、发新任务,已经部署的查询仍可跑。反例是权限服务:它虽然属于控制面,但在查询路径上做判定,必须加本地缓存与降级策略,不能简单当成「离线控制面」。

Q3 ODS → ADS 直连为什么是硬错误?什么情况下可以例外?

▸ 答案:它把本应集中在 DWD 的清洗、去重、状态过滤复制进每个报表,导致同一指标多套口径、上游字段变更时影响面不可枚举。例外只应发生在「一次性取数、不进生产 DAG、有到期日」的场景,并且必须在数据集清单里登记工单号与失效时间;没有到期日的例外等于永久放弃分层。

Q4 数据集清单里 owner / SLA / classification / retention 四项,各缺一项会导致什么具体后果?

▸ 答案:缺 owner → 口径争议和告警无人认领;缺 SLA → 无法区分「晚到」与「故障」,也没法做错误预算;缺分级 → 无法决定加密、脱敏、审批强度,合规审计说不清;缺留存 → 存储成本无上限,且「到期删除」没有依据。四项共同构成「这个数据集能不能被信任、能被谁用、能用多久」的最小契约。

Q5 延迟预算为什么必须用 P99 而不是平均值?

▸ 答案:数据链路的延迟分布是长尾的:平均值 20 分钟的链路,P99 可能是 3 小时(大促排队、上游补数、资源争抢)。业务承诺的是「最晚什么时候能看到」,对应 P99 或 P95;用平均值定预算,会在每个晚高峰违约,而且平均值会把少数极慢分区的影响完全抹掉。

Q6 一个 5 人的数据团队,日增 50 GB,是否应该自建湖仓?

▸ 答案:通常不该。自建湖仓的控制面(catalog、表格式维护、compaction、权限、元数据、调度)至少需要 1–2 人长期维护,5 人团队会被运维吃掉建模与业务分析产能。更合理的路径是托管数仓 / 托管湖仓(BigQuery、Snowflake、Databricks、MaxCompute)+ dbt + 托管 catalog,把控制面外包;等数据量或合规要求使托管成本超过自建总成本(含人力)时再迁移。

Q7 分层门禁上线,怎样避免「第一天全红、第二天全关」?

▸ 答案:分三阶段:① 只报告,把存量违规按 owner 分派并给豁免期;② 只阻断增量,本次变更新增的违规才失败,存量走白名单(带到期日);③ 豁免到期后全量阻断。同时把规则数量控制在 4–6 条、每条都有可执行的修法,否则团队会把它当噪音,最终关掉门禁。

关联