Блок 4 - Python и расширенные возможности

20 вопросов: UDF, Pandas UDF, Arrow, Structured Streaming, MLlib pipeline и тестирование.

core streaming

Что такое UDF?

UDF (User Defined Function) - пользовательская функция, позволяющая применять произвольный Python/Scala/Java код к колонкам DataFrame.

from pyspark.sql.functions import udf
from pyspark.sql.types import StringType

def clean_email(email):
    return email.strip().lower() if email else None

clean_email_udf = udf(clean_email, StringType())
df = df.withColumn("email", clean_email_udf("raw_email"))

# Или через декоратор
@udf("string")
def clean_email(email):
    return email.strip().lower() if email else None

Почему стандартные Python UDF медленнее встроенных функций?

Встроенные функции (functions.col, regexp_replace, etc.) выполняются в JVM через Tungsten - компилируются в Java bytecode, векторизованы, без сериализации. Python UDF вынуждает Spark:

  1. Сериализовать каждую строку (pickle) из JVM в Python-процесс
  2. Передать через IPC (межпроцессный канал)
  3. Выполнить Python-код строка за строкой
  4. Сериализовать результат обратно в JVM
  5. Десериализовать

Накладные расходы: 5–100× медленнее встроенных функций. Правило: если задача решается через pyspark.sql.functions.* - не писать UDF.


Что такое Pandas UDF (Vectorized UDF)?

Pandas UDF - UDF, использующие Apache Arrow для передачи данных батчами (pandas Series/DataFrame) вместо поштучной сериализации. Значительно быстрее обычных UDF.

from pyspark.sql.functions import pandas_udf
import pandas as pd

# SCALAR: Series → Series (одна колонка → одна колонка)
@pandas_udf("double")
def multiply_by_pi(s: pd.Series) -> pd.Series:
    return s * 3.14159

df = df.withColumn("area", multiply_by_pi("radius"))

# GROUPED_MAP: группа строк → DataFrame (для sessionization, custom agg)
from pyspark.sql.functions import PandasUDFType

@pandas_udf("id long, value double", PandasUDFType.GROUPED_MAP)
def normalize_group(df: pd.DataFrame) -> pd.DataFrame:
    df["value"] = (df["value"] - df["value"].mean()) / df["value"].std()
    return df

df.groupby("group_id").apply(normalize_group)

Как работает Apache Arrow в PySpark?

Apache Arrow - in-memory columnar формат, стандарт обмена данными между процессами без копирования (zero-copy). В PySpark Arrow используется для:

  • Pandas UDF: данные передаются из JVM в Python как Arrow RecordBatch - целый батч строк в колоночном виде, без построчной сериализации
  • toPandas() / createDataFrame(pd_df): при spark.sql.execution.arrow.pyspark.enabled=true конвертация идёт через Arrow - в 10–100× быстрее
  • Pandas API on Spark: весь transport layer
# Включить Arrow для toPandas/createDataFrame
spark.conf.set("spark.sql.execution.arrow.pyspark.enabled", "true")
pd_df = spark_df.toPandas()  # Теперь через Arrow

Как передать Python-библиотеку на все узлы кластера?

Варианты:

# 1. addPyFile - простой py-файл или zip
spark.sparkContext.addPyFile("utils.py")
spark.sparkContext.addPyFile("my_lib.zip")

# 2. --py-files при spark-submit
# spark-submit --py-files libs.zip job.py

# 3. conda-pack / venv-pack - полная упаковка окружения
# conda-pack -o env.tar.gz
# spark-submit --archives env.tar.gz#env --conf spark.pyspark.python=./env/bin/python job.py

# 4. Docker image - для Kubernetes
# Поместить библиотеки в Dockerfile Executor'а

Для production рекомендуется Docker image - воспроизводимо, не требует копирования файлов в рантайме.


Как настроить логирование в PySpark (log4j)?

import logging

# Метод 1: стандартный Python logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
logger.info("Processing started")

# Метод 2: log4j через SparkContext (пишет в тот же поток, что Spark)
log4j = spark._jvm.org.apache.log4j
logger = log4j.LogManager.getLogger("MyApp")
logger.info("Processing started")
logger.warn("Warning message")

