SCD Type 2: реализация через MERGE INTO и Iceberg row-level deletes

Slowly Changing Dimensions Type 2 - стандарт хранения истории изменений в Data Warehouse. Реализация через Spark MERGE INTO + Iceberg с valid_from/valid_to и is_current флагом.

core streaming

Контекст: почему SCD Type 2 важен в Lakehouse-архитектуре

В аналитических системах часто задают исторические вопросы: «Какой email был у клиента, когда он сделал заказ в марте?», «В каком регионе находился склад на момент инвентаризации?». Обычная перезапись данных (overwrite) уничтожает такую информацию - после UPDATE старое значение теряется навсегда. Slowly Changing Dimensions Type 2 (SCD Type 2) - это паттерн, который сохраняет всю историю изменений строки в виде последовательности версий.

Исторически SCD Type 2 реализовывался в традиционных Data Warehouse (Teradata, Netezza, Greenplum) через хранимые процедуры и ETL-инструменты вроде Informatica. В современной Lakehouse-архитектуре на базе Apache Spark и Apache Iceberg появились новые возможности: row-level deletes, MERGE INTO, Copy-on-Write vs Merge-on-Read - которые кардинально меняют производительность SCD-паттернов.

Ключевая проблема SCD Type 2 - write amplification: чтобы «закрыть» одну строку, нужно переписать весь файл, который её содержит. На таблице в миллиарды строк это означает переписывание гигабайт данных при каждом batch-обновлении. Iceberg решает эту проблему через position delete files - механизм, который позволяет пометить строку удалённой без физической перезаписи файла данных.

Три типа SCD: сравнение и выбор

Прежде чем переходить к реализации, важно понять, когда применять каждый тип и почему SCD Type 2 является золотым стандартом для большинства аналитических задач.

Тип Стратегия Хранение Сложность Восстановление истории
Type 1 Перезаписать O(1) Минимальная Невозможно
Type 2 Добавить версию O(n_версий) Высокая Полное
Type 3 Добавить колонку O(1) Средняя Только 1 версия назад
Type 4 Отдельная history-таблица O(n) Высокая Полное
Type 6 Type 1+2+3 гибрид O(n) Очень высокая Полное + текущее

Type 1 применяется, когда история изменений не имеет бизнес-смысла: исправление опечаток, обновление технических атрибутов (версия системы, категория для внутреннего использования).

Type 2 используется, когда нужна точность бизнес-анализа: «Каким был статус клиента в момент транзакции?», «Какова была цена товара при размещении заказа?». Это требование compliance, финансовой аналитики и ML feature stores.

Type 3 применяется редко - только когда бизнес явно требует «текущее vs предыдущее значение» без полной истории.

Схема SCD Type 2 таблицы: анатомия полей

Правильно спроектированная SCD Type 2 таблица содержит несколько категорий полей:

CREATE TABLE dim_customers (
    -- Бизнес-ключ (натуральный ключ из источника):
    customer_id     BIGINT        NOT NULL,

    -- Бизнес-атрибуты (то, что изменяется):
    email           STRING,
    full_name       STRING,
    country         STRING,
    city            STRING,
    loyalty_tier    STRING,        -- bronze / silver / gold

    -- SCD2 технические поля:
    valid_from      TIMESTAMP     NOT NULL,  -- начало действия версии
    valid_to        TIMESTAMP,               -- конец действия, NULL = текущая
    is_current      BOOLEAN       NOT NULL,  -- быстрый фильтр текущих записей
    surrogate_key   BIGINT        NOT NULL,  -- суррогатный PK для JOIN с фактами
    record_hash     STRING        NOT NULL,  -- SHA-256 атрибутов (для change detection)

    -- Аудит:
    created_at      TIMESTAMP     NOT NULL,  -- когда строка появилась в DWH
    source_system   STRING,                  -- откуда пришли данные
    batch_id        STRING                   -- ID ETL-запуска, создавшего строку
)
USING iceberg
PARTITIONED BY (is_current, days(valid_from))
TBLPROPERTIES (
    'write.format.default' = 'parquet',
    'write.parquet.compression-codec' = 'zstd',
    'format-version' = '2'
);

Детальное объяснение каждого поля:

  • customer_id - натуральный ключ из источника. Не уникален в SCD Type 2 таблице, так как один customer может иметь множество версий. Уникален только в паре с valid_from.

  • valid_from / valid_to - временной интервал действия версии. Запись «активна», если valid_from <= NOW() < valid_to (или valid_to IS NULL). Использование NULL вместо условного значения (9999-12-31) - предпочтительный современный подход: NULL явно означает «бесконечность».

  • is_current - денормализованный флаг для производительности. Технически избыточен (можно вычислить через valid_to IS NULL), но критически важен для скорости: запрос WHERE is_current = true использует partition pruning в Iceberg, а не full scan.

  • surrogate_key - синтетический уникальный ключ. Таблицы фактов хранят surrogate_key, а не customer_id, что позволяет однозначно ссылаться на конкретную версию dimension без указания временного интервала.

  • record_hash - SHA-256 (или xxhash64) от конкатенации всех отслеживаемых атрибутов. Позволяет за одно сравнение строк определить, изменилось ли что-либо, без покомпонентного сравнения 10+ полей.

Apache Iceberg: Copy-on-Write vs Merge-on-Read

Выбор режима записи в Iceberg кардинально влияет на производительность SCD Type 2 операций. Понять разницу критически важно перед проектированием пайплайна.

Copy-on-Write (CoW)

Принцип: при любом UPDATE или DELETE Iceberg читает весь файл данных, применяет изменение в памяти и записывает новый файл. Старый файл помечается удалённым в манифестах.

Для SCD Type 2: когда нужно закрыть 1000 записей, разбросанных по 500 файлам, CoW перезапишет все 500 файлов целиком. Если каждый файл по 128MB, это 64GB I/O только для операции закрытия.

