StructType: вложенные схемы, schema inference и schema evolution

StructType и StructField в деталях, типовая система Spark, schema inference и её риски, corrupt records, withField(), schema evolution в Parquet и Delta Lake

core optimization

Схема как контракт

В классической реляционной базе данных схема - это строгое ограничение: нельзя записать строку, не соответствующую DDL. В Data Lake эта гарантия намеренно ослаблена - raw-данные хранятся «как есть», и схема накладывается при чтении, а не при записи. Это называется schema-on-read.

Гибкость schema-on-read - большое преимущество при ingestion: не нужно заранее знать точную структуру всех источников. Но за это приходится платить: если схема не зафиксирована явно, она может незаметно измениться между запусками, и пайплайн либо упадёт с криптичной ошибкой, либо, что хуже, продолжит работу, молча искажая данные.

Явная схема (explicit schema) - это контракт между данными и кодом. Контракт между командой, которая пишет данные, и командой, которая их читает. Этот контракт:

  • Определяет что каждое поле значит (имя, тип, обязательность)
  • Защищает downstream-потребителей от неожиданных изменений
  • Позволяет Spark оптимизировать чтение (column pruning, predicate pushdown)
  • Делает пайплайн предсказуемым - его поведение не зависит от текущего содержимого файлов

В Spark явная схема описывается через StructType - дерево из StructField-объектов, каждый из которых описывает одну колонку: имя, тип, обязательность и дополнительные метаданные.

Анатомия StructType и StructField

StructField: четыре атрибута

Каждое поле схемы описывается объектом StructField с четырьмя атрибутами:

from pyspark.sql.types import StructField, StringType

# Полная сигнатура:
StructField(
    name="field_name",      # 1. Имя колонки (строка)
    dataType=StringType(),  # 2. Тип данных
    nullable=True,          # 3. Может ли значение быть null (по умолчанию True)
    metadata={}             # 4. Произвольные метаданные (словарь) - по умолчанию {}
)

Рассмотрим каждый атрибут:

name - строка, имя колонки. Чувствительно к регистру. При чтении JSON/Parquet должно совпадать с именем поля в источнике.

dataType - тип данных. Может быть примитивным (StringType, IntegerType...) или составным (ArrayType, MapType, StructType).

nullable - булевый флаг: может ли значение этой колонки быть null. Это не жёсткое ограничение (Spark не бросит исключение если придёт null в non-nullable колонку), но это важная семантическая аннотация для оптимизатора. Если Catalyst знает что колонка non-nullable, он может исключить некоторые null-проверки из кодогенерации.

metadata - словарь {"key": "value"} для хранения произвольных аннотаций. Spark использует его для хранения комментариев к колонкам, ML-аннотаций (ml_attr), единиц измерения и любой другой метаинформации, которая должна «путешествовать» с данными.

from pyspark.sql.types import StructField, StringType, DoubleType, Metadata

# Поле с метаданными: описание, единицы измерения, источник
amount_field = StructField(
    name="transaction_amount",
    dataType=DoubleType(),
    nullable=False,
    metadata={
        "description": "Сумма транзакции в валюте счёта",
        "unit": "currency_minor_units",   # например, копейки для RUB
        "source": "payment_gateway.v2",
        "pii": False,
    }
)

# Прочитать метаданные поля
df = spark.createDataFrame([(1.0,)], schema=StructType([amount_field]))
field = df.schema["transaction_amount"]
print(field.metadata)
# {'description': 'Сумма транзакции в валюте счёта', 'unit': 'currency_minor_units', ...}

# Метаданные сохраняются при записи в Parquet и ORC
# При чтении обратно метаданные восстанавливаются из footer файла

Метаданные не влияют на выполнение запросов, но они видны в Spark UI, сохраняются в Hive Metastore и могут быть прочитаны downstream-кодом для документирования пайплайнов.

Полная типовая система Spark

Прежде чем строить схемы, нужно знать все доступные типы:

Несколько важных нюансов, которые часто игнорируются:

DecimalType(precision, scale) вместо DoubleType для финансовых расчётов. DoubleType - это IEEE 754 floating point, он имеет проблему представления дробных чисел: 0.1 + 0.2 ≠ 0.3. Для денег всегда используйте DecimalType(18, 2) (18 значащих цифр, 2 после запятой).

TimestampType включает временну́ю зону (хранится в UTC, отображается в локальной зоне JVM). TimestampNTZType (Spark 3.4+) - метка времени без часового пояса, аналог datetime в Python. Для событий с абсолютным временем используйте TimestampType; для «локального» времени без привязки к TZ - TimestampNTZType.

