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.