Блок 2 - Оптимизация и производительность
20 вопросов: data skew, AQE, broadcast join, repartition vs coalesce, bucketing, memory tuning.
Что такое Data Skew и как с ним бороться?¶
Data Skew (перекос данных) - ситуация, когда одна или несколько партиций содержат несоразмерно больше данных, чем остальные. В Spark UI видно: большинство Tasks завершились за 2 секунды, одна - за 10 минут.
Способы борьбы:
| Метод | Суть | Когда |
|---|---|---|
| AQE Skew Join | Автоматически дробит hot partition | Spark 3.x+, включён по умолчанию |
| Salting | Добавить случайный суффикс к ключу, join в 2 прохода | Hot key известен |
| Broadcast | Маленькую таблицу - в broadcast | Одна сторона ≤ порог |
| Repartition по другому ключу | Разбросать данные иначе | Составной ключ |
Разница между cache() и persist()¶
cache() - синоним persist(StorageLevel.MEMORY_AND_DISK). persist() позволяет указать уровень хранения явно.
| StorageLevel | Суть |
|---|---|
MEMORY_ONLY |
Только RAM, при нехватке - пересчёт |
MEMORY_AND_DISK |
RAM + spill на диск (default для cache) |
MEMORY_ONLY_SER |
RAM в сериализованном виде - меньше места, медленнее |
DISK_ONLY |
Только диск |
OFF_HEAP |
Off-heap память (Tungsten), без GC |
Когда использовать: DataFrame используется в нескольких Action-ах или итерируется в цикле. После использования - df.unpersist(), чтобы освободить память.
Нетривиальный gotcha с input_file_name(): функция F.input_file_name() возвращает пустую строку, если вызвать её после cache(), потому что кеш разрывает связь с исходным файлом в плане выполнения. Колонку с именем файла нужно добавить до кеширования:
# ❌ Неверно - input_file_name() вернёт пустую строку
df = spark.read.parquet("s3a://bucket/data/")
df.cache()
df = df.withColumn("source_file", F.input_file_name()) # всегда ""
# ✓ Верно - добавить до cache()
df = spark.read.parquet("s3a://bucket/data/") \
.withColumn("source_file", F.input_file_name())
df.cache()
Тот же эффект даёт вызов .limit() перед input_file_name() - он также разрывает lineage к источнику.
Что такое Adaptive Query Execution (AQE)?¶
AQE - оптимизатор, который перепланирует запрос в рантайме на основе реальной статистики партиций после каждого shuffle. Включён по умолчанию с Spark 3.2 (spark.sql.adaptive.enabled=true).
Три ключевых возможности:
- Adaptive partition coalescing - уменьшает число shuffle partitions после сбора реальных данных (избегает миллионов мелких партиций)
- Adaptive join switching - меняет Sort-Merge Join на Broadcast Join если одна сторона оказалась маленькой
- Skew Join - автоматически разбивает hot partitions на подзадачи
Как работает Broadcast Join и когда его использовать?¶
При Broadcast Join маленькая таблица целиком копируется в память каждого Executor'а - shuffle не нужен. Spark сравнивает каждую строку большой таблицы с локальной копией маленькой.
from pyspark.sql.functions import broadcast
result = big.join(broadcast(small), "id")
Порог: spark.sql.autoBroadcastJoinThreshold (по умолчанию 10 МБ). При AQE Spark может повышать этот порог автоматически. Broadcast не работает для full outer join и при очень большом числе Executor'ов (сеть под нагрузкой).
Разница между repartition() и coalesce()¶
repartition(N) |
coalesce(N) |
|
|---|---|---|
| Shuffle | Полный shuffle | Нет shuffle (только объединение) |
| Направление | Увеличение или уменьшение | Только уменьшение |
| Равномерность | Равномерное распределение | Не гарантировано |
| Применение | Перед heavy join/groupBy | Перед write для уменьшения файлов |
coalesce(1) перед записью - быстрый способ получить один файл, но вся нагрузка ляжет на один Executor.
Что такое Predicate Pushdown и Column Pruning?¶
Обе оптимизации Catalyst направлены на уменьшение объёма данных, читаемых с диска.
Predicate Pushdown - фильтры (WHERE, filter()) применяются как можно раньше, на уровне источника данных. Для Parquet: Spark передаёт фильтры в reader, который пропускает Row Groups, не удовлетворяющие условию (не читает их с диска). Для JDBC: фильтр преобразуется в SQL WHERE и выполняется в БД.
Column Pruning - Spark читает только те колонки, которые реально нужны для запроса, остальные пропускаются на этапе чтения. Parquet - колоночный формат, поэтому неиспользуемые колонки физически не читаются с диска.
# Оба механизма работают автоматически
# Плохо: читает все колонки, потом фильтрует в Spark
df = spark.read.parquet("events/").filter("date = '2024-01'")
# Хорошо: тот же код, но Catalyst автоматически:
# 1. Pushdown: читает только row groups с date = '2024-01'
# 2. Pruning: если дальше идёт .select("user_id", "amount"), читает только эти колонки
df.filter("date = '2024-01'").select("user_id", "amount")
Проверить в explain(): строки PushedFilters и ReadSchema покажут, что реально попало в reader.
df.filter("date = '2024-01'").select("user_id").explain()
# Parquet: ReadSchema: struct<user_id:string> ← только нужная колонка
# PushedFilters: [IsNotNull(date), EqualTo(date,2024-01)]
Как использовать Spark UI для поиска узких мест?¶
- Jobs → найти долгий Job → перейти в его Stages
- Stages → колонка
Duration→ найти Stage с максимальным временем - Tasks → смотреть на
Task Durationраспределение - если одна Task сильно длиннее → skew; смотреть наShuffle Read Size- если неравномерно → skew на читателе - SQL → физический план → найти
Exchange(shuffle),BroadcastHashJoin,SortMergeJoin - Executors → колонка
GC Time> 10% от Task Time → проблемы с памятью
Что такое Salting и как оно помогает?¶
Salting - добавление случайного числа к ключу Join для равномерного распределения hot-key строк по партициям.
from pyspark.sql.functions import concat, col, lit, floor, rand, explode, array
SALT_FACTOR = 10
# Большая таблица: добавляем случайный суффикс
big = big.withColumn("salted_key",
concat(col("key"), lit("_"), (rand() * SALT_FACTOR).cast("int").cast("string")))
# Маленькая таблица: дублируем строки для каждого значения соли
small = small.withColumn("salt", explode(array([lit(i) for i in range(SALT_FACTOR)])))
small = small.withColumn("salted_key",
concat(col("key"), lit("_"), col("salt").cast("string")))
result = big.join(small, "salted_key").drop("salted_key", "salt")
Параметр spark.sql.shuffle.partitions: на что влияет?¶
Определяет число партиций после каждого Shuffle (groupBy, join, distinct). По умолчанию: 200. Слишком мало → большие партиции, риск OOM. Слишком много → overhead на планирование, мелкие файлы при записи. С AQE (spark.sql.adaptive.enabled=true) Spark динамически уменьшает число партиций после оценки реального объёма. Хороший starting point: целевой_объём_данных_GB * 10 (100 ГБ → 1000 партиций).
Как избежать сериализации больших объектов в замыканиях?¶
Если замыкание (closure) UDF ссылается на большой Python-объект (словарь, список), Spark сериализует его и рассылает на все воркеры при каждом запуске. Решения:
# Плохо: большой dict сериализуется в каждую Task
lookup = {1: "a", 2: "b", ...} # 100 МБ
df.filter(lambda row: lookup.get(row.id))
# Хорошо: передать как broadcast-переменную
bc = spark.sparkContext.broadcast(lookup)
df.rdd.filter(lambda row: bc.value.get(row.id))
Для DataFrame лучше сделать lookup-таблицу DataFrame'ом и использовать broadcast() при join.
Зачем использовать Broadcast-переменные?¶
Broadcast-переменная (sc.broadcast(value)) - механизм эффективной доставки read-only данных на все Executor'ы. Данные передаются один раз через BitTorrent-подобный протокол, кешируются в памяти каждого Executor'а и переиспользуются всеми Tasks этого Executor'а. Без broadcast каждая Task получила бы копию через Shuffle-механизм.
bc_dict = spark.sparkContext.broadcast({"US": "United States", "RU": "Russia"})
@udf("string")
def expand_code(code):
return bc_dict.value.get(code, code)
Что такое Accumulators?¶
Accumulators - распределённые счётчики/агрегаторы, которые можно безопасно обновлять из Task'ов и читать только на Driver. Гарантируют корректность при повторном выполнении Tasks (задача idempotent). Встроенные типы: LongAccumulator, DoubleAccumulator. Можно создавать кастомные.
error_count = spark.sparkContext.accumulator(0)
def process(row):
if row.value is None:
error_count.add(1)
return row
df.foreach(process)
print(f"Errors: {error_count.value}")
Влияние формата хранения на скорость¶
| Формат | Сжатие | Column pruning | Row filtering | Streaming |
|---|---|---|---|---|
| CSV | Нет | Нет | Нет | Нет |
| JSON | Нет | Нет | Нет | Да |
| Parquet | Да (snappy/zstd) | Да | Row group stats | Нет |
| ORC | Да | Да | Bloom filter | Нет |
| Avro | Да (snappy) | Нет | Нет | Да (Kafka) |
Parquet - де-факто стандарт для аналитики. Avro - для Kafka-стриминга (schema registry). CSV - только для обмена с внешними системами.
Что такое Bucketing и чем отличается от Partitioning?¶
| Partitioning | Bucketing | |
|---|---|---|
| Уровень | Директории в файловой системе | Файлы внутри партиции |
| Ключ | Низкая кардинальность (дата, страна) | Высокая кардинальность (user_id) |
| Эффект на join | Partition pruning | Sort-Merge Join без shuffle |
| Как задать | df.write.partitionBy("date") |
df.write.bucketBy(N, "user_id").sortBy("user_id") |
При Bucketing обе таблицы должны быть bucketed по одному ключу с одинаковым N. Тогда join("user_id") не создаёт Exchange в плане.
Как работает Dynamic Resource Allocation?¶
DRA (spark.dynamicAllocation.enabled=true) позволяет Spark добавлять и убирать Executor'ы в рантайме в зависимости от нагрузки. Нет Task'ов в очереди → Executor'ы освобождаются. Очередь Tasks растёт → новые Executor'ы запрашиваются у Cluster Manager. Работает с YARN и Kubernetes. Требует spark.shuffle.service.enabled=true (внешний shuffle service) или spark.dynamicAllocation.shuffleTracking.enabled=true (Spark 3.x).
Проблема Small Files и как её решить¶
Много мелких файлов → NameNode (HDFS) или Object Store (S3) перегружены метаданными, Spark тратит секунды на LIST, каждый файл = отдельная Task → планировщик перегружен.
Решения:
- При записи:
coalesce(N)илиrepartition(N)передwrite() - После записи: compaction job (
df.repartition().write.parquet(...)) - Iceberg:
spark.sql("CALL system.rewrite_data_files('table')") - AQE coalescing: автоматически уменьшает число файлов через коалесцинг shuffle-партиций
Разница между map() и mapPartitions()¶
map(f) вызывает функцию f для каждой строки. mapPartitions(f) получает итератор всей партиции - позволяет открыть соединение один раз на партицию (DB connection pool паттерн).
def write_to_db(rows):
conn = db.connect() # Одно соединение на всю партицию
for row in rows:
conn.insert(row)
conn.close()
return []
df.rdd.mapPartitions(write_to_db).count()
В DataFrame API эквивалент - foreachPartition().
Что такое Speculative Execution?¶
При spark.speculation=true Spark запускает дублирующую Task для медленных (straggler) Tasks - тех, что выполняются значительно дольше медианы Stage'а. Первая завершившаяся копия принимается как результат, вторая убивается. Помогает при временных аппаратных проблемах (slow disk, network jitter), но бесполезен при настоящем data skew - дубль тоже будет медленным.
Как настроить память Executor'а?¶
spark.executor.memory = heap память (для хранения и вычислений)
spark.executor.memoryOverhead = off-heap (JVM metadata, native библиотеки)
spark.memory.fraction = 0.6 (доля heap для execution + storage, остаток - user code)
spark.memory.storageFraction = 0.5 (из fraction - доля для cache)
Практическая формула для контейнера 8 ГБ:
executor.memory = 6g(6 ГБ heap)executor.memoryOverhead = 1g(JVM + Python worker)executor.cores = 4(правило: не больше 5 cores на Executor)- Итого: 7 ГБ из 8 ГБ контейнера (запас на OS)
Как работает Cost-Based Optimizer (CBO)?¶
CBO анализирует статистику таблиц (число строк, уникальных значений, размер колонок) и использует её для выбора оптимального плана - в отличие от Rule-Based Optimizer, который применяет правила независимо от объёма данных.
CBO помогает Spark принимать лучшие решения о порядке join-ов, выборе стратегии join и размещении broadcast-таблиц.
-- Собрать статистику для CBO
ANALYZE TABLE users COMPUTE STATISTICS;
ANALYZE TABLE users COMPUTE STATISTICS FOR COLUMNS user_id, age, country;
# Включить CBO (по умолчанию включён в Spark 3.x)
spark.conf.set("spark.sql.cbo.enabled", "true")
spark.conf.set("spark.sql.statistics.histogram.enabled", "true")
# Проверить что статистика учитывается
df.explain(mode="cost") # Покажет estimated rows и sizes
Практический эффект заметен при join-ах нескольких таблиц - CBO может перестроить порядок join'ов, начав с меньшей таблицы.
В чём опасность чрезмерного кеширования данных?¶
Кеш занимает Unified Memory Executor'а - ту же область, что используется для выполнения операций (join, sort, aggregation). При нехватке execution memory Spark вынужден делать spill на диск, что замедляет работу.
Конкретные риски:
- Execution memory вытесняется → join/aggregation начинают spill → замедление
- GC pressure → большой heap с объектами → длинные паузы GC
- Stale данные - кеш не обновляется автоматически, если источник изменился
- LRU-вытеснение → при нехватке памяти Spark вытесняет старый кеш, потом его же пересчитывает
Правила:
- Кешировать только то, что реально используется ≥2 раз в pipeline
- После использования вызывать
df.unpersist(blocking=True) - Не кешировать в начале pipeline - лучше ближе к месту повторного использования
- Проверять Spark UI → Storage - сколько памяти занято кешем
Как работает Spill на диск во время Shuffle или агрегации?¶
Когда данные для операции (sort, hash aggregation, build-side таблица join'а) не помещаются в execution memory Executor'а, Spark сбрасывает часть данных на локальный диск и продолжает работу:
- Executor заполняет буфер в памяти до лимита
- Сериализует буфер и записывает в spill-файл на
spark.local.dir - После завершения - читает все spill-файлы + оставшееся в памяти, выполняет N-way merge
Как обнаружить: Spark UI → Stages → колонки Spill (Memory) и Spill (Disk). Если Spill (Disk) > 0 - есть spill.
Решения:
# Увеличить execution memory
spark.conf.set("spark.executor.memory", "8g")
# Уменьшить storage fraction (больше памяти для execution)
spark.conf.set("spark.memory.storageFraction", "0.3") # было 0.5
# Больше партиций → меньше данных в каждой → меньше spill
spark.conf.set("spark.sql.shuffle.partitions", "500")
Как определить оптимальное количество ядер (CPU cores) для Executor'ов?¶
Практическое правило: не более 5 cores на один Executor. При большем числе cores HDFS-клиент начинает испытывать конкуренцию за соединения, а GC-паузы затрагивают больше одновременных Tasks.
Формула для выделения Executor'ов на узле:
Cores на узле: 16
Cores на Executor: 5
Executor'ов на узле: (16 - 1) / 5 = 3 (1 core оставить на OS/YARN)
Executor memory = (total_node_RAM - OS_RAM) / 3
# Типичная конфигурация для узла 16 CPU / 64 GB RAM
conf.set("spark.executor.cores", "5")
conf.set("spark.executor.memory", "18g") # (64-4) / 3 ≈ 20GB, минус overhead
conf.set("spark.executor.memoryOverhead", "2g")
С Dynamic Allocation число Executor'ов подстраивается под нагрузку - фиксировать их количество нет необходимости.
Что такое Z-Ordering и как он помогает в оптимизации запросов?¶
Z-Ordering (Z-order curve, Morton code) - алгоритм многомерного упорядочивания данных, который позволяет файлам с нужными данными физически располагаться рядом на диске.
Обычная сортировка работает по одной колонке. Z-ordering объединяет несколько колонок в один ключ, сохраняя пространственную близость данных по всем измерениям сразу.
# Delta Lake
spark.sql("OPTIMIZE events ZORDER BY (user_id, event_date)")
# Iceberg
spark.sql("""
CALL system.rewrite_data_files(
table => 'events',
strategy => 'sort',
sort_order => 'zorder(user_id, event_date)'
)
""")
Как помогает: файлы содержат более однородные данные по обеим колонкам → при фильтре WHERE user_id = 123 AND event_date = '2024-01-01' Spark пропускает больше файлов (data skipping по min/max статистикам).
Когда не помогает: для high-cardinality колонок с высоко случайным распределением (UUID) - эффект минимален. Сортировка по одной колонке эффективнее Z-ordering при фильтре только по ней.
Что такое Off-heap memory и когда её стоит настраивать?¶
Стандартная Java-куча (heap) управляется JVM и сборщиком мусора (GC). Когда объектов много - GC-паузы достигают сотен миллисекунд, что сказывается на скорости.
Off-heap memory - память вне JVM heap, выделяемая напрямую через sun.misc.Unsafe. GC её не видит и не трогает. Spark использует её в Tungsten для хранения данных в бинарном формате UnsafeRow.
Executor Memory Layout:
┌─────────────────────────────────────────────┐
│ JVM Heap (spark.executor.memory = 6g) │
│ ├── Unified Memory (execution + storage) │
│ └── User Memory (JVM objects, UDF state) │
├─────────────────────────────────────────────┤
│ Off-Heap (spark.executor.memoryOverhead) │ ← Python worker, native libs
│ Off-Heap Execution (spark.memory.offHeap.*) │ ← Tungsten, explicit off-heap
└─────────────────────────────────────────────┘
Два вида off-heap:
spark.executor.memoryOverhead- всегда нужен: Python worker-процессы, JVM metadata, native libraries. Правило: max(384 МБ, 10% от executor.memory).spark.memory.offHeap.enabled=true+spark.memory.offHeap.size- явный off-heap для execution. Помогает при частых GC-паузах на больших heap'ах (>16 ГБ).
# Когда включать off-heap execution:
# GC Time > 10% в Spark UI → переносим часть execution memory в off-heap
spark.conf.set("spark.memory.offHeap.enabled", "true")
spark.conf.set("spark.memory.offHeap.size", "4g")
spark.conf.set("spark.executor.memory", "8g") # Heap уменьшить
spark.conf.set("spark.executor.memoryOverhead", "2g") # Python + JVM meta
В большинстве случаев memoryOverhead достаточно - явный off-heap нужен только при реальных проблемах с GC на больших Executor'ах.
Как исправить java.io.NotSerializableException?¶
java.io.NotSerializableException означает, что в замыкании (closure) UDF или трансформации есть объект, который нельзя сериализовать для передачи на Executor.
Причина: Spark сериализует весь контекст функции (замыкание) и отправляет на каждый Executor. Если в замыкании есть non-serializable объект (JDBC Connection, файловый дескриптор, ML-модель без __reduce__), - ошибка.
# Неверно: объект conn создаётся на Driver, не сериализуется
class DBWriter:
def __init__(self):
self.conn = psycopg2.connect(...) # non-serializable!
writer = DBWriter()
df.foreach(lambda row: writer.write(row)) # → NotSerializableException
# Верно: создаём объект на Executor внутри mapPartitions
def write_partition(rows):
conn = psycopg2.connect(...) # Создаётся на Executor, не сериализуется
for row in rows:
conn.execute("INSERT ...", row)
conn.close()
df.rdd.mapPartitions(write_partition)
# Или: df.foreachPartition(write_partition)
Паттерн: Любой non-serializable объект - создавать внутри mapPartitions/foreachPartition прямо на Executor'е.
Что означает WARN TaskSetManager: task of very large size?¶
Предупреждение WARN TaskSetManager: task of very large size 100500 KB означает, что сериализованный Task (включая замыкание) занимает сотни КБ или МБ. Это приводит к избыточному трафику Driver → Executor'ы и замедлению планирования.
Причина: В замыкании UDF или трансформации есть ссылка на большую Python-коллекцию (словарь, список), которая сериализуется и отправляется в каждый Task отдельно.
# Проблема: большой словарь сериализуется в каждую Task
big_lookup = {str(i): f"value_{i}" for i in range(1_000_000)} # ~100 МБ
@udf("string")
def enrich(key):
return big_lookup.get(key) # словарь попадает в каждый Task!
# Решение 1: Broadcast variable (отправляется один раз на Executor)
bc = spark.sparkContext.broadcast(big_lookup)
@udf("string")
def enrich(key):
return bc.value.get(key)
# Решение 2: Создавать объект на Executor в mapPartitions
def process_partition(rows):
lookup = load_lookup_from_db() # Загружается один раз на партицию
for row in rows:
yield enrich_row(row, lookup)
df.rdd.mapPartitions(process_partition)
Как работает AQE детально - опишите 4-шаговый цикл?¶
AQE (Adaptive Query Execution) переоптимизирует план запроса в рантайме после каждого shuffle/exchange.
4-шаговый цикл для каждого Stage:
1. Выполнить текущий Stage (compute)
↓
2. Shuffle Write / Broadcast + сбор реальной статистики партиций
(знаем точный размер каждой partition - не оценочный из ANALYZE TABLE)
↓
3. Re-оптимизация оставшегося (невыполненного) плана
на основе РЕАЛЬНЫХ данных:
- Если partition стала мала → Coalescing (объединить несколько в одну)
- Если таблица после фильтра < threshold → Switch SortMerge → BroadcastHash
- Если partition в 5× больше медианы → SkewJoin (дробить + дублировать)
↓
4. Выполнить следующий Stage с обновлённым планом
↓ (повторить для каждого Exchange узла)
Ключевое отличие от CBO: CBO работает до выполнения на основе ANALYZE TABLE (которая может быть устаревшей). AQE работает во время выполнения на основе реальных данных. Оба механизма дополняют друг друга.
spark.conf.set("spark.sql.adaptive.enabled", "true") # default с Spark 3.2
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")