Преимущества CoW:

  • Чтение без накладных расходов - нет delete files для merge
  • Простая структура: один файл = одна версия данных
  • Хорошо для workload с редкими обновлениями и частыми аналитическими запросами

Недостатки CoW:

  • Write amplification при частых updates
  • Долгое время выполнения MERGE INTO при большом проценте обновлений

Merge-on-Read (MoR)

Принцип: UPDATE/DELETE добавляет position delete file - небольшой Avro-файл со списком позиций строк, которые нужно считать удалёнными. Сам data file не изменяется. Новые версии строк пишутся в отдельный data file.

Для SCD Type 2: закрытие 1000 записей создаёт маленький delete file (~100KB) вместо перезаписи 500 файлов. Вставка 1000 новых версий пишет один новый data file.

Преимущества MoR:

  • Быстрые writes - минимальный I/O
  • Оптимален для SCD2 с высокой частотой обновлений

Недостатки MoR:

  • При чтении нужно merge data files с delete files - overhead на каждый запрос
  • При накоплении delete files производительность чтения деградирует
  • Требует периодической компакции (OPTIMIZE)

Конфигурация для SCD Type 2

# Включить MoR для таблицы измерений с частыми обновлениями:
spark.sql("""
    ALTER TABLE catalog.dwh.dim_customers SET TBLPROPERTIES (
        'write.delete.mode'          = 'merge-on-read',
        'write.update.mode'          = 'merge-on-read',
        'write.merge.mode'           = 'merge-on-read',
        -- Автоматическая компакция после N операций:
        'write.target-file-size-bytes' = '134217728',   -- 128 MB target
        'write.distribution-mode'    = 'hash'           -- hash by partition key
    )
""")

Правило выбора: если процент обновляемых записей за batch > 5% от таблицы → MoR. Если < 5% и таблица в основном читается → CoW.

Change Detection через хеширование атрибутов

Первая задача в SCD Type 2 пайплайне - определить, какие записи реально изменились. Наивный подход - покомпонентное сравнение каждого поля - работает, но неэффективен для таблиц с 20+ атрибутами. Хеширование решает это за O(1) сравнений.

from pyspark.sql import functions as F
from pyspark.sql import SparkSession, DataFrame
from typing import List


def add_record_hash(df: DataFrame, columns: List[str]) -> DataFrame:
    """
    Вычислить SHA-256 хеш от конкатенации указанных колонок.

    Важные детали:
    - COALESCE(col, '') для NULL-safety: sha2('a|NULL|b') != sha2('a||b')
      при разных NULL-подходах, поэтому нормализуем NULL → пустая строка.
    - Разделитель '|' выбран так, чтобы не встречаться в данных.
      Для надёжности можно использовать chr(1) как non-printable separator.
    - sha2(..., 256) возвращает 64-символьный hex string.
    - xxhash64 быстрее, но может иметь коллизии на больших объёмах.
    """
    normalized = [F.coalesce(F.col(c).cast("string"), F.lit("")).alias(c)
                  for c in columns]
    concat_expr = F.concat_ws("|", *[F.col(c) for c in columns])

    return (
        df
        .withColumn("_norm_concat", concat_expr)
        .withColumn("record_hash", F.sha2(F.col("_norm_concat"), 256))
        .drop("_norm_concat")
    )


# Пример использования:
TRACKED_COLUMNS = ["email", "full_name", "country", "city", "loyalty_tier"]

source_with_hash = add_record_hash(source_df, TRACKED_COLUMNS)

Почему sha2 а не md5: MD5 считается криптографически сломанным (намеренные коллизии возможны). Для change detection SHA-256 достаточен и безопаснее. Если производительность критична - xxhash64 быстрее и подходит для non-adversarial данных.

Проблема порядка NULL: sha2("a|NULL|b") даст другой результат, чем sha2("a|b"), если NULL просто пропускается. COALESCE(col, "") обеспечивает стабильный результат.

Source Expansion Pattern: анатомия SCD2 MERGE

Классический MERGE INTO имеет ограничение: одна строка в source может совпасть только с одной строкой в target - либо UPDATE, либо INSERT, но не то и другое одновременно. Для SCD2 нам нужно и закрыть старую запись, и вставить новую. Решение - Source Expansion Pattern.

Идея: для каждой изменившейся записи в источнике создаём две строки: одну для UPDATE (закрыть старую), другую для INSERT (новая версия). Затем используем единый MERGE.

from pyspark.sql import functions as F, SparkSession, DataFrame
from pyspark.sql.types import BooleanType, TimestampType, StringType, LongType
from datetime import datetime, timezone
import xxhash


TRACKED_COLUMNS = ["email", "full_name", "country", "city", "loyalty_tier"]


def build_source_expanded(
    spark: SparkSession,
    source_df: DataFrame,
    target_table: str,
    now: str = "current_timestamp()"
) -> DataFrame:
    """
    Source Expansion для SCD Type 2:
    - Новые записи   → одна строка с action='insert'
    - Изменившиеся  → две строки: action='close' + action='insert'
    - Без изменений → пропускаются
    """

    source_with_hash = add_record_hash(source_df, TRACKED_COLUMNS)
    source_with_hash.createOrReplaceTempView("_scd2_source")

    # Найти текущие версии из target:
    current_target = spark.sql(f"""
        SELECT customer_id, surrogate_key, record_hash
        FROM {target_table}
        WHERE is_current = true
    """)
    current_target.createOrReplaceTempView("_scd2_current")

    # Классифицировать изменения:
    classified = spark.sql(f"""
        SELECT
            s.*,
            CASE
                WHEN c.customer_id IS NULL         THEN 'new'
                WHEN s.record_hash != c.record_hash THEN 'changed'
                ELSE 'unchanged'
            END AS _change_type,
            c.surrogate_key AS _old_surrogate_key
        FROM _scd2_source s
        LEFT JOIN _scd2_current c ON s.customer_id = c.customer_id
    """)

    # Строки для закрытия старых версий (только changed):
    rows_to_close = classified.filter(
        F.col("_change_type") == "changed"
    ).select(
        F.col("_old_surrogate_key").alias("surrogate_key"),
        F.col("customer_id"),
        F.lit("close").alias("_action"),
        F.lit(None).cast("string").alias("email"),
        F.lit(None).cast("string").alias("full_name"),
        F.lit(None).cast("string").alias("country"),
        F.lit(None).cast("string").alias("city"),
        F.lit(None).cast("string").alias("loyalty_tier"),
        F.lit(None).cast("string").alias("record_hash"),
    )

    # Строки для вставки новых версий (new + changed):
    rows_to_insert = classified.filter(
        F.col("_change_type").isin("new", "changed")
    ).select(
        # Детерминированный surrogate_key через хеш business_key + valid_from:
        F.xxhash64(
            F.col("customer_id"),
            F.expr(now)
        ).alias("surrogate_key"),
        F.col("customer_id"),
        F.lit("insert").alias("_action"),
        F.col("email"),
        F.col("full_name"),
        F.col("country"),
        F.col("city"),
        F.col("loyalty_tier"),
        F.col("record_hash"),
    )

    return rows_to_close.union(rows_to_insert)

