12000 个 dbt 模型、100 个团队:Monzo 的数据 mesh 实战拆解

2026-05-17 21 预计阅读时间:1 分钟
来源:infoq.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:14 分钟

当一家银行的数据仓库膨胀到 12000 个 dbt 模型、被 100 多个团队同时踩踏时,"再加一台集群"的粗暴扩容路线就走到了尽头。Monzo 最近公开了他们重构数据仓库的方案——一种自称为 "meshy" 的数据 mesh 落地方式,最终把仓库成本砍掉约 40%,数据交付速度提升 25%。数字本身足够吸引人,但更值得拆解的是:他们到底改了什么,治理结构怎么搭,以及这些决定对正在挣扎于 dbt 模型爆炸的团队意味着什么。

从单体仓库到 mesh:问题比你想的更早出现

Monzo 早期的数据架构是典型的中心化模式:一个大的 dbt 项目,所有团队往同一个仓库里堆模型。这在几十个模型时运行良好——统一的 CI、统一的数据测试、一份 dbt_project.yml 管天下。但当模型数量跨过千级门槛,几个结构性矛盾开始反复发作:

  • CI 爆炸:任何团队改一行 SQL,全量 12000 模型的 dbt run 都要跑,CI 时间从分钟级滑向小时级。
  • 所有权模糊:模型没有明确的团队归属,出问题时没人认领,数据质量滑坡却找不到责任人。
  • 计算浪费:大量模型被全量刷新,但下游真正消费的只有一小部分;冷热数据混在一起,仓库账单失控。

这三个问题本质上指向同一个根因:中心化仓库把所有权和计算耦合在一起了。数据 mesh 的核心思路就是拆掉这层耦合——让每个团队拥有自己的数据域(domain),在自己的项目里定义模型、跑测试、管质量,同时通过清晰的接口向其他域发布数据产品。

Monzo 的 "meshy" 方案:不是教科书数据 mesh,但够用

Monzo 没有照搬 Zhamak Dehghani 的完整数据 mesh 理论——那需要独立的计算基础设施、联邦治理平台、自服务数据基础设施,对一家正在快速扩张的银行来说工程量太大。他们的 "meshy" 是务实版:在同一个云仓库上,用 dbt 项目拆分模拟域自治,用治理层保证跨域一致性

关键设计有三层:

1. 域级 dbt 项目拆分

把单体 dbt 项目拆成多个子项目,每个域(比如 "payments"、"lending"、"customer")拥有自己的 dbt 项目目录,包含自己的模型、测试、macros。域内变更只触发域内 CI,不再拖动全局。

2. 公共接口模型(Public Models)作为数据产品

域内模型分为 publicprivate。只有 public 模型是对其他域的契约——有严格的 schema 约定、freshness 声明、owner 标注。private 模型是域内部实现细节,随时可以改、删、重构,下游域不应该依赖它们。

3. 跨域依赖通过 dbt ref + 项目依赖声明

域 A 引用域 B 的 public 模型时,通过 dbt 的 ref() 函数跨项目引用,同时在 dependencies.yml 中显式声明依赖关系。这使得跨域依赖图可追踪、可治理,而不是隐式的 SQL 引用到处蔓延。

用 dbt 实现域拆分:一个可改造的示例

下面的示例展示了如何把一个单体 dbt 项目拆成两个域项目,并建立跨域引用。你可以直接在自己的 dbt 项目中改造使用。

域项目结构

monzo-data-mesh/
├── domains/
│   ├── payments/
│   │   ├── dbt_project.yml
│   │   ├── models/
│   │   │   ├── public/
│   │   │   │   ├── fct_payments.yml
│   │   │   │   └── fct_payments.sql
│   │   │   ├── private/
│   │   │   │   ├── int_payments_enriched.sql
│   │   ├── tests/
│   │   ├── macros/
│   ├── customer/
│   │   ├── dbt_project.yml
│   │   ├── dependencies.yml
│   │   ├── models/
│   │   │   ├── public/
│   │   │   │   ├── dim_customers.yml
│   │   │   │   ├── dim_customers.sql
│   │   │   │   ├── fct_customer_lifetime_value.sql

