StructType: вложенные схемы, schema inference и schema evolution
StructType и StructField в деталях, типовая система Spark, schema inference и её риски, corrupt records, withField(), schema evolution в Parquet и Delta Lake
Схема как контракт¶
В классической реляционной базе данных схема - это строгое ограничение: нельзя записать строку, не соответствующую 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).