RDD: неизменяемость, lineage и отказоустойчивость

Урок разбирает фундаментальную абстракцию Spark — RDD — и главный фокус: как Spark переживает падение машины, ничего не дублируя.

RDD (Resilient Distributed Dataset, «устойчивый распределённый набор данных») — неизменяемая коллекция элементов, поделённая на партиции по узлам кластера, которую можно обрабатывать параллельно и которая умеет восстанавливаться после сбоя.

Три буквы в названии

Имя RDD расшифровывает всю суть абстракции:

  • Resilient (устойчивый). Если узел кластера падает и партиция теряется, Spark пересчитывает её заново — без потери всего вычисления. Как именно — главная тема урока.
  • Distributed (распределённый). Данные живут не в одном месте, а кусками-партициями на разных исполнителях, и обрабатываются параллельно.
  • Dataset (набор данных). Это коллекция элементов — строк, объектов, кортежей «ключ-значение».

RDD — низкоуровневое сердце Spark. Сегодня в прикладном коде напрямую RDD используют редко (есть удобный и более быстрый DataFrame), но понимать RDD обязательно: на нём держатся модель вычислений, ленивость и отказоустойчивость всего Spark, включая DataFrame, который под капотом компилируется в операции над RDD.

Неизменяемость: почему RDD нельзя «менять»

RDD неизменяем (immutable). Вы не модифицируете существующий RDD — любая трансформация создаёт новый RDD на основе старого. Это не каприз дизайна, а основа отказоустойчивости: раз исходные RDD не меняются, любой производный RDD всегда можно пересчитать из его «родителей», применив ту же функцию. Если бы данные менялись на месте, восстановить потерянную партицию было бы невозможно — её прежнее состояние уже затёрто.

Неизменяемость даёт и побочную выгоду: безопасность при параллелизме. Несколько задач читают один RDD, не мешая друг другу, потому что менять его нельзя.

Lineage: рецепт вместо резервной копии

Вот центральная идея. Spark не хранит резервных копий данных (как сделал бы Hadoop, реплицируя блоки на 3 узла). Вместо этого он запоминает родословную (lineage) — последовательность трансформаций, которая привела к этому RDD. Lineage — это граф зависимостей: «этот RDD получен из того фильтром, тот — из файла такого-то». Этот граф и есть DAG (Directed Acyclic Graph, направленный ациклический граф).

Когда узел падает и партиция теряется, Spark смотрит в lineage и говорит: «эта партиция получалась так-то из такой-то родительской партиции» — и пересчитывает только её, заново применив записанные операции. Не нужно реплицировать терабайты: нужно лишь помнить рецепт. Смоделируем lineage на Python — будем хранить не данные, а цепочку шагов, и «восстановим» результат, проиграв её заново.

# Lineage — это просто список шагов (рецепт), как получить данные.
lineage = [
    ("source", [1, 2, 3, 4, 5, 6, 7, 8]),  # из какого источника читали
    ("filter", lambda xs: [x for x in xs if x % 2 == 0]),
    ("map",    lambda xs: [x * x for x in xs]),
]

def recompute(lineage):
    """Восстанавливаем результат, проигрывая рецепт заново — как Spark после сбоя."""
    data = lineage[0][1]
    for kind, fn in lineage[1:]:
        data = fn(data)
    return data

print("Источник:", lineage[0][1])
print("Узел упал, партиция потеряна. Пересчитываем по lineage...")
print("Восстановленный результат:", recompute(lineage))

Вывод:

Источник: [1, 2, 3, 4, 5, 6, 7, 8]
Узел упал, партиция потеряна. Пересчитываем по lineage...
Восстановленный результат: [4, 16, 36, 64]

Это и есть устойчивость Spark в одной идее: храним рецепт (lineage), а не копию. Терабайты данных не дублируются по сети — Spark просто помнит, как их пересоздать.

Партиции RDD

RDD поделён на партиции, и это снова определяет параллелизм. Каждая партиция обрабатывается одной задачей на одном ядре. Число партиций берётся из источника (например, число блоков файла в HDFS) или задаётся явно. Партиция — минимальная единица и параллелизма, и восстановления: при сбое пересчитывают потерянные партиции, а не весь RDD.

Узкие и широкие зависимости в lineage