payments 域的 dbt_project.yml

name: payments
version: 1.0.0
config-version: 2

profile: monzo_payments

model-paths: ["models"]
macro-paths: ["macros"]
test-paths: ["tests"]

models:
  payments:
    # 公共模型:跨域可引用,必须声明 owner 和 freshness
    public:
      +materialized: table
      +tags: ["public", "data-product"]
      +meta:
        owner: "payments-data-team"
        freshness_sla: "24h"
    # 私有模型:域内实现细节,下游不应依赖
    private:
      +materialized: incremental
      +tags: ["private"]

payments 域的公共模型定义

models/public/fct_payments.sql

{{
  config(
    meta={
      'owner': 'payments-data-team',
      'freshness_sla': '24h',
      'description': 'Payments fact table — public data product for cross-domain consumption'
    },
    tags=['public', 'data-product']
  )
}}

select
    payment_id,
    customer_id,
    amount,
    currency,
    payment_status,
    created_at,
    updated_at
from {{ ref('raw_payments') }}
where payment_status != 'cancelled'

models/public/fct_payments.yml——公共模型的 schema 契约:

version: 2
models:
  - name: fct_payments
    description: "Payments fact table. Public data product owned by payments-data-team."
    meta:
      owner: payments-data-team
      freshness_sla: 24h
    columns:
      - name: payment_id
        tests:
          - unique
          - not_null
      - name: customer_id
        tests:
          - not_null
          - relationships:
              to: ref('dim_customers')
              field: customer_id
      - name: amount
        tests:
          - not_null
      - name: payment_status
        tests:
          - accepted_values:
              values: ['completed', 'pending', 'failed']

customer 域声明对 payments 域的依赖

domains/customer/dependencies.yml

projects:
  - name: payments
    # 指向 payments 域项目的 Git 仓库或本地路径
    git: "https://github.com/monzo/data-domain-payments.git"
    # 也可以用本地路径在开发阶段
    # path: "../payments"

customer 域引用 payments 的公共模型

domains/customer/models/public/fct_customer_lifetime_value.sql

{{
  config(
    meta={
      'owner': 'customer-data-team',
      'freshness_sla': '48h'
    },
    tags=['public', 'data-product']
  )
}}

select
    c.customer_id,
    c.customer_name,
    coalesce(sum(p.amount), 0) as lifetime_value,
    count(p.payment_id) as payment_count
from {{ ref('dim_customers') }} c
left join {{ ref('payments', 'fct_payments') }} p
    on c.customer_id = p.customer_id
group by 1, 2

注意 ref('payments', 'fct_payments')——这是 dbt 跨项目引用的写法,第一个参数是依赖项目名,第二个是模型名。只有被 dependencies.yml 声明且目标模型标记为 public 的引用才是合法的。

本地开发运行

# 在 customer 城目录下安装依赖并运行
cd domains/customer
dbt deps          # 拉取 payments 域项目依赖
dbt run           # 只跑 customer 域的模型
dbt test          # 只跑 customer 域的测试

# 如果只想验证跨域引用是否正确,可以跑指定模型
dbt run --select fct_customer_lifetime_value

成本砍 40%、速度提 25%:背后的机制

Monzo 投入 mesh 架构后拿到的两个硬指标,不是魔法,而是几个具体工程决定的叠加效果:

成本下降 40% 的主要驱动力:

  • 增量刷新替代全量刷新:域拆分后,每个域可以独立调度自己的 dbt run。不再需要每天全量跑 12000 个模型,而是按域的 freshness SLA 分级调度——payments 域 24 小时刷新一次,customer 域 48 小时,某些内部 private 模型按需触发。
  • 冷热分离:public 模型(被多域消费)物化为 table,private 模型大量转为 incremental 或 ephemeral,减少仓库存储和计算占用。
  • 死模型清理:域所有权明确后,各团队开始主动清理自己域内不再使用的 private 模型,之前单体仓库里没人认领的僵尸模型被批量删除。

