Learning Platform
Глоссарий Troubleshooting
Урок 02.01 · 25 мин
Средний
medallionstagingintermediatemartsproject structurereorg

Medallion-паттерн на production-нагрузке

NOTE

Три слоя ты знаешь по dbt-i/13: staging нормализует raw (1-к-1 с source, view, никаких join), intermediate агрегирует бизнес-логику между staging, marts отдаёт BI стабильный fct_/dim_. Direction-rule — поток только слева направо. Если забыл — освежи там. Здесь — про то, как эти три слоя живут под production-нагрузкой 200+ моделей, и что ломается, когда вы доходите до этого размера на наивной структуре.

В dbt-i вы видели medallion на проекте из 20 моделей. Там всё помещается в staging/<source>/, intermediate/, marts/, и каждый файл легко найти Cmd+P. Этот урок — про то, что происходит с этой структурой на проекте из 200+ моделей, когда у вас 4 sources по 30 таблиц каждый, 3 бизнес-домена, и 5 аналитиков с разными PR в неделю.

Medallion: концептуальная основа dbt-i: первый взгляд на три слоя

Спойлер: «плоская» medallion-структура из dbt-i на этом масштабе разваливается. И ломается она не сразу — она протекает медленно, и к тому моменту, когда вы это замечаете, рефакторинг уже стоит несколько недель.

Что ломается на 100+ моделях

Рассмотрим симптомы по порядку, как они появляются на реально растущих проектах.

Симптом 1: один staging/ каталог на 50 файлов

Вы стартуете проект с одним source — stripe. Кладёте все staging как models/staging/stg_stripe__charges.sql, stg_stripe__customers.sql и так далее. Через полгода добавляется salesforce, потом postgres_app, потом intercom, потом segment. И вот у вас в models/staging/ лежит 50 файлов вперемешку: stg_stripe__charges, stg_intercom__conversations, stg_segment__page_views, stg_salesforce__opportunities.

Что плохого: PR review, IDE-листинг, поиск файла — всё становится медленнее. Когда добавляется новая Stripe-таблица, вы открываете staging/ и сначала ищете глазами другие stg_stripe__*, чтобы понять конвенции, которые команда применяет к этому source. Это занимает не секунды, а минуты — и так каждый PR.

Симптом 2: fanout в marts/

fct_orders оказывается общим для finance, marketing и product команд. Каждая команда хочет видеть свою версию — finance нужны refunds и chargebacks, marketing нужна attribution-логика, product нужны session-метрики. В итоге появляются fct_orders_finance, fct_orders_with_attribution, fct_orders_enriched, и через год никто не знает, какая из них «настоящая».

Симптом 3: dependency cycles через intermediate

Аналитик создаёт int_customer_segments, которая нужна для fct_orders. Через месяц другой аналитик создаёт int_orders_summary для dim_customers. Через ещё месяц кому-то нужно, чтобы int_customer_segments использовала int_orders_summary — и DAG превращается в граф «каждый ссылается на каждого внутри intermediate». Циклы dbt не пропустит, но «почти-циклы» с глубиной 5+ DAG пропускает легко, и parse-время растёт нелинейно.

Симптом 4: parse > 10 секунд

dbt parse начинает занимать 10-20 секунд на холодном кэше. На каждом локальном dbt run вы ждёте 10 секунд, прежде чем что-либо начнёт считаться. На 50 запусках в день это 8 минут чистого ожидания на одного разработчика. На команде из 5 — 40 минут команды в день.

Симптом 5: lineage граф нечитаем

dbt docs serve открывает граф, и вы видите 200 нод, переплетённых стрелками. Найти, откуда берётся конкретная колонка в fct_orders, занимает 5-10 минут ручного раскапывания. Новые аналитики тратят первую неделю на «изучение DAG», вместо того чтобы делать модели.

TIP

Эти пять симптомов появляются не одновременно. Обычно последовательность такая: сначала parse замедляется (несколько недель), потом lineage становится нечитаем (1-2 месяца), потом начинаются конфликты на staging-каталоге (3-6 месяцев). Если вы заметили хотя бы один — не ждите остальных, начинайте reorg.

Sub-directory pattern — основной инструмент reorg

Единственный масштабируемый способ организации medallion на 100+ моделях — sub-directory разбиение внутри каждого слоя.

Staging — по source