Обратите внимание: surrogate_key для новых строк вычисляется через xxhash64(customer_id, valid_from). Это детерминированный подход - при повторном запуске пайплайна с теми же данными получим тот же surrogate_key, что обеспечивает idempotency.

Полная реализация: двухшаговый SCD Type 2

Самый надёжный подход - разбить MERGE на два явных шага: сначала закрыть устаревшие, затем вставить новые. Это проще понять, проще отлаживать и проще делать idempotent.

from pyspark.sql import SparkSession, DataFrame
from pyspark.sql import functions as F
from dataclasses import dataclass
from typing import List, Optional
import logging

logger = logging.getLogger(__name__)


@dataclass
class SCD2Config:
    """Конфигурация SCD Type 2 пайплайна."""
    target_table: str            # catalog.schema.table_name
    business_key: str            # колонка натурального ключа
    tracked_columns: List[str]   # атрибуты, изменение которых создаёт новую версию
    audit_columns: List[str]     # колонки, которые не триггерят новую версию
    batch_id: str                # ID текущего batch для аудита
    source_system: str = "crm"


def run_scd2_pipeline(
    spark: SparkSession,
    source_df: DataFrame,
    config: SCD2Config
) -> dict:
    """
    Двухшаговый SCD Type 2:
    1. Закрыть устаревшие версии (MERGE / UPDATE)
    2. Вставить новые версии (INSERT)

    Возвращает метрики: rows_closed, rows_inserted, rows_unchanged.
    """

    # Шаг 0: Вычислить хеши и подготовить source
    source_hashed = add_record_hash(source_df, config.tracked_columns)

    # Зафиксировать timestamp для всего batch (консистентность):
    # Используем одно значение NOW для всех строк в batch
    batch_ts = spark.sql("SELECT current_timestamp() AS ts").first()["ts"]
    batch_ts_str = f"TIMESTAMP '{batch_ts}'"

    source_hashed = source_hashed.withColumn("_batch_ts", F.lit(batch_ts))
    source_hashed.createOrReplaceTempView("_scd2_source")

    # -----------------------------------------------------------------------
    # Шаг 1: Закрыть устаревшие версии
    # Найти записи в target, для которых в source пришёл изменённый hash
    # -----------------------------------------------------------------------
    logger.info("SCD2 Step 1: Closing outdated records")

    close_sql = f"""
        MERGE INTO {config.target_table} AS target
        USING (
            SELECT
                s.{config.business_key},
                s.record_hash AS new_hash,
                s._batch_ts   AS close_ts
            FROM _scd2_source s
        ) AS src
        ON  target.{config.business_key} = src.{config.business_key}
        AND target.is_current = true
        AND target.record_hash != src.new_hash   -- реально изменилось
        WHEN MATCHED THEN UPDATE SET
            target.is_current = false,
            target.valid_to   = src.close_ts
    """

    spark.sql(close_sql)

    closed_count = spark.sql(f"""
        SELECT COUNT(*)
        FROM {config.target_table}
        WHERE is_current = false
          AND valid_to = {batch_ts_str}
    """).first()[0]

    logger.info(f"SCD2 Step 1: Closed {closed_count} records")

    # -----------------------------------------------------------------------
    # Шаг 2: Вставить новые версии
    # new = не было в target | changed = только что закрыли на шаге 1
    # -----------------------------------------------------------------------
    logger.info("SCD2 Step 2: Inserting new versions")

    insert_sql = f"""
        INSERT INTO {config.target_table}
        SELECT
            s.{config.business_key},
            s.email,
            s.full_name,
            s.country,
            s.city,
            s.loyalty_tier,
            s._batch_ts                                    AS valid_from,
            CAST(NULL AS TIMESTAMP)                        AS valid_to,
            true                                           AS is_current,
            xxhash64(s.{config.business_key}, s._batch_ts) AS surrogate_key,
            s.record_hash,
            s._batch_ts                                    AS created_at,
            '{config.source_system}'                       AS source_system,
            '{config.batch_id}'                            AS batch_id
        FROM _scd2_source s
        LEFT JOIN {config.target_table} t
            ON  s.{config.business_key} = t.{config.business_key}
            AND t.is_current = true
        WHERE t.{config.business_key} IS NULL   -- новый customer (нет текущей версии)
           OR t.record_hash != s.record_hash    -- изменился (шаг 1 уже закрыл)
    """

    spark.sql(insert_sql)

    inserted_count = spark.sql(f"""
        SELECT COUNT(*)
        FROM {config.target_table}
        WHERE batch_id = '{config.batch_id}'
          AND is_current = true
    """).first()[0]

    unchanged_count = source_df.count() - closed_count

    logger.info(f"SCD2 Step 2: Inserted {inserted_count} new versions")

    return {
        "closed":    closed_count,
        "inserted":  inserted_count,
        "unchanged": unchanged_count,
        "batch_ts":  str(batch_ts),
    }