# Уровень логирования Spark
spark.sparkContext.setLogLevel("WARN")  # ERROR, WARN, INFO, DEBUG

Как интегрировать PySpark с базами данных через JDBC?

# Чтение
df = spark.read.format("jdbc") \
    .option("url", "jdbc:postgresql://host:5432/db") \
    .option("dbtable", "public.users") \
    .option("user", "user") \
    .option("password", "pass") \
    .option("driver", "org.postgresql.Driver") \
    .option("numPartitions", 16) \
    .option("partitionColumn", "id") \
    .option("lowerBound", 1) \
    .option("upperBound", 1000000) \
    .load()

# Запись
df.write.format("jdbc") \
    .option("url", "jdbc:postgresql://host:5432/db") \
    .option("dbtable", "public.result") \
    .option("batchsize", 10000) \
    .mode("append") \
    .save()

numPartitions + partitionColumn - ключ к параллельному чтению без shuffle.


Что такое Spark Structured Streaming?

Structured Streaming - потоковый движок поверх DataFrame API. Концепция: входящий поток рассматривается как бесконечная таблица, к которой непрерывно добавляются строки. Обработка - те же DataFrame-операции, что и в batch.

# Чтение из Kafka
stream_df = spark.readStream \
    .format("kafka") \
    .option("kafka.bootstrap.servers", "kafka:9092") \
    .option("subscribe", "events") \
    .load()

# Обработка
result = stream_df.selectExpr("CAST(value AS STRING) as json") \
    .select(from_json(col("json"), schema).alias("data")) \
    .select("data.*") \
    .groupBy(window("timestamp", "5 minutes"), "user_id") \
    .count()

# Запись
query = result.writeStream \
    .format("parquet") \
    .option("checkpointLocation", "s3://bucket/checkpoints/") \
    .trigger(processingTime="1 minute") \
    .start()

Event Time и Watermarking

Event Time - время, когда событие произошло в источнике (поле в данных). Processing Time - время, когда Spark обработал событие. Для аналитики важен Event Time, но данные приходят с задержкой (out-of-order).

Watermark - порог опоздания. Spark сохраняет state для событий, которые могут ещё придти с опозданием до X. После watermark данные отбрасываются.

df.withWatermark("event_timestamp", "10 minutes") \
  .groupBy(window("event_timestamp", "5 minutes"), "user_id") \
  .count()

При watermark Spark очищает state store - без него state растёт бесконечно.


Типы Window в стриминге: Tumbling, Sliding, Session

Тип Описание Пример
Tumbling Фиксированные не перекрывающиеся окна window("ts", "5 minutes") - 0:00–0:05, 0:05–0:10
Sliding Перекрывающиеся окна с шагом window("ts", "10 min", "5 min") - каждые 5 мин строится окно 10 мин
Session Группировка по неактивности События с паузой > N - новая сессия
# Tumbling
window("timestamp", "5 minutes")

# Sliding
window("timestamp", "10 minutes", "5 minutes")

# Session (через mapGroupsWithState - нет встроенного синтаксиса)

Разница между микро-пакетами и Continuous Processing

Micro-Batch (default) Continuous Processing
Latency Секунды Миллисекунды
Гарантии Exactly-once At-least-once
API Полный DataFrame API Ограниченный набор операций
Статус Production-ready Experimental (Spark 3.x)

Для большинства задач (latency ≥ 1 сек) micro-batch достаточен и значительно надёжнее.


Как работает Checkpointing в Structured Streaming?

Checkpoint сохраняет в надёжное хранилище (S3/HDFS) три компонента:

  1. Offsets - позиции в Kafka/source, до которых уже обработаны данные
  2. Committed offsets - что успешно записано в sink
  3. State - текущий state store для stateful операций (aggregations, joins)

При рестарте query Spark восстанавливает exactly-once семантику: читает offsets, воспроизводит только непроцессированные данные.

query = df.writeStream \
    .option("checkpointLocation", "s3://bucket/checkpoints/query1/") \
    .start()

Checkpoint - обязателен в production. Без него рестарт = потеря прогресса и дубли.


Основы Spark MLlib: как создать Pipeline?

from pyspark.ml import Pipeline
from pyspark.ml.feature import StringIndexer, VectorAssembler, StandardScaler
from pyspark.ml.classification import LogisticRegression

