Medallion-паттерн на production-нагрузке
Три слоя ты знаешь по 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 на этом масштабе разваливается. И ломается она не сразу — она протекает медленно, и к тому моменту, когда вы это замечаете, рефакторинг уже стоит несколько недель.
Что ломается на 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», вместо того чтобы делать модели.
Эти пять симптомов появляются не одновременно. Обычно последовательность такая: сначала 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/, а аналитикам — место для денормализованных или экспериментальных моделей в их же домене.
Три слоя medallion разбиты по перпендикулярным осям: staging по source-системе, intermediate и marts по business domain. Cross-team boundaries проходят по domain-папкам.
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-отчётах.
Не ставьте +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», тренировка к разделению.
Каждая команда владеет группой. private модели — внутренние, public — контракт с другими командами. dbt валидирует на parse, ломается до prod.
Сигналы, что reorg необходим прямо сейчас
Резюме — список конкретных метрик, которые говорят «пора». Если хотя бы два из этих сигналов — планируйте reorg в следующий sprint, не «когда-нибудь».
dbt parseзанимает > 10 секунд. Замерьте черезtime dbt parse. На холодном кэше, без partial parse.dbt docs servelineage не рендерится за 30 секунд или вы не можете найти upstream конкретной колонки за 2 минуты ручного клика.- Два аналитика конфликтуют на одном
_models.ymlили_sources.ymlхотя бы раз в неделю. Это значит, что YAML слишком крупный. - PR review занимает > 1 часа потому что reviewer не понимает, в какой части проекта живут изменения.
- Новые модели создаются в случайных папках —
models/temp/,models/<analyst_name>/,models/2024_q4/. Это симптом того, что текущая структура не отвечает на вопрос «куда положить эту модель». - Один файл
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, если ещё нет своего) и выполните:
- Замерьте
time dbt parseна холодном кэше — запомните цифру. - Откройте
models/staging/— если там 20+ файлов без sub-каталогов, разбейте наstaging/<source>/. - Откройте
dbt_project.yml— если+materializedне указан по слою, добавьте: stagingview, intermediateephemeral, martstable. - Создайте
_models.ymlс одной группой иaccess: publicна ваши fct/dim — посмотрите, ломается лиdbt parse. Если ломается — нашли скрытую зависимость, которую нужно разрешать. - Замерьте
time dbt parseещё раз. Должно стать на 10-30% быстрее за счёт более компактных манифестов.