Почему два шага надёжнее одного MERGE

Попытка сделать SCD2 одним MERGE INTO сталкивается с ограничением SQL: одна исходная строка не может одновременно выполнить UPDATE существующей записи и INSERT новой. Iceberg (как и Delta Lake) поддерживает только одно действие на совпадение.

Двухшаговый подход:

  • Атомарность: шаг 1 и шаг 2 - отдельные транзакции. При сбое на шаге 2 данные не повреждены (старые записи закрыты, новые не вставлены). Повторный запуск восстановит состояние.
  • Отлаживаемость: легко проверить результат каждого шага по отдельности.
  • Idempotency: повторный запуск с теми же данными не создаст дублей (шаг 1 найдёт 0 записей для закрытия, шаг 2 не вставит дублей из-за LEFT JOIN условия).

Surrogate Key: генерация без дублей

Surrogate key (суррогатный ключ) - синтетический уникальный идентификатор каждой версии SCD2 записи. Таблицы фактов хранят surrogate_key, а не business_key с timestamp, что упрощает join и экономит место.

# ❌ Антипаттерн 1: monotonically_increasing_id()
# Не детерминирован при retry, зависит от порядка партиций
df.withColumn("surrogate_key", F.monotonically_increasing_id())

# ❌ Антипаттерн 2: UUID
# UUID v4 случаен - разные значения при retry, нет idempotency
import uuid
df.withColumn("surrogate_key", F.lit(str(uuid.uuid4())))

# ✅ Подход 1: xxhash64 от business_key + valid_from
# Детерминирован, быстр (non-cryptographic hash)
df.withColumn(
    "surrogate_key",
    F.xxhash64(F.col("customer_id"), F.col("valid_from"))
)

# ✅ Подход 2: hash64 через sha2 + бит-усечение (для совместимости с BIGINT)
# SHA-256 → берём первые 16 hex символов → приводим к int64
df.withColumn(
    "surrogate_key",
    F.conv(F.substring(F.sha2(
        F.concat_ws("|", F.col("customer_id").cast("string"),
                    F.col("valid_from").cast("string")), 256
    ), 1, 15), 16, 10).cast("long")
)

# ✅ Подход 3: Iceberg sequence number (автоинкремент на уровне snapshot)
# Доступен через метаданные Iceberg, уникален в рамках таблицы
# Не подходит для cross-table joins без дополнительного маппинга

Рекомендация: xxhash64(business_key, valid_from) - оптимальный выбор. Детерминирован (idempotent pipeline), быстр, достаточно уникален для практических объёмов (вероятность коллизии при 2^32 строк = ~0.00001%).

Edge Cases: сложные ситуации в production

1. Множественные изменения в одном batch

Что происходит, если в одном batch пришли два изменения для одного customer_id? Например, email изменился в 10:00, потом снова изменился в 10:05, и оба события попали в один hourly batch.

Решение: перед SCD2 MERGE сортировать события по event_timestamp и обрабатывать их упорядоченно. Если точные timestamps известны - использовать их как valid_from вместо current_timestamp().

from pyspark.sql import functions as F
from pyspark.sql.window import Window


def deduplicate_within_batch(
    df: DataFrame,
    business_key: str,
    event_ts_col: str,
    tracked_cols: List[str]
) -> DataFrame:
    """
    Если в batch несколько событий для одного business_key:
    - Взять последнее состояние для финального INSERT в target
    - Сохранить промежуточные версии если event_ts известен

    Для простоты: берём последнее событие (если промежуточные версии не нужны).
    """
    window = Window.partitionBy(business_key).orderBy(F.col(event_ts_col).desc())

    return (
        df
        .withColumn("_row_num", F.row_number().over(window))
        .filter(F.col("_row_num") == 1)
        .drop("_row_num")
    )

2. Late-arriving data (запоздавшие данные)

Данные из upstream системы могут прийти с задержкой: событие произошло вчера, но дошло до нас сегодня. Наивная реализация с current_timestamp() поставит неверный valid_from.

def handle_late_arriving(
    spark: SparkSession,
    source_df: DataFrame,
    target_table: str,
    event_ts_col: str
) -> None:
    """
    Late-arriving SCD Type 2:
    Использует event_timestamp как valid_from вместо processing_time.

    Проблема: если запись уже была закрыта позже event_ts,
    нужно «вклинить» новую версию в середину истории.
    """
    source_df.createOrReplaceTempView("_late_source")

    # Найти пересекающиеся интервалы в target:
    overlaps = spark.sql(f"""
        SELECT
            t.customer_id,
            t.surrogate_key,
            t.valid_from AS t_valid_from,
            t.valid_to   AS t_valid_to,
            s.{event_ts_col} AS event_ts
        FROM {target_table} t
        JOIN _late_source s
            ON t.customer_id = s.customer_id
           AND s.{event_ts_col} BETWEEN t.valid_from
               AND COALESCE(t.valid_to, TIMESTAMP '9999-01-01')
        WHERE t.is_current = false OR t.valid_to IS NULL
    """)

    if overlaps.count() > 0:
        logger.warning(
            f"Late-arriving data: {overlaps.count()} records "
            f"require timeline surgery - inserting mid-history versions"
        )
        # Логика «timeline surgery» - сложна и error-prone.
        # Рекомендация для production: отправлять в DLQ для ручного review.

Практическая рекомендация: late-arriving данные с задержкой > 1 batch - отдельный кейс для ручного review или специализированного пайплайна. Не пытайтесь автоматически «вклинить» версии в середину истории: это нарушает целостность временных интервалов и сложно тестируется.

3. Integrity Checks: проверки целостности SCD2 таблицы

После каждого SCD2 запуска рекомендуется проверять корректность временных интервалов:

def check_scd2_integrity(spark: SparkSession, target_table: str) -> dict:
    """
    Проверки целостности SCD Type 2 таблицы:
    1. Ровно одна текущая версия на customer_id
    2. Непрерывные интервалы (no gaps, no overlaps)
    3. is_current соответствует valid_to IS NULL
    """

    # Проверка 1: дублирующиеся текущие версии
    dups = spark.sql(f"""
        SELECT customer_id, COUNT(*) AS cnt
        FROM {target_table}
        WHERE is_current = true
        GROUP BY customer_id
        HAVING COUNT(*) > 1
    """)
    dup_count = dups.count()

    # Проверка 2: несоответствие is_current и valid_to
    inconsistent = spark.sql(f"""
        SELECT COUNT(*) AS cnt
        FROM {target_table}
        WHERE (is_current = true  AND valid_to IS NOT NULL)
           OR (is_current = false AND valid_to IS NULL)
    """).first()[0]

    # Проверка 3: пересекающиеся интервалы
    # Используем self-join (expensive! запускать только в режиме аудита)
    overlaps = spark.sql(f"""
        SELECT COUNT(*) AS cnt
        FROM {target_table} a
        JOIN {target_table} b
            ON  a.customer_id = b.customer_id
            AND a.surrogate_key < b.surrogate_key
            AND a.valid_from < COALESCE(b.valid_to, TIMESTAMP '9999-01-01')
            AND b.valid_from < COALESCE(a.valid_to, TIMESTAMP '9999-01-01')
    """).first()[0]

    integrity_ok = (dup_count == 0 and inconsistent == 0 and overlaps == 0)

    return {
        "integrity_ok":    integrity_ok,
        "duplicate_currents": dup_count,
        "inconsistent_flags": inconsistent,
        "overlapping_intervals": overlaps,
    }

Temporal Joins: запросы к SCD2 таблице

Самое частое использование SCD2 таблицы - temporal join: присоединить атрибуты dimension на момент события в fact table.

-- Temporal join: факты с атрибутами на момент события
SELECT
    o.order_id,
    o.order_date,
    o.amount,
    c.email,
    c.country,
    c.loyalty_tier     -- tier клиента НА МОМЕНТ заказа
FROM fact_orders o
JOIN dim_customers c
    ON  o.customer_id = c.customer_id
    AND o.order_date >= c.valid_from
    AND (o.order_date <  c.valid_to OR c.valid_to IS NULL);

-- Эквивалент через surrogate_key (быстрее если fact хранит surrogate_key):
SELECT
    o.order_id,
    o.order_date,
    o.amount,
    c.email,
    c.country
FROM fact_orders_with_sk o       -- уже содержит surrogate_key
JOIN dim_customers c
    ON o.customer_sk = c.surrogate_key;

-- Состояние dimension на конкретную дату (point-in-time query):
SELECT *
FROM dim_customers
WHERE customer_id = 42
  AND valid_from <= TIMESTAMP '2024-06-01 00:00:00'
  AND (valid_to > TIMESTAMP '2024-06-01 00:00:00' OR valid_to IS NULL);

-- История изменений конкретного клиента:
SELECT
    customer_id,
    email,
    country,
    loyalty_tier,
    valid_from,
    valid_to,
    DATEDIFF(
        COALESCE(valid_to, current_timestamp()),
        valid_from
    ) AS days_active
FROM dim_customers
WHERE customer_id = 42
ORDER BY valid_from;

-- Текущее состояние (используем partition pruning):
SELECT *
FROM dim_customers
WHERE is_current = true;    -- partition pruning → читается только partition is_current=true

Оптимизация temporal joins

Temporal join по условию o.order_date BETWEEN c.valid_from AND c.valid_to - тяжёлая операция: Spark не может использовать hash join для неэквивалентных условий. Оптимизации:

# Оптимизация 1: Broadcast-join для небольшой dimension
from pyspark.sql import functions as F

result = (
    fact_orders
    .join(
        F.broadcast(dim_customers.filter("is_current = true")),
        on="customer_id",
        how="left"
    )
    # Для факта нужны именно текущие атрибуты → broadcast текущего среза
)

# Оптимизация 2: Partition dimension по date ranges для filter pushdown
# Если dim_customers партиционирована по year(valid_from):
spark.sql("""
    SELECT o.*, c.email, c.country
    FROM fact_orders o
    JOIN dim_customers c
        ON  o.customer_id = c.customer_id
        AND o.order_date >= c.valid_from
        AND (o.order_date < c.valid_to OR c.valid_to IS NULL)
        AND YEAR(c.valid_from) BETWEEN 2023 AND 2024  -- partition pruning
""")

# Оптимизация 3: Surrogate key lookup (самый быстрый join)
# Требует pre-computation surrogate_key в fact pipeline
spark.sql("""
    SELECT o.*, c.email, c.country
    FROM fact_orders_enriched o    -- surrogate_key уже вычислен в fact pipeline
    JOIN dim_customers c ON o.customer_dim_sk = c.surrogate_key
""")

Производительность: Iceberg SCD2 Optimization

Компакция и управление delete files

Merge-on-Read накапливает delete files при каждом batch. После N batches производительность чтения деградирует. Периодическая компакция восстанавливает её.

from pyspark.sql import SparkSession


