Transformations vs Actions: анатомия ленивых вычислений
Полный каталог трансформаций и actions, механика пайплайнинга DAG, двойная работа при нескольких Actions, ловушки ленивого выполнения - side effects, отложенные ошибки, action в цикле, OOM.
В уроке 3 мы познакомились с Lazy Evaluation концептуально. Теперь разберём механику вглубь: как именно Spark строит DAG, что происходит при нескольких Actions, и какие ловушки расставляет «ленивое» выполнение для невнимательного разработчика. Этот урок - фундамент, без которого невозможно осознанно профилировать и оптимизировать Spark-пайплайны.
Философия: почему Spark «ленивый»?¶
Два мира: императивный и декларативный¶
Прежде чем разбирать механику, важно понять, откуда вообще взялась эта архитектура. Существуют два принципиально разных подхода к вычислениям.
Императивный подход - это то, как работает классический Python или Pandas. Вы пишете инструкции, и каждая из них выполняется немедленно, строчка за строчкой. Интерпретатор не знает, что будет написано в следующей строке, поэтому не может «заглянуть вперёд» и оптимизировать.
# Pandas: императивный, eager execution
df = pd.read_csv("events.csv") # читает ВСЕ колонки сразу
df = df[df["status"] == "active"] # создаёт копию в RAM немедленно
df = df[["user_id", "revenue"]] # ещё одна копия
result = df.groupby("user_id").sum() # ещё одна копия
Каждая строка немедленно создаёт новый объект в памяти. На ноутбуке с файлом в 100 МБ это работает прекрасно. Но когда данные - 5 ТБ на кластере из 50 машин, такой подход ломается: нет центрального места, которое «держит» всю таблицу, а выполнение каждого шага немедленно требует синхронизации между узлами.
Декларативный подход - это то, что используют SQL-базы и Spark. Вы описываете что нужно получить, а не как это вычислить. Система накапливает описание всей трансформационной цепочки и только потом строит оптимальный план выполнения.
# Spark: декларативный, lazy evaluation
df = spark.read.parquet("s3a://data/events/") # только открывает метаданные
df = df.filter(col("status") == "active") # добавляет узел в граф
df = df.select("user_id", "revenue") # добавляет узел в граф
df = df.groupBy("user_id").sum("revenue") # добавляет узел в граф
# Пока ничего не вычислено. Spark держит в памяти лишь "рецепт"
Ключевое слово - рецепт. Spark не готовит блюдо при добавлении каждого ингредиента. Он записывает рецепт целиком, а потом готовит всё сразу - наиболее эффективным способом.
Что даёт лень оптимизатору¶
Пока план не выполнен, оптимизатор Catalyst видит всю цепочку операций целиком. Это позволяет ему делать то, что невозможно при немедленном выполнении:
Перестановка операций. Если вы написали join() перед filter(), но Catalyst видит, что фильтр можно применить раньше - он переставит их. Фильтрация до join уменьшает объём обрабатываемых данных на порядки. При eager execution вы уже выполнили join - делать фильтрацию «до» него невозможно.
Устранение ненужных колонок. Если в конце пайплайна нужны только 3 из 40 колонок, Catalyst может сказать Parquet reader: «читай только эти три». Это называется column pruning - и оно работает только потому, что Spark знает всю цепочку заранее.
Объединение операций в один проход. Вместо того чтобы делать filter, потом withColumn, потом ещё один filter тремя отдельными проходами по данным - Spark «сплавляет» их в единый цикл. Каждая строка проходит через все три операции за один проход по памяти.
Выбор стратегии join. Если в конце пайплайна выяснилось, что одна из таблиц после фильтрации стала маленькой - Catalyst может заменить дорогой Shuffle Hash Join на дешёвый Broadcast Join. Но это возможно только если план строится целиком, а не построчно.
Левый путь - то, что вы написали в коде. Правый - то, что Spark реально выполняет после оптимизации. Разница в скорости может быть 10–100× на реальных данных.
Жизненный цикл Spark-запроса¶
Прежде чем говорить о трансформациях и actions, важно понять полный цикл от вашего кода до физического выполнения. Это знание позволит осмысленно читать explain() и Spark UI.
Разберём каждый шаг подробно.
Unresolved Logical Plan. Каждый вызов трансформации добавляет узел в дерево. На этом этапе Spark не знает, существует ли колонка user_id в источнике - он лишь записывает намерение. Попытка обратиться к несуществующей колонке пока не является ошибкой.
Analyzed Logical Plan. Spark обращается к Catalog (метаданные источников данных), разрешает имена колонок, проверяет типы данных. Если колонка user_id не существует - ошибка возникает именно здесь, не при вызове трансформации.
Optimized Logical Plan. Catalyst применяет десятки правил оптимизации к логическому плану. Правила делятся на две категории: rule-based (применяются всегда: predicate pushdown, column pruning, constant folding) и cost-based (требуют статистик: join reordering, broadcast threshold). Результат - более эффективный логический план с теми же семантикой и результатом.
Physical Plans. Catalyst генерирует несколько вариантов физического плана. Для каждого join, например, рассматриваются: SortMergeJoin (универсальный, требует сортировки), BroadcastHashJoin (быстрый, работает только если одна таблица помещается в память), ShuffleHashJoin (хэш-join без сортировки). Выбор зависит от размеров данных и настроек.
WholeStageCodegen. Выбранный физический план компилируется в «сырой» Java-байткод. Вместо цепочки виртуальных вызовов методов Spark генерирует один единственный цикл, который делает все операции над каждой строкой. Это убирает накладные расходы на вызовы методов и делает узкие трансформации почти бесплатными.
Execution. Только на этом этапе данные реально читаются из источника. Executors получают Tasks - каждый Task обрабатывает одну партицию данных, применяя весь скомпилированный план к каждой строке.
Ключевой момент: шаги 1–6 происходят на Driver. Executors задействуются только на шаге 7. Именно поэтому
explain()выполняется мгновенно - он строит план, но не читает данных.
Полный каталог трансформаций¶
Трансформация - это операция, которая принимает один или несколько DataFrame/RDD и возвращает новый DataFrame/RDD. При вызове трансформации ничего не вычисляется - добавляется узел в логический граф (DAG lineage).
Трансформации делятся на два принципиально разных класса по типу зависимости между входными и выходными партициями.
Narrow трансформации - без shuffle¶
Узкая зависимость означает: каждая выходная партиция зависит ровно от одной входной партиции. Данные не перемещаются между узлами кластера. Все операции над одной партицией выполняются одним Executor без координации с остальными.
Это делает narrow трансформации фундаментально дешёвыми: нет сетевого обмена, нет записи на диск, нет ожидания других Executors.
Каждый Executor обрабатывает свою партицию независимо. Ни байта данных не пересекает сетевую границу.
| Функция | Что делает | Почему узкая |
|---|---|---|
select(col1, col2, ...) |
Оставляет только указанные колонки | Каждая строка обрабатывается независимо |
drop(col) |
Удаляет колонку | Строки независимы |
filter(condition) / where(condition) |
Фильтрует строки по условию | Каждая строка проверяется независимо |
withColumn(name, expr) |
Добавляет или заменяет колонку | Новое значение вычисляется из той же строки |
withColumnRenamed(old, new) |
Переименовывает колонку | Только смена имени в метаданных |
withColumns({name: expr, ...}) |
Добавляет несколько колонок сразу | Batch-версия withColumn |
cast(type) / .cast(type) |
Преобразует тип данных колонки | Конвертация по строкам |
limit(n) |
Ограничивает количество строк | Локально на каждой партиции |
union() / unionAll() |
Вертикально объединяет два DataFrame | Партиции просто конкатенируются |
unionByName() |
Объединяет по имени колонок | Аналогично union, но с сопоставлением схем |
sample(fraction) |
Случайная выборка строк | Каждая строка решается локально |
na.fill() / fillna() |
Заполняет null-значения | Строки независимы |
na.drop() / dropna() |
Удаляет строки с null | Строки независимы |
na.replace() |
Заменяет значения | Строки независимы |
map(func) (RDD API) |
Применяет функцию к каждому элементу | Один элемент → один результат |
flatMap(func) (RDD API) |
Один элемент → ноль или несколько результатов | Независимо по элементам |
mapPartitions(func) (RDD API) |
Функция получает целую партицию | Одна партиция → одна партиция |
coalesce(n) |
Уменьшает количество партиций | Объединяет без shuffle (только уменьшение) |
Важное замечание про coalesce(n). Эта функция - пограничный случай. Когда вы уменьшаете число партиций (например, с 200 до 10), Spark просто объединяет несколько партиций на одном Executor без перемещения данных по сети. Но если попытаться увеличить число партиций через coalesce - это невозможно без shuffle, поэтому для увеличения нужен repartition.
Важное замечание про union(). В отличие от SQL UNION, union() в Spark работает по позиции колонок, а не по именам. Если схемы двух DataFrame совпадают по структуре, но колонки переставлены - union молча перепутает данные. Используйте unionByName() если схемы могут отличаться.
Wide трансформации - через shuffle¶
Широкая зависимость означает: выходная партиция зависит от нескольких входных партиций, возможно из разных узлов кластера. Данные физически перемещаются по сети - это называется shuffle.
Одинаковые ключи user_id могут быть в разных партициях на разных узлах. Чтобы агрегировать их вместе, Spark должен собрать все записи с одинаковым ключом в одном месте. Это требует сетевого обмена - shuffle.
Shuffle - самая дорогостоящая операция в Spark по нескольким причинам:
- Запись на диск: перед shuffle Executor записывает свои данные на локальный диск (shuffle write). Это защита от потери данных при сбое.
- Чтение с диска: после shuffle Executor читает данные, предназначенные для него, с дисков других Executors (shuffle read).
- Сетевой обмен: данные передаются по сети между узлами кластера.
- Сортировка: большинство shuffle-алгоритмов требуют сортировки данных по ключу.
- Барьер синхронизации: второй Stage не может начаться, пока первый полностью не завершился и не записал shuffle files.
| Функция | Почему требует shuffle | Особенности |
|---|---|---|
groupBy(key).agg(...) |
Все записи с одним ключом должны попасть к одному Executor | Частичная агрегация выполняется до shuffle (Map-side combiner) |
join() (SortMergeJoin) |
Записи с одинаковыми ключами из двух таблиц должны встретиться | Самый тяжёлый вариант join |
repartition(n) |
Полное перераспределение данных | Может как увеличивать, так и уменьшать партиции |
repartition(n, col) |
Перераспределение с хэшированием по колонке | Одинаковые значения col → одна партиция |
distinct() |
Нужно сравнить все копии одного значения | Аналогичен groupBy по всем колонкам |
dropDuplicates(cols) |
Дедупликация по подмножеству колонок | Частичная дедупликация внутри партиций до shuffle |
orderBy() / sort() |
Глобальная сортировка требует видеть все данные | Дорого: собирает разбросанные данные в порядке |
pivot() |
Группировка для поворота строк в колонки | Shuffle + агрегация |
cube() / rollup() |
Многомерные агрегации | Несколько shuffle за один вызов |
Window с PARTITION BY |
Оконная функция по партициям | Shuffle по partition key |
crossJoin() |
Декартово произведение | Самый тяжёлый: M × N строк |
checkpoint() |
Материализует lineage в надёжное хранилище | Shuffle + запись в HDFS/S3 |
Особенность orderBy(). Глобальная сортировка требует, чтобы все данные были собраны в правильном порядке. Это означает shuffle со специальным алгоритмом: Spark сначала семплирует данные для определения границ диапазонов, а затем делает range partitioned shuffle. Это особенно дорого на больших данных. Если глобальная сортировка не критична - предпочитайте sortWithinPartitions(), которая сортирует только внутри каждой партиции без shuffle.
Особые трансформации¶
Некоторые трансформации заслуживают отдельного упоминания, потому что их поведение не очевидно:
cache() и persist(). Технически это трансформации - они не запускают вычисления немедленно. Они лишь помечают DataFrame для кэширования при первом Action. Реальное кэширование происходит при первом выполнении. Это контринтуитивно: вызов .cache() не означает «сейчас закэшировать» - он означает «закэшировать при следующем Action».
explode(col). Узкая трансформация, которая «взрывает» массив: каждый элемент массива становится отдельной строкой. Если строка содержит массив из 5 элементов - explode превращает её в 5 строк. Это может существенно увеличить размер DataFrame.
from pyspark.sql.functions import explode, col
# До explode: 3 строки
# | user_id | tags |
# | 1 | [spark, python] |
# | 2 | [spark, scala] |
# | 3 | [hadoop] |
df.select("user_id", explode("tags").alias("tag"))
# После explode: 5 строк
# | user_id | tag |
# | 1 | spark |
# | 1 | python |
# | 2 | spark |
# | 2 | scala |
# | 3 | hadoop |
from_json() и to_json(). Трансформации для работы с JSON-строками. from_json(col, schema) парсит JSON-строку в StructType. to_json(col) сериализует StructType обратно в JSON-строку. Парсинг - дорогая операция, поэтому по возможности храните данные в нативных форматах (Parquet, ORC) вместо JSON-строк.
Higher-order functions (transform, filter, aggregate из pyspark.sql.functions). Работают с элементами массивов и словарей без explode. Выполняются на JVM, поэтому существенно быстрее Python UDF для аналогичных задач.
from pyspark.sql.functions import transform, filter as spark_filter
# transform: применить функцию к каждому элементу массива (не explode)
df.select(transform("scores", lambda x: x * 2).alias("doubled_scores"))
# filter: оставить только элементы массива, удовлетворяющие условию
df.select(spark_filter("tags", lambda x: x != "deprecated").alias("active_tags"))
Полный каталог Actions¶
Action - это операция, которая запускает выполнение всего накопленного DAG. Action либо возвращает данные в Driver (Python-процесс на вашей машине), либо записывает данные во внешнее хранилище. После каждого Action DAG выполняется полностью - с нуля, если результат не был закэширован.
Понимание Actions критично для оптимизации: каждый Action - потенциально дорогая операция.
Возвращают данные в Driver¶
| Action | Возвращаемый тип | Риск | Когда использовать |
|---|---|---|---|
collect() |
list[Row] - все строки |
⚠️ OOM при больших данных | Только для маленьких результирующих DataFrame |
toPandas() |
pd.DataFrame - все строки |
⚠️ OOM при больших данных | Только для финальных небольших результатов |
take(n) |
list[Row] - первые N строк |
Безопасен | Отладка, просмотр выборки |
head(n) |
list[Row] или Row |
Безопасен | Аналог take, первый Row без аргументов |
first() |
Row - первая строка |
Безопасен | Быстрая проверка наличия данных |
tail(n) |
list[Row] - последние N строк |
Может быть дорогим | Редко, почти всегда лучше sort + take |
О collect() и toPandas(). Оба метода переносят весь DataFrame на одну машину - Driver. Если в DataFrame 100 млн строк по 1 КБ каждая, это 100 ГБ на Driver. Driver типично имеет 4–16 ГБ RAM. Результат - OutOfMemoryError. Используйте эти методы только для небольших агрегированных результатов (тысячи строк, не миллионы).
Возвращают агрегат, не данные¶
| Action | Возвращаемый тип | Примечание |
|---|---|---|
count() |
int - количество строк |
Сканирует все партиции |
show(n) |
None - печатает в stdout |
Не «бесплатный» - запускает Job |
describe() |
DataFrame |
Возвращает DataFrame со статистиками |
isEmpty() |
bool |
Оптимизирован: останавливается при первой строке |
reduce(func) (RDD) |
скалярное значение | Распределённая редукция |
О count(). Этот Action выглядит безобидно, но он сканирует все партиции данных. Если ваш DataFrame читается из S3 и содержит 10 ТБ - count() прочитает все 10 ТБ. Для отладки используйте df.limit(1000).count() вместо df.count(). В продакшн-коде count() оправдан только если число строк действительно нужно.
О show(). Многие разработчики думают, что show(10) - это «просто просмотреть несколько строк, это быстро». На самом деле show(n) запускает полноценный Spark Job. Если ваш DataFrame строится через сложный join и groupBy - show(10) выполнит весь этот join и groupBy, а потом покажет 10 строк результата. Разница с take(10) только в том, что show красиво форматирует вывод и не возвращает данные в Python.
Об isEmpty(). Это оптимизированный Action: Spark останавливает выполнение после нахождения первой строки. Он не сканирует весь DataFrame. Используйте его вместо count() == 0 для проверки на пустоту.
Записывают данные во внешнее хранилище¶
| Action | Что делает | Особенности |
|---|---|---|
write.parquet(path) |
Запись в Parquet | Наиболее эффективный формат |
write.csv(path) |
Запись в CSV | Без сжатия, не рекомендуется для аналитики |
write.json(path) |
Запись в JSON | Большой overhead на сериализацию |
write.orc(path) |
Запись в ORC | Аналог Parquet, популярен в Hive-экосистеме |
write.format("delta").save(path) |
Запись в Delta Lake | ACID транзакции, versioning |
write.insertInto("table") |
Вставка в таблицу Hive/Spark | Требует существующей таблицы |
write.saveAsTable("table") |
Создание таблицы в Catalog | Создаёт новую таблицу |
saveAsTextFile(path) (RDD) |
RDD в текстовый файл | Устаревший API |
Важно о записи. Методы write.*() - это Actions, которые запускают выполнение DAG. До вызова write.parquet() ничего не записывается. Это означает: если ваш пайплайн завершится ошибкой до write - на диске не будет никаких частичных данных (если используется режим записи overwrite или атомарный формат вроде Delta Lake).
Выполняют действие на каждой строке (на Executors)¶
| Action | Что делает | Риск |
|---|---|---|
foreach(func) |
Применяет функцию к каждой строке на Executor | Side effects на Executor |
foreachPartition(func) |
Применяет функцию к целой партиции на Executor | Side effects, более эффективно |
Использование foreachPartition. Предпочтительнее foreach, когда нужно делать что-то с каждой строкой и это дорого инициализировать (например, открывать соединение с базой данных). С foreachPartition соединение открывается один раз на партицию, а не на каждую строку.
def write_to_postgres(rows):
# Соединение открывается один раз на партицию
conn = psycopg2.connect("host=db user=spark...")
cursor = conn.cursor()
for row in rows:
cursor.execute("INSERT INTO events VALUES (%s, %s)", (row["id"], row["value"]))
conn.commit()
conn.close()
df.foreachPartition(write_to_postgres) # эффективно
# vs
df.foreach(lambda row: write_row_to_postgres(row)) # N соединений, неэффективно
Специальный случай: explain()¶
explain() - это не совсем Action в традиционном смысле. Он не запускает выполнение DAG и не читает данные. Но он выводит результат немедленно, поэтому визуально похож на Action. На самом деле он запускает только первые 6 шагов жизненного цикла (до физического плана) и печатает результат.
df.explain() # краткий физический план
df.explain(mode="formatted") # читаемый форматированный план
df.explain(mode="extended") # полный план: logical + physical
df.explain(mode="codegen") # сгенерированный Java код
df.explain(mode="cost") # с оценками стоимости (если есть статистики)
DAG под капотом: пайплайнинг и Operator Fusion¶
Что такое DAG и зачем он нужен¶
DAG (Directed Acyclic Graph, направленный ациклический граф) - это формальное представление вашего пайплайна в виде графа, где узлы - это операции (трансформации), а рёбра - зависимости между ними.
Слово «ациклический» важно: граф не может иметь циклов. Это фундаментальное свойство, которое позволяет:
- Восстанавливать данные без репликации: если Executor упал и потерял свою партицию - Spark просто повторно выполняет часть DAG для этой партиции. Не нужно хранить резервную копию данных.
- Оптимизировать глобально: без циклов можно применять топологическую сортировку и глобальные правила оптимизации.
- Строить Stage-ы: широкие трансформации (shuffle) разделяют DAG на Stage-ы, которые выполняются последовательно.
Как Spark разбивает DAG на Stage-ы. DAG Scheduler смотрит на граф и находит границы shuffle (Exchange operators). Каждый Exchange создаёт новый Stage. Stage-ы выполняются последовательно: Stage 2 не может начаться, пока Stage 1 не записал все свои shuffle files. Внутри Stage все операции над одной партицией выполняются в один проход - это и есть пайплайнинг.
Пайплайнинг и Operator Fusion¶
Внутри одного Stage Spark объединяет несколько операций в один физический цикл выполнения. Это называется Operator Fusion или пайплайнинг. Вместо того чтобы:
- Пройти по всем строкам партиции, применить
filter(), сохранить результат в промежуточный буфер - Пройти по промежуточному буферу, применить
withColumn(), сохранить - Пройти по результату, применить второй
filter(), сохранить - Пройти ещё раз, применить
select(), сохранить
Spark делает всё в один проход:
- Читает строку из партиции
- Применяет
filter()- если строка не прошла, берём следующую - Применяет
withColumn()- вычисляем новое значение - Применяет второй
filter()- проверяем условие - Применяет
select()- оставляем нужные колонки - Передаём результирующую строку на shuffle write
Это даёт огромную выгоду по памяти: промежуточные буферы не создаются вообще. Строки текут через пайплайн по одной.
Whole-Stage Codegen: *(N) в explain()¶
Пайплайнинг идёт ещё дальше. Вместо цепочки вызовов виртуальных методов (которые в JVM требуют dynamic dispatch) Spark генерирует Java-байткод, объединяющий все операции в одну скомпилированную функцию.
В выводе explain() это обозначается звёздочкой: *(1) Project, *(1) Filter. Все операции с одинаковым числом в скобках компилируются в один сгенерированный класс. Exchange (shuffle) разрывает кодоген-цикл.
result.explain(mode="formatted")
== Physical Plan ==
AdaptiveSparkPlan isFinalPlan=false
+- HashAggregate(keys=[user_id#10L], functions=[sum(amount_usd#11)])
+- Exchange hashpartitioning(user_id#10L, 200) ← граница кодогена
+- HashAggregate(keys=[user_id#10L], functions=[partial_sum(amount_usd)])
+- Project [user_id#10L, (amount#12 / rate#13) AS amount_usd#11] ← *(1)
+- Filter (event_type = purchase AND (amount / rate) > 0.0) ← *(1)
+- FileScan parquet [...] ← *(1)
PushedFilters: [IsNotNull(event_type), EqualTo(event_type,purchase)]
ReadSchema: struct<user_id:bigint,amount:double,rate:double>
Читается снизу вверх:
-
FileScan parquet - исток данных.
PushedFiltersозначает, что часть фильтров передана прямо в Parquet reader: строки не читаются в JVM, если уже понятно, что они не пройдут фильтр.ReadSchemaпоказывает, что читаются только 3 из 40 колонок (column pruning). -
Filter - часть фильтров, которую нельзя передать в Parquet reader (например, вычисляемые условия). Выполняется в JVM.
-
Project -
withColumn()иselect(). Вычисляет новую колонкуamount_usdи оставляет только нужные колонки. -
Exchange - граница Stage-ов. Здесь данные сортируются по
user_idи записываются на диск для shuffle. -
HashAggregate (partial) - частичная агрегация до shuffle. Если в одной партиции есть записи с одинаковым
user_id, их суммы считаются ещё до отправки по сети. Это называется Map-side combiner - значительно уменьшает объём данных в shuffle. -
HashAggregate (final) - финальная агрегация после shuffle, когда все записи с одним
user_idуже собраны вместе.
Два HashAggregate - это не ошибка и не неэффективность. Это намеренная оптимизация: частичная агрегация сокращает объём shuffle write в разы.
Catalyst и Lazy Evaluation: синергия оптимизаций¶
Рассмотрим три ключевых оптимизации Catalyst, которые возможны только благодаря Lazy Evaluation.
Predicate Pushdown: фильтруем до чтения¶
Predicate Pushdown - перенос условий фильтрации (filter(), where()) как можно «ближе» к источнику данных, вплоть до передачи их в формат хранения.
# Что вы написали:
df = spark.read.parquet("s3a://data/events/")
df = df.filter(col("country") == "RU")
df = df.filter(col("event_date") >= "2024-01-01")
df.write.parquet("s3a://result/")
Без оптимизации Spark прочитал бы все данные из Parquet, передал в JVM, и только там применил фильтры. С Predicate Pushdown:
- Фильтр по
event_date- если данные партиционированы по дате, Spark не читает партиции за прошлые периоды вообще (Partition Pruning). - Фильтр по
country- Parquet хранит статистики (min/max) для каждого row group. Если в row group нет записейcountry = 'RU'(min > 'RU' или max < 'RU') - этот блок пропускается без чтения (Statistics-based row group skipping). - Оставшиеся фильтры передаются в виде
PushedFiltersв Parquet reader - строки отбрасываются ещё до передачи в JVM.
Практический эффект: чтение 10% данных вместо 100% при правильной партиционированности и хорошей статистике.
Column Pruning: читаем только нужные колонки¶
Если в конце пайплайна нужны только 4 колонки из 80 - Catalyst передаёт Parquet reader информацию: «читай только эти 4 колонки». Parquet - колоночный формат, поэтому остальные 76 колонок даже не читаются с диска.
# 80 колонок в таблице, но нужны только 4
result = df.select("user_id", "revenue", "country", "event_date")
.groupBy("country")
.agg(sum("revenue"))
Catalyst видит весь план: в финальном результате нужны только country и revenue. Колонка user_id нужна только в промежуточных операциях. В ReadSchema вы увидите только 4 колонки, не 80.
Практический эффект: Parquet-файл с 80 колонками, но если запрос использует только 4 - чтение в 10–20 раз быстрее.
Join Reordering: порядок join имеет значение¶
Если вы джоините три таблицы: большую (10 ТБ), среднюю (100 ГБ) и маленькую (10 МБ) - порядок join определяет сложность вычислений.
# Написали так - большая × средняя, потом результат × маленькая
result = large_df.join(medium_df, "key").join(small_df, "id")
# Catalyst может переставить так - маленькая переводится в broadcast,
# large × broadcast_small, потом × medium - меньше shuffle
Catalyst использует статистики о размерах таблиц (если они доступны через ANALYZE TABLE или собираются автоматически в Delta Lake/Iceberg) для выбора оптимального порядка join. Без Lazy Evaluation это было бы невозможно: первый join уже выполнен к тому моменту, когда мы узнаём о втором.
Ловушка №1: Двойная работа при нескольких Actions¶
Почему Spark не помнит результаты¶
По умолчанию Spark - stateless движок. Когда Action завершился, вычисленные данные не сохраняются автоматически. Они живут в памяти Executors только в процессе выполнения. После завершения Action - память освобождается.
Это означает: следующий Action на том же DataFrame перечитает данные из источника и выполнит все трансформации заново.
# Строим тяжёлый граф: join + groupBy + несколько фильтров
heavy_df = (
raw_events
.filter(col("status") == "active")
.join(enrichment_df, "user_id") # дорогой join: shuffle
.join(geo_df, "ip_address") # ещё один join: ещё shuffle
.groupBy("region", "category")
.agg(
count("*").alias("cnt"),
sum("revenue").alias("total_revenue"),
avg("session_duration").alias("avg_session")
)
)
# ❌ S3 читается дважды, оба join выполняются дважды, groupBy - дважды
print(f"Строк в отчёте: {heavy_df.count()}") # Action 1: полный DAG
heavy_df.write.parquet("s3a://bucket/daily_report/") # Action 2: полный DAG заново
В Spark UI вы увидите два отдельных Job с идентичными Stage-ами. Вся работа сделана дважды.
Цена ошибки на реальном кластере. Предположим, кластер стоит $10/час, пайплайн занимает 20 минут ($3.33). Два Action без кэширования = два пайплайна = $6.66. С кэшированием - $3.33 + небольшой overhead на materialization. На 100 запусков в месяц разница = $333. На больших данных (час вместо 20 минут) - $1000+.
Решение: persist() обрывает Lineage¶
# ✅ Вычисляем один раз, используем дважды
heavy_df = (
raw_events
.filter(col("status") == "active")
.join(enrichment_df, "user_id")
.join(geo_df, "ip_address")
.groupBy("region", "category")
.agg(
count("*").alias("cnt"),
sum("revenue").alias("total_revenue"),
avg("session_duration").alias("avg_session")
)
.persist() # помечаем для кэширования
)
# Action 1: выполняет DAG полностью, сохраняет результат в память Executors
print(f"Строк в отчёте: {heavy_df.count()}")
# Action 2: читает из памяти Executors, не идёт к источнику
heavy_df.write.parquet("s3a://bucket/daily_report/")
# Освобождаем память явно - не ждём сборщика мусора
heavy_df.unpersist()
Важные нюансы persist():
persist()сам по себе - lazy трансформация. Реальное кэширование происходит при первом Action.- По умолчанию
persist()без аргументов использует уровеньMEMORY_AND_DISK: сначала в RAM, при нехватке - на диск Executor. cache()- синонимpersist()без аргументов.unpersist()нужно вызывать явно. Spark самостоятельно инвалидирует кэш при нехватке памяти (LRU eviction), но явныйunpersist()освобождает память немедленно.
Уровни хранения¶
| Уровень | Хранение | Скорость | Когда использовать |
|---|---|---|---|
MEMORY_ONLY |
Только RAM, десериализованно | Быстрее всего | Данные помещаются в RAM гарантированно |
MEMORY_AND_DISK |
RAM + диск при нехватке | Хорошо | Стандартный выбор, надёжно |
MEMORY_ONLY_SER |
RAM, сериализованно | Чуть медленнее | RAM мало, хотим сжать данные |
DISK_ONLY |
Только диск | Медленно | RAM совсем нет |
MEMORY_AND_DISK_2 |
RAM + диск, реплицировано | Надёжно | Критически важные данные |
Когда кэшировать: дерево решений¶
DataFrame используется в 2+ Actions?
├── НЕТ → не кэшировать (overhead на сериализацию не оправдан)
└── ДА:
└── Вычисление дорогое (join, groupBy, dropDuplicates)?
├── НЕТ → скорее всего не стоит (пересчитать может быть быстрее)
└── ДА:
└── Данные помещаются в RAM?
├── ДА → persist(MEMORY_ONLY) или cache()
└── НЕТ → persist(MEMORY_AND_DISK) или пересчитывать
Ловушка №2: Отложенные ошибки и «логические бомбы»¶
Ошибки в трансформациях не возникают сразу¶
Поскольку трансформации не выполняются при вызове, ошибки в данных (деление на ноль, несовместимые типы, нарушение схемы) не возникают при написании трансформации. Они «прячутся» до первого Action.
# Строка 15: трансформация с потенциальной ошибкой - никакой ошибки нет
df_with_ratio = df.withColumn("conversion_rate", col("purchases") / col("visits"))
# Если в данных есть строки где visits=0 - здесь ничего не произойдёт
# Строка 16: ещё одна трансформация - ещё ничего
df_enriched = df_with_ratio.filter(col("conversion_rate") > 0.01)
# ...50 строк кода позже...
# Строка 100: первый Action - ВОТ ЗДЕСЬ взрыв произойдёт,
# потому что Spark попытается вычислить 0/0 = NaN или выбросит исключение
df_enriched.write.parquet("s3a://result/")
# SparkException: Division by zero at line 15 in your_script.py
Это делает отладку Spark-кода сложнее, чем отладку обычного Python. Стектрейс укажет на строку с Action, но ошибка концептуально находится в трансформации намного раньше.
Как защититься:
# ✅ Защита от деления на ноль
from pyspark.sql.functions import when, col
df_with_ratio = df.withColumn(
"conversion_rate",
when(col("visits") > 0, col("purchases") / col("visits")).otherwise(0.0)
)
# ✅ Проверяйте данные на выборке перед полным запуском
sample = df.limit(10000)
sample_ratio = sample.withColumn("conversion_rate", col("purchases") / col("visits"))
sample_ratio.show(5) # быстрая проверка на маленьких данных
# Если здесь проблема - увидим быстро, не через 40 минут full job
Схемы: ошибки до Action, не во время¶
В отличие от ошибок в данных, ошибки в схеме (обращение к несуществующей колонке) обнаруживаются раньше - на этапе Analyzed Logical Plan, который строится при вызове трансформации. Это происходит потому что Catalyst проверяет схему немедленно.
df = spark.read.parquet("s3a://data/events/")
# df имеет колонки: user_id, event_type, amount
# ❌ Ошибка будет здесь, при вызове трансформации - не при Action
df_bad = df.filter(col("typo_column") > 0)
# AnalysisException: Column 'typo_column' not found in [user_id, event_type, amount]
# ❌ Ошибка в данных - только при Action
df_ok = df.withColumn("ratio", col("amount") / col("user_id")) # нет ошибки синтаксиса
df_ok.count() # здесь может быть ошибка если user_id = 0
Ловушка №3: Side effects в трансформациях¶
Почему print() внутри map() не работает как ожидается¶
Код внутри map(), filter(), withColumn() (через UDF), foreach() выполняется на Executors - процессах на удалённых машинах. Driver (ваша Python-сессия) не получает их stdout напрямую.
В локальном режиме (local[*]) вы увидите вывод, потому что Driver и Executors - один процесс. Это маскирует проблему. В production-кластере вывод уходит в логи Executor на удалённых серверах.
Non-idempotent операции в трансформациях¶
Spark повторяет Tasks при сбое. Если Task упал и был перезапущен - функция внутри трансформации вызовется повторно для тех же данных. Это называется retry. Spark гарантирует exactly-once семантику только для записи (с некоторыми оговорками), но не для кода внутри трансформаций.
Что это означает практически:
# ❌ HTTP-запрос внутри трансформации - может вызваться 2, 3, N раз
def enrich_from_api(row):
response = requests.post("https://api.example.com/enrich", json={"id": row["id"]})
return Row(**row.asDict(), **response.json())
enriched = df.rdd.map(enrich_from_api).toDF()
enriched.write.parquet("result/")
# Если Task упадёт на 90% - повторный запрос к API, деньги списываются дважды?
# ❌ Вставка в базу данных внутри трансформации - дубликаты
def insert_to_db(row):
db.execute("INSERT INTO events VALUES (...)")
df.foreach(insert_to_db) # может вставить дважды при retry
# ✅ Правильно: используйте idempotent операции
# - Upsert (INSERT OR REPLACE, MERGE INTO) вместо INSERT
# - Idempotency keys на стороне API
# - foreachPartition с явным управлением транзакцией
Мутация глобальных переменных¶
# ❌ Не работает: bad_count останется 0 на Driver
bad_count = 0
def validate_row(row):
global bad_count
if row["amount"] < 0:
bad_count += 1 # обновляется КОПИЯ на Executor, не оригинал на Driver
return row
df.rdd.foreach(validate_row)
print(bad_count) # всегда 0
# ✅ Правильно: Accumulator для распределённых счётчиков
bad_count = spark.sparkContext.accumulator(0)
def validate_row(row):
if row["amount"] < 0:
bad_count.add(1) # thread-safe, агрегируется на Driver
df.foreach(validate_row)
print(f"Некорректных строк: {bad_count.value}") # читается на Driver
Ловушка №4: Action в цикле¶
Анатомия катастрофы¶
Запуск Action внутри Python-цикла - один из самых частых антипаттернов. Каждая итерация цикла создаёт отдельный Spark Job. Каждый Job включает: DAG-планирование, выделение ресурсов, отправку Tasks на Executors, сбор результатов, освобождение ресурсов. Overhead одного Job - сотни миллисекунд даже для тривиальных вычислений.
# ❌ Антипаттерн: 30 Spark Jobs в Spark UI
dates = pd.date_range("2024-01-01", "2024-01-30").strftime("%Y-%m-%d").tolist()
for date in dates:
# Каждая итерация: отдельный Job, отдельный S3 read, отдельный filter
daily_count = df.filter(col("event_date") == date).count()
print(f"{date}: {daily_count} событий")
# Общее время: 30 × (time_per_job + overhead) ≈ 30 × 5 сек = 150 сек
# ✅ Правильно: один Job, одна агрегация
from pyspark.sql.functions import count
result = (
df.filter(col("event_date").between("2024-01-01", "2024-01-30"))
.groupBy("event_date")
.agg(count("*").alias("daily_count"))
.orderBy("event_date")
.collect()
)
for row in result:
print(f"{row['event_date']}: {row['daily_count']} событий")
# Общее время: 1 Job ≈ 8 сек (группировка сложнее, но overhead один)
В Spark UI разница будет наглядной: 30 отдельных Job против 1. При этом один Job с groupBy будет быстрее 30 отдельных filter+count даже если данных немного - просто за счёт устранения overhead.
Когда цикл с Actions всё-таки допустим¶
Не любой цикл - антипаттерн. Цикл оправдан, когда:
- Разные итерации обрабатывают принципиально разные источники (разные файлы, разные таблицы)
- Логика итераций нельзя выразить как агрегацию (например, итеративный алгоритм ML)
- Количество итераций мало (2–5) и каждая итерация неизбежна
# Допустимо: обрабатываем разные источники, нет общей агрегации
for table_name in ["users", "products", "orders"]:
df = spark.read.parquet(f"s3a://lake/{table_name}/")
cleaned = clean_table(df)
cleaned.write.mode("overwrite").parquet(f"s3a://cleaned/{table_name}/")
# Недопустимо: однотипная обработка одного источника
for category in categories: # ❌ если categories > 5
df.filter(col("category") == category).count()
Ловушка №5: collect() и OOM на Driver¶
Почему collect() - самая опасная операция¶
collect() (и toPandas()) транспортируют все данные из распределённого хранилища (Executors, расположенные на десятках машин) в одну машину - Driver. Driver - это обычно ваш ноутбук или head-node кластера с 8–32 ГБ RAM.
Правило безопасного использования collect():
Используйте collect() только когда вы точно знаете, что результат мал. «Мал» - это субъективно, но практическое правило: если результат содержит меньше миллиона строк и каждая строка < 1 КБ (< 1 ГБ суммарно), collect() обычно безопасен.
# ❌ Опасно: collect() на большом DataFrame
all_events = df.collect() # если df = 10 ТБ → OOM
# ✅ Безопасно: collect() только на агрегированных результатах
summary = df.groupBy("region").agg(sum("revenue")).collect()
# Если регионов 20 → 20 строк → безопасно
# ✅ Безопасно: toPandas() на небольшой выборке
sample = df.limit(10000).toPandas() # явно ограничиваем
toPandas() - отдельная история¶
toPandas() делает collect() внутри, плюс конвертирует данные в Pandas DataFrame. Кроме риска OOM есть ещё один нюанс: при конвертации некоторые типы данных теряют точность (например, Spark DecimalType → Python float). При работе с финансовыми данными это критично.
# ❌ Плохо: конвертируем весь DataFrame
df.toPandas()
# ✅ Хорошо: сначала агрегируем, потом конвертируем
df.groupBy("product").agg(sum("revenue").alias("total")).toPandas()
# ✅ Хорошо: Arrow для быстрой конвертации (если нужно всё)
spark.conf.set("spark.sql.execution.arrow.pyspark.enabled", "true")
sampled_df = df.sample(fraction=0.01).toPandas() # выборка
Ловушка №6: Data Skew невидим до Action¶
Lazy Evaluation скрывает дисбаланс данных до момента Shuffle. Вы не узнаете о data skew из трансформаций - Spark не читает данные и не знает распределения ключей. Проблема проявляется только при Action, когда один Executor обрабатывает 80% данных, а остальные простаивают.
# Эта трансформация не покажет skew - данные не читаются
df_grouped = df.groupBy("city").agg(sum("revenue").alias("total"))
# Skew обнаруживается в Spark UI → Stage → Tasks:
# Task для "Москва": 15 минут (70% данных на одном Executor)
# Task для "Казань": 45 секунд
# Task для "Новгород": 30 секунд
# ...
# ✅ Диагностируйте распределение ДО запуска дорогой агрегации
key_distribution = df.groupBy("city").count().orderBy(col("count").desc())
key_distribution.show(20)
# Если топ-1 ключ >> остальных - нужен salting или AQE skew handling:
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes", "256m")
Подробно о борьбе с data skew - в модуле оптимизации. Здесь важно запомнить: проверяйте распределение ключей до запуска дорогих агрегаций.
Checkpoint vs Cache: когда обрывать Lineage¶
Обе техники материализуют промежуточные результаты, но делают это по-разному и решают разные проблемы.
Проблема длинного Lineage¶
Если вы выполняете много итераций (например, в ML-алгоритме) и каждая итерация добавляет трансформации в граф, Lineage может вырасти до тысяч узлов. Это создаёт два проблемы:
- Планирование медленное: Catalyst должен обработать весь граф при каждом Action.
- Восстановление катастрофично: если Executor упадёт, Spark попытается восстановить данные, повторно выполнив весь Lineage с самого начала.
# Итеративный алгоритм: каждая итерация удваивает глубину DAG
rdd = sc.parallelize(range(1000))
for i in range(100): # 100 итераций
rdd = rdd.map(lambda x: x + 1) # DAG растёт на 1 узел каждую итерацию
rdd.count() # планирование 100-узлового DAG - медленно
cache() vs checkpoint()¶
| Характеристика | cache() / persist() |
checkpoint() |
|---|---|---|
| Хранение | RAM / диск Executor | HDFS / S3 (надёжное хранилище) |
| Lineage | Сохраняется | Обрывается |
| Скорость чтения | Быстро (локально) | Медленнее (сетевой доступ) |
| Выживание при сбое Executor | Нет - пересчитывается | Да - читается из хранилища |
| Когда использовать | Переиспользование в рамках одного Job | Итеративные алгоритмы, длинные Lineage |
# checkpoint() требует настройки директории
spark.sparkContext.setCheckpointDir("hdfs:///spark-checkpoints/")
# В итеративном алгоритме: checkpoint каждые N итераций
rdd = sc.parallelize(range(1000))
for i in range(100):
rdd = rdd.map(lambda x: x + 1)
if i % 10 == 0: # каждые 10 итераций
rdd = rdd.checkpoint() # обрываем Lineage
rdd.count() # материализуем (checkpoint тоже lazy!)
Практика: профилирование и рефакторинг¶
Кейс: пайплайн, который работает 40 минут¶
Рассмотрим реалистичный «плохой» пайплайн с несколькими типичными ошибками:
# ❌ Плохой пайплайн - 40 минут, 15 Jobs в Spark UI
from pyspark.sql.functions import col, sum, count, avg
# 1. Читаем данные
events = spark.read.parquet("s3a://data-lake/events/year=2024/")
users = spark.read.parquet("s3a://data-lake/users/")
products = spark.read.parquet("s3a://data-lake/products/")
# 2. Проверяем что данные есть - ОШИБКА: ненужный Action
print(f"Events: {events.count()}") # Action #1: сканирует весь S3
print(f"Users: {users.count()}") # Action #2: сканирует S3
print(f"Products: {products.count()}") # Action #3: сканирует S3
# 3. Основной пайплайн - тяжёлые join-ы
enriched = events.join(users, "user_id").join(products, "product_id")
# 4. Ещё одна проверка - ОШИБКА: join выполнится первый раз
print(f"После join: {enriched.count()}") # Action #4: full join + scan
# 5. Фильтрация и агрегация
filtered = enriched.filter(col("status") == "active")
revenue_by_region = filtered.groupBy("region").agg(
sum("revenue").alias("total"),
count("*").alias("cnt"),
avg("session_duration").alias("avg_session")
)
# 6. Проверяем результат - ОШИБКА: join выполнится второй раз
revenue_by_region.show() # Action #5: full DAG заново
# 7. Записываем результат - ОШИБКА: join выполнится третий раз
revenue_by_region.write.mode("overwrite").parquet("s3a://result/revenue/") # Action #6
# 8. Отдельные агрегации по категориям - ОШИБКА: цикл с Actions
for category in ["electronics", "clothing", "food", "sports"]:
cat_stats = filtered.filter(col("category") == category).agg(
sum("revenue"),
count("*")
)
cat_stats.write.mode("overwrite").parquet(f"s3a://result/{category}/")
# Action #7, #8, #9, #10: join выполняется 4 раза
В Spark UI: 10 отдельных Jobs, полный join events×users×products выполняется 5+ раз.
Рефакторинг: 8 минут, 2 Jobs¶
# ✅ Оптимизированный пайплайн
from pyspark.sql.functions import col, sum, count, avg
# 1. Читаем данные
events = spark.read.parquet("s3a://data-lake/events/year=2024/")
users = spark.read.parquet("s3a://data-lake/users/")
products = spark.read.parquet("s3a://data-lake/products/")
# 2. Основной пайплайн с кэшированием - join будет выполнен ОДИН раз
enriched = (
events
.filter(col("status") == "active") # фильтруем ДО join - меньше данных
.join(users, "user_id")
.join(products, "product_id")
.persist() # помечаем для кэширования
)
# 3. Все агрегации в рамках одного DAG
revenue_by_region = (
enriched
.groupBy("region")
.agg(
sum("revenue").alias("total"),
count("*").alias("cnt"),
avg("session_duration").alias("avg_session")
)
)
# Агрегации по категориям - через pivot, не цикл!
revenue_by_category = (
enriched
.filter(col("category").isin(["electronics", "clothing", "food", "sports"]))
.groupBy("category")
.agg(
sum("revenue").alias("total"),
count("*").alias("cnt")
)
)
# 4. Action #1: материализует кэш + записывает регионы
# (первый Action на enriched - кэш заполняется здесь)
revenue_by_region.write.mode("overwrite").parquet("s3a://result/revenue/")
# 5. Action #2: категории из кэша - join НЕ повторяется
for row in revenue_by_category.collect():
category = row["category"]
# пишем на S3 от Driver - тут суммарно мало строк
spark.createDataFrame([row]).write.mode("overwrite").parquet(f"s3a://result/{category}/")
# 6. Освобождаем кэш
enriched.unpersist()
# Диагностика: убеждаемся, что план разумен
print(f"Регионов: {revenue_by_region.count()}") # маленький count - допустим
Что изменилось:
filter("status" == "active")перенесён до join - уменьшает объём join в разы- Убраны диагностические
count()в начале - они не давали полезной информации persist()добавлен после join - дорогая операция выполняется один раз- Цикл с Actions заменён на
groupBy().agg()+collect()на маленьком результате unpersist()явно освобождает память
Чтение Spark UI: что искать¶
После запуска откройте http://localhost:4040 (или ссылку из spark.sparkContext.uiWebUrl):
print(spark.sparkContext.uiWebUrl) # прямая ссылка на Spark UI
| Вкладка | Что смотрим | Что ищем |
|---|---|---|
| Jobs | Количество и время выполнения | Много Jobs на простой задаче → Action в цикле |
| Stages | Количество Stage в Job | Много Stage → много shuffle |
| Stage Details → Tasks | Время выполнения каждой Task | Один Task намного дольше других → data skew |
| Stage Summary → Spill | Spill (Memory) / Spill (Disk) | > 0 → нехватка памяти, нужно больше RAM или меньше партиций |
| Stage Summary → Input/Output | Размер читаемых данных | Больше ожидаемого → нет partition pruning |
| SQL / DataFrame | DAG визуализация | Видны Exchange (shuffle), FileScan, Join |
| Executors | GC Time | GC > 10% от Task Time → проблема с памятью |
Итог: правила работы с ленивым Spark¶
Чек-лист инженера¶
Перед запуском любого пайплайна:
- [ ]
explain(mode="formatted")- проверить физический план: Column Pruning работает? Фильтры pushed down? Join-стратегия разумна? - [ ] Проверить количество Actions: больше одного на одном DataFrame без
persist()- потенциальная двойная работа - [ ] Проверить: нет ли Actions в Python-циклах? Заменить на
groupBy()илиfilter().isin() - [ ] Проверить:
try/exceptоборачивает Actions, а не трансформации - [ ] Проверить: нет ли
collect()/toPandas()на потенциально больших DataFrame
Кэширование:
- [ ]
persist()добавлен после дорогих операций (join, groupBy, dropDuplicates), если результат используется в 2+ Actions - [ ]
unpersist()вызывается явно после использования кэша - [ ] Уровень хранения выбран осознанно:
MEMORY_AND_DISKдля надёжности,MEMORY_ONLYдля скорости
Side effects:
- [ ] Нет
print(), HTTP-запросов, DB insert внутриmap(),filter(), UDF - только идемпотентные операции - [ ] Для счётчиков используются Accumulator, а не глобальные переменные Python
- [ ]
foreachPartition()вместоforeach()для операций с соединениями к внешним системам
После запуска:
- [ ] Spark UI открыт: Jobs → Stages → Tasks. Skew? Spill? GC overhead? Неожиданно большой Input?
- [ ] Количество Jobs в Spark UI соответствует ожидаемому количеству Actions в коде
В следующем уроке разберём Narrow vs Wide трансформации ещё подробнее: почему groupBy() несравнимо дороже filter(), как shuffle организован физически на уровне файловой системы и как читать Shuffle Read/Write в Spark UI.