Зависимости в lineage бывают двух видов, и это определяет, насколько дорого восстановление (и вообще вычисление):

  • Узкая зависимость (narrow). Каждая партиция-родитель питает ровно одну партицию-потомка (map, filter). Восстановление дешёвое: потеряли партицию — пересчитали только её родителя.
  • Широкая зависимость (wide). Партиция-потомок зависит от многих родительских партиций (groupBy, join — там, где был shuffle). Восстановление дорогое: чтобы пересчитать одну потерянную партицию, может понадобиться пересчитать множество родительских. Поэтому перед широкими зависимостями Spark часто сбрасывает промежуток (checkpoint/persist).

Этой паре «узкие vs широкие» и shuffle посвящён отдельный урок этого раздела — это ключ к производительности.

Lineage против репликации: компромисс

Стоит честно увидеть обе стороны подхода «хранить рецепт, а не копию». У него есть цена, и понимание этой цены отличает инженера от пользователя.

Lineage (пересчёт)Репликация (копии)
Память/дискэкономит: данные не дублируютсятратит: 2–3 копии каждого блока
Восстановлениестоит времени: пересчитать зановопочти мгновенно: взять готовую копию
Цена широких зависимостейвысокая: пересчёт может потянуть половину графане зависит от графа

Spark по умолчанию выбирает пересчёт, потому что для большинства аналитических задач это выгоднее: данные огромны (дублировать дорого), а пересчёт по узким зависимостям дёшев. Но Spark даёт и рычаги, когда пересчёт нежелателен: persist с уровнем репликации (держать копию закэшированных данных на втором узле) и checkpoint (материализовать на надёжное хранилище и обрубить lineage). Эти инструменты применяют там, где пересчёт особенно дорог — например, перед широкой зависимостью в длинном итеративном вычислении.

Подводные камни

  • Слишком длинный lineage. В итеративных алгоритмах (тысячи шагов) граф зависимостей разрастается, и при сбое пересчёт «от самого начала» становится непомерным, а сам план — тяжёлым для планировщика. Лекарство — checkpoint(): материализовать RDD на надёжное хранилище и «обрубить» lineage.
  • Иллюзия, что RDD «хранит данные». Без кэширования RDD не держит данных в памяти — он держит рецепт. Каждое новое действие пересчитывает всё заново по lineage.
  • Репликация vs пересчёт. Пересчёт по lineage экономит память/диск, но стоит времени. Для очень дорогих в пересчёте данных иногда выгоднее persist с репликацией.

Best practices

  • Думайте об RDD/DataFrame как о рецепте (плане), а не как о таблице с данными в памяти.
  • В длинных итеративных вычислениях обрубайте lineage через checkpoint(), чтобы граф не рос бесконечно.
  • Для прикладных задач предпочитайте DataFrame: вы получаете ту же модель lineage/DAG плюс оптимизатор и колоночное представление.

Итог

  • RDD — неизменяемая распределённая коллекция, поделённая на партиции.
  • Неизменяемость — основа отказоустойчивости: производный RDD всегда можно пересчитать из родителей.
  • Spark хранит lineage (граф трансформаций, DAG), а не резервные копии; при сбое пересчитывает потерянные партиции по рецепту.
  • Зависимости бывают узкие (дешёвое восстановление) и широкие (дорогое, связано с shuffle).
  • Длинный lineage обрубают через checkpoint.
Проверьте себя
1. Как Spark восстанавливает партицию, потерянную при падении узла?
AБерёт реплику этой партиции с другого узла
BПересчитывает её заново по lineage — записанной последовательности трансформаций
CЗапрашивает её у драйвера, где хранится копия
DПропускает потерянные данные и продолжает
2. Почему неизменяемость (immutability) RDD важна для отказоустойчивости?
AНеизменяемые данные занимают меньше места
BРаз исходные RDD не меняются, любой производный RDD всегда можно пересчитать из его родителей
CНеизменяемость ускоряет shuffle
DЭто требование языка Scala
3. Чем узкая (narrow) зависимость в lineage отличается от широкой (wide) с точки зрения восстановления?
AУзкая требует репликации, широкая — нет
BПри узкой одна родительская партиция питает одну дочернюю, восстановление дешёвое; при широкой дочерняя зависит от многих родительских, восстановление дорогое
CУзкая зависимость всегда вызывает shuffle
DРазницы в восстановлении нет
Поддержать проект