models/staging/
  stripe/
    _stripe__sources.yml
    _stripe__models.yml
    stg_stripe__charges.sql
    stg_stripe__customers.sql
    stg_stripe__subscriptions.sql
    stg_stripe__invoices.sql
  salesforce/
    _salesforce__sources.yml
    _salesforce__models.yml
    stg_salesforce__accounts.sql
    stg_salesforce__opportunities.sql
  postgres_app/
    _postgres_app__sources.yml
    _postgres_app__models.yml
    stg_postgres_app__users.sql
    stg_postgres_app__sessions.sql

Конвенция: одна папка на один source. Все YAML конфиги source — рядом со staging-моделями этого source.

Это даёт три выгоды. Первое — ownership: можно назначить CODEOWNERS per source-папку, и Stripe-PR проверяет команда, отвечающая за Stripe-интеграцию. Второе — изоляция изменений: если Stripe меняет схему API, вы трогаете только models/staging/stripe/ и _stripe__sources.yml — другие source не задеты. Третье — параллельная работа: два аналитика могут одновременно работать с разными source без merge conflicts на _sources.yml.

Intermediate — по business domain

models/intermediate/
  finance/
    _int_finance__models.yml
    int_finance__orders_with_payments.sql
    int_finance__invoices_normalized.sql
    int_finance__revenue_recognition.sql
  marketing/
    _int_marketing__models.yml
    int_marketing__campaign_events_enriched.sql
    int_marketing__user_attribution.sql
  product/
    _int_product__models.yml
    int_product__user_sessions_funnel.sql

Конвенция: одна папка на один business domain. Имена моделей с двойным подчёркиванием — int_<domain>__<entity> — чтобы при ref-поиске сразу видно было, к какому domain относится модель.

Domain в intermediate не равен source в staging. Это намеренно: staging организован вокруг input-системы, intermediate — вокруг бизнес-смысла. Один intermediate может джойнить Stripe и Salesforce; одна сущность Stripe может питать три разных intermediate в трёх разных доменах.

Marts — по domain и далее по area

models/marts/
  finance/
    _finance__models.yml
    _finance__exposures.yml
    core/
      fct_orders.sql
      fct_invoices.sql
      dim_customers.sql
    reporting/
      agg_daily_revenue.sql
      agg_monthly_mrr.sql
  marketing/
    _marketing__models.yml
    core/
      fct_campaign_events.sql
      dim_campaigns.sql
    attribution/
      fct_touchpoints.sql
      dim_attribution_models.sql

Здесь добавляется второй уровень — core/ (стабильные fact/dim, контракт для BI) и reporting/ или attribution/ или experimentation/ (специфика domain, обычно меньше стабильности). Это даёт BI-команде стабильный API через core/, а аналитикам — место для денормализованных или экспериментальных моделей в их же домене.

Sub-directory pattern на 200 моделях

Три слоя medallion разбиты по перпендикулярным осям: staging по source-системе, intermediate и marts по business domain. Cross-team boundaries проходят по domain-папкам.

staging/stripe/Sub-каталог per source. CODEOWNERS @data-eng-stripe. Изменения в API Stripe -> PR только здесь.
intermediate/finance/Sub-каталог per business domain. CODEOWNERS @finance-analytics. Здесь живёт логика 'что такое revenue', 'как считать MRR'.
marts/finance/core/Финальные fct/dim для finance-домена. Стабильный API. CODEOWNERS @finance-analytics + reviewers @bi-team. Меняется через model versions.
staging/salesforce/Отдельный source. PR на него не блокируется PR на stripe.
intermediate/marketing/Marketing-domain. Может джойнить и Salesforce, и Stripe. Domain != source.
marts/marketing/attribution/Attribution-area внутри marketing-домена. Менее стабильна, чем core/. Аналитики могут экспериментировать без риска сломать BI.

Materialization defaults в dbt_project.yml

На 200 моделях вы не хотите ставить {{ config(materialized='view') }} руками в каждом staging-файле. Это вызывает дрейф — кто-то забудет, кто-то поставит table, кто-то incremental, и через год materialization-стратегия в проекте превращается в случайный набор.

Решение — defaults по слою в dbt_project.yml:

# dbt_project.yml
models:
  my_project:
    staging:
      +materialized: view
      +schema: staging
      stripe:
        +tags: ["source:stripe", "team:data-eng"]
      salesforce:
        +tags: ["source:salesforce", "team:revops"]
    intermediate:
      +materialized: ephemeral
      +schema: intermediate
      finance:
        +tags: ["domain:finance"]
        +materialized: view
      marketing:
        +tags: ["domain:marketing"]
    marts:
      +materialized: table
      +schema: marts
      finance:
        +tags: ["domain:finance"]
        core:
          +materialized: table
        reporting:
          +materialized: incremental
          +incremental_strategy: merge
      marketing:
        +tags: ["domain:marketing"]