LongType - не IntegerType** для идентификаторов. Если ID бизнес-объектов генерируются автоинкрементом или UUID-подобным образом, они могут превыситьInt.MaxValue(около 2.1 миллиарда).LongType` безопаснее по умолчанию для любых numeric ID.

Построение вложенных схем

Рассмотрим реальный пример: схема события из платёжного шлюза.

from pyspark.sql.types import *
from pyspark.sql import SparkSession

spark = SparkSession.builder.appName("schema-demo").getOrCreate()

# Вложенная схема: транзакция с вложенными структурами
payment_schema = StructType([

    # Идентификатор: LongType (не Int - может быть > 2 млрд)
    StructField("transaction_id", LongType(), nullable=False),

    # Временна́я метка: TimestampType хранит UTC
    StructField("created_at", TimestampType(), nullable=False),

    # Деньги: всегда Decimal, никогда Double
    StructField("amount", DecimalType(18, 2), nullable=False),
    StructField("currency_code", StringType(), nullable=False),

    # Вложенная структура: данные пользователя
    StructField("payer", StructType([
        StructField("user_id",      LongType(),   nullable=False),
        StructField("email",        StringType(), nullable=True,
                    metadata={"pii": True}),   # Метаданные: пометить PII
        StructField("country_code", StringType(), nullable=True),
        StructField("ip_address",   StringType(), nullable=True,
                    metadata={"pii": True}),
    ]), nullable=False),

    # Вложенная структура: данные получателя
    StructField("payee", StructType([
        StructField("merchant_id",   LongType(),   nullable=False),
        StructField("merchant_name", StringType(), nullable=True),
        StructField("mcc",           IntegerType(), nullable=True,
                    metadata={"description": "Merchant Category Code"}),
    ]), nullable=False),

    # Массив тегов (строк)
    StructField("tags", ArrayType(StringType(), containsNull=False), nullable=True),

    # Массив связанных транзакций (структуры)
    StructField("related_transactions", ArrayType(StructType([
        StructField("ref_id",     LongType(),   nullable=False),
        StructField("ref_type",   StringType(), nullable=False),
    ])), nullable=True),

    # Словарь с динамическими метаданными из gateway
    StructField("gateway_metadata", MapType(StringType(), StringType()), nullable=True),

    # Статус транзакции
    StructField("status", StringType(), nullable=False),
])

payment_schema.simpleString()
# 'struct<transaction_id:bigint,created_at:timestamp,amount:decimal(18,2),...>'

Обратите внимание на несколько паттернов в этой схеме. Поля, которые всегда должны быть (идентификатор, сумма, создатель, получатель, статус) - nullable=False. Поля, которые могут отсутствовать (теги, метаданные) - nullable=True. PII-поля (email, IP) помечены через metadata - это позволяет downstream-системам автоматически применять маскировку.

printSchema() и другие методы интроспекции

# Создаём DataFrame с нашей схемой (пока пустой для просмотра структуры)
df = spark.createDataFrame([], payment_schema)

# printSchema() - человекочитаемое дерево
df.printSchema()
# root
#  |-- transaction_id: long (nullable = false)
#  |-- created_at: timestamp (nullable = false)
#  |-- amount: decimal(18,2) (nullable = false)
#  |-- currency_code: string (nullable = false)
#  |-- payer: struct (nullable = false)
#  |    |-- user_id: long (nullable = false)
#  |    |-- email: string (nullable = true)
#  |    |-- country_code: string (nullable = true)
#  |    |-- ip_address: string (nullable = true)
#  |-- payee: struct (nullable = false)
#  ...

# schema - Python-объект StructType
print(type(df.schema))   # <class 'pyspark.sql.types.StructType'>
print(df.schema.names)   # ['transaction_id', 'created_at', 'amount', ...]
print(df.schema.fields)  # список StructField-объектов

# Доступ к конкретному полю по имени
payer_field = df.schema["payer"]
print(payer_field.dataType)   # StructType([StructField('user_id', LongType()...)])
print(payer_field.nullable)   # False

# Доступ к вложенному полю через dataType
email_field = df.schema["payer"].dataType["email"]
print(email_field.metadata)   # {'pii': True}

# json() - схема в виде JSON-строки (для сериализации)
schema_json = payment_schema.json()
# '{"fields":[{"metadata":{},"name":"transaction_id","nullable":false,"type":"long"},...'

# fromJson() - восстановить схему из JSON
restored_schema = StructType.fromJson(eval(schema_json))  # через ast.literal_eval или json.loads

# Сравнение схем
print(payment_schema == restored_schema)  # True

schema.json() и StructType.fromJson() - очень полезная пара для хранения схем в конфигурационных файлах, метаданных пайплайна или передачи между системами. Многие data catalog'и хранят схемы именно в JSON-представлении Spark.

Schema Inference: магия и её цена

Как работает автоматический вывод схемы

Schema inference - это процесс, при котором Spark сам читает образец данных и определяет типы колонок. Это удобно для интерактивной работы, но требует понимания того, как именно это происходит.

Процесс состоит из двух фаз: сначала Spark читает образец данных для определения схемы, затем - читает все данные уже с готовой схемой. Это означает двойное чтение части файлов, что существенно замедляет начало работы с большими датасетами.

Для CSV: Spark читает все строки из образца и определяет тип каждой колонки. По умолчанию inferSchema=False - все колонки будут StringType. При inferSchema=True все значения парсятся для определения типа: integer? float? timestamp? boolean?

Для JSON: Spark читает образец JSON-документов и строит схему рекурсивно. Если разные документы имеют разные поля - Spark объединяет их, делая отсутствующие поля nullable=True.

Для Parquet/ORC: Schema inference не требует чтения данных - схема хранится в footer файла. Это быстро и точно.

Риски schema inference

Проблема 1: нестабильный вывод типов. Если в выборке все значения колонки выглядят как целые числа (100, 200, 300), Spark выведет IntegerType. Но если в продакшн-данных появится 2.5 или 0.001 - пайплайн упадёт с ошибкой приведения типов.

# Опасный паттерн: inference на CSV
df_inferred = spark.read.csv("transactions.csv", header=True, inferSchema=True)
# Сегодня amount → IntegerType (все значения целые)
# Завтра в CSV появляется 99.99 → ломается join с другой таблицей

# Безопасный паттерн: явная схема
explicit_schema = StructType([
    StructField("transaction_id", LongType(),         nullable=False),
    StructField("amount",         DecimalType(18, 2), nullable=False),
    StructField("currency",       StringType(),        nullable=True),
])
df_safe = spark.read.csv("transactions.csv", header=True, schema=explicit_schema)
# Работает одинаково даже если в CSV появятся дробные числа

Проблема 2: Long vs Double при смешанных данных. Если в JSON-файле большинство значений - целые числа, но одно значение дробное (например, "rate": 1.5), Spark выведет DoubleType. Если в другом образце все значения целые - выведет LongType. Один и тот же пайплайн на разных батчах данных может получить разную схему.

Проблема 3: Даты и время. Spark может вывести StringType вместо TimestampType если формат даты нестандартный. Строка "2024-01-15" выводится как DateType, а "15/01/2024" - как StringType. Явная схема + option("timestampFormat", "...") - единственный надёжный способ.

Проблема 4: Производительность на больших датасетах. Для inference Spark читает образец файлов до начала основной работы. При тысячах маленьких файлов (small files problem) это тысячи дополнительных открытий файлов только для определения схемы.

# Параметр samplingRatio для JSON: сколько данных читать для inference
df = spark.read.option("samplingRatio", "0.1").json("large_dataset/")
# Читает 10% файлов для inference - быстрее, но может ошибиться с редкими типами
# По умолчанию 1.0 - читает всё, надёжно но медленно

# Для production: ВСЕГДА задавайте схему явно
# Для exploration/notebook: inferSchema допустим, но осознанно

schema_of_json и schema_of_csv: быстрый старт для явных схем

Когда вы впервые работаете с новым источником, полезен компромиссный подход: пусть Spark выведет схему из образца, вы её изучите, исправите и зафиксируете:

from pyspark.sql.functions import schema_of_json, lit, schema_of_csv

# schema_of_json: получить DDL-строку схемы из JSON-примера
sample_json = """
{
  "order_id": "ORD-1001",
  "user_id": 42,
  "amount": 1999.99,
  "items": [{"id": "P-100", "qty": 2}],
  "created_at": "2024-01-15T10:30:00Z"
}
"""

schema_ddl = spark.range(1) \
    .select(schema_of_json(lit(sample_json))) \
    .collect()[0][0]
print(schema_ddl)
# STRUCT<amount: DOUBLE, created_at: STRING, items: ARRAY<STRUCT<id: STRING, qty: BIGINT>>,
#        order_id: STRING, user_id: BIGINT>

# Внимание: нужно проверить и исправить:
# 1. amount: DOUBLE → DecimalType(18,2) для денег
# 2. created_at: STRING → TimestampType с форматом
# 3. Поля отсортированы по алфавиту, а не в порядке оригинала

# После проверки - зафиксировать как явный StructType в коде
# Это "one-time" операция: inference для изучения, explicit schema в production

Работа с вложенными полями

Star expansion: разворачивание структуры

Когда нужно «вытащить» все поля вложенной структуры в плоскую таблицу, можно использовать синтаксис struct.*:

from pyspark.sql.functions import col

# Предположим что у нас есть DataFrame с вложенным payer
# payer.user_id, payer.email, payer.country_code, payer.ip_address

# Способ 1: перечислить поля явно (рекомендуется - явность важнее краткости)
df.select(
    col("transaction_id"),
    col("payer.user_id"),
    col("payer.email"),
    col("payer.country_code"),
)

# Способ 2: star expansion - разворачивает ВСЕ поля структуры
df.select(
    col("transaction_id"),
    col("payer.*"),   # Добавляет user_id, email, country_code, ip_address как отдельные колонки
)

# Способ 3: полное разворачивание в SQL
df.createOrReplaceTempView("payments")
spark.sql("""
    SELECT transaction_id, payer.*
    FROM payments
""")

# ОСТОРОЖНО: star expansion в нестабильных схемах
# Если структура payer расширится новым полем - SELECT * без явного списка
# незаметно добавит это поле в результат, сломав downstream обработку
# Всегда перечисляйте поля явно в production-коде

Star expansion удобен для разведки, но в production-пайплайнах его стоит избегать в пользу явного перечисления полей. Новое поле в структуре источника незаметно появится в результате и может нарушить ожидаемую структуру downstream.

withField(): обновление вложенных полей без пересборки

До Spark 3.1 обновление вложенного поля структуры было болезненным: нужно было вручную пересобирать всю структуру через struct(), перечисляя все поля. withField() (появился в Spark 3.1) решает эту проблему элегантно.

from pyspark.sql.functions import struct, col, upper, lit, current_timestamp

# Пример: нужно обновить поле payer.email - привести к нижнему регистру
# и добавить новое поле payer.verified_at

# ❌ СТАРЫЙ СПОСОБ (до Spark 3.1): пересборка всей структуры
# Нужно перечислить ВСЕ поля, включая неизменившиеся
df_old_way = df.withColumn("payer",
    struct(
        col("payer.user_id"),                      # не изменилось, но нужно перечислить
        F.lower(col("payer.email")).alias("email"), # изменилось
        col("payer.country_code"),                 # не изменилось
        col("payer.ip_address"),                   # не изменилось
        current_timestamp().alias("verified_at"),  # новое поле
    )
)
# Проблема: если структура расширится - нужно обновить этот код

# ✅ НОВЫЙ СПОСОБ (Spark 3.1+): withField - хирургически точно
df_new_way = df \
    .withColumn("payer",
        col("payer")
        .withField("email", F.lower(col("payer.email")))  # обновить существующее
        .withField("verified_at", current_timestamp())    # добавить новое
    )

# Можно цепочкой: несколько withField подряд
df_updated = df.withColumn("payee",
    col("payee")
    .withField("merchant_name", F.upper(col("payee.merchant_name")))
    .withField("is_verified", lit(True))
    .withField("last_seen", current_timestamp())
)

# dropFields(): удалить поле из структуры (тоже Spark 3.1+)
# Полезно для удаления PII-полей перед передачей в downstream
df_no_pii = df.withColumn("payer",
    col("payer")
    .dropFields("email")      # убрать email
    .dropFields("ip_address") # убрать IP
)
df_no_pii.select("payer.*").printSchema()
# Схема payer без email и ip_address

withField() принципиально важен для больших вложенных структур: он не требует знать все поля структуры, не ломается при добавлении новых полей в источник, и читается как явное намерение - "изменить именно это поле".

Глубокое обновление: nested withField

withField() поддерживает dot-notation для обращения к полям в глубоко вложенных структурах:

# Схема: order.shipping.address.city
# Нужно обновить только city

deeply_nested = spark.createDataFrame([
    ("ORD-1", {"shipping": {"address": {"city": "москва", "zip": "101000"}}},),
], StructType([
    StructField("order_id", StringType()),
    StructField("order", StructType([
        StructField("shipping", StructType([
            StructField("address", StructType([
                StructField("city", StringType()),
                StructField("zip",  StringType()),
            ]))
        ]))
    ]))
]))

# withField поддерживает путь через точку
result = deeply_nested.withColumn("order",
    col("order").withField(
        "shipping.address.city",               # путь к полю
        F.initcap(col("order.shipping.address.city"))  # значение
    )
)

result.select("order.shipping.address.city").show()
# +-----+
# |city |
# +-----+
# |Москва|
# +-----+

Nullable семантика

Что значит nullable=True/False

nullable - это декларация намерений, а не жёсткое ограничение на уровне хранения. Spark не бросает исключение при записи null в nullable=False колонку. Вместо этого nullable используется Catalyst для оптимизации:

from pyspark.sql.functions import col, isnull

# DataFrame с nullable=False колонкой
strict_schema = StructType([
    StructField("id",     LongType(),   nullable=False),
    StructField("amount", DoubleType(), nullable=False),
])

# Spark не выбросит ошибку, но логически это некорректно
df_with_null = spark.createDataFrame(
    [(1, None)],    # amount = None, хотя nullable=False
    strict_schema
)
df_with_null.show()
# +---+------+
# | id|amount|
# +---+------+
# |  1|  null|  ← записано! nullable=False не является constraint
# +---+------+

Зачем тогда nullable=False если это не ограничение? Это подсказка Catalyst:

  • Catalyst может пропустить null-проверки в кодогенерации для non-nullable колонок - это небольшое, но реальное ускорение
  • Catalyst может применять правила оптимизации, которые предполагают отсутствие null (например, некоторые join-стратегии)
  • Это документация: сообщает разработчику что поле не должно быть null. Если null всё-таки появляется - это баг в upstream-данных, который нужно отловить

Работа с nullable в схемах при чтении

При чтении из реальных источников почти всегда используйте nullable=True. Даже если поле "всегда заполнено" в спецификации - данные реального мира преподносят сюрпризы:

# Реалистичная схема для ingestion: по умолчанию nullable=True
# Только идентификаторы и технические поля - nullable=False
ingestion_schema = StructType([
    StructField("event_id",   StringType(),    nullable=False),  # PK, обязателен
    StructField("event_type", StringType(),    nullable=False),  # должен быть всегда
    StructField("user_id",    LongType(),      nullable=True),   # может быть анонимный
    StructField("session_id", StringType(),    nullable=True),   # может не быть сессии
    StructField("amount",     DecimalType(18,2), nullable=True), # не у всех событий есть сумма
    StructField("created_at", TimestampType(), nullable=False),  # время обязательно
])

После чтения проверяйте null-counts для критичных полей:

from pyspark.sql.functions import count, when, col, isnan

# Проверка null-количества для всех колонок (полезно при ingestion)
null_counts = df.select([
    count(when(col(c).isNull(), c)).alias(c)
    for c in df.columns
])
null_counts.show()
# Если event_id имеет null_count > 0 - это критическая ошибка в upstream

Режимы чтения и обработка плохих данных

Три режима при несоответствии схемы

Когда данные не соответствуют заданной схеме, Spark может вести себя тремя разными способами:

PERMISSIVE (по умолчанию) - самый мягкий режим. Данные, которые не соответствуют схеме, конвертируются в null. Целые записи, которые нельзя распарсить, сохраняются в специальную колонку _corrupt_record. Пайплайн продолжает работу.

DROPMALFORMED - плохие записи молча удаляются. Это опасный режим в production: потеря данных может остаться незамеченной. Используется только когда вы абсолютно уверены, что некоторый процент плохих данных допустим и не нужно их анализировать.

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

_corrupt_record: ловим плохие данные

_corrupt_record - специальная техническая колонка, в которую Spark записывает сырую строку входящего документа, если его не удалось разобрать. Это позволяет анализировать проблемные данные, не теряя их:

# Схема с _corrupt_record для отлова плохих JSON-документов
schema_with_corrupt = StructType([
    StructField("transaction_id", LongType(),         nullable=True),
    StructField("amount",         DecimalType(18, 2), nullable=True),
    StructField("currency",       StringType(),        nullable=True),
    StructField("_corrupt_record", StringType(),       nullable=True),  # техническая колонка
])

# Читаем JSON с PERMISSIVE mode и _corrupt_record
raw_df = spark.read \
    .option("mode", "PERMISSIVE") \
    .option("columnNameOfCorruptRecord", "_corrupt_record") \
    .schema(schema_with_corrupt) \
    .json("payments_raw/")

# Разделяем на хорошие и плохие записи
good_records = raw_df.filter(col("_corrupt_record").isNull()).drop("_corrupt_record")
bad_records  = raw_df.filter(col("_corrupt_record").isNotNull()) \
                     .select("_corrupt_record")

print(f"Хорошие записи: {good_records.count()}")
print(f"Плохие записи:  {bad_records.count()}")

# Анализируем плохие данные
bad_records.show(5, truncate=False)
# Сохраняем для последующего разбора
bad_records.write.mode("append").text("/data/quarantine/payments/")

# Алертинг: если плохих записей > 1% - это аномалия
total = raw_df.count()
bad_rate = bad_records.count() / total
if bad_rate > 0.01:
    raise RuntimeError(f"Слишком много битых записей: {bad_rate:.1%}")

Паттерн good/bad split с сохранением плохих записей в карантин - стандарт для production ingestion. Никогда не теряйте данные молча: сохраните в quarantine-зону и разберите отдельно.

Schema Evolution: схемы меняются

Типы изменений схемы

Схемы реальных источников меняются по мере развития продукта. Изменения делятся на совместимые и несовместимые:

Добавление новых nullable-колонок - единственное безопасное изменение. Если downstream-код не использует новую колонку - всё продолжает работать. Если использует - получает значение (или null для старых записей).

Удаление колонки - всегда breaking change. Downstream-код, который обращается к удалённой колонке, упадёт. Решение: deprecated-период (добавьте пустую колонку-заглушку на переходный период) или версионирование схем.

Изменение типа - обычно breaking, но некоторые преобразования допустимы: Int → Long (расширение), Float → Double (расширение). String → Int без изменения данных - невозможно, Int → String - возможно через cast.

Schema evolution в Parquet

Parquet поддерживает эволюцию схем на уровне формата: каждый файл хранит свою схему в footer. При чтении нескольких файлов с разными схемами Spark объединяет их через mergeSchema:

# Без mergeSchema: Spark читает схему первого файла и применяет ко всем
# Если новые файлы имеют лишние колонки - они игнорируются
# Если старые файлы не имеют колонок из новых файлов - ошибка

# С mergeSchema: Spark читает footer каждого файла и объединяет схемы
df = spark.read \
    .option("mergeSchema", "true") \
    .parquet("/data/events/year=2024/")

# Результат: объединённая схема
# Новые колонки (есть только в новых файлах): nullable=True, старые записи → null
# Старые колонки (есть только в старых файлах): nullable=True, новые записи → null

# ОСТОРОЖНО: mergeSchema читает footer каждого файла
# При тысячах файлов - тысячи HDFS/S3 запросов только для планирования
# Рекомендация: spark.sql.parquet.mergeSchema=false в production,
# mergeSchema=true только при добавлении новой партиции с обновлённой схемой

Именно из-за этой проблемы в production-пайплайне calculate_mart_v18.py явно задано spark.conf.set("spark.sql.parquet.mergeSchema", "false") - при 12,000 файлах включённый mergeSchema означал бы 12,000 дополнительных сетевых запросов в фазе планирования.

Schema evolution в Delta Lake

Delta Lake предоставляет транзакционное управление схемой через transaction log. Это намного более контролируемый подход, чем mergeSchema в сыром Parquet:

# Delta Lake: два режима эволюции схемы при записи

# 1. mergeSchema=true: автоматически добавляет новые колонки
#    Существующие данные остаются нетронутыми, новые колонки → null для старых партиций
df_new_schema.write \
    .format("delta") \
    .mode("append") \
    .option("mergeSchema", "true") \   # добавить новые колонки в таблицу
    .save("/delta/events/")

# 2. overwriteSchema=true: полностью заменяет схему
#    Используйте ТОЛЬКО при полной перезаписи данных (mode="overwrite")
#    Опасен для инкрементальных обновлений - ломает совместимость
df_completely_different.write \
    .format("delta") \
    .mode("overwrite") \
    .option("overwriteSchema", "true") \  # полная замена схемы
    .save("/delta/events/")

# Просмотр истории изменений схемы через Delta
from delta.tables import DeltaTable

delta_table = DeltaTable.forPath(spark, "/delta/events/")
history = delta_table.history()
history.select("version", "timestamp", "operation", "operationParameters") \
       .show(10, truncate=False)
# Каждая операция включает изменения схемы в operationParameters

# ALTER TABLE в Delta (SQL)
spark.sql("""
    ALTER TABLE delta.`/delta/events/`
    ADD COLUMNS (new_field STRING COMMENT 'Добавлено в v2.1')
""")

Delta Lake фиксирует каждое изменение схемы в transaction log с временной меткой. Это позволяет откатиться к предыдущей версии данных (Time Travel) если новая схема оказалась ошибочной.

Schema drift: неожиданные изменения источника

Schema drift - это ситуация, когда схема источника изменилась, но ваш пайплайн об этом не знает. Без защиты это приводит к ошибкам или потере данных:

# Паттерн защиты от schema drift: сравнение схем при запуске

EXPECTED_SCHEMA = StructType([
    StructField("event_id",   StringType(),    nullable=False),
    StructField("event_type", StringType(),    nullable=False),
    StructField("user_id",    LongType(),      nullable=True),
    StructField("created_at", TimestampType(), nullable=False),
])

def check_schema_compatibility(actual: StructType, expected: StructType) -> list:
    """Возвращает список проблем совместимости схем."""
    issues = []
    expected_fields = {f.name: f for f in expected.fields}
    actual_fields   = {f.name: f for f in actual.fields}

    # Проверяем что все обязательные поля присутствуют
    for name, field in expected_fields.items():
        if not field.nullable and name not in actual_fields:
            issues.append(f"CRITICAL: Отсутствует non-nullable поле '{name}'")
        elif name in actual_fields:
            actual_type   = actual_fields[name].dataType
            expected_type = field.dataType
            if type(actual_type) != type(expected_type):
                issues.append(
                    f"WARNING: Тип поля '{name}' изменился: "
                    f"{expected_type} → {actual_type}"
                )

    # Проверяем что нет неожиданных новых полей
    new_fields = set(actual_fields) - set(expected_fields)
    if new_fields:
        issues.append(f"INFO: Новые поля в источнике: {new_fields}")

    return issues

# Использование при ingestion
incoming_df = spark.read.json("/data/events/today/")
issues = check_schema_compatibility(incoming_df.schema, EXPECTED_SCHEMA)

for issue in issues:
    if issue.startswith("CRITICAL"):
        raise RuntimeError(f"Schema drift: {issue}")
    else:
        print(f"Schema drift: {issue}")

Динамическое построение схем

StructType - это обычный Python-объект. Его можно генерировать программно, что открывает мощные паттерны для работы с конфигурируемыми пайплайнами:

# Паттерн 1: генерация схемы из конфигурации
COLUMN_CONFIG = [
    {"name": "id",           "type": "long",       "nullable": False},
    {"name": "name",         "type": "string",      "nullable": True},
    {"name": "score",        "type": "double",      "nullable": True},
    {"name": "category",     "type": "string",      "nullable": True},
    {"name": "created_date", "type": "date",        "nullable": False},
]

TYPE_MAP = {
    "string":    StringType(),
    "long":      LongType(),
    "int":       IntegerType(),
    "double":    DoubleType(),
    "boolean":   BooleanType(),
    "date":      DateType(),
    "timestamp": TimestampType(),
}

def build_schema_from_config(config: list) -> StructType:
    fields = []
    for col_cfg in config:
        spark_type = TYPE_MAP.get(col_cfg["type"], StringType())
        fields.append(StructField(
            name=col_cfg["name"],
            dataType=spark_type,
            nullable=col_cfg.get("nullable", True),
            metadata=col_cfg.get("metadata", {}),
        ))
    return StructType(fields)

dynamic_schema = build_schema_from_config(COLUMN_CONFIG)
dynamic_schema.simpleString()

# Паттерн 2: расширение схемы программно
def add_audit_fields(schema: StructType) -> StructType:
    """Добавить технические поля аудита к любой схеме."""
    audit_fields = [
        StructField("_ingested_at",    TimestampType(), nullable=False,
                    metadata={"technical": True}),
        StructField("_source_file",    StringType(),    nullable=True,
                    metadata={"technical": True}),
        StructField("_pipeline_version", StringType(), nullable=False,
                    metadata={"technical": True}),
    ]
    return StructType(schema.fields + audit_fields)

enriched_schema = add_audit_fields(payment_schema)

# Паттерн 3: сериализация схемы для хранения в метаданных
import json

schema_dict = json.loads(payment_schema.json())
# Сохранить в конфигурационный файл, базу данных, data catalog

# Восстановить
restored = StructType.fromJson(schema_dict)
print(payment_schema == restored)  # True

Возможность программно генерировать и сериализовать схемы крайне полезна при построении data platform: конфигурация схем хранится в YAML/JSON в репозитории (Schema as Code), а пайплайны загружают схему при старте.

Практика: ingestion API с эволюцией схемы

from pyspark.sql import SparkSession
from pyspark.sql.types import *
from pyspark.sql import functions as F

spark = SparkSession.builder.appName("schema-evolution-demo").getOrCreate()

# ── Шаг 1: Определяем схему явно ──────────────────────────────────────────────
# Версия 1: базовая схема пользовательского профиля

user_schema_v1 = StructType([
    StructField("user_id",     LongType(),   nullable=False),
    StructField("username",    StringType(), nullable=False),
    StructField("email",       StringType(), nullable=True,
                metadata={"pii": True}),
    StructField("created_at",  TimestampType(), nullable=False),
    StructField("profile",     StructType([
        StructField("bio",      StringType(), nullable=True),
        StructField("location", StringType(), nullable=True),
        StructField("website",  StringType(), nullable=True),
    ]), nullable=True),
    StructField("_corrupt_record", StringType(), nullable=True),
])

# ── Шаг 2: Читаем данные с PERMISSIVE mode ──────────────────────────────────
# Симулируем JSON-данные (в реальности: spark.read.json("path/"))
from pyspark.sql.functions import col

json_data = spark.createDataFrame([
    ('{"user_id": 1, "username": "alice", "created_at": "2024-01-01T10:00:00Z", "profile": {"bio": "Data Engineer", "location": "Moscow"}}',),
    ('{"user_id": 2, "username": "bob", "email": "bob@example.com", "created_at": "2024-01-02T15:00:00Z"}',),
    ('{"user_id": "INVALID", "username": "charlie"}',),  # битая запись
], ["raw"])

# В реальном коде:
raw_df = spark.read \
    .option("mode", "PERMISSIVE") \
    .option("columnNameOfCorruptRecord", "_corrupt_record") \
    .schema(user_schema_v1) \
    .json("/data/users/batch=2024-01-15/")

# Разделяем хорошие и плохие записи
good_df = raw_df.filter(col("_corrupt_record").isNull()).drop("_corrupt_record")
bad_df  = raw_df.filter(col("_corrupt_record").isNotNull())

print(f"Хорошие: {good_df.count()}, Плохие: {bad_df.count()}")
bad_df.show(truncate=False)

# ── Шаг 3: Добавляем поле в вложенную структуру через withField ──────────────
# Версия 2: добавляем followers_count в profile

enriched_df = good_df.withColumn("profile",
    F.when(col("profile").isNull(),
        F.struct(
            F.lit(None).cast(StringType()).alias("bio"),
            F.lit(None).cast(StringType()).alias("location"),
            F.lit(None).cast(StringType()).alias("website"),
            F.lit(0).cast(LongType()).alias("followers_count"),
        )
    ).otherwise(
        col("profile").withField("followers_count", F.lit(0).cast(LongType()))
    )
)

enriched_df.select("user_id", "profile.*").show()
# +-------+--------+-------+-------+---------------+
# |user_id|bio     |location|website|followers_count|
# +-------+--------+-------+-------+---------------+
# |1      |Data ...|Moscow  |null   |0              |
# |2      |null    |null    |null   |0              |
# +-------+--------+-------+-------+---------------+

# ── Шаг 4: Добавляем технические поля ────────────────────────────────────────
from pyspark.sql.functions import current_timestamp, input_file_name

final_df = enriched_df \
    .withColumn("_ingested_at",     current_timestamp()) \
    .withColumn("_pipeline_version", F.lit("v2.1.0")) \
    .withColumn("_batch_date",       F.lit("2024-01-15").cast(DateType()))

# ── Шаг 5: Запись в Delta с mergeSchema ──────────────────────────────────────
# При первой записи: создаёт таблицу
# При последующих с новыми полями: расширяет схему
final_df.write \
    .format("delta") \
    .mode("append") \
    .option("mergeSchema", "true") \
    .partitionBy("_batch_date") \
    .save("/delta/users/")

# ── Проверка: схема Delta-таблицы обновилась ─────────────────────────────────
updated_schema = spark.read.format("delta").load("/delta/users/").schema
print("Обновлённая схема:")
spark.read.format("delta").load("/delta/users/").printSchema()

Best Practices

Всегда задавайте схему явно в production. inferSchema=True - только для exploration. В production-пайплайнах схема должна быть описана в коде или загружена из data catalog. Это контракт, а не угадывание.

Используйте DecimalType для денег. DoubleType имеет ошибки представления с плавающей точкой (0.1 + 0.2 ≠ 0.3). Для финансовых расчётов - только DecimalType(precision, scale).

Используйте _corrupt_record + quarantine-зону. Никогда не теряйте данные молча. PERMISSIVE + сохранение битых записей в отдельную директорию + алерт если процент > порога.

withField() вместо пересборки структуры. Spark 3.1+ - используйте withField() и dropFields() для хирургических обновлений вложенных структур. Это не ломается при добавлении новых полей в источник.

Аддитивные изменения схемы. Единственное безопасное изменение - добавление nullable-колонок. Удаление, переименование, изменение типа - всегда планируйте заранее с deprecation-периодом.

Документируйте поля через metadata. Особенно PII-поля ({"pii": True}), единицы измерения, источник данных. Это бесплатная документация, которая путешествует вместе с данными.

mergeSchema=false по умолчанию в production. Включайте только при необходимости и только для конкретного чтения. При тысячах файлов включённый глобальный mergeSchema означает тысячи дополнительных запросов в фазе планирования каждого job'а.

Schema as Code. Храните схемы в Python/JSON-файлах в git-репозитории. Изменения схемы проходят code review как обычный код. Версионируйте схемы (user_schema_v1, user_schema_v2).