def run_scd2_maintenance(
    spark: SparkSession,
    target_table: str,
    max_delete_files_ratio: float = 0.1
) -> None:
    """
    Обслуживание SCD2 Iceberg таблицы.
    Запускать по расписанию (например, 1 раз в сутки).
    """

    # Проверить соотношение delete files к data files:
    stats = spark.sql(f"""
        SELECT
            SUM(CASE WHEN content = 0 THEN 1 ELSE 0 END) AS data_files,
            SUM(CASE WHEN content = 1 THEN 1 ELSE 0 END) AS pos_delete_files,
            SUM(CASE WHEN content = 2 THEN 1 ELSE 0 END) AS eq_delete_files
        FROM {target_table}.files
    """).first()

    delete_ratio = (stats["pos_delete_files"] or 0) / max(stats["data_files"] or 1, 1)

    if delete_ratio > max_delete_files_ratio:
        # Компакция: rewrite data files с применением delete files
        spark.sql(f"""
            CALL spark_catalog.system.rewrite_data_files(
                table => '{target_table}',
                strategy => 'binpack',
                options => map(
                    'target-file-size-bytes', '134217728',
                    'max-concurrent-file-group-rewrites', '5'
                )
            )
        """)

    # Удалить orphan files (файлы без ссылок в метаданных):
    spark.sql(f"""
        CALL spark_catalog.system.remove_orphan_files(
            table => '{target_table}',
            older_than => TIMESTAMP '{spark.sql("SELECT current_timestamp() - INTERVAL 3 DAYS").first()[0]}'
        )
    """)

    # Expire старые snapshots (сохранить последние 14 дней):
    spark.sql(f"""
        CALL spark_catalog.system.expire_snapshots(
            table => '{target_table}',
            older_than => TIMESTAMP '{spark.sql("SELECT current_timestamp() - INTERVAL 14 DAYS").first()[0]}',
            retain_last => 10
        )
    """)

    # Обновить статистику для query optimizer:
    spark.sql(f"ANALYZE TABLE {target_table} COMPUTE STATISTICS")

Партиционирование SCD2 таблицы

Правильная стратегия партиционирования критически важна для производительности:

-- Вариант 1: Только is_current (простой, хорошо для текущих запросов)
PARTITIONED BY (is_current)
-- + Текущий срез: partition pruning → только is_current=true partition
-- - Историческая партиция огромна, нет temporal pruning

-- Вариант 2: is_current + год valid_from (баланс)
PARTITIONED BY (is_current, years(valid_from))
-- + Temporal queries могут использовать pruning по году
-- + Текущие данные изолированы в отдельной партиции
-- - Мелкие партиции если мало изменений в старых годах

-- Вариант 3: Только years(valid_from) (для глубокой истории)
PARTITIONED BY (years(valid_from))
-- + Равномерное распределение по времени
-- - Текущие записи разбросаны по всем партициям (нет partition pruning для is_current)

-- Рекомендация для большинства случаев: Вариант 2

Z-ordering для SCD2 таблиц

Iceberg поддерживает Z-ordering (multi-dimensional clustering) для оптимизации запросов по нескольким колонкам:

# Z-order по customer_id + valid_from для оптимизации temporal joins:
spark.sql(f"""
    CALL spark_catalog.system.rewrite_data_files(
        table => '{target_table}',
        strategy => 'sort',
        sort_order => 'customer_id ASC NULLS LAST, valid_from ASC NULLS LAST',
        options => map('target-file-size-bytes', '134217728')
    )
""")

После Z-ordering Spark будет читать значительно меньше файлов при temporal join, так как строки с близкими customer_id и valid_from физически соседствуют.

Anti-patterns: типичные ошибки в SCD Type 2

Антипаттерн 1: Non-idempotent MERGE

# ❌ Проблема: повторный запуск создаёт дубли
spark.sql(f"""
    INSERT INTO dim_customers
    SELECT *, current_timestamp() AS valid_from
    FROM source_changes
""")
# При retry → duplicate rows с разными valid_from

# ✅ Решение: проверять перед вставкой
spark.sql(f"""
    INSERT INTO dim_customers
    SELECT s.*, '{batch_ts}' AS valid_from
    FROM source_changes s
    LEFT JOIN dim_customers t
        ON s.customer_id = t.customer_id
       AND t.batch_id = '{batch_id}'    -- уже вставлено в этом batch?
    WHERE t.customer_id IS NULL         -- не вставлено → вставляем
""")

Антипаттерн 2: current_timestamp() без фиксации

# ❌ Проблема: current_timestamp() вычисляется для каждой строки отдельно
# На многоузловом кластере строки получают разные timestamps!
df.withColumn("valid_from", F.current_timestamp())

# ✅ Решение: зафиксировать одно значение для всего batch
batch_ts = spark.sql("SELECT current_timestamp() AS ts").first()["ts"]
df.withColumn("valid_from", F.lit(batch_ts))

Антипаттерн 3: Overlapping intervals

# ❌ Проблема: valid_to(v1) != valid_from(v2)
# Версия 1: valid_from=10:00:00.001, valid_to=10:00:01.005
# Версия 2: valid_from=10:00:01.006, valid_to=NULL
# Gap между версиями: 10:00:01.005 → 10:00:01.006

# ✅ Решение: закрывать точно тем же timestamp, что открывает следующую
# Шаг 1: вычислить batch_ts
# Шаг 2: в UPDATE SET valid_to = batch_ts
# Шаг 3: в INSERT SET valid_from = batch_ts (тот же timestamp!)
# → valid_to(v1) == valid_from(v2), пробел нулевой

Антипаттерн 4: Игнорирование NULL в tracked_columns

# ❌ Проблема: NULL != NULL в SQL → бесконечные изменения
# Если поле email стало NULL, а в target уже было NULL:
# SHA2("a|NULL|b") vs SHA2("a|NULL|b") могут дать разные результаты
# если NULL сериализуется по-разному

# ✅ Решение: всегда COALESCE(col, '') перед хешированием
F.sha2(
    F.concat_ws("|",
        F.coalesce(F.col("email"),       F.lit("")),
        F.coalesce(F.col("country"),     F.lit("")),
        F.coalesce(F.col("loyalty_tier"),F.lit(""))
    ), 256
)

Production Case: Customer Dimension SCD Type 2 через Spark + Iceberg

Полная production-ready реализация SCD2 пайплайна с логированием, метриками и обработкой edge cases.

from pyspark.sql import SparkSession, DataFrame
from pyspark.sql import functions as F
from pyspark.sql.window import Window
from dataclasses import dataclass, field
from typing import List, Optional
import logging
import uuid

logger = logging.getLogger(__name__)


