Apache Spark — одна из самых успешных высокоуровневых абстракций в истории распределённых вычислений. DataFrame API позволяет писать SQL-подобный код, который работает на кластере из тысяч машин, не думая о том, как данные перемещаются по сети, как организована память, как процессор исполняет скомпилированный байткод. Абстракция действительно хорошая — до определённого момента.
Этот момент наступает в production.
Один запрос, два варианта
Представьте: ваш pipeline на Spark читает таблицу транзакций (500 GB, Parquet, секционирована по дате), джойнит с таблицей пользователей (10 GB), фильтрует нужный диапазон дат, агрегирует по user_id. Это стандартный ETL, который выполняется сотнями команд каждый день.
На staging-кластере с теми же данными (семпл 10%) запрос работает за 4 минуты. Вы проводите capacity planning: на полных данных будет 40 минут. Деплоите в production. Первый запуск на реальных данных занимает 2 часа 20 минут.
Что пошло не так? Если вы не знаете internals, вы видите: Job succeeded, result correct. Просто медленно. Значит нужно больше ресурсов. Добавляете executor’ы. Становится 1 час 40 минут. Добавляете ещё. 1 час 20 минут. Деньги заканчиваются быстрее, чем производительность растёт.
Если вы знаете internals, вы видите в Spark UI: Stage 2 имеет 200 задач, 195 завершились за 3 минуты, 5 задач выполняются уже 70 минут. Один executor держит 90% нагрузки. Это data skew — несколько значений user_id доминируют в данных (боты, тестовые аккаунты), и вся их обработка падает на одну партицию. Решение: salting с broadcast join или AQE с spark.sql.adaptive.skewJoin.enabled=true. 15 минут рефакторинга. Запрос работает за 18 минут.
Разница не в ресурсах. Разница в понимании движка.
Теория протекающих абстракций
Joel Spolsky сформулировал Закон протекающих абстракций (Law of Leaky Abstractions, 2002): все нетривиальные абстракции в той или иной мере протекают. Абстракция скрывает детали реализации, но не скрывает их полностью. Когда нижний слой ведёт себя неожиданно, абстракция “протекает” — верхний слой обязан знать о нижнем.
Spark — это многослойная абстракция. Каждый слой скрывает сложность от вышележащего. DataFrame API не требует знать о Catalyst. Catalyst не требует знать о RDD. RDD не требует знать о DAGScheduler. DAGScheduler не требует знать о shuffle-буферах на уровне OS.
Каждый слой протекает в определённых ситуациях. В 95% случаев абстракция держит, и вы можете работать не думая о реализации. В 5% случаев — production-инцидент, дедлайн, дорогостоящий кластер — она протекает. И именно для этих 5% нужны internals.
Пять классов проблем, которые нельзя решить без знания движка
1. Data skew и неравномерная нагрузка
Data skew — когда данные распределены неравномерно по партициям. В большинстве реальных датасетов 20% значений ключей дают 80% строк: боты, системные пользователи, null-значения, популярные категории. При shuffle Spark распределяет данные по хэшу ключа, и несколько задач получают несравнимо больше данных, чем остальные.
Симптомы в Spark UI: почти все задачи stage завершены, 3-5 задач продолжают выполняться. Duration stage определяется самой медленной задачей.
Решение зависит от типа skew:
- Join skew: salting ключа + explode + join, или AQE auto-skew handling (
spark.sql.adaptive.skewJoin.enabled). - GroupBy skew: двухфазная агрегация (partial aggregate -> shuffle -> final aggregate), или рекомпозиция партиций.
- Null skew: фильтрация null перед join, затем union.
Ни одно из этих решений не видно на уровне DataFrame API. Чтобы применить правильное, нужно понимать, что происходит на уровне shuffle partitioner и task execution.
2. Shuffle spill и нехватка памяти
Shuffle в Spark состоит из двух фаз: map side (write данных в shuffle files) и reduce side (read + sort + aggregate). Если данных в reduce-фазе больше, чем выделено памяти для shuffle (определяется spark.executor.memory и spark.memory.fraction), Spark начинает spill — сбрасывает данные на диск.
Spill сам по себе не фатален, но катастрофически медленен. SSD: 200-500 MB/s последовательная запись. Сеть: 10-25 Gbps = 1.25-3.1 GB/s. Spill на диск в 3-10x медленнее чтения из сети. Если у вас spill 50 GB — это десятки минут потеряно.
Спил не бросает исключений. Он просто делает задачу медленной. Без знания internals вы видите “задача медленная” и добавляете cores. С internals — видите в Spark UI метрику “Spill (Disk)” в деталях задачи и понимаете: нужно либо увеличить executor memory, либо уменьшить data per executor, либо настроить AQE coalescing.
3. Broadcast join OOM
Broadcast join — когда одна из сторон join маленькая, Spark копирует её целиком на каждый executor и делает локальный hash lookup. Это в разы быстрее SortMergeJoin, который требует shuffle обеих сторон.
Catalyst автоматически применяет BroadcastHashJoin, если размер таблицы ниже порога spark.sql.autoBroadcastJoinThreshold (по умолчанию 10 MB в Spark 4.0). Проблема: порог определяется по статистике из каталога, которая может быть устаревшей или отсутствующей. Если реальный размер таблицы 2 GB, а статистика говорит 5 MB (после ANALYZE TABLE не запускался несколько месяцев), Catalyst выберет broadcast.
Результат: 2 GB данных отправляются через SparkContext.broadcast(), материализуются в memory каждого executor-а. При 50 executor-ах — 100 GB RAM на broadcast. Driver может упасть с OOM ещё при сериализации. Executor-ы падают при десериализации.
Решение: обновить статистики (ANALYZE TABLE), явно отключить broadcast hint, или настроить порог. Но диагностировать можно только зная, что broadcast — это не “магия оптимизатора”, а конкретный план, который можно увидеть в explain() и который можно явно контролировать.
4. Codegen-регрессии
Tungsten Whole-Stage Code Generation (WSCG) — одна из ключевых оптимизаций Spark 2.0+. Вместо интерпретированного исполнения операторов, Catalyst генерирует Java-код, который JIT-компилирует в нативный машинный код. Это даёт 2-10x ускорение на типичных аналитических запросах.
WSCG ломается в нескольких случаях:
- Python UDF: любой Python UDF в плане отключает codegen для всего stage, потому что Python-процесс работает вне JVM. Stage деградирует до интерпретированного режима.
- Слишком сложный план: codegen имеет лимит на размер генерируемого метода (64 KB bytecode). Широкие SELECT с сотнями колонок или deep expression trees превышают лимит и fallback-ятся на интерпретированный режим.
- Нестандартные типы: complex nested types (map of arrays of structs) плохо поддаются vectorization.
Symtom: запрос работает нормально, вы рефакторите один WHERE clause или добавляете UDF, производительность падает в 3 раза без очевидной причины. explain() не показывает ничего подозрительного. Только df.queryExecution.debug.codegen() покажет, что сгенерированный код вернулся к interpreted path.
5. Speculative execution и retry-шторм
Spark имеет механизм speculative execution: если задача выполняется медленно (медленнее median времени задач stage на 1.5x — настраивается через spark.speculation.multiplier), Spark запускает её копию на другом executor-е. Первая завершившаяся побеждает.
В нормальных условиях это полезно: медленный executor (сетевая проблема, GC pause) не блокирует весь stage. Но в патологических случаях speculation создаёт retry-шторм: задача медленная из-за data skew (данных реально много), запускается speculative copy, тоже медленная, запускается ещё одна — и так далее. При включённом spark.speculation и значимом skew вы можете получить в 3-5 раз больше задач, чем нужно, каждая читает одни и те же shuffle данные, кластер перегружен.
Эти пять классов проблем объединяет одно: симптомы видны на уровне API (медленно, OOM, неправильный результат), причины — на уровне internals.
Что меняется при переходе от middle к senior
Middle Spark-инженер знает API. Он может написать сложный multi-join pipeline, правильно настроить партиционирование для своего use case, использовать window functions, структурировать streaming job. Это ценные навыки.
Senior Spark-инженер знает, что API — это интерфейс к конкретному движку с конкретным поведением. Он работает на уровне двух слоёв:
Разница не в количестве знаний, а в уровне абстракции, на котором инженер работает. Senior не просто знает больше конфигов — он понимает, почему каждый конфиг работает именно так, может предсказать последствия изменения до запуска, и может решить проблему, которой ещё нет в Stack Overflow.
В 2026 году это различие ещё острее. Spark 4.0 принёс ANSI по умолчанию, улучшенный AQE, Spark Connect как основной путь к кластеру, тесную интеграцию с Apache Arrow для Python workloads. Новые возможности = новые точки протечек, новые слои абстракции, новые сценарии, где нужно знать движок.
Как устроен этот курс и как его проходить
Курс построен как путешествие от философии к железу и обратно. Каждый модуль — это один слой движка, разобранный до уровня, когда поведение становится предсказуемым.
Структура:
- Модули 01-03 — фундамент: зачем internals, архитектурный mental model, RDD как базовая абстракция.
- Модули 04-05 — данные в движении и в памяти: shuffle и memory management.
- Модули 06-08 — оптимизационный стек: Catalyst, Tungsten, AQE.
- Модули 09-10 — специальные рантаймы: Structured Streaming internals, Arrow/Spark Connect.
- Модули 11-16 — расширение, дебаг, капстоун.
Каждый урок содержит:
- Концептуальный раздел — что происходит и почему именно так.
- Механику — конкретные классы, конфиги, метрики.
- Production-паттерн — как это знание применяется в реальных сценариях.
- Попробуй сам — практическое задание или эксперимент.
Наибольшая отдача от курса — если рядом есть реальный Spark-кластер или облачный Spark-as-a-service (Databricks, EMR, Dataproc). Многие разделы “Попробуй сам” рассчитаны на наблюдение за реальными метриками в Spark UI. Если кластера нет — Docker Compose с single-node Spark 4.0 достаточен для большинства экспериментов.
Предполагаемый фон: вы работаете с Spark в production (или активно работали), понимаете DataFrame API, умеете писать production pipelines. Этот курс не учит Spark с нуля — он учит понимать то, что вы уже используете.
Оптимизация shuffle: практикаЧто означает Spark 4.0 для senior-инженера
Spark 4.0 — первый major release с 2016 года. Изменения, которые напрямую касаются internals:
ANSI по умолчанию. Раньше 1 / 0 возвращал null, деление строки на int не бросало исключение. Теперь — стандартное ANSI SQL поведение: исключение при делении на ноль, строгая типизация. Это ломает части legacy-кода, но убирает класс silent data corruption bugs.
Spark Connect как основной транспорт. Spark Connect — gRPC-based протокол отсоединения клиента от драйвера. В 4.0 это не experimental: Python-клиенты по умолчанию работают через Spark Connect. Это меняет где и как строится план запроса — часть Catalyst-работы теперь происходит на клиентской стороне.
AQE зрелость. Adaptive Query Execution был введён в Spark 3.0. В 4.0 он включён по умолчанию (spark.sql.adaptive.enabled=true) и значительно расширен. Понимание AQE internals — не optional, это core knowledge для Spark 4.0.
Structured Streaming улучшения. Новые source/sink APIs, улучшенный watermark handling, better integration с Kafka.
Этот курс написан для Spark 4.0. Там, где поведение изменилось с 3.x, это отмечено явно.
Попробуй сам
Это задание — диагностика вашего текущего уровня. Не тест на правильные ответы, а карта того, где белые пятна.
Возьмите любой production Spark job, который вы запускали последние несколько месяцев. Откройте его Spark UI (или History Server). Попробуйте ответить на следующие вопросы:
-
Сколько stages в job-е? Совпадает ли это число с вашей интуицией? Можете ли вы сказать, какая операция в коде соответствует каждому stage boundary?
-
Какой stage является bottleneck? Посмотрите на timeline: какой stage занимает больше всего времени? Это ожидаемо?
-
Есть ли spill? В деталях задач stage найдите колонки “Spill (Memory)” и “Spill (Disk)”. Если есть значительный spill — знаете ли вы, почему?
-
Какой тип join выбран? В physical plan (
explain("extended")) найдите операцию join. Это BroadcastHashJoin, SortMergeJoin или ShuffledHashJoin? Почему именно этот? -
Есть ли stragglers? В деталях stage посмотрите на distribution времени задач. Если медиана 30 секунд, а max — 15 минут, у вас straggler. Знаете ли вы причину?
Если вы смогли ответить на все пять — хорошая база, курс углубит понимание. Если на 2-3 — именно для этого мы здесь. Если ни на одного — идеальный момент начать с нуля, с правильной mental model.