交付速度提升 25% 的主要驱动力:

  • CI 从全量变为域级:一个域的 PR 只触发该域的 dbt run + test,CI 时间从小时级降到分钟级。开发者更快拿到反馈,更快合并。
  • 跨域依赖图可视化dependencies.yml 使得域间依赖显式化,数据平台团队可以快速定位瓶颈链路,优先优化高依赖度的 public 模型。
  • 减少等待排队:之前全量 run 时,100 个团队的变更排队等同一个 CI 资源池;拆分后各域并行跑 CI,吞吐量直接提升。

落地数据 mesh 的现实代价

Monzo 的方案不是零成本的。在考虑引入之前,需要正视几个tradeoff:

治理复杂度上升。域拆分后,你需要一个中心化的治理层来管理跨域契约——谁可以发布 public 模型、schema 变更如何通知下游、freshness SLA 谁来监控。Monzo 用 dbt 的 meta + tags 做了轻量标注,但背后还有人工流程和平台工具支撑。如果你的团队不到 20 个,这个治理层的建设成本可能比收益更大。

跨域调试变难。之前单体项目里,一条 SQL 可以 ref() 任意模型,开发调试很方便。拆分后,跨域引用需要先 dbt deps 拉依赖,本地开发环境要同时加载多个项目。调试一条跨域 pipeline 的启动成本明显上升。

版本协调。域 A 的 public 模型改了 schema,域 B 的 CI 可能突然挂掉。你需要制定 schema 变更的通知和过渡流程——比如先加新列再删旧列,给下游域留迁移窗口。这不是 dbt 本身能解决的问题,需要组织流程配合。

什么时候该考虑 mesh 拆分

不是所有团队都需要走到这一步。一个简单的判断框架:

信号 说明
CI 时间超过 30 分钟 全量 dbt run 已经拖慢开发节奏
模型数量超过 500 且无人清理 僵尸模型开始堆积
3+ 个团队频繁踩踏同一张表 ownership 纠纷反复出现
仓库月账单环比增长 > 20% 全量刷新策略在烧钱

如果你只命中一条,优先尝试增量物化 + 模型标签分组,不一定需要项目级拆分。命中两条以上,开始认真评估域拆分。三条以上,mesh 拆分大概率是正确方向——但先从一个试点域开始,不要一次性拆 100 个。

一个最小试点方案

如果你决定开始,不要试图一次拆完。选一个边界清晰的域(比如 "payments" 或 "marketing"),把它从主项目中抽出,跑一个月,观察 CI 时间、计算成本、跨域引用摩擦。确认流程可行后再逐步扩展。

# 第一步:在主项目中给目标域的模型打标签
# 在 dbt_project.yml 中添加:
#   models:
#     your_project:
#       payments:
#         +tags: ["domain:payments"]

# 第二步:用 dbt list 验证域边界
dbt list --select "tag:domain:payments" > payments_models.txt

# 第三步:创建域项目,把列出的模型迁移过去
# 按上面的示例结构创建 domains/payments/ 目录
# 迁移模型文件,更新 ref() 引用

# 第四步:在主项目中声明域依赖,验证跨域 ref
# 主项目的 dependencies.yml 加入:
#   projects:
#     - name: payments
#       path: "domains/payments"

# 第五步:域内独立 CI
cd domains/payments
dbt run --full-refresh
dbt test

数据 mesh 不是架构信仰问题,而是规模压力下的工程选择。Monzo 的 "meshy" 方案证明了一点:你不需要完美实现理论,只需要在所有权、计算效率和治理可追踪性之间找到当前规模的平衡点。12000 个模型是极端场景,但 500 个模型时的痛苦已经足够让你开始思考拆分了。


相关推荐