@dataclass
class SCD2PipelineConfig:
    """Полная конфигурация SCD2 пайплайна."""
    # Таблицы:
    source_table:  str  = "catalog.raw.crm_customers"
    target_table:  str  = "catalog.dwh.dim_customers"

    # Ключи и атрибуты:
    business_key:  str  = "customer_id"
    tracked_cols: List[str] = field(default_factory=lambda: [
        "email", "full_name", "country", "city", "loyalty_tier", "phone"
    ])

    # Аудит:
    source_system: str  = "crm"
    batch_id:      str  = field(default_factory=lambda: str(uuid.uuid4()))

    # Производительность:
    broadcast_threshold_mb: int = 512
    enable_integrity_check: bool = True
    enable_maintenance: bool = False   # запускать компакцию?
    maintenance_delete_ratio: float = 0.15


def create_dim_customers_table(spark: SparkSession, cfg: SCD2PipelineConfig) -> None:
    """DDL для SCD2 таблицы (idempotent через IF NOT EXISTS)."""
    spark.sql(f"""
        CREATE TABLE IF NOT EXISTS {cfg.target_table} (
            customer_id   BIGINT        NOT NULL,
            email         STRING,
            full_name     STRING,
            country       STRING,
            city          STRING,
            loyalty_tier  STRING,
            phone         STRING,
            valid_from    TIMESTAMP     NOT NULL,
            valid_to      TIMESTAMP,
            is_current    BOOLEAN       NOT NULL,
            surrogate_key BIGINT        NOT NULL,
            record_hash   STRING        NOT NULL,
            created_at    TIMESTAMP     NOT NULL,
            source_system STRING,
            batch_id      STRING
        )
        USING iceberg
        PARTITIONED BY (is_current, years(valid_from))
        TBLPROPERTIES (
            'write.delete.mode'  = 'merge-on-read',
            'write.update.mode'  = 'merge-on-read',
            'write.merge.mode'   = 'merge-on-read',
            'format-version'     = '2',
            'write.parquet.compression-codec' = 'zstd'
        )
    """)
    logger.info(f"Table {cfg.target_table} ensured")


def load_and_prepare_source(
    spark: SparkSession,
    cfg: SCD2PipelineConfig
) -> DataFrame:
    """
    Загрузить источник и подготовить к SCD2:
    1. Прочитать из raw слоя
    2. Дедуплицировать (оставить последнее событие на customer_id)
    3. Вычислить record_hash
    """
    raw = spark.table(cfg.source_table)

    # Дедупликация: оставить последнее событие для каждого customer_id
    w = Window.partitionBy(cfg.business_key).orderBy(
        F.col("event_timestamp").desc(),
        F.col("_file_modification_time").desc()  # tiebreak
    )
    deduped = (
        raw
        .withColumn("_rn", F.row_number().over(w))
        .filter(F.col("_rn") == 1)
        .drop("_rn", "event_timestamp", "_file_modification_time")
    )

    # Вычислить record_hash
    hash_expr = F.sha2(
        F.concat_ws("|", *[
            F.coalesce(F.col(c).cast("string"), F.lit(""))
            for c in cfg.tracked_cols
        ]), 256
    )
    return deduped.withColumn("record_hash", hash_expr)


def step1_close_outdated(
    spark: SparkSession,
    source_df: DataFrame,
    cfg: SCD2PipelineConfig,
    batch_ts: str
) -> int:
    """
    Шаг 1: Закрыть устаревшие версии.
    MERGE INTO: найти текущие записи с изменившимся hash → UPDATE valid_to, is_current=false.
    """
    source_df.createOrReplaceTempView("_scd2_src_step1")

    spark.sql(f"""
        MERGE INTO {cfg.target_table} AS target
        USING (
            SELECT {cfg.business_key}, record_hash
            FROM _scd2_src_step1
        ) AS src
        ON  target.{cfg.business_key} = src.{cfg.business_key}
        AND target.is_current          = true
        AND target.record_hash        != src.record_hash
        WHEN MATCHED THEN UPDATE SET
            target.is_current = false,
            target.valid_to   = TIMESTAMP '{batch_ts}'
    """)

    closed = spark.sql(f"""
        SELECT COUNT(*) FROM {cfg.target_table}
        WHERE is_current = false
          AND valid_to = TIMESTAMP '{batch_ts}'
          AND batch_id != '{cfg.batch_id}'
    """).first()[0]

    logger.info(f"[SCD2] Step 1 CLOSE: {closed} records closed, batch_ts={batch_ts}")
    return closed


def step2_insert_new_versions(
    spark: SparkSession,
    source_df: DataFrame,
    cfg: SCD2PipelineConfig,
    batch_ts: str
) -> int:
    """
    Шаг 2: Вставить новые версии.
    INSERT: новые customer + те, что были закрыты на шаге 1.
    """
    source_df.createOrReplaceTempView("_scd2_src_step2")

    spark.sql(f"""
        INSERT INTO {cfg.target_table}
        SELECT
            s.{cfg.business_key},
            s.email,
            s.full_name,
            s.country,
            s.city,
            s.loyalty_tier,
            s.phone,
            TIMESTAMP '{batch_ts}'                              AS valid_from,
            CAST(NULL AS TIMESTAMP)                            AS valid_to,
            true                                               AS is_current,
            xxhash64(s.{cfg.business_key},
                     CAST('{batch_ts}' AS TIMESTAMP))          AS surrogate_key,
            s.record_hash,
            TIMESTAMP '{batch_ts}'                             AS created_at,
            '{cfg.source_system}'                              AS source_system,
            '{cfg.batch_id}'                                   AS batch_id
        FROM _scd2_src_step2 s
        LEFT JOIN {cfg.target_table} t
            ON  s.{cfg.business_key} = t.{cfg.business_key}
            AND t.is_current = true
        WHERE t.{cfg.business_key} IS NULL        -- новый customer
           OR t.record_hash != s.record_hash      -- изменился (закрыт на шаге 1)
    """)

    inserted = spark.sql(f"""
        SELECT COUNT(*) FROM {cfg.target_table}
        WHERE batch_id = '{cfg.batch_id}'
          AND is_current = true
    """).first()[0]

    logger.info(f"[SCD2] Step 2 INSERT: {inserted} new versions inserted")
    return inserted


