AQE: Adaptive Query Execution - включение, параметры и что оно реально делает
Полный разбор Adaptive Query Execution в Spark 3.x: смена парадигмы от статического к динамическому оптимизатору, три опоры AQE (Coalesce Partitions, Join Strategy Switching, Skew Join), Local Shuffle Reader, чтение AdaptiveSparkPlan в .explain() и Spark UI, тюнинг параметров и лабораторный батл ручной настройки против AQE.
1. Смена парадигмы: от статической оптимизации к динамической¶
Чтобы понять зачем нужен AQE, необходимо сначала разобраться в ограничениях классического оптимизатора. Эти ограничения - не ошибки в дизайне Spark. Это фундаментальная проблема любого статического оптимизатора, работающего с неизвестными данными.
Ограничения классического Catalyst Optimizer¶
Классический Catalyst строит Physical Plan до начала выполнения запроса. В этот момент оптимизатор опирается только на:
- Статистику таблиц в HMS (количество строк, размер файлов, гистограммы распределений значений)
- Параметры конфигурации Spark
- Логические свойства плана (число join'ов, фильтры, тип операций)
Проблема в том, что эта информация зачастую устаревшая, неполная или вовсе отсутствует:
Схема показывает фундаментальную проблему: все решения по физическому плану принимаются в шаге 4, когда реальные данные ещё не тронуты. Фактический объём данных может отличаться от оценки на порядки.
Конкретный пример провала статического оптимизатора:
-- Запрос: джойн транзакций с информацией о стране
SELECT t.*, c.country_name
FROM transactions t
JOIN countries c ON t.country_code = c.code
WHERE t.country_code = 'IS' -- Исландия: ~370 000 человек населения
Catalyst видит таблицу transactions с 10 GB данных. Он решает: оба датасета большие → нужен SortMergeJoin (тяжёлый: sort + shuffle + merge). Но фильтр WHERE country_code = 'IS' оставит в transactions буквально несколько тысяч строк (~2 MB). После фильтрации можно было бы сделать BroadcastHashJoin (передать 2 MB + 500 KB таблицы стран по сети, без sort/shuffle). Разница в производительности - 10-100x.
Почему статический CBO не спасает?
Cost-Based Optimizer (CBO, включается через spark.sql.cbo.enabled = true) использует статистику колонок из HMS: количество уникальных значений (NDV), гистограммы распределений. Это лучше, чем ничего, но:
- CBO не знает что именно останется после применения фильтра
WHERE country_code = 'IS'к реальным данным - Статистика протухает - после ETL никто не запускает
ANALYZE TABLEавтоматически - UDF'ы непрозрачны для CBO - Catalyst не может оценить их selectivity
Суть концепции AQE: ленивые Query Stages¶
AQE решает эту проблему радикальным способом: подождать с принятием части решений до тех пор, пока реальные данные не будут доступны.
Ключевая идея: Shuffle - это естественный барьер в плане выполнения Spark. До начала следующей стадии все данные предыдущей стадии уже записаны на диск (Shuffle Write). В этот момент Spark точно знает: сколько байт в каждой partition, каков размер каждого «сегмента» данных.
AQE использует эти барьеры как точки адаптации: после каждого Shuffle Spark получает точную статистику и может пересмотреть план для оставшейся части запроса.
Диаграмма показывает главное: AQE работает на стыке между Stage'ами. Он использует информацию о реальных данных, которая появляется только после выполнения предыдущего Stage. Это то, чего никогда не мог делать статический Catalyst.
Физика процесса: как Query Stage собирает статистику¶
После того как Stage завершил Shuffle Write, каждый Executor отправляет Driver'у MapOutputStatistics - компактный отчёт о размере каждой Shuffle Partition. Этот отчёт содержит:
- Число не-пустых партиций
- Размер каждой партиции в байтах
- Суммарный размер всех данных
На основе этой статистики AQE принимает три категории решений (три «опоры» AQE), которые мы разберём подробно в следующих разделах.
2. Включение и базовый механизм контроля AQE¶
Исторический контекст: эволюция AQE в версиях Spark¶
| Версия Spark | Статус AQE | Примечание |
|---|---|---|
| Spark 2.x | ❌ Не существует | Только статический Catalyst |
| Spark 3.0 | ⚠️ Экспериментальный, выключен по умолчанию | spark.sql.adaptive.enabled=false |
| Spark 3.1 | ✅ Стабильный, выключен по умолчанию | Исправлены основные баги |
| Spark 3.2+ | ✅ Включён по умолчанию | spark.sql.adaptive.enabled=true |
| Spark 4.x | ✅ Включён по умолчанию | Расширенный AQE с новыми правилами |
Если ваш кластер работает на Spark 3.2+, AQE уже включён. Если на 3.0-3.1 - требуется явное включение.
Главный тумблер и проверка активности¶
from pyspark.sql import SparkSession
spark = SparkSession.builder \
.appName("aqe-demo") \
# Spark 3.0-3.1: явно включаем AQE
.config("spark.sql.adaptive.enabled", "true") \
# Spark 3.2+: включён по умолчанию, но явное указание не помешает
# Все три механизма AQE включены по умолчанию при AQE=true:
.config("spark.sql.adaptive.coalescePartitions.enabled", "true")
.config("spark.sql.adaptive.skewJoin.enabled", "true")
.config("spark.sql.adaptive.localShuffleReader.enabled", "true")
.getOrCreate()
# Проверяем статус AQE программно:
print("AQE enabled:", spark.conf.get("spark.sql.adaptive.enabled"))
# Вывод: AQE enabled: true
Как убедиться что AQE реально работает: .explain()¶
Самый надёжный способ убедиться в работе AQE - посмотреть на AdaptiveSparkPlan в выводе .explain():
from pyspark.sql import functions as F
# Создадим датасет для демонстрации
df1 = spark.range(1_000_000).select(
F.col("id"),
(F.rand() * 100).cast("int").alias("country_id"),
(F.rand() * 1000).alias("amount")
)
df2 = spark.range(100).select(
F.col("id").alias("country_id"),
F.concat(F.lit("Country_"), F.col("id").cast("string")).alias("country_name")
)
result = df1.join(df2, "country_id").groupBy("country_name").sum("amount")
# explain() ДО выполнения: покажет начальный план с AdaptiveSparkPlan
result.explain("formatted")
== Physical Plan ==
AdaptiveSparkPlan isFinalPlan=false ← AQE активен, план ещё не финальный!
+- == Current Plan ==
HashAggregate(keys=[country_name#17], functions=[sum(amount#12)])
+- Exchange hashpartitioning(country_name#17, 200), ENSURE_REQUIREMENTS, [plan_id=15]
+- HashAggregate(keys=[country_name#17], functions=[partial_sum(amount#12)])
+- Project [amount#12, country_name#17]
+- SortMergeJoin [country_id#11], [country_id#7], Inner ← SMJ до AQE
:- Sort [country_id#11 ASC], false, 0
: +- Exchange hashpartitioning(country_id#11, 200)
: +- Range (0, 1000000, step=1, splits=8)
+- Sort [country_id#7 ASC], false, 0
+- Exchange hashpartitioning(country_id#7, 200)
+- Range (0, 100, step=1, splits=8)
== Initial Plan == ← Что Catalyst запланировал изначально
... (полный план без AQE адаптаций)
После выполнения (через .collect() или .count()):
result.collect()
# Теперь смотрим финальный план:
result.explain("formatted")
== Physical Plan ==
AdaptiveSparkPlan isFinalPlan=true ← ПЛАН ФИНАЛЬНЫЙ!
+- == Final Plan ==
HashAggregate(keys=[country_name#17], functions=[sum(amount#12)])
+- HashAggregate(keys=[country_name#17], functions=[partial_sum(amount#12)])
+- Project [amount#12, country_name#17]
+- BroadcastHashJoin [country_id#11], [country_id#7], Inner ← AQE заменил SMJ→BHJ!
:- Range (0, 1000000, step=1, splits=8)
+- BroadcastExchange HashedRelationBroadcastMode(...)
+- Range (0, 100, step=1, splits=8) ← 100 строк → broadcast!
Ключевые изменения которые сделал AQE:
SortMergeJoin→BroadcastHashJoin(таблица стран из 100 строк = микроскопическая)- Убраны два
Sortузла (BHJ не требует сортировки) - Убраны два
Exchange hashpartitioningдля Sort-стороны
3. Опора первая: динамическое схлопывание партиций (Coalesce Post-Shuffle Partitions)¶
Это наиболее часто срабатывающий механизм AQE. Он решает одну из самых болезненных практических проблем: что поставить в spark.sql.shuffle.partitions?
Проблема фиксированного shuffle partitions¶
Схема показывает почему единое значение shuffle.partitions невозможно: одно и то же приложение может обрабатывать как 10 GB так и 100 MB в зависимости от фильтров, JOIN'ов и конфигурации входных данных.
Дефолтные 200 партиций - это исторический компромисс: достаточно мало чтобы не создавать слишком много файлов на маленьких данных, достаточно много чтобы не было OOM на больших. На практике это почти всегда неправильно.
Механика AQE Coalesce: как работает схлопывание¶
AQE Coalesce работает жадным алгоритмом: он обходит партиции слева направо и накапливает их до тех пор, пока суммарный размер не превысит advisoryPartitionSizeInBytes. Партиции остаются последовательными - нельзя слить партицию 1 и партицию 100, если между ними есть «жирные» партиции. Это гарантирует предсказуемое поведение и упрощает вычисление диапазонов.
Ключевые параметры Coalesce¶
spark = SparkSession.builder \
# Главный тумблер coalesce (включён по умолчанию с AQE)
.config("spark.sql.adaptive.coalescePartitions.enabled", "true") \
# Целевой размер одной партиции после coalesce.
# AQE склеивает мелкие партиции пока их суммарный размер не достигает этого порога.
# Дефолт: 64 MB. Рекомендации:
# - Для HDFS/S3 аналитики: 128 MB (совпадает с HDFS блоком)
# - Для быстрых интерактивных запросов: 32-64 MB
# - Для heavy aggregations с большими state: 128-256 MB
.config("spark.sql.adaptive.advisoryPartitionSizeInBytes", "128MB") \
# Стартовое максимальное число партиций для Shuffle.
# AQE может только УМЕНЬШАТЬ это число (coalesce), не увеличивать!
# Правило: ставьте с большим запасом. Лучше 2000 мелких партиций
# которые AQE сольёт в 50, чем 50 с риском OOM.
# None = использовать spark.sql.shuffle.partitions
.config("spark.sql.adaptive.coalescePartitions.initialPartitionNum", "2000") \
# Минимальный размер партиции после coalesce.
# Партиции меньше этого порога будут объединяться с соседями.
# Дефолт: 1 MB. Уменьшите до 512 KB если данных очень мало.
.config("spark.sql.adaptive.coalescePartitions.minPartitionSize", "1MB") \
.getOrCreate()
Демонстрация Coalesce в действии¶
from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder \
.master("local[4]") \
.config("spark.sql.adaptive.enabled", "true") \
.config("spark.sql.adaptive.coalescePartitions.enabled", "true") \
.config("spark.sql.adaptive.advisoryPartitionSizeInBytes", "64MB") \
.config("spark.sql.shuffle.partitions", "200") \
.getOrCreate()
spark.sparkContext.setLogLevel("WARN")
# Маленький датасет: ~10 MB
df = spark.range(100_000).select(
F.col("id"),
(F.rand() * 100).cast("int").alias("group_id"),
F.rand().alias("value")
)
# Без AQE: 200 тасок для 10 MB данных (нелепо!)
df_agg = df.groupBy("group_id").sum("value")
# Смотрим сколько партиций получилось ПОСЛЕ AQE
import time
t0 = time.time()
result = df_agg.collect()
elapsed = time.time() - t0
print(f"Время выполнения: {elapsed:.2f}с")
# Смотрим финальный план - сколько задач реально запустил Spark:
df_agg.explain("formatted")
# В Adaptive Plan увидим: CoalesceShufflePartitions
# и реальное число партиций (например, 2-5 вместо 200)
# Программная проверка числа партиций после AQE:
# (через collect нельзя, но можно через промежуточный persist)
df_agg_persisted = df_agg.persist()
df_agg_persisted.count() # материализуем
print(f"Партиций в результате: {df_agg_persisted.rdd.getNumPartitions()}")
# Вывод: Партиций в результате: 3 (вместо 200!)
Когда Coalesce НЕ работает¶
AQE Coalesce не применяется если:
spark.sql.adaptive.coalescePartitions.enabled = false- Запрос не содержит Shuffle (чтение файлов, операции без groupBy/join)
- Стоит
spark.sql.adaptive.coalescePartitions.parallelismFirst = true(дефолт: false) - тогда AQE приоритизирует параллелизм над размером партиций
4. Опора вторая: динамическая смена стратегии Join¶
Это один из наиболее впечатляющих механизмов AQE, который может ускорить запрос в 10-100 раз без единой строки изменения SQL.
Классическая боль SortMergeJoin¶
SortMergeJoin (SMJ) - алгоритм для джойна двух больших датасетов. Он требует:
- Shuffle обеих таблиц по ключу JOIN
- Sort каждой партиции по ключу
- Merge: слияние отсортированных потоков с двух сторон
SMJ работает в O(N log N) времени и требует хранить и сортировать все данные. Это правильный выбор когда обе стороны большие.
BroadcastHashJoin (BHJ) работает иначе:
- Маленькую таблицу передают (broadcast) на все Executor'ы
- Строим хэш-таблицу из маленькой стороны в памяти
- Читаем большую таблицу без shuffle и для каждой строки делаем hash lookup
BHJ работает в O(N) и не требует shuffle для большой стороны. Разница колоссальная.
Spark выбирает BHJ автоматически если одна сторона ≤ spark.sql.autoBroadcastJoinThreshold (дефолт 10 MB). Но проблема в том, что он оценивает размер до фильтрации.
Как AQE переключает стратегию: демонстрация¶
Диаграмма показывает ключевой момент: Catalyst выбрал SMJ до выполнения потому что не знал что фильтр уберёт 99.98% данных. AQE видит реальный размер уже выполненного Stage и переключает план.
Параметры управления Join Strategy¶
spark = SparkSession.builder \
# Порог для автоматического broadcast при статическом планировании
# Дефолт: 10 MB. Это пороговое значение для СТАТИЧЕСКОГО выбора BHJ.
.config("spark.sql.autoBroadcastJoinThreshold", "10MB") \
# AQE-специфичный порог для ДИНАМИЧЕСКОГО upgrade до BHJ.
# По умолчанию равен autoBroadcastJoinThreshold.
# Рекомендуется поднять для AQE - раз мы уже знаем реальный размер,
# можем безопасно broadcast'ить большие таблицы:
.config("spark.sql.adaptive.autoBroadcastJoinThreshold", "30MB") \
.getOrCreate()
Когда AQE НЕ может переключить SMJ → BHJ:
- Одна сторона join'а - потоковые данные (Structured Streaming)
- Используется Anti Join или Full Outer Join (BHJ не поддерживает все типы)
- Реальный размер данных всё равно больше порога
Полный пример с анализом плана¶
from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder \
.master("local[4]") \
.config("spark.sql.adaptive.enabled", "true") \
.config("spark.sql.autoBroadcastJoinThreshold", "10MB") \
.config("spark.sql.adaptive.autoBroadcastJoinThreshold", "50MB") \
.getOrCreate()
# Симулируем ситуацию: большая таблица с сильным фильтром
transactions = spark.range(10_000_000).select(
F.col("id").alias("txn_id"),
(F.rand() * 1000).cast("int").alias("country_id"),
(F.rand() * 500).alias("amount")
)
# Маленькая таблица стран
countries = spark.range(1000).select(
F.col("id").alias("country_id"),
F.concat(F.lit("Country_"), F.col("id")).alias("country_name")
)
# Фильтруем transactions до ~10 000 строк (0.1% от исходных)
txn_filtered = transactions.filter(F.col("country_id") < 10)
# Join
result = txn_filtered.join(countries, "country_id") \
.groupBy("country_name").agg(F.sum("amount").alias("total"))
# До выполнения: Catalyst видит transactions без фильтра → SMJ
print("=== ПЛАН ДО ВЫПОЛНЕНИЯ ===")
result.explain("formatted")
# Увидим: AdaptiveSparkPlan isFinalPlan=false
# Current Plan: SortMergeJoin (т.к. 10M строк > 10MB threshold)
# Выполняем
result.collect()
# После выполнения: AQE увидел реальный размер
print("=== ПЛАН ПОСЛЕ ВЫПОЛНЕНИЯ (АДАПТИВНЫЙ) ===")
result.explain("formatted")
# Увидим: AdaptiveSparkPlan isFinalPlan=true
# Final Plan: BroadcastHashJoin (AQE увидел что осталось ~10K строк)
5. Опора третья: борьба со скошенными данными (Skew Join Optimization)¶
Data Skew - «убийца» production Spark-приложений. Стraggler task (последняя зависшая задача) из-за перекошенных данных может растянуть 30-минутный джоб на 3 часа.
Анатомия проблемы Data Skew¶
Схема наглядно показывает: без AQE один Task обрабатывает 847 MB пока все остальные Executor'ы простаивают. AQE разбивает скошенную партицию на части и запускает их параллельно, сокращая время в несколько раз.
Как AQE Skew Join работает технически¶
AQE Skew Join решает нетривиальную задачу: он должен разбить скошенную партицию левой стороны и при этом правильно сджойнить её с правой стороной.
Алгоритм:
- AQE обнаруживает скошенную партицию (например, Partition 199 левой таблицы)
- AQE физически делит её на N частей по диапазонам offset'ов в Shuffle файле
- Для каждой части из левой таблицы AQE дублирует соответствующую партицию правой таблицы
- Каждая пара (часть_левой, копия_правой) обрабатывается отдельной Task'ой
- Результаты всех Task'ов объединяются
Важно: это работает только для Inner Join и Left Outer Join. Для Full Outer Join AQE Skew не применяется.
Параметры детекции и устранения Skew¶
spark = SparkSession.builder \
# Главный тумблер Skew Join оптимизации
.config("spark.sql.adaptive.skewJoin.enabled", "true") \
# Коэффициент: партиция считается скошенной если она в N раз больше медианы.
# Дефолт: 5 (партиция должна быть в 5x раз больше медианной).
# Снизьте до 3 если хотите более агрессивное разбиение.
# Поднимите до 10 если видите слишком много ложных срабатываний.
.config("spark.sql.adaptive.skewJoin.skewedPartitionFactor", "5") \
# Минимальный абсолютный размер для признания партиции скошенной.
# Дефолт: 256 MB. Партиция должна быть > skewedPartitionFactor*median
# И > skewedPartitionThresholdInBytes одновременно.
# Снизьте если работаете с небольшими данными:
.config("spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes", "256MB") \
# Целевой размер части при разбиении скошенной партиции.
# Дефолт: advisoryPartitionSizeInBytes (обычно 64 MB).
# Разбиение: skewed_partition_size / advisoryPartitionSizeInBytes = N частей
.config("spark.sql.adaptive.advisoryPartitionSizeInBytes", "64MB") \
.getOrCreate()
# При настройках выше:
# Если медиана = 50 MB, threshold = 256 MB:
# Партиция 847 MB > 5 * 50 MB (= 250 MB) AND > 256 MB → SKEW!
# 847 MB / 64 MB = ~13 частей
Полная демонстрация Skew Join с искусственным перекосом¶
from pyspark.sql import SparkSession, functions as F
from pyspark.sql.types import StructType, StructField, LongType, StringType, DoubleType
spark = SparkSession.builder \
.master("local[4]") \
.config("spark.sql.adaptive.enabled", "true") \
.config("spark.sql.adaptive.skewJoin.enabled", "true") \
.config("spark.sql.adaptive.skewJoin.skewedPartitionFactor", "3") \
.config("spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes", "10MB") \
.config("spark.sql.adaptive.advisoryPartitionSizeInBytes", "10MB") \
.getOrCreate()
# Создаём сильно скошенный датасет
# 90% строк имеют category_id = 0 (один "горячий" ключ)
def create_skewed_data(spark, n_rows=1_000_000):
# 900K строк с category_id=0, 100K строк с category_id=1..9
hot_data = spark.range(900_000).select(
F.col("id").alias("event_id"),
F.lit(0).alias("category_id"),
F.rand().alias("revenue")
)
normal_data = spark.range(100_000).select(
(F.col("id") + 900_000).alias("event_id"),
(F.col("id") % 9 + 1).cast("int").alias("category_id"),
F.rand().alias("revenue")
)
return hot_data.union(normal_data)
events = create_skewed_data(spark)
# Таблица категорий
categories = spark.range(10).select(
F.col("id").alias("category_id"),
F.concat(F.lit("Cat_"), F.col("id")).alias("category_name")
)
# Join с перекошенными данными
result = events.join(categories, "category_id") \
.groupBy("category_name") \
.agg(F.sum("revenue").alias("total_revenue"))
import time
t0 = time.time()
result.collect()
elapsed = time.time() - t0
print(f"С AQE Skew Join: {elapsed:.1f}с")
# Повторяем без skew оптимизации для сравнения:
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "false")
events2 = create_skewed_data(spark)
categories2 = spark.range(10).select(
F.col("id").alias("category_id"),
F.concat(F.lit("Cat_"), F.col("id")).alias("category_name")
)
result2 = events2.join(categories2, "category_id") \
.groupBy("category_name") \
.agg(F.sum("revenue").alias("total_revenue"))
t0 = time.time()
result2.collect()
elapsed_no_skew = time.time() - t0
print(f"Без AQE Skew Join: {elapsed_no_skew:.1f}с")
print(f"Ускорение AQE: {elapsed_no_skew/elapsed:.1f}x")
Ограничения AQE Skew Join¶
AQE не может устранить Skew полностью в следующих ситуациях:
-
Слишком маленький кластер: если скошенная партиция = 100 GB, а каждый Executor имеет 8 GB RAM, разбиение на 4 части (25 GB каждая) всё равно вызовет spill на диск.
-
Null-тяжёлый ключ join: если 50% ключей = NULL, AQE разобьёт партицию на части, но каждая часть всё равно будет содержать много NULL. Лучшее решение - явная фильтрация NULL перед join'ом.
-
Nested Skew: если данные скошены на нескольких уровнях, AQE обработает только верхний уровень.
6. Local Shuffle Reader: оптимизация сетевого трафика¶
Local Shuffle Reader - четвёртый механизм AQE, который менее известен, но важен для больших кластеров.
Проблема без Local Shuffle Reader¶
В стандартном Shuffle каждый Executor читает данные от всех других Executor'ов. При 100 Executor'ах и Shuffle-операции: каждый Executor делает 100 HTTP-запросов на чтение данных. Это создаёт O(N²) сетевых соединений.
Как работает Local Shuffle Reader¶
Когда AQE переключает план с SMJ на BHJ, некоторые Shuffle данные больше не нужно передавать по сети. AQE позволяет Executor'ам читать Shuffle данные со своего же локального диска вместо чтения с удалённых Executor'ов.
spark = SparkSession.builder \
# Local Shuffle Reader: включён по умолчанию с AQE
.config("spark.sql.adaptive.localShuffleReader.enabled", "true") \
.getOrCreate()
Local Shuffle Reader автоматически активируется когда:
- AQE переключил план таким образом, что данные после Shuffle уже лежат на том же Executor, который их будет читать
- При преобразовании SMJ → BHJ часть Stage'ов может читаться локально
7. Чтение адаптивных планов в Spark UI и логах¶
Анатомия SQL/Queries вкладки с AQE¶
В Spark UI при включённом AQE SQL граф выглядит иначе, чем без него:
До выполнения (Initial Plan):
- Граф выглядит как обычный физический план
- Видно
AdaptiveSparkPlan isFinalPlan=false
После выполнения (Final Plan):
- Появляются блоки
AQE Query Stage- серые прямоугольники, обозначающие завершённые стадии - Видно реальное число задач в каждом Stage (может сильно отличаться от запланированного)
- Если AQE изменил Join стратегию - видно
BroadcastHashJoin (from SMJ)
Ключевые маркеры в .explain()¶
# explain("formatted") показывает полный адаптивный план
df.explain("formatted")
# Ищите в выводе:
# 1. Признак активности AQE:
# AdaptiveSparkPlan isFinalPlan=false|true
# 2. Coalesce:
# CoalesceShufflePartitions
# (указывает что партиции были слиты)
# 3. Join downgrade:
# BroadcastHashJoin ... (skipped=(SortMergeJoin))
# или просто BroadcastHashJoin где ожидался SortMergeJoin
# 4. Skew handling:
# SkewJoin(skewedPartitions=[SkewedPartition(0, 199)], ...
# AQEShuffleRead (вместо обычного ShuffleReader)
Детальные логи AQE через log4j¶
Для глубокой диагностики включите DEBUG-логирование AQE:
spark.sparkContext.setLogLevel("WARN")
# Только для AQE логов:
log4j = spark.sparkContext._jvm.org.apache.log4j
log4j.LogManager.getLogger(
"org.apache.spark.sql.execution.adaptive"
).setLevel(log4j.Level.DEBUG)
В логах появятся строки вида:
DEBUG AdaptiveSparkPlanExec: Final plan:
BroadcastHashJoin (replaced SortMergeJoin due to runtime statistics)
DEBUG CoalesceShufflePartitions: Coalesced from 200 to 8 partitions
DEBUG OptimizeSkewedJoin: Partition 199 is skewed (size=847MB, median=1.1MB, factor=770x)
Splitting into 13 sub-partitions
Программная диагностика: сколько задач создал AQE¶
from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder \
.master("local[8]") \
.config("spark.sql.adaptive.enabled", "true") \
.getOrCreate()
# Listener для сбора статистики AQE
class AQEMetricsListener:
"""Собирает метрики о применении AQE оптимизаций."""
def __init__(self):
self.stage_stats = {}
def collect(self, df):
"""
Выполняет запрос и собирает метрики партиций.
Возвращает число задач в каждом Stage.
"""
df.collect()
# Получаем информацию через SparkContext StatusTracker
sc = df.sparkSession.sparkContext
stage_ids = sc.statusTracker().getActiveJobIds()
return {
"shuffle_partitions_setting": df.sparkSession.conf.get(
"spark.sql.shuffle.partitions"
),
"aqe_enabled": df.sparkSession.conf.get(
"spark.sql.adaptive.enabled"
),
}
# Сравнение с AQE и без
df = spark.range(100_000).select(
F.col("id"),
(F.rand() * 100).cast("int").alias("key"),
F.rand().alias("value")
)
# С AQE
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.shuffle.partitions", "200")
df_agg_aqe = df.groupBy("key").sum("value")
df_agg_aqe.persist()
count_aqe = df_agg_aqe.count()
partitions_aqe = df_agg_aqe.rdd.getNumPartitions()
# Без AQE
spark.conf.set("spark.sql.adaptive.enabled", "false")
df_agg_no_aqe = df.groupBy("key").sum("value")
df_agg_no_aqe.persist()
count_no_aqe = df_agg_no_aqe.count()
partitions_no_aqe = df_agg_no_aqe.rdd.getNumPartitions()
print(f"С AQE: {partitions_aqe} партиций (было настроено: 200)")
print(f"Без AQE: {partitions_no_aqe} партиций (было настроено: 200)")
# Пример вывода:
# С AQE: 3 партиций (было настроено: 200)
# Без AQE: 200 партиций (было настроено: 200)
Полная конфигурация AQE для production¶
# production_aqe_config.py
# Рекомендуемые настройки AQE для production HDFS/Data Lakehouse кластера
from pyspark.sql import SparkSession
def create_spark_with_aqe(app_name: str, data_size_estimate: str = "medium") -> SparkSession:
"""
Создаёт SparkSession с настройками AQE под конкретный размер данных.
data_size_estimate:
- "small" : данные < 10 GB
- "medium" : данные 10-500 GB (большинство ETL)
- "large" : данные > 500 GB
"""
configs = {
"small": {
"shuffle_partitions": "1000",
"advisory_partition_size": "32MB",
"broadcast_threshold": "30MB",
"skew_factor": "3",
"skew_threshold": "32MB",
},
"medium": {
"shuffle_partitions": "2000",
"advisory_partition_size": "128MB",
"broadcast_threshold": "50MB",
"skew_factor": "5",
"skew_threshold": "256MB",
},
"large": {
"shuffle_partitions": "5000",
"advisory_partition_size": "256MB",
"broadcast_threshold": "100MB",
"skew_factor": "5",
"skew_threshold": "512MB",
},
}
cfg = configs[data_size_estimate]
return SparkSession.builder \
.appName(app_name) \
# ── Основные AQE переключатели ────────────────────────────────
.config("spark.sql.adaptive.enabled", "true") \
.config("spark.sql.adaptive.coalescePartitions.enabled", "true") \
.config("spark.sql.adaptive.skewJoin.enabled", "true") \
.config("spark.sql.adaptive.localShuffleReader.enabled", "true") \
# ── Начальное число партиций (с запасом, AQE уменьшит) ────────
# Всегда ставьте spark.sql.shuffle.partitions с запасом.
# AQE может только УМЕНЬШАТЬ число партиций, не увеличивать!
.config("spark.sql.shuffle.partitions", cfg["shuffle_partitions"]) \
.config(
"spark.sql.adaptive.coalescePartitions.initialPartitionNum",
cfg["shuffle_partitions"]
) \
# ── Целевой размер партиции после coalesce ────────────────────
# Рекомендуется 128 MB для HDFS (совпадает с блоком HDFS)
# Для S3/MinIO: 64-128 MB
.config(
"spark.sql.adaptive.advisoryPartitionSizeInBytes",
cfg["advisory_partition_size"]
) \
# ── Broadcast threshold для AQE ───────────────────────────────
# AQE использует реальный размер, поэтому можно поднять
# выше статического autoBroadcastJoinThreshold
.config(
"spark.sql.adaptive.autoBroadcastJoinThreshold",
cfg["broadcast_threshold"]
) \
# ── Настройки детекции Skew ───────────────────────────────────
# Партиция считается скошенной если:
# size > skewedPartitionFactor * median_size
# AND size > skewedPartitionThresholdInBytes
.config(
"spark.sql.adaptive.skewJoin.skewedPartitionFactor",
cfg["skew_factor"]
) \
.config(
"spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes",
cfg["skew_threshold"]
) \
# ── CBO статистика для лучших начальных оценок ────────────────
# CBO + AQE = лучший результат чем по отдельности
.config("spark.sql.cbo.enabled", "true") \
.config("spark.sql.statistics.histogram.enabled", "true") \
.getOrCreate()
# Примеры использования:
spark_etl = create_spark_with_aqe("daily-silver-etl", "medium")
spark_report = create_spark_with_aqe("monthly-gold-report", "large")
Лабораторная работа: батл «ручной тюнинг против AQE»¶
# lab_aqe_benchmark.py
# Полное сравнение: ручной тюнинг vs AQE
import time
from dataclasses import dataclass
from pyspark.sql import SparkSession, functions as F
@dataclass
class BenchmarkResult:
name: str
elapsed_sec: float
straggler_task_sec: float # время самой долгой задачи
def create_benchmark_data(spark: SparkSession):
"""
Создаёт реалистичный датасет с:
1. Data Skew (90% данных на key=0)
2. Таблица которая сильно фильтруется
"""
# Скошенные транзакции
skewed_events = spark.range(2_000_000).select(
F.col("id").alias("event_id"),
# 90% событий имеют product_id=0 (хит-продукт)
F.when(F.rand() < 0.9, F.lit(0))
.otherwise((F.rand() * 999 + 1).cast("int"))
.alias("product_id"),
(F.rand() * 1000).alias("revenue"),
F.date_add(F.lit("2024-01-01"), (F.rand() * 365).cast("int")).alias("date"),
F.array(F.lit("RU"), F.lit("DE"), F.lit("US")).getItem(
(F.rand() * 3).cast("int")
).alias("country")
)
# Таблица продуктов (будет сильно фильтроваться)
products = spark.range(1000).select(
F.col("id").alias("product_id"),
F.concat(F.lit("Product_"), F.col("id")).alias("product_name"),
F.array(F.lit("Electronics"), F.lit("Books"), F.lit("Food")).getItem(
(F.rand() * 3).cast("int")
).alias("category"),
(F.rand() * 100).alias("margin_pct")
)
return skewed_events, products
def run_without_aqe(spark: SparkSession, events, products) -> float:
"""
Запуск без AQE: все проблемы накапливаются.
"""
spark.conf.set("spark.sql.adaptive.enabled", "false")
spark.conf.set("spark.sql.shuffle.partitions", "200")
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "10MB")
# Запрос: фильтруем до Electronics + skewed join
result = events \
.join(products.filter(F.col("category") == "Electronics"), "product_id") \
.groupBy("product_name", "country") \
.agg(
F.sum("revenue").alias("total_revenue"),
F.count("*").alias("event_count"),
) \
.orderBy(F.desc("total_revenue"))
t0 = time.time()
result.collect()
return time.time() - t0
def run_with_manual_tuning(spark: SparkSession, events, products) -> float:
"""
Ручная оптимизация: требует знания данных заранее.
"""
spark.conf.set("spark.sql.adaptive.enabled", "false")
# Знаем что после фильтра Electronics останется ~333 продукта
# Знаем что данные сильно скошены → нужен salting
spark.conf.set("spark.sql.shuffle.partitions", "50")
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "50MB") # принудительный broadcast
# Salting для скошенных данных (ручной метод)
SALT = 10
events_salted = events.withColumn("salt", (F.rand() * SALT).cast("int")) \
.withColumn("product_id_salted",
F.concat(F.col("product_id").cast("string"), F.lit("_"), F.col("salt")))
products_electronics = products.filter(F.col("category") == "Electronics")
products_expanded = products_electronics \
.crossJoin(spark.range(SALT).select(F.col("id").alias("salt"))) \
.withColumn("product_id_salted",
F.concat(F.col("product_id").cast("string"), F.lit("_"), F.col("salt")))
result = events_salted \
.join(F.broadcast(products_expanded), "product_id_salted") \
.groupBy("product_name", "country") \
.agg(F.sum("revenue"), F.count("*")) \
.orderBy(F.desc("sum(revenue)"))
t0 = time.time()
result.collect()
return time.time() - t0
def run_with_aqe(spark: SparkSession, events, products) -> float:
"""
AQE: минимальные настройки, максимальный результат.
"""
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionFactor", "3")
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes", "10MB")
spark.conf.set("spark.sql.adaptive.autoBroadcastJoinThreshold", "50MB")
spark.conf.set("spark.sql.shuffle.partitions", "2000")
spark.conf.set("spark.sql.adaptive.advisoryPartitionSizeInBytes", "64MB")
# Тот же запрос, без salting и hints!
result = events \
.join(products.filter(F.col("category") == "Electronics"), "product_id") \
.groupBy("product_name", "country") \
.agg(
F.sum("revenue").alias("total_revenue"),
F.count("*").alias("event_count"),
) \
.orderBy(F.desc("total_revenue"))
t0 = time.time()
result.collect()
return time.time() - t0
if __name__ == "__main__":
spark = SparkSession.builder \
.master("local[8]") \
.appName("aqe-benchmark") \
.getOrCreate()
spark.sparkContext.setLogLevel("ERROR")
events, products = create_benchmark_data(spark)
events.cache()
products.cache()
events.count()
products.count()
print("\n" + "=" * 60)
print("БАТЛ: Ручной тюнинг vs AQE")
print("=" * 60)
t_no_aqe = run_without_aqe(spark, events, products)
print(f"Без AQE (дефолты): {t_no_aqe:.1f}с")
t_manual = run_with_manual_tuning(spark, events, products)
print(f"Ручной тюнинг + salting: {t_manual:.1f}с")
t_aqe = run_with_aqe(spark, events, products)
print(f"AQE (минимум настроек): {t_aqe:.1f}с")
print("\nВывод:")
print(f" AQE быстрее дефолтов: {t_no_aqe/t_aqe:.1f}x")
print(f" AQE vs ручной тюнинг: {t_manual/t_aqe:.1f}x")
print(f" Строк кода настройки (AQE): 5")
print(f" Строк кода настройки (ручн): ~40 (salting + hints)")
spark.stop()
Итоги: когда AQE помогает, а когда нет¶
AQE максимально эффективен при:¶
- Непредсказуемых данных - ad-hoc аналитика, Data Lake с разными партициями по объёму
- Сложных фильтрах -
WHEREубирает большую часть данных, Catalyst не знал об этом заранее - Многотабличных JOIN'ах - некоторые промежуточные результаты оказываются значительно меньше ожидаемого
- Data Skew - неравномерные ключи JOIN'а (NULL, hot keys вроде крупных клиентов)
- Multi-tenant кластерах - разные пользователи запускают запросы с разными объёмами данных
AQE имеет минимальный эффект при:¶
- Очень маленьких датасетах - overhead AQE (сбор статистики, пересмотр плана) превышает выигрыш
- Structured Streaming - AQE не применяется к streaming запросам (только к batch)
- Хорошо известных данных с актуальной статистикой - если CBO уже имеет точные данные, AQE не добавляет значительного прироста
- Запросах без Shuffle - фильтры, простые проекции, чтение файлов без join/group
Главный вывод урока: AQE - это не «ускоритель Spark», который автоматически делает всё быстрым. Это механизм исправления ошибок статического оптимизатора во время выполнения. Его главная ценность - в том, что он корректирует неизбежные ошибки оценки Catalyst на основе реальных данных. Чем более непредсказуемы ваши данные и сложнее запросы - тем больше выигрыш от AQE.