# Этапы пайплайна
indexer   = StringIndexer(inputCol="category", outputCol="category_idx")
assembler = VectorAssembler(
    inputCols=["age", "salary", "category_idx"], outputCol="features")
scaler    = StandardScaler(inputCol="features", outputCol="scaled_features")
lr        = LogisticRegression(featuresCol="scaled_features", labelCol="label")

pipeline = Pipeline(stages=[indexer, assembler, scaler, lr])

# Обучение
model = pipeline.fit(train_df)

# Предсказание
predictions = model.transform(test_df)

Разница между Estimator, Transformer и Evaluator

Тип Метод Суть Примеры
Estimator .fit(df) → Model Обучается на данных, возвращает Transformer LogisticRegression, StandardScaler
Transformer .transform(df) → df Применяет преобразование StringIndexer (после fit), VectorAssembler
Evaluator .evaluate(df) → metric Вычисляет метрику качества BinaryClassificationEvaluator, RegressionEvaluator

Pipeline - Estimator, состоящий из Estimator'ов и Transformer'ов. Pipeline.fit() вызывает fit() на каждом Estimator в цепочке.


Как сохранять и загружать модели в Spark?

# Сохранение
model.save("s3://bucket/models/lr_model_v1")
pipeline.save("s3://bucket/models/pipeline_v1")

# Загрузка
from pyspark.ml.classification import LogisticRegressionModel
from pyspark.ml import PipelineModel

loaded_model    = LogisticRegressionModel.load("s3://bucket/models/lr_model_v1")
loaded_pipeline = PipelineModel.load("s3://bucket/models/pipeline_v1")

# Предсказание
predictions = loaded_pipeline.transform(new_df)

Для production рекомендуется MLflow Model Registry - версионирование, staging/production промоты, интеграция с CI/CD.


Что такое VectorAssembler?

VectorAssembler - Transformer, который объединяет несколько числовых колонок в одну колонку типа Vector - обязательный формат для большинства ML-алгоритмов Spark.

from pyspark.ml.feature import VectorAssembler

assembler = VectorAssembler(
    inputCols=["age", "salary", "tenure", "score"],
    outputCol="features",
    handleInvalid="skip"  # "error" или "keep" (для null)
)
df = assembler.transform(df)
# Новая колонка features: DenseVector([25, 50000, 3, 0.87])

Как реализовать One-Hot Encoding в PySpark?

from pyspark.ml.feature import StringIndexer, OneHotEncoder

# Шаг 1: Категориальные строки → числа
indexer = StringIndexer(inputCol="country", outputCol="country_idx")
indexed = indexer.fit(df).transform(df)

# Шаг 2: Числа → бинарный вектор
encoder = OneHotEncoder(inputCol="country_idx", outputCol="country_ohe")
encoded = encoder.fit(indexed).transform(indexed)

# В Pipeline - автоматически
pipeline = Pipeline(stages=[indexer, encoder])

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


Как тестировать PySpark-код?

import pytest
from pyspark.sql import SparkSession
from pyspark.testing import assertDataFrameEqual

@pytest.fixture(scope="session")
def spark():
    return SparkSession.builder.master("local[2]").appName("test").getOrCreate()

def test_dedup(spark):
    input_df = spark.createDataFrame([
        (1, "a"), (1, "a"), (2, "b")
    ], ["id", "value"])

    result = input_df.dropDuplicates(["id"])

    expected = spark.createDataFrame([(1, "a"), (2, "b")], ["id", "value"])
    assertDataFrameEqual(result, expected, checkRowOrder=False)

Запуск: pytest tests/ -v. Для CI избегать mock'ирования SparkSession - лучше реальный local[2]. Библиотека chispa предоставляет дополнительные assert-хелперы.


Как дебажить PySpark-приложения?

  1. Spark UI (localhost:4040) - DAG, plan, task metrics, GC, shuffle
  2. explain() - физический план запроса:
    df.explain(mode="formatted")  # Читаемый формат (Spark 3.x)
    df.explain(mode="cost")       # С оценками CBO
    
  3. Локальный запуск local[1] - полный Python traceback, pdb работает
  4. SparkMeasure - programmatic сбор метрик Stage/Task
  5. Структурированные логи - logger.info(f"Processed {df.count()} rows") - только после агрегации!
  6. print(df.schema) - проверить схему при неожиданных null или типах