def run_scd2_full_pipeline(
    spark: SparkSession,
    cfg: SCD2PipelineConfig
) -> dict:
    """
    Точка входа: полный SCD Type 2 пайплайн.
    """
    logger.info(f"[SCD2] Starting pipeline batch_id={cfg.batch_id}")

    # DDL
    create_dim_customers_table(spark, cfg)

    # Подготовить source
    source_df = load_and_prepare_source(spark, cfg)
    source_count = source_df.count()
    source_df.cache()
    logger.info(f"[SCD2] Source records: {source_count}")

    # Зафиксировать batch_ts для консистентности всего batch
    batch_ts = spark.sql("SELECT current_timestamp() AS ts").first()["ts"]
    batch_ts_str = str(batch_ts)

    # Шаг 1: Закрыть устаревшие
    closed = step1_close_outdated(spark, source_df, cfg, batch_ts_str)

    # Шаг 2: Вставить новые версии
    inserted = step2_insert_new_versions(spark, source_df, cfg, batch_ts_str)

    unchanged = source_count - inserted

    # Проверка целостности
    if cfg.enable_integrity_check:
        integrity = check_scd2_integrity(spark, cfg.target_table)
        if not integrity["integrity_ok"]:
            logger.error(f"[SCD2] Integrity check FAILED: {integrity}")
            raise RuntimeError(f"SCD2 integrity violation: {integrity}")
        logger.info("[SCD2] Integrity check passed")

    # Обслуживание (если включено)
    if cfg.enable_maintenance:
        run_scd2_maintenance(spark, cfg.target_table,
                             cfg.maintenance_delete_ratio)

    source_df.unpersist()

    metrics = {
        "batch_id":   cfg.batch_id,
        "batch_ts":   batch_ts_str,
        "source":     source_count,
        "closed":     closed,
        "inserted":   inserted,
        "unchanged":  unchanged,
    }
    logger.info(f"[SCD2] Pipeline completed: {metrics}")
    return metrics

Мониторинг SCD2 пайплайна

Ключевые метрики для алертинга:

from dataclasses import dataclass
from typing import Optional


@dataclass
class SCD2Metrics:
    """Метрики одного SCD2 batch для мониторинга."""
    batch_id:         str
    source_count:     int
    closed_count:     int
    inserted_count:   int
    unchanged_count:  int
    batch_duration_s: float

    @property
    def change_rate(self) -> float:
        """Доля изменившихся записей. Аномалии: внезапный рост/падение."""
        return self.inserted_count / max(self.source_count, 1)

    @property
    def insert_to_close_ratio(self) -> float:
        """
        Для нормального SCD2: inserted ≈ closed (каждая закрытая → новая).
        Аномалия: inserted >> closed (дубли?) или inserted << closed (потеря данных?).
        """
        return self.inserted_count / max(self.closed_count, 1)

    def alert_conditions(self) -> list:
        alerts = []
        if self.change_rate > 0.5:  # >50% записей изменились за batch
            alerts.append(f"HIGH_CHANGE_RATE: {self.change_rate:.1%}")
        if self.insert_to_close_ratio > 1.2 or self.insert_to_close_ratio < 0.8:
            alerts.append(f"INSERT_CLOSE_MISMATCH: ratio={self.insert_to_close_ratio:.2f}")
        if self.source_count == 0:
            alerts.append("EMPTY_SOURCE: нет входных данных")
        return alerts

Дашборд SCD2 в Grafana/Prometheus: отслеживайте change_rate по времени - внезапный всплеск (10% → 80%) сигнализирует о проблеме в upstream системе (массовый сброс данных, ошибка CDC).

Сравнение с Delta Lake

Большинство концепций переносятся между Iceberg и Delta Lake, но есть различия:

Аспект Apache Iceberg Delta Lake
Row-level deletes Position + Equality deletes Position deletes (DV)
MERGE поддержка spark.sql("MERGE INTO ...") DeltaTable.merge() API
Compaction rewrite_data_files procedure OPTIMIZE SQL
Time travel VERSION AS OF / TIMESTAMP AS OF VERSION AS OF
MoR write.delete.mode=merge-on-read Deletion Vectors (DV)
Partition evolution Без перезаписи данных Требует перезаписи
Hidden partitioning Да (hours(ts), days(ts)) Нет

Для SCD Type 2 оба движка подходят. Если уже используете Delta Lake - DeltaTable.merge() более идиоматичен и лучше оптимизирован для Databricks-среды.

Итого: SCD Type 2 Best Practices

  1. Фиксируйте batch_ts один раз для всего batch - не используйте current_timestamp() в SQL-выражениях для отдельных строк.

  2. Детерминированный surrogate_key через xxhash64(business_key, valid_from) - обеспечивает idempotency при retry.

  3. record_hash с COALESCE - COALESCE(col, '') перед хешированием предотвращает ложные изменения из-за NULL.

  4. Двухшаговый подход (CLOSE + INSERT) надёжнее однострочного MERGE для SCD2 - проще отлаживать и делать idempotent.

  5. Merge-on-Read для частых обновлений - если процент обновляемых записей > 5%, MoR значительно быстрее CoW.

  6. Периодическая компакция - после накопления delete files запускайте rewrite_data_files, иначе производительность чтения деградирует.

  7. Integrity check после каждого batch - проверяйте: no duplicate currents, no inconsistent flags, no overlapping intervals.

  8. Партиционирование по is_current - быстрый доступ к текущему срезу через partition pruning.

  9. Temporal join через surrogate_key - если таблицы фактов хранят surrogate_key, join выполняется за O(1) без диапазонного сравнения.

  10. Late-arriving data → DLQ - данные с задержкой > 1 batch отправляйте в Dead Letter Queue для ручного review, не пытайтесь автоматически «вклинивать» их в середину истории.