Блок 4 - Python и расширенные возможности
20 вопросов: UDF, Pandas UDF, Arrow, Structured Streaming, MLlib pipeline и тестирование.
Что такое 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:
- Сериализовать каждую строку (pickle) из JVM в Python-процесс
- Передать через IPC (межпроцессный канал)
- Выполнить Python-код строка за строкой
- Сериализовать результат обратно в JVM
- Десериализовать
Накладные расходы: 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) три компонента:
- Offsets - позиции в Kafka/source, до которых уже обработаны данные
- Committed offsets - что успешно записано в sink
- 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-приложения?¶
- Spark UI (
localhost:4040) - DAG, plan, task metrics, GC, shuffle - explain() - физический план запроса:
df.explain(mode="formatted") # Читаемый формат (Spark 3.x) df.explain(mode="cost") # С оценками CBO - Локальный запуск
local[1]- полный Python traceback, pdb работает - SparkMeasure - programmatic сбор метрик Stage/Task
- Структурированные логи -
logger.info(f"Processed {df.count()} rows")- только после агрегации! - 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 = каждое событие обрабатывается и записывается ровно один раз - без потерь и без дублей.
Три составляющих:
- Replayable source - источник (Kafka) позволяет перечитать данные с конкретного offset
- Checkpoint - Spark сохраняет обработанные offsets на S3/HDFS; при рестарте продолжает с последнего committed offset
- 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/throughputminPartitions- минимальное число входных партиций (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:
- Данные из источника (Kafka, Flume) принимаются Receiver'ом
- Перед обработкой они записываются в WAL-файл в надёжном хранилище (HDFS/S3)
- При рестарте 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 ручной настройки.