Разница между Spark Streaming (DStreams) и Structured Streaming

DStreams (Spark ≤2.x) Structured Streaming (Spark 2.x+)
Абстракция RDD разбитые по batch-интервалам Бесконечный DataFrame
API map/filter/reduce на RDD DataFrame/SQL API
Оптимизация Нет Catalyst Полная Catalyst оптимизация
Event Time Сложно, вручную Встроено (withWatermark)
Exactly-once Только с idempotent sink вручную Гарантировано при checkpoint
Статус Deprecated с Spark 3.4 Текущий стандарт

DStreams разбивают поток на RDD по временным интервалам и обрабатывают каждый batch как отдельный RDD. Structured Streaming представляет поток как единую бесконечную таблицу с инкрементальным выполнением запроса - те же DataFrame-операции, что и в batch.


Три Output Mode в Structured Streaming: Append, Complete, Update

Mode Что записывается в sink Условия применимости
Append Только новые строки, гарантированно финальные Без stateful агрегации или с watermark
Complete Весь результат при каждом batch Только с агрегацией (полный перезапись)
Update Только строки, изменившиеся с прошлого batch С любой агрегацией
# Append: для Parquet/Iceberg - записать новые события, не трогать старые
query = result.writeStream.outputMode("append").format("parquet").start(path)

# Complete: для live-dashboard - перезаписать текущий топ целиком
query = top_n.writeStream.outputMode("complete").format("memory").queryName("top").start()

# Update: для Delta/Cassandra - обновить только изменившиеся агрегаты
query = counts.writeStream.outputMode("update").format("delta").start(path)

Как обеспечить гарантию Exactly-once в Structured Streaming?

Exactly-once = каждое событие обрабатывается и записывается ровно один раз - без потерь и без дублей.

Три составляющих:

  1. Replayable source - источник (Kafka) позволяет перечитать данные с конкретного offset
  2. Checkpoint - Spark сохраняет обработанные offsets на S3/HDFS; при рестарте продолжает с последнего committed offset
  3. Idempotent sink - повторная запись одного batch не создаёт дублей (Delta Lake и Iceberg поддерживают через transaction log)
query = df.writeStream \
    .format("delta") \
    .option("checkpointLocation", "s3://bucket/checkpoints/stream1/") \
    .outputMode("append") \
    .start("s3://bucket/output/")

Kafka сам по себе даёт at-least-once. Exactly-once обеспечивается совместно: Kafka + checkpoint + transactional/idempotent sink.


Как соединить статические данные с потоковыми (Stream-Static Join)?

Stream-Static Join - стандартная и поддерживаемая операция: каждый micro-batch потока джойнится с полным статическим DataFrame.

# Статический справочник (читается один раз при старте)
products = spark.read.parquet("s3://bucket/products/")

# Поток событий из Kafka
events = spark.readStream \
    .format("kafka") \
    .option("subscribe", "purchase_events") \
    .load() \
    .selectExpr("CAST(value AS STRING) AS json")

parsed = events.select(from_json(col("json"), schema).alias("d")).select("d.*")

# Join: каждый batch событий × статический справочник (без shuffle)
enriched = parsed.join(broadcast(products), "product_id", "left")

enriched.writeStream \
    .format("delta") \
    .option("checkpointLocation", "s3://checkpoints/") \
    .start("s3://output/enriched/")

Статический DataFrame не обновляется автоматически - если данные справочника меняются, нужно перезапускать query или использовать Stream-Stream join с вторым потоком.


Что такое Stateful Transformations в Structured Streaming?

Stateful трансформации требуют хранения данных между batch-ами (state). Spark сохраняет state в State Store на локальном диске Executor'а и периодически checkpointит его в S3/HDFS.

Основные виды:

# 1. Агрегация с window - нарастающие счётчики по временным окнам
df.withWatermark("event_time", "10 minutes") \
  .groupBy(window("event_time", "5 minutes"), "user_id") \
  .count()

# 2. Дедупликация - запоминает уже виденные ключи
df.withWatermark("event_time", "1 hour") \
  .dropDuplicates(["event_id"])