Несколько важных деталей.

Defaults наследуются вниз. staging.+materialized: view применится ко всем моделям в models/staging/, если они не переопределят сами. То есть staging-файлам не нужен {{ config(materialized='view') }} — они автоматически получают view от dbt_project.yml.

Точечный override. Если одна staging-модель должна быть table (например, stg_postgres_app__events где source — view-only readonly replica с медленным запросом), вы добавляете {{ config(materialized='table') }} именно в этом файле. Остальной staging остаётся view.

Расхождения по слою. Intermediate по умолчанию ephemeral (инлайнится в downstream CTE) — это хороший дефолт, потому что большинство intermediate используется один раз. Но intermediate/finance/ переопределен на view — потому что finance-модели часто переиспользуются между несколькими marts, и ephemeral тогда выполняется N раз.

Tags для observability. Tag domain:finance позволяет запустить dbt build --select tag:domain:finance — частая операция, когда finance-команда хочет пересчитать только свои модели. Tag team:data-eng помогает атрибутировать стоимость warehouse в FinOps-отчётах.

WARNING

Не ставьте +materialized: table на staging-слой, даже если warehouse «дешёвый». Staging — это view-слой, и переход на table удваивает storage и убивает live-связь с source. Если staging-запрос медленный — проблема в source-таблице или в qualify/distinct-логике staging-модели, не в materialization.

Cross-team boundaries — model groups как preface к Mesh

Когда у вас 5+ команд работают в одном dbt-проекте, sub-directory недостаточно. Нужны model groups — формальные границы ownership, которые dbt валидирует на parse-этапе.

Декларация группы в YAML рядом со staging/intermediate/marts моделями:

# models/marts/finance/_finance__groups.yml
version: 2

groups:
  - name: finance
    owner:
      name: Finance Analytics Team
      email: [email protected]
      slack: "#finance-data"

models:
  - name: fct_orders
    group: finance
    access: public
  - name: fct_invoices
    group: finance
    access: public
  - name: int_finance__orders_with_payments
    group: finance
    access: private

Что это даёт:

access: private — модель видна только внутри своей группы. Если marketing попробует ref('int_finance__orders_with_payments')dbt parse упадёт с ошибкой Node ... is not part of finance group. Это первый раз, когда «не лезь в чужое intermediate» становится не социальной нормой, а проверяемой компилятором constraint.

access: public — модель доступна откуда угодно. Это публичный контракт finance-домена для остального проекта.

access: protected (default) — модель доступна только внутри того же проекта (но не extension через dbt Mesh).

Model groups — это прямой preface к dbt Mesh (модуль 15). В Mesh каждая команда выносит свой набор моделей в отдельный dbt-проект, и access: public модели становятся cross-project. Группы внутри одного проекта — это «Mesh без выноса в отдельный repo», тренировка к разделению.

dbt Mesh: следующий уровень — отдельные проекты Data Mesh и Data Contracts в контексте governance
Model groups как контракт между командами

Каждая команда владеет группой. private модели — внутренние, public — контракт с другими командами. dbt валидирует на parse, ломается до prod.

finance groupOwner: finance-analytics. Содержит int_finance__* (private) и fct_orders, dim_customers (public). Marketing не может ref на private модели.
ref public
marketing groupOwner: marketing-analytics. Может ref('fct_orders') потому что public. Не может ref('int_finance__orders_with_payments') — private.
int_finance__ordersaccess: private. Видна только внутри finance group. PR от marketing с ref на эту модель упадёт на dbt parse.
dbt parse ERROR
errorNode int_finance__orders_with_payments is not accessible from group marketing. parse падает, в prod не попадает.

Сигналы, что reorg необходим прямо сейчас

