Gold Layer: serving marts, агрегаты для BI, KPI и дилемма Iceberg vs ClickHouse
Бизнес-миссия Gold Layer, паттерны периодических снапшотов и накопительных витрин, GROUP BY ROLLUP/CUBE, Data Quality Gateway и Circuit Breaker, архитектурный выбор между Iceberg и ClickHouse для serving
Мы прошли весь путь от сырого байтового потока до чистых бизнес-сущностей. Bronze собрал сырой материал, Silver очистил и дедуплицировал его, превратив события в уникальные объекты. Теперь Gold Layer - это финишная прямая: здесь данные перестают быть «хорошо организованными» и становятся готовыми ответами на бизнес-вопросы.
Gold - это единственный слой, о существовании которого знают аналитики, менеджеры и BI-инструменты. Всё, что было до - Bronze, Silver, Spark-пайплайны, CDC, дедупликация - это «инженерная кухня», невидимая для конечного потребителя. Gold - это фасад всей платформы, и именно по нему судят о качестве всей работы команды.
1. Философия Gold Layer¶
1.1 Конец ETL, начало Serving¶
На слоях Bronze и Silver мы выполняли ETL (Extract, Transform, Load) - технически сложные операции по перемещению и преобразованию данных. На Gold Layer мы занимаемся Data Serving - предоставлением готовых, оптимизированных наборов данных конечным потребителям.
Это принципиально меняет дизайн-вопросы:
| Bronze / Silver | Gold |
|---|---|
| «Как правильно хранить данные?» | «Как быстро отдавать данные?» |
| «Каков бизнес-ключ сущности?» | «Какой вопрос задаёт бизнес?» |
| «Как обработать CDC-поток?» | «Каков SLA обновления дашборда?» |
| «Как дедуплицировать?» | «Какие KPI нужны завтра утром?» |
| Инженерная перспектива | Бизнес-перспектива |
1.2 Потребители Gold Layer¶
Каждый тип потребителя предъявляет разные требования к данным:
- BI-инструменты запускают повторяющиеся запросы к одним и тем же агрегатам. Им нужна стабильная схема и быстрый ответ.
- Data Scientists делают ad-hoc запросы, часто с кастомными фильтрами. Им важна гибкость и доступ к историческим данным.
- C-Level дашборды обновляются раз в сутки, но при этом требуют абсолютной точности - ошибка в метрике CEO заметит сразу.
- REST API потребляет конкретные числа (например, текущий рейтинг продукта) и требует latency в десятки миллисекунд.
- ClickHouse используется как специализированный serving-слой для интерактивных дашбордов с высокой конкурентностью запросов.
1.3 Денормализация: отказ от 3NF¶
На уровне Silver данные хранятся в нормализованном виде: таблица silver.orders содержит user_id, но не имя пользователя - за ним нужно идти в silver.users. Это правильная проектировка для уровня сущностей.
На Gold уровне нормализация становится врагом производительности. Аналитику, который хочет видеть «выручку по регионам», придётся делать три JOIN: orders → users → regions. Каждый JOIN в Spark - это потенциальный Shuffle. Поэтому Gold денормализует данные заранее: все необходимые атрибуты уже встроены в широкую плоскую таблицу.
Такая таблица называется OBT - One Big Table. Она нарушает все каноны нормализации, но позволяет BI-инструментам делать быстрые запросы без JOIN. Альтернатива - Star Schema (схема «Звезда»): центральная таблица фактов (fact_orders) и несколько маленьких таблиц измерений (dim_users, dim_products, dim_dates), преджойненных аналитическим движком.
2. Паттерны расчёта Gold-витрин¶
2.1 Паттерн 1: Периодические снапшоты (Periodic Snapshots)¶
Снапшот - это «фотография» состояния метрики на фиксированный момент времени. Раз в день (или раз в месяц) мы полностью пересчитываем метрику за этот период и записываем результат в Gold.
Когда использовать: когда нужно знать состояние на конкретную дату - остатки на складах на конец месяца, активная клиентская база на 1 января, LTV клиента за полгода. Эти метрики нельзя рассчитать инкрементально - нужна полная картина на момент времени.
from pyspark.sql import functions as F
# Ежедневный снапшот: состояние каждого заказа на конец дня
daily_snapshot_df = spark.table("lakehouse.silver.orders") \
.filter(
F.col("updated_at") < F.date_trunc("day", F.current_timestamp())
) \
.groupBy(
F.date_trunc("day", F.col("updated_at")).alias("snapshot_date"),
F.col("status")
) \
.agg(
F.count("order_id").alias("order_count"),
F.sum("amount").alias("total_revenue"),
F.avg("amount").alias("avg_order_value"),
) \
.withColumn("_calculated_at", F.current_timestamp())
# Снапшот перезаписывается целиком - это нормально для периодических витрин
daily_snapshot_df.writeTo("lakehouse.gold.orders_daily_snapshot") \
.tableProperty("write.format.default", "parquet") \
.overwritePartitions()
Снапшоты хранятся накопительно: сегодняшний расчёт дополняет, а не перезаписывает вчерашний. Это позволяет строить временны́е ряды и анализировать изменения метрик во времени.
2.2 Паттерн 2: Накопительные витрины (Cumulative / Incremental Marts)¶
Инкрементальная витрина обновляется только для новых данных за последний период (час, день), не пересчитывая всю историю. Это главный инструмент управления стоимостью пересчёта.
Когда использовать: когда таблица слишком большая для полного пересчёта, но при этом агрегат можно «достроить» - добавить новые дни к уже рассчитанному ряду. Например, выручка по дням за 3 года: каждый день добавляем одну строку для вчерашнего дня через MERGE INTO.
from pyspark.sql import functions as F
# Определяем окно инкремента: всё, что обновилось после последнего запуска
last_run_date = spark.sql(
"SELECT COALESCE(MAX(report_date), DATE '2020-01-01') FROM lakehouse.gold.daily_revenue"
).collect()[0][0]
# Считаем только за новый период
incremental_df = spark.table("lakehouse.silver.orders") \
.filter(F.col("updated_at").cast("date") > F.lit(last_run_date)) \
.groupBy(F.col("updated_at").cast("date").alias("report_date")) \
.agg(
F.sum("amount").alias("total_revenue"),
F.count("order_id").alias("total_orders"),
F.countDistinct("user_id").alias("unique_customers"),
)
# Идемпотентный MERGE: если день уже есть - обновляем, нет - вставляем
incremental_df.createOrReplaceTempView("daily_revenue_increment")
spark.sql("""
MERGE INTO lakehouse.gold.daily_revenue AS target
USING daily_revenue_increment AS source
ON target.report_date = source.report_date
WHEN MATCHED THEN
UPDATE SET
target.total_revenue = source.total_revenue,
target.total_orders = source.total_orders,
target.unique_customers = source.unique_customers
WHEN NOT MATCHED THEN
INSERT *
""")
Инкрементальный подход снижает стоимость расчёта с O(N) до O(delta) - вместо сканирования всей таблицы Silver читаем только новый кусок.
2.3 Паттерн 3: Многомерные агрегаты - ROLLUP, CUBE, GROUPING SETS¶
Классическая BI-задача: аналитик хочет видеть выручку по разным уровням иерархии - по конкретному городу, по региону, по стране, и общий итог - в одном отчёте. Наивный подход: сделать четыре отдельных запроса. Умный подход: использовать ROLLUP или CUBE.
GROUP BY ROLLUP(A, B, C) - вычисляет агрегаты для всех «левых» срезов иерархии:
- (A, B, C) - конкретный город в регионе в стране
- (A, B) - регион в стране (итого по всем городам)
- (A) - страна (итого по всем регионам)
- () - общий итог по всем странам
Это N+1 группировок за один проход по данным, что значительно эффективнее N отдельных запросов.
rollup_df = spark.sql("""
SELECT
country,
region,
city,
SUM(amount) AS total_revenue,
COUNT(order_id) AS total_orders,
GROUPING(country) AS is_country_total,
GROUPING(region) AS is_region_total,
GROUPING(city) AS is_city_total
FROM lakehouse.silver.orders_enriched
GROUP BY ROLLUP (country, region, city)
""")
Функция GROUPING(column) возвращает 1, когда строка является итоговой по данному измерению, и 0 - когда это конкретное значение. Это помогает различить NULL-значение поля (нет данных) от итогового агрегата (город не задан, потому что это итог по региону).
GROUP BY CUBE(A, B, C) - вычисляет агрегаты для всех возможных комбинаций измерений: (A,B,C), (A,B), (A,C), (B,C), (A), (B), (C), (). Это 2^N группировок - полный гиперкуб. Используется реже из-за высокой стоимости, но даёт максимальную гибкость аналитикам.
GROUP BY GROUPING SETS((A,B), (A), ()) - явное перечисление нужных комбинаций. Используется, когда нужно не всё дерево ROLLUP и не весь гиперкуб CUBE, а конкретный набор срезов.
Один ROLLUP-запрос заменяет четыре отдельных GROUP BY. Под капотом Spark выполняет один Shuffle и несколько aggregation-проходов по уже перемешанным данным, что значительно эффективнее.
3. SLA и Data Quality Gateways¶
3.1 Что такое SLA для Gold Layer¶
SLA (Service Level Agreement) для Gold витрины - это формальное обязательство: «данные за вчерашний день будут готовы, корректны и доступны не позднее 08:00 утра». Нарушение SLA означает, что топ-менеджер открыл дашборд и видит устаревшие цифры или ошибку загрузки.
SLA включает три составляющих:
| Компонент | Что значит | Пример |
|---|---|---|
| Freshness | Данные не старее N часов | Витрина за вчера готова к 08:00 |
| Availability | Таблица доступна для чтения | Нет блокировок, нет сбоев в Spark |
| Correctness | Данные прошли DQ-проверки | Нет отрицательной выручки, нет дублей |
Отсутствие SLA-мышления на Gold уровне приводит к тому, что аналитики строят недоверие к данным: «не уверен, обновилось ли уже» или «помню, в прошлом квартале цифры были неправильные». Восстановить доверие после таких инцидентов крайне сложно.
3.2 Data Quality Gateway: не пускать плохие данные в Gold¶
Data Quality Gateway - это набор автоматических проверок, которые выполняются до записи данных в Gold. Если хоть одна проверка провалилась, данные не записываются, а pipeline останавливается с сигналом тревоги. Это называется Circuit Breaker (предохранитель) - аналог предохранителя в электрической цепи, который разрывает цепь при опасном токе.
Ключевая идея Circuit Breaker: лучше показать бизнесу вчерашние правильные данные, чем сегодняшние неправильные. Неправильные данные в дашборде CEO - это доверительный кризис. Задержка данных на несколько часов - это операционная проблема, которую можно объяснить.
3.3 Реализация DQ-проверок¶
from dataclasses import dataclass
from typing import Callable
from pyspark.sql import DataFrame
@dataclass
class DQCheck:
name: str
description: str
check_fn: Callable[[DataFrame], int] # возвращает число нарушений
severity: str = "critical" # critical | warning
def run_dq_gateway(df: DataFrame, checks: list[DQCheck]) -> None:
"""Запускает все DQ-проверки. При critical-ошибке бросает исключение."""
failures = []
for check in checks:
violation_count = check.check_fn(df)
status = "✅ PASSED" if violation_count == 0 else "❌ FAILED"
print(f"DQ [{check.severity.upper()}] {check.name}: {status} ({violation_count} нарушений)")
if violation_count > 0 and check.severity == "critical":
failures.append(f"{check.name}: {violation_count} нарушений - {check.description}")
if failures:
failure_report = "\n".join(failures)
raise RuntimeError(
f"Circuit Breaker сработал! Pipeline остановлен.\n"
f"Gold таблица НЕ обновлена. Нарушения:\n{failure_report}"
)
# Определяем конкретные DQ-правила для нашей Gold-витрины
dq_checks = [
DQCheck(
name="revenue_non_negative",
description="Выручка не может быть отрицательной",
check_fn=lambda df: df.filter(df["total_revenue"] < 0).count(),
severity="critical",
),
DQCheck(
name="no_null_report_date",
description="Дата отчёта не может быть NULL",
check_fn=lambda df: df.filter(df["report_date"].isNull()).count(),
severity="critical",
),
DQCheck(
name="no_duplicate_dates",
description="Каждая дата должна быть уникальна в витрине",
check_fn=lambda df: df.count() - df.select("report_date").distinct().count(),
severity="critical",
),
DQCheck(
name="orders_count_reasonable",
description="Число заказов за день не должно быть аномально маленьким (< 10 - подозрительно)",
check_fn=lambda df: df.filter(df["total_orders"] < 10).count(),
severity="warning", # не блокирует, но логирует
),
]
# Использование в пайплайне
gold_df = compute_gold_aggregates() # ваша функция расчёта
run_dq_gateway(gold_df, dq_checks) # если упадёт - запись в Gold не дойдёт
gold_df.writeTo("lakehouse.gold.daily_revenue").overwritePartitions()
3.4 Мониторинг SLA: проверка свежести данных¶
Помимо проверок содержимого, нужно мониторить когда последний раз обновлялась витрина:
# Эту проверку запускает внешний монитор (cron-задача, Airflow sensor)
def check_gold_freshness(table_name: str, max_age_hours: int = 26) -> None:
"""Проверяет, что Gold-витрина обновлялась не позднее max_age_hours назад."""
from datetime import datetime, timedelta
last_update = spark.sql(f"""
SELECT MAX(_calculated_at) as last_ts
FROM {table_name}
""").collect()[0]["last_ts"]
age_hours = (datetime.utcnow() - last_update).total_seconds() / 3600
if age_hours > max_age_hours:
raise RuntimeError(
f"SLA BREACH: {table_name} не обновлялась {age_hours:.1f} ч. "
f"Порог: {max_age_hours} ч."
)
print(f"Freshness OK: {table_name} обновлена {age_hours:.1f} ч. назад")
4. Великая архитектурная дилемма: Iceberg vs ClickHouse¶
4.1 Границы возможностей Lakehouse¶
Apache Iceberg на S3 - блестящая технология для хранения и трансформации больших данных. Но у неё есть принципиальные ограничения на уровне query latency, которые важно понимать при проектировании Gold слоя.
Что происходит при запросе к Iceberg таблице:
- Spark читает метаданные (
metadata/) - JSON-файлы с информацией о снапшотах - Читает manifest-файлы - списки Parquet-файлов с их min/max статистикой
- Применяет predicate pushdown - исключает нерелевантные файлы
- Запускает задачи на Executors для сканирования оставшихся Parquet-файлов
- Выполняет агрегацию с потенциальным Shuffle
Шаги 1–3 занимают от 500 мс до нескольких секунд даже если финальный ответ - одно число. Это называется cold start latency. Для пакетной аналитики это незаметно. Для интерактивного дашборда, где менеджер кликает на фильтр и ждёт обновления - это катастрофически медленно.
4.2 Когда Iceberg достаточно¶
Iceberg + Spark SQL - правильный выбор для:
- Ежедневных batch-отчётов: рассчитать KPI раз в ночь и сохранить - идеально.
- Ad-hoc аналитики аналитиков: аналитик может подождать 5–30 секунд на сложный запрос.
- Исторических расчётов: пересчитать выручку за 3 года - Spark справится, ClickHouse не выгоднее.
- ML Feature Stores: обучение модели не требует sub-second latency.
4.3 Когда нужен ClickHouse¶
ClickHouse - это специализированная MPP (Massively Parallel Processing) СУБД, оптимизированная для аналитических запросов с sub-second latency при высокой конкурентности. Это не замена Iceberg - это другой инструмент, решающий другую проблему.
ClickHouse побеждает Iceberg в сценариях:
| Требование | Iceberg + Spark | ClickHouse |
|---|---|---|
| Latency первого запроса | 2–30 сек | 50–200 мс |
| Конкурентных запросов | 5–20 (Spark DAG сложный) | 1000+ одновременно |
| Интерактивные фильтры | Медленно (каждый фильтр = новый Spark job) | Быстро (индексы и sparse index) |
| Операционные дашборды (30-сек обновление) | Плохо (overhead коммитов) | Хорошо |
| Хранение сотен TB истории | Хорошо (S3 дёшев) | Дорого (NVMe SSD) |
Специализированные движки таблиц ClickHouse для Gold-витрин:
SummingMergeTree- хранит суммы, автоматически схлопывая строки с одинаковым ключом при фоновом merge. Идеален для счётчиков и выручки.AggregatingMergeTree- хранит промежуточные состояния агрегатных функций (HLL для COUNT(DISTINCT), quantile-sketches для перцентилей). Позволяет делать точные aggrgate-функции над огромными объёмами данных без хранения всех строк.
4.4 Hybrid Architecture: Iceberg → ClickHouse¶
Правильная архитектура не выбирает между Iceberg и ClickHouse - она использует оба. Spark выполняет тяжёлую трансформацию и агрегацию, результат сохраняется в Gold Iceberg как canonical storage, а инкремент дополнительно пушится в ClickHouse для serving.
Пунктирная стрелка - это инкрементальная заливка в ClickHouse. Не перезаписываем всё с нуля, а пушим только свежий кусок (вчерашние данные). ClickHouse хранит только «горячую» историю (последние 90–180 дней), Iceberg хранит всё.
4.5 Интеграция со Spark через spark-clickhouse-connector¶
# Запись инкремента из Gold Iceberg в ClickHouse
# Требует: com.clickhouse.spark:clickhouse-spark-runtime-3.4_2.12:0.8.0
yesterday = F.date_add(F.current_date(), -1)
gold_increment = spark.table("lakehouse.gold.daily_kpi") \
.filter(F.col("report_date") == yesterday)
gold_increment.write \
.format("clickhouse") \
.option("host", "clickhouse-01.internal") \
.option("port", "8123") \
.option("database", "marts") \
.option("table", "daily_kpi") \
.option("user", "spark_writer") \
.option("password", "${CH_PASSWORD}") \
.mode("append") \
.save()
Важные нюансы интеграции:
- ClickHouse таблица должна существовать заранее с правильным
ORDER BYключом. - Используйте
mode("append"), а неoverwrite- ClickHouse сам схлопнет дубли черезSummingMergeTreeпри фоновом merge. - Фильтруйте
NULLв ключевых полях перед заливкой - ClickHouse строже к типам, чем Spark. - Для больших инкрементов используйте batching: пишите по N строк за раз через
.option("batch_size", "1000000").
5. Практика: полный Gold Pipeline¶
5.1 Полный код пайплайна¶
import uuid
from datetime import datetime
from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder \
.appName("Gold-Daily-KPI-Pipeline") \
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions") \
.config("spark.sql.catalog.lakehouse",
"org.apache.iceberg.spark.SparkCatalog") \
.config("spark.sql.catalog.lakehouse.type", "hadoop") \
.config("spark.sql.catalog.lakehouse.warehouse", "/tmp/warehouse") \
.getOrCreate()
run_id = str(uuid.uuid4())
# ─── 0. Целевая таблица ───────────────────────────────────────────────────────
spark.sql("""
CREATE TABLE IF NOT EXISTS lakehouse.gold.daily_revenue_kpi (
report_date DATE COMMENT 'Дата отчётного периода',
country STRING COMMENT 'NULL = итог по всем странам',
category STRING COMMENT 'NULL = итог по всем категориям',
total_revenue DECIMAL(18,2),
total_orders BIGINT,
unique_customers BIGINT,
avg_order_value DECIMAL(18,2),
grouping_level STRING COMMENT 'detail | by_country | by_category | total',
_calculated_at TIMESTAMP,
_run_id STRING
) USING iceberg
PARTITIONED BY (report_date)
""")
# ─── 1. Имитация Silver данных ────────────────────────────────────────────────
silver_data = [
("order_1", "2026-05-22", "SHOES", "RU", "user_a", 150.0),
("order_2", "2026-05-22", "SHOES", "RU", "user_b", 50.0),
("order_3", "2026-05-22", "BOOKS", "DE", "user_c", 30.0),
("order_4", "2026-05-22", "BOOKS", "RU", "user_a", 40.0),
("order_5", "2026-05-22", "SHOES", "DE", "user_d", 200.0),
("order_6", "2026-05-23", "LAPTOP", "RU", "user_b", 800.0),
("order_7", "2026-05-23", "SHOES", "DE", "user_c", 90.0),
]
silver_df = spark.createDataFrame(
silver_data,
["order_id", "order_date", "category", "country", "user_id", "amount"]
)
silver_df.createOrReplaceTempView("silver_orders")
# ─── 2. Многомерный агрегат через ROLLUP ─────────────────────────────────────
# ROLLUP(country, category) даст 4 уровня:
# country + category | только country | только category | общий итог
gold_raw = spark.sql("""
SELECT
CAST(order_date AS DATE) AS report_date,
country,
category,
SUM(amount) AS total_revenue,
COUNT(order_id) AS total_orders,
COUNT(DISTINCT user_id) AS unique_customers,
AVG(amount) AS avg_order_value,
GROUPING(country) AS g_country,
GROUPING(category) AS g_category
FROM silver_orders
GROUP BY ROLLUP (order_date, country, category)
HAVING report_date IS NOT NULL
""")
# Добавляем читаемый уровень группировки
gold_df = gold_raw.withColumn(
"grouping_level",
F.when((F.col("g_country") == 0) & (F.col("g_category") == 0), F.lit("detail"))
.when((F.col("g_country") == 0) & (F.col("g_category") == 1), F.lit("by_country"))
.when((F.col("g_country") == 1) & (F.col("g_category") == 0), F.lit("by_category"))
.otherwise(F.lit("total"))
).drop("g_country", "g_category") \
.withColumn("_calculated_at", F.current_timestamp()) \
.withColumn("_run_id", F.lit(run_id))
# ─── 3. Data Quality Gateway ──────────────────────────────────────────────────
print("=== Data Quality Gateway ===")
gold_df.cache() # кэшируем, чтобы не пересчитывать при каждой проверке
checks_passed = True
neg_revenue = gold_df.filter(F.col("total_revenue") < 0).count()
print(f"[CRITICAL] Revenue >= 0: {'✅ PASSED' if neg_revenue == 0 else f'❌ FAILED ({neg_revenue} строк)'}")
if neg_revenue > 0:
checks_passed = False
null_dates = gold_df.filter(F.col("report_date").isNull()).count()
print(f"[CRITICAL] No null dates: {'✅ PASSED' if null_dates == 0 else f'❌ FAILED ({null_dates} строк)'}")
if null_dates > 0:
checks_passed = False
total_row = gold_df.filter(F.col("grouping_level") == "total").agg(F.sum("total_revenue")).collect()[0][0]
print(f"[INFO] Общая выручка в витрине: {total_row}")
if not checks_passed:
raise RuntimeError(
"Circuit Breaker: Gold таблица НЕ обновлена из-за ошибок качества данных!"
)
# ─── 4. Запись в Gold Iceberg (идемпотентно) ──────────────────────────────────
gold_df.writeTo("lakehouse.gold.daily_revenue_kpi") \
.overwritePartitions()
print(f"\n✅ Gold Pipeline завершён. Run ID: {run_id}")
print("Результат витрины:")
spark.table("lakehouse.gold.daily_revenue_kpi").show(truncate=False)
5.2 Разбор плана выполнения ROLLUP¶
gold_raw.explain(mode="formatted")
В физическом плане ROLLUP виден как серия Expand → Aggregate узлов. Spark преобразует ROLLUP в специальный Expand оператор, который дублирует строки с разными комбинациями NULL для измерений, а затем выполняет обычную агрегацию. Это позволяет сделать единственный Shuffle по всем группировкам сразу.
6. Финальный кейс: маркетинг vs логистика¶
Разберём реальный архитектурный сценарий, иллюстрирующий выбор между Iceberg и ClickHouse.
Ситуация: два отдела, два совершенно разных требования к данным.
Почему Iceberg провалится для логистики: каждые 30 секунд - это 2880 микро-коммитов в сутки. Каждый коммит создаёт новый snapshot-файл в метаданных. За месяц таблица накопит 86 400 snapshot-файлов - чтение метаданных при каждом запросе станет занимать секунды. Плюс Spark имеет overhead инициализации job (~1–2 секунды), что несовместимо с 30-секундным SLA обновления.
Почему ClickHouse для маркетинга - перебор: хранить 5 лет исторических данных на NVMe SSD ClickHouse стоит в 10–50 раз дороже, чем на S3. При этом 30-секундная latency для еженощного отчёта - совершенно неоправданное требование. Iceberg + Trino (или Spark SQL) даст 5–15 секунд latency, что для ночного агрегата абсолютно приемлемо.
7. Анти-паттерны Gold Layer¶
Разные определения одной метрики - самый дорогой анти-паттерн. Если в Gold есть gold.sales_marketing с одной формулой выручки и gold.sales_finance с другой, рано или поздно на совете директоров два отдела покажут разные цифры за один период. Разбирательство «чья метрика правильная» занимает недели и разрушает доверие к данным на месяцы. Решение: единственное определение KPI, одна Gold-таблица для каждой метрики.
8. Сквозная картина: Bronze → Silver → Gold¶
Завершим урок взглядом на весь путь данных через Medallion Architecture:
| Слой | Ключевой принцип | Главный паттерн | Потребитель |
|---|---|---|---|
| Bronze | Иммутабельность, replayability | Append-only, audit columns | Silver Pipeline |
| Silver | Уникальность, типизация | ROW_NUMBER + MERGE INTO | Gold Pipeline, ML |
| Gold | Serving, SLA, DQ | ROLLUP + Circuit Breaker | BI, CEO, API |
Итог¶
Gold Layer завершает цикл Medallion Architecture. Ключевые решения, которые определяют успех этого слоя:
- Денормализация под потребителя - JOIN выполняется в ETL, а не в каждом BI-запросе. Это переносит стоимость с «сотен пользователей, делающих запросы» на «одного инженера, строящего витрину».
- ROLLUP для многомерных агрегатов - один проход по данным вместо N отдельных GROUP BY. Результат: предрассчитанные срезы, которые BI читает без агрегации на лету.
- Circuit Breaker перед записью - неправильные данные в Gold наносят репутационный ущерб, который сложно восстановить. Лучше показать вчерашние правильные данные, чем сегодняшние неправильные.
- Iceberg для истории, ClickHouse для интерактивности - это не конкурирующие технологии, а complementary слои. Iceberg - дешёвое хранение полной истории, ClickHouse - быстрый ответ на интерактивные запросы. Использовать ClickHouse вместо Iceberg - это платить за NVMe там, где достаточно S3. Использовать Iceberg там, где нужен sub-second ответ для 100 диспетчеров - это гарантированно нарушить SLA.
Medallion Architecture - это не просто три папки на S3. Это система гарантий: Bronze гарантирует сохранность, Silver гарантирует уникальность, Gold гарантирует готовность к потреблению. Инженер, понимающий эти гарантии, строит платформу, которой доверяет бизнес.