Schema Anti-Patterns: inferSchema в продакшне, вложенные структуры и from_json без схемы

Плохая работа со схемой - скрытый источник ошибок и замедлений: inferSchema читает весь файл, from_json без схемы теряет типы, тройная вложенность убивает читаемость и производительность.

core

Анти-паттерн 1: inferSchema в продакшне

# ❌ inferSchema = полное сканирование файла для определения типов
df = spark.read.option("inferSchema", "true").csv("s3://data/events.csv")
# Spark читает ВСЕ данные дважды:
# 1. Проход для inferSchema
# 2. Проход для реального чтения
# На 1 TB CSV - 2 TB чтения вместо 1 TB

# Хуже: тип int → long, float → double выбирается по максимальному значению
# "123" в одной строке, "1234567890123" в другой → Long для всей колонки
# Изменение данных → изменение схемы → падение downstream-кода
# ✅ Явная схема всегда
from pyspark.sql.types import StructType, StructField, StringType, LongType, DoubleType

schema = StructType([
    StructField("user_id",    LongType(),   nullable=False),
    StructField("event_type", StringType(), nullable=True),
    StructField("amount",     DoubleType(), nullable=True),
    StructField("event_ts",   LongType(),   nullable=True),
])

df = spark.read.schema(schema).csv("s3://data/events.csv")
# Одно чтение, типы определены заранее, схема задокументирована в коде
# ✅ Для Parquet - схема хранится в файле, inferSchema безопасен
# (Parquet самоописывающий формат)
df = spark.read.parquet("s3://data/events.parquet")   # OK - схема из метаданных

Анти-паттерн 2: from_json без схемы (schema_of_json)

# ❌ schema_of_json инфериит схему из одной строки
sample = '{"user_id": 1, "tags": ["a", "b"]}'
inferred_schema = schema_of_json(sample)

df = df.withColumn("parsed", from_json("json_col", inferred_schema))
# Проблемы:
# - sample может не представлять все возможные поля
# - типы угадываются: "1" → LongType, но в других строках может быть "1.5"
# - null-поля в sample → StructField с NullType → всё null после парсинга
# ✅ Явная схема JSON
from pyspark.sql.types import *

json_schema = StructType([
    StructField("user_id",  LongType(),  True),
    StructField("email",    StringType(), True),
    StructField("tags",     ArrayType(StringType()), True),
    StructField("metadata", MapType(StringType(), StringType()), True),
])

df.withColumn("parsed", from_json("json_col", json_schema))

Анти-паттерн 3: тройная вложенность структур

# ❌ Тройная вложенность - nightmare для разработчика и для Spark
df = spark.read.json("s3://events/")
# Схема: event.context.device.os.version - 4 уровня!

# Доступ к глубокому полю:
df.select("event.context.device.os.version")   # Если хоть одно поле null - NPE

# Column pruning работает только до определённой глубины
# Весь вложенный struct читается даже если нужно одно поле
# ✅ Flatten при чтении - один раз, на входе
from pyspark.sql.functions import col

df_flat = df.select(
    col("event.id").alias("event_id"),
    col("event.type").alias("event_type"),
    col("event.context.device.os.version").alias("os_version"),
    col("event.context.session_id").alias("session_id"),
)
# Плоская схема: быстрее, понятнее, лучше pushdown

Анти-паттерн 4: from_json в цикле / в UDF

# ❌ from_json с разной схемой для разных строк - невозможно векторизовать
@udf("string")
def parse_dynamic(json_str, event_type):
    data = json.loads(json_str)
    if event_type == "purchase":
        return data.get("amount")
    else:
        return data.get("duration")
# Весь JSON парсится Python построчно, нет Arrow оптимизации
# ✅ Единая схема + when/otherwise
from pyspark.sql.functions import from_json, when

unified_schema = StructType([
    StructField("amount",   DoubleType(), True),
    StructField("duration", IntegerType(), True),
])

df.withColumn("parsed",  from_json("json_col", unified_schema)) \
  .withColumn("value",
    when(col("event_type") == "purchase", col("parsed.amount"))
    .otherwise(col("parsed.duration").cast("double"))
  )

Анти-паттерн 5: изменение схемы без явного указания mergeSchema

# ❌ Новая колонка добавлена в Parquet-файл, старый код падает
df = spark.read.parquet("s3://data/")   # new_column появилась в новых файлах
# Результат: AnalysisException - поле отсутствует в части файлов
# или: новое поле заполнено null без предупреждения
# ✅ Явно включить schema merging
df = spark.read \
    .option("mergeSchema", "true") \
    .parquet("s3://data/")
# Spark объединяет схемы всех файлов, новые колонки заполняются null

# ✅ Для Iceberg - schema evolution встроена в таблицу
spark.sql("ALTER TABLE events ADD COLUMN new_field STRING")

Диагностика схемных проблем

# Всегда проверять схему после чтения
df.printSchema()    # дерево типов
df.dtypes           # список (name, type)

# Искать подозрительные типы:
# StringType там, где должен быть IntegerType → вероятно inferSchema промахнулся
# NullType → поле есть в схеме, но всегда null

# Проверить null-distribution
from pyspark.sql.functions import isnan, when, count, col
null_counts = df.select([
    count(when(col(c).isNull(), c)).alias(c) for c in df.columns
])
null_counts.show()