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.

optimization

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) - алгоритм для джойна двух больших датасетов. Он требует:

  1. Shuffle обеих таблиц по ключу JOIN
  2. Sort каждой партиции по ключу
  3. Merge: слияние отсортированных потоков с двух сторон

SMJ работает в O(N log N) времени и требует хранить и сортировать все данные. Это правильный выбор когда обе стороны большие.

BroadcastHashJoin (BHJ) работает иначе:

  1. Маленькую таблицу передают (broadcast) на все Executor'ы
  2. Строим хэш-таблицу из маленькой стороны в памяти
  3. Читаем большую таблицу без 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 решает нетривиальную задачу: он должен разбить скошенную партицию левой стороны и при этом правильно сджойнить её с правой стороной.

Алгоритм:

  1. AQE обнаруживает скошенную партицию (например, Partition 199 левой таблицы)
  2. AQE физически делит её на N частей по диапазонам offset'ов в Shuffle файле
  3. Для каждой части из левой таблицы AQE дублирует соответствующую партицию правой таблицы
  4. Каждая пара (часть_левой, копия_правой) обрабатывается отдельной Task'ой
  5. Результаты всех 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 полностью в следующих ситуациях:

  1. Слишком маленький кластер: если скошенная партиция = 100 GB, а каждый Executor имеет 8 GB RAM, разбиение на 4 части (25 GB каждая) всё равно вызовет spill на диск.

  2. Null-тяжёлый ключ join: если 50% ключей = NULL, AQE разобьёт партицию на части, но каждая часть всё равно будет содержать много NULL. Лучшее решение - явная фильтрация NULL перед join'ом.

  3. 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 автоматически активируется когда:

  1. AQE переключил план таким образом, что данные после Shuffle уже лежат на том же Executor, который их будет читать
  2. При преобразовании 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.