# 3. Stream-Stream Join - state обеих сторон до появления совпадения
events.join(clicks, ["session_id"], "inner")  # Требует watermark на обоих

# 4. Произвольный stateful processing (сессии, автоматы)
df.groupBy("user_id").flatMapGroupsWithState(
    outputMode=OutputMode.Append,
    timeoutConf=GroupStateTimeout.EventTimeTimeout,
    func=update_session_state
)

Без watermark state растёт бесконечно → OOM. Watermark ограничивает, как долго держать state для "запоздавших" данных.


Как масштабировать потребителя Kafka в Spark Streaming?

Spark Structured Streaming читает из Kafka параллельно: количество Task = количество Kafka партиций. Масштабирование достигается увеличением Kafka партиций.

# Один поток чтения с несколькими партициями Kafka
stream = spark.readStream \
    .format("kafka") \
    .option("kafka.bootstrap.servers", "kafka:9092") \
    .option("subscribe", "events") \
    .option("maxOffsetsPerTrigger", 100_000)  # Ограничить батч-размер
    .load()

Ключевые параметры:

  • maxOffsetsPerTrigger - максимум offset'ов за один micro-batch → контролирует latency/throughput
  • minPartitions - минимальное число входных партиций (Spark может дробить)
  • Kafka partitions → Tasks: при 10 партициях в топике Spark создаёт 10 Tasks на чтение

При медленной обработке (lag растёт) → увеличить число Kafka партиций (kafka-topics --alter) → Spark автоматически создаст больше Tasks.


Как обрабатывать изменения схемы в источнике данных при стриминге?

При изменении схемы источника (добавление/удаление колонки) Structured Streaming по умолчанию падает с AnalysisException.

# Вариант 1: явная схема + PERMISSIVE режим - новые колонки → null, лишние - игнорируются
schema = StructType([...])  # Зафиксированная схема
stream = spark.readStream \
    .format("json") \
    .schema(schema) \
    .option("mode", "PERMISSIVE") \
    .load(path)

# Вариант 2: Для Kafka+JSON - schema-on-read с from_json
stream.select(from_json(col("value"), schema).alias("d"))
# Новые поля в JSON просто игнорируются; удалённые → null

# Вариант 3: Delta Lake как sink - schema merging при записи
stream.writeStream \
    .format("delta") \
    .option("mergeSchema", "true") \  # Добавляет новые колонки в таблицу
    .start(path)

Best practice: использовать Schema Registry (Confluent) для контроля эволюции схемы Avro-событий - backward/forward/full compatibility гарантируют, что продюсеры и консьюмеры не сломают друг друга.


Что такое Write-Ahead Log (WAL) в Spark Streaming и зачем он нужен?

Write-Ahead Log (WAL) - механизм обеспечения отказоустойчивости в Spark Streaming (DStreams), введённый в Spark 1.2.

Проблема без WAL: при сбое Driver'а все Executor'ы завершаются вместе с данными в памяти. Данные, полученные из Kafka/Flume, но ещё не обработанные, теряются.

Как работает WAL:

  1. Данные из источника (Kafka, Flume) принимаются Receiver'ом
  2. Перед обработкой они записываются в WAL-файл в надёжном хранилище (HDFS/S3)
  3. При рестарте Driver'а незавершённые операции воспроизводятся из лога
# Включение WAL в Spark Streaming
ssc = StreamingContext(sc, batchDuration=5)

# 1. Обязательно - checkpoint directory
ssc.checkpoint("hdfs:///checkpoints/myapp")

# 2. Включить WAL
spark.conf.set("spark.stream.receiver.writeAheadLog.enable", "true")

# 3. Для Kafka - использовать Direct API (без Receiver), тогда WAL не нужен:
stream = KafkaUtils.createDirectStream(ssc, topics, kafkaParams)
Подход Гарантия WAL нужен?
Receiver-based (Flume, старый Kafka) at-least-once Да, для fault tolerance
Direct Kafka API exactly-once (с Kafka offset) Нет
Structured Streaming exactly-once Нет (checkpoint встроен)

Практика: для новых приложений используйте Structured Streaming - WAL/checkpoint уже встроен в движок, not requiring ручной настройки.