Резюме — список конкретных метрик, которые говорят «пора». Если хотя бы два из этих сигналов — планируйте reorg в следующий sprint, не «когда-нибудь».

  1. dbt parse занимает > 10 секунд. Замерьте через time dbt parse. На холодном кэше, без partial parse.
  2. dbt docs serve lineage не рендерится за 30 секунд или вы не можете найти upstream конкретной колонки за 2 минуты ручного клика.
  3. Два аналитика конфликтуют на одном _models.yml или _sources.yml хотя бы раз в неделю. Это значит, что YAML слишком крупный.
  4. PR review занимает > 1 часа потому что reviewer не понимает, в какой части проекта живут изменения.
  5. Новые модели создаются в случайных папкахmodels/temp/, models/<analyst_name>/, models/2024_q4/. Это симптом того, что текущая структура не отвечает на вопрос «куда положить эту модель».
  6. Один файл fct_*.sql превышает 500 строк SQL. Это значит, что intermediate-слой не вытащил из него агрегации и join’ы.

Cost of reorg vs cost of waiting

Reorg на 200 моделях занимает 1-2 недели работы senior-аналитика плюс координацию downstream-команд. Это много. Но цена ожидания растёт нелинейно: каждый месяц без reorg добавляет ~20 моделей в существующую плоскую структуру, которые потом тоже придётся переносить.

Эмпирическое правило: если вы прямо сейчас на 100 моделей, reorg занимает неделю. На 300 моделей — три недели. На 500 моделей — два месяца. Откладывание reorg окупается отрицательно через ~3-4 месяца.

DuckDB-замечания

  • dbt parse на DuckDB обычно быстрее, чем на Snowflake/BQ — manifest строится локально без сетевых вызовов в warehouse metadata API. Если на DuckDB parse занимает > 5 секунд, на Snowflake то же дерево моделей будет > 15 секунд. Имейте этот множитель в голове.
  • +materialized: ephemeral на DuckDB работает идентично остальным warehouses — компилируется в CTE.
  • Model groups с access: public/private — feature dbt core, работает на DuckDB начиная с dbt-core 1.5+.

Попробуй сам

Возьмите свой проект (или Jaffle Shop, если ещё нет своего) и выполните:

  1. Замерьте time dbt parse на холодном кэше — запомните цифру.
  2. Откройте models/staging/ — если там 20+ файлов без sub-каталогов, разбейте на staging/<source>/.
  3. Откройте dbt_project.yml — если +materialized не указан по слою, добавьте: staging view, intermediate ephemeral, marts table.
  4. Создайте _models.yml с одной группой и access: public на ваши fct/dim — посмотрите, ломается ли dbt parse. Если ломается — нашли скрытую зависимость, которую нужно разрешать.
  5. Замерьте time dbt parse ещё раз. Должно стать на 10-30% быстрее за счёт более компактных манифестов.
Проверка знанийKnowledge check
Команда стартует новый dbt-проект. Один senior-аналитик предлагает сразу разбить staging на sub-каталоги по source, хотя пока есть только один source 'stripe' и 8 staging-моделей. Junior-аналитик возражает: 'это over-engineering, мы и так увидим эти 8 файлов в одной папке'. Кто прав, и какой технический аргумент должен решить спор?
ОтветAnswer
Прав senior, и аргумент технический, а не эстетический. Дело в том, что reorg structure 'плоская -> sub-directories' не бесплатный. Каждый rename файла в staging — это: rename SQL-файла (git history теряется без --follow), update _sources.yml путей (если они там жёстко прописаны), update CODEOWNERS, update dbt_project.yml override-правил, update любых external ссылок на пути файлов (CI скрипты, deployment automation), update IDE-bookmarks и сохранённых query-history у каждого аналитика в команде. На 8 файлах это полчаса работы одного человека. На 80 файлах — 2-3 дня работы плюс координация всей команды плюс неизбежные merge conflicts с in-flight PR. Второй технический аргумент — миграция сабкаталогов вынуждает менять CODEOWNERS и группы доступа в неудобный момент (когда команда уже растёт), а не в момент старта проекта (когда все договариваются и нет legacy). На старте проекта sub-directory bootstrap занимает 5 минут (создать пустые папки stripe/), на масштабе он занимает недели. Третий аргумент — sub-directory pattern это не только структура для людей, но и hook для materialization defaults в dbt_project.yml. Если у вас staging/ плоская, вы не можете сказать 'все Stripe-модели — tag:source:stripe' через project-level config — придётся перечислять имена. С sub-directory это одна строка YAML. Юниор прав в одном: на 8 файлах разница не заметна. Но мы не строим проект 'на 8 файлов', мы строим основу, которая через год будет 80 моделей. Все production-конвенции должны быть приняты на старте — это дешевле, чем переходить на них в середине.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Почему staging-слой в medallion обычно материализуется как 'view', а не 'table'?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4