Як працює Spark під капотом і як створити ефективний Big Data пайплайн

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Всім привіт. Мене звати Дмитро Лазаренко, працюю як Data Engineer в компанії HERE — одній із провідних компаній, яка займається геолокаційними даними. Займаюся проєктуванням, розробкою та налаштуванням Spark та Flink пайплайнів, оперуючи більше ніж 200 ТБ даних, та обробкою 6 млн повідомлень/день.

У цій статті хочу розповісти про загальне розуміння процесу роботи Spark — одного із найпопулярніших фреймворків для обробки великих масивів даних (Big Data), та підводні камені роботи з ним. Spark має складну структуру, оскільки виконується у кластері одночасно на багатьох серверах, тому якщо проблеми з’являються, то, найімовірніше, вони будуть комплексні. Знаючи основи, можна уникнути багатьох проблем у написанні архітектури програми. Стаття базована на власному досвіді з фокусуванням на важливих деталях.

Для кого призначена публікація?

  • для тих, хто тільки почав вивчати Spark;
  • хто хоче стати Data Engineer;
  • хто цікавиться цією тематикою для розширення своїх знань.

Spark написаний на Scala, і приклади будуть використовуватися теж на Scala для лаконічності. Їх буде небагато, лише для пояснення принципів.

Невідворотність Big Data

Big Data і все, що пов’язано навколо, з кожним роком стають все популярнішими. Чому так стається? Згадаємо славнозвісний закон Мура, який передбачає подвоєння транзисторів кожні два роки. І, звісно, потужність серверів, що мають можливість обробляти більшу кількість даних, зростає в тій же пропорції. Відомий статистичний сервіс Statista говорить, що щороку даних генерується на 25% більше. Або іншими словами, подвоюється кожні три роки. І з цим потрібно щось робити, точніше — якось обробляти.

Компанії розуміють, що завдяки цим даним вони можуть отримати конкурентні переваги, тому так активно розвиваються всі напрями, пов’язані з обробкою даних. І цей ринок вже достатньо великий, світові витрати на аналітичні рішення Big Data становитимуть понад 274,3 млрд доларів у 2022 році. І це лише початок.

Типи обробки даних

Є дві основні підгрупи напрямів з обробки даних:

  • Batch Processing — пакетна обробка даних. Робота з уже збереженими терабайтами даних.
  • Streaming Processing — потокова обробка даних. Це зазвичай робота із «свіжими» даними.

Дотичні технології, що використовують ці масиви даних:

  • Machine Learning.
  • Statistics/Analytics/Metrics.
  • Interactive Processing.
  • Graph Processing.

Spark працює з більшістю типів обробки, але початково був заточений під роботу Batch Processing, і в статті буде розказуватися саме про Batch.
Але щоб було поняття про Streaming в Spark, то він не працює в класичному розумінні потокової обробки, натомість використовує micro-batch processing (обробка малими пакетами даних). Що накладає певні обмеження, тому для роботи потокових даних рекомендуватиму використовувати фреймворк Apache Flink.

Отже, Apache Spark — це вдалий фреймворк для розподіленої обробки пакетних даних (Batch) у кластері. І тут ми доходимо до самих даних. Вони повинні десь зберігатися і якимось чином витягуватися, отже, все це є в наявності:

Як видно із зображення, дані можуть зберігатися в різних сервісах, таких як розподіленій файловій системі (HDFS), реляційних базах, NoSQL і багатьох інших. Деякі з них вбудовані у сам фреймворк, а інші потрібно підключати як Connector (External Data Source). Але якщо вам вони не підходять, то є завжди можливість написати власний External Data Source. Зверну увагу на те, що основна проблема великого обсягу даних — це те, що:

  • даних досить багато, щоб зберігати в одному місці;
  • недостатньо пропускної здатності для того, щоб прочитати їх за комфортний час.

Ось один з прикладів обробки великих масивів. Напевно, багато хто користується Netflix. Як ви могли помітити, цей сервіс персонально під користувача визначає рейтинг відео і таким чином робить сервіс більш цікавим (залежним). Так от цей аналіз даних робить Spark із 1000+ нодів, зберігаючи 100TB+ даних.

За моїми підрахунками, зважаючи на джерела продуктивності серверів, виходить як мінімум 100 TFLOPS. Якщо порівнювати із суперкомп’ютерами, то це трохи менше, аніж найбільший в Україні суперкомп’ютер у 300 TFLOPS. Досить потужно, чи не так?

Трохи історії

Основоположниками Big Data були пошукові системи, оскільки вони взяли на себе перший інформаційний удар. Google розробила технологію MapReduce. В певний момент пошуковик розсекретив основні принципи роботи системи, і на основі цього в компанії Yahoo почали створювати відкритий фреймворк Hadoop MapReduce для обробки даних. На той момент лише HDD приводи могли зберігати проміжні результати, оскільки їх було досить багато. Для сучасних задач цей підхід не ефективний, де потрібно багато разів фільтрувати, об’єднувати, розділяти дані, щоб досягти бажаного результату. А для Machine Learning(ML) взагалі потрібно багато разів пройтися по тому же сценарію, щоб знайти оптимальний результат. Отже, зберігати, а потім знову вичитувати із жорстких дисків займає дуже багато часу і є вузьким місцем у продуктивності всієї системи.

Тому в далекому 2009 році у каліфорнійському університеті Берклі в лабораторії «роботи з біг датою» Матей Захарій почав роботу над Spark.

Переваги фреймворку

Чому Spark такий популярний? Основна причина — це те, що проміжні дані зберігаються в оперативній пам’яті, але не завжди, про це розкажу потім.

Як видно на графіку, продуктивність збільшена в 100 разів, і причина не лише в «оперативці», Spark використовує принцип лінивої обробки, має власні алгоритми оптимізації, про це ми поговоримо теж далі.

Сам фреймворк написаний на Scala, оскільки це частково функціональна мова, вона дає кращу можливість паралелізації процесів, що так важливо для Big Data фреймворків. Але якщо ви не будете писати додатки до Spark (External Data Sources), то вам не доведеться залазити всередину і розуміти Scala.

Архітектура

Ось ми й підійшли до базової архітектури. Spark, як і більшість розподілених фреймворків, використовує master/worker архітектуру.

Почнемо з блоку, з якого все стартує — master-нода. Вона відповідає за підготовку початкових даних, старт роботи та отримання результату, а worker-нода обробляє певний шматок даних, який був надісланий із master-ноди або з попередньої worker-ноди. Наче все просто. Але якщо розглянути детальніше, то master також містить компоненти, такі як DAG Scheduler, Task Scheduler, Back-end Scheduler та Block Manager, які відповідають за трансформування коду в завдання для workers та роздавання на ноди. Master-нода працює разом з Cluster Manager. Він виконує роботу з розподілу ресурсів. Лише Cluster Manager знає, скільки і де містяться worker-ноди. На кожній worker-ноді запускається JVM із частиною коду. Він зображений на блок-схемі як executor, в якому є спільний кеш для оптимізації процесів для одного або більше завдань. За логіку групування завдань за нодами відповідає DAG Scheduler, про нього розкажу далі.

Є три основних кластер-менеджери, з якими працює Spark. Це Hadoop Yarn, Apache Mesos, Kubernetes. Зазначу, що Spark має власний кластер-менеджер, який називається Spark Standalone Cluster Manager — це спрощений кластер-менеджер. Він потрібен зазвичай для локального запуску, перевірки тестів.

Модулі фреймворку

Отже, ми перейшли до логічних модулів. Це спрощена схема, яка охоплює лише базовий функціонал фреймворку.

Spark має два основних модулі:

  1. Spark SQL — для високорівневої роботи з даними (структурованими).
  2. Spark Core (RDD) — для низькорівневої роботи з даними (неструктурованими/напівструктурованими).

Spark SQL наслідується від компонента RDD.

Ми можемо працювати зі Spark трьома основними способами:

  1. SQL. Spark має власний JDBC-конектор під капотом, тому ми можемо під’єднати SQL-сервер і прямим SQL-запитом робити обчислення.
  2. Console дає легко з консолі створювати задачі для Spark.
  3. Programs — робота через мови програмування. Основний спосіб роботи.

Почнемо з RDD детальний розгляд. Тому що він має основний базис, з яким працює весь Spark.

RDD

RDD (Resilient Distributed Datasets) — це розподілена колекція. Ми можемо її вважати такою самою колекцією, як Stream в Java або List в Scala. Він має ті ж методи й таку ж сигнатуру. Приклад можемо бачити в структурі класу:

abstract class RDD[T] {
 def map[U](f: T => U): RDD[U]
 def filter[U](f: T => Boolean): RDD[T]
 def union(other: RDD[T]): RDD[T]
 def sum(): Double
 def saveAsTextFile(path: String): Unit
}

Основний плюс RDD — це можливість роботи із неструктурованими даними. Наприклад, якщо ми працюємо із текстом. Але це і мінус в іншому випадку, оскільки Spark не зможе оптимізувати процес, тому що не знає, який за структурою об’єкт він обробляє. Якщо повернемося до зображення модулів у архітектурі Spark, то там побачимо Catalyst Optimizer — відповідальний за оптимізацію процесу. Тому у модулі Spark SQL використовуються лише структуровані дані.

Важливо зазначити, що RDD-колекція незмінна: при отриманні нового об’єкта RDD останній не змінюється. Абстрактно RDD можна вважати колекцією, але через те, що це розподілена колекція, яка під капотом виконуватиметься на різних нодах, вона має деякі відмінності. Відмінності у створенні RDD. Наприклад, початково її можна створити лише із SparkContext.

val conf = new SparkConf()
 .setAppName("Simple Application")
 .setMaster("local[*]")// only for local usage. Omit this line in a cluster.

//entry point for RDD
val sparkContext = new SparkContext(conf)

SparkContext — це entry point/вхідна точка для роботи зі Spark.

Далі у нас є можливість витягувати дані, далі показаний найпростіший спосіб — із файлу.

val rdd: RDD[String] = sparkContext.textFile("data.txt")

І ось приклад найпростішої логіки MapReduce:

val countSymbols = rdd
 .map(line => line.length) // transformation operation
 .sum() // action operation

Де метод map(...) — розділяє задачі, а sum() — згруповує у результат.

RDD має два основних типи методів:

  • transformation;
  • action.

Можемо порівняти Transformations і Actions із Transformers і Accessors в Scala List, або аналогія з Java 8+, та сама реалізація в Stream Api, де у нас Intermediate і Terminal операції.

Суть у них всіх в тому, що перший тип — це лінива операція, яка виконується, лише коли буде виконана кінцева операція.

Transformation — це лінива операція, яка не виконується самостійно.

Action-операція — це та, яка запускає Spark в роботу, і ми отримуємо бажаний результат.

По суті, RDD-сигнатура реалізує дизайн-патерн Builder, де transformation-операція завжди повертає той самий клас RDD.

Є список Transformation-операцій і список Action-операцій.

Розподілення обов’язків

Як зазначалося і видно за архітектурою, Spark працює одночасно на різних нодах, це може бути master- або worker-нода. І ось тут починаються нюанси, без розуміння яких сервіс не буде взагалі/коректно/ефективно працювати.

Отже, приклад:

val rdd: RDD[Int] = sparkContext.parallelize(Array(1,2,3))

var counter = 0

rdd.foreach(x => {
 //worker node
 counter += x
})

println("Counter Result: " + counter)
// Counter result: 0

На перший погляд, результат повинен бути: 0 + 1 + 2 + 3 = 6, але отримали 0.

Причина в тому, що частина коду (в анонімній функції) виконується на worker-ноді, а все інше — на master-ноді.
Правило: все, що ви передаєте всередину операції — серилізується і відправляється на worker-ноду.

Часто буває, що потрібно передати не тільки логіку з анонімної функції, а й певний стронній метод або клас.

Типи передачі об’єктів.

Використовувати статичні методи:

object MyFunctions {
 def func1(str: String): String = ???
}

rdd.map(MyFunctions.func1)

Зовнішній клас або метод:

class MyClass() {
 val field = "Hello"

 //option 1: passing a whole class into a worker
 def doStuff(rdd: RDD[String]): RDD[String] = {
   rdd.map(x => field + x)
 }

 //option 2: passing this method into a worker
 def doStuff2(rdd: RDD[String]): RDD[String] = {
   val _field = this.field
   rdd.map(x => _field + x)
 }
}

В останньому прикладі, якщо звертатися до doStuff метода, то серіалізуватиметься весь клас. При роботі із методом doStuff2 серіалізується лише метод, який може містити об’єкти, які не можуть бути серіалізованими.

Допоміжні дані

У статті попередньо розглядалося, як розділити логіку для різних типів нод, де також часто потрібно оперувати допоміжними даними всередині worker-ноди. Може бути таке, що у вас є список країн і ви хочете їх порівнювати з даними, які обробляються на worker. Звичайним способом їх туди не перекинеш, як ми бачили у попередньому прикладі.

Тому для цього були розроблені broadcast-змінні. Найкраща їхня властивість — це те, що вони не передаються кожного разу з даними. Таким чином, не витрачають наш основний ресурс — час. Перед стартом роботи Spark копіює дані на кожну worker-ноду. З цієї причини broadcast-змінні не можна редагувати.

Добре, а якщо я хочу, навпаки, витягнути допоміжні дані із worker-ноди?

І що це може бути? Зазвичай це метрики, наприклад, скільки месиджів обробилося. В цьому випадку у нас є Accumulators. Як видно з назви, вони акумулюють результат. Він може бути у вигляді числа або списку об’єктів, що доволі зручно. Якщо це список об’єктів, то нічого не акумулюється, лише групується у список результатів кожного паралельного обчислення (partition). Також можна створити свій акумулятор, але потрібно знати деякі нюанси. Дані, які акумулюються, повинні бути асоціативними і комутативними, оскільки послідовність виконання операцій на різних нодах неможлива і в цих умовах потрібно забезпечити обчислення агрегованого результату.

Затратність операцій

Працюючи з розподіленими обчисленнями, ми повинні розуміти, як процеси відбуватимуться під капотом, наприклад, використовуючи операцію map або groupByKey. Чому це так важливо? На початку статті я розказував про те, що Spark прийшов замість Hadoop MapReduce, тому що замість жорсткого диска використовує оперативну пам’ять. І це йому дало 100Х ефективності. У розподілених систем, окрім цього, є ще третя складова — передача даних через сітку (Network). Що ще помаліше, аніж збереження на диск, і тому цінніше потрібно його використовувати. Spark не може уникнути мережевої взаємодії, тому найкращий спосіб — це зменшити кількість передач даних між нодами та їхній об’єм.

Отже, за принципом роботи Spark всі вхідні дані розбиває на рівномірні групи для того, щоб завантажити роботою всі worker-ноди. Ці групи називаються partitions. І от якщо цей partition в наступній операції був розбитий на створення інших partitions, то ця операція затратна! І така залежність називається Wide:

Ще раз, кожен partition — це самостійна група даних, яка зберігається на ноді. Якщо partition розбивається для того, щоб створити нові partitions, то це означає, що кожен з нових partitions буде міститися на різних нодах і тому дані будуть перетікати через мережу. Всі інші операції мають narrow-залежність:

Як ми бачимо, вони лише модифікують partition. До речі, операція union має narrow-залежність, тому що partition не розбивається, а лише групується з іншим partition.

DAG Scheduler

Тепер, знаючи про типи залежностей, можна легко пояснити роботу DAG Scheduler.

DAG (Directed Acyclic Graph) — направлений нециклічний граф з вершинами і ребрами, де ребра — це наші wide/narrow операції, а вершини — це дані у вигляді partitions.

Коли викликається action-операція, DAG Scheduler починає свою роботу зі створення графа. Головна ціль — згрупувати операції разом, щоб досягти максимальної ефективності. Наприклад, спочатку нам потрібно профільтрувати дані, а потім перетворити їх. Ці дві операції будуть виконуватися на одній worker-ноді разом без проміжного результату. Таке групування операцій називається Stage.

Розбивання на stages дає можливість зробити весь процес відмовостійким (fault tolerance). Уявимо, що один із worker’ів перестав працювати. Spark через Cluster Manager перестворює/перекидає на нову ноду попередній partition, і процес відновлюється.

Також DAG Scheduler має власну оптимізацію, як ми можемо побачити під буквою D, деякі partitions захешовані. Чому так сталося? Scheduler розуміє, що граф має залежності map, а потім filter. Тому, щоб оптимізувати процес, він переміщає їх місцями, виконуючи спочатку фільтрування, а потім перетворення відфільтрованих даних.

Паралелізм

Паралелізм відіграє основну роль у швидкості обробки інформації, що більше partitions створиться (один partition — це результат роботи одного паралелізму), то швидше вони обробляться. Так у теорії, а на практиці трохи інакше.
Спочатку поговоримо про паралелізм, коли програма запускається локально. На початку статті перший приклад коду містив conf.setMaster(local[*]). Що це значить?

По-перше, цей шматок коду пишеться, лише коли Spark запускається на локальній машині. Для кластера потрібно видалити цей код, оскільки Cluster Manager бере всю відповідальність на себе. Отже, розшифровується це так: запустіть мені Spark в такій паралелізації, скільки логічних ядер на вашій машині. Якщо ми запишемо ось так: local[2] — у нас паралелізм буде = 2.
Щодо кластерної версії, то встановлення паралелізму за замовчуванням відбувається ось так: conf.set("spark.default.parallelism", "2").
Але ж ми хочемо працювати ефективно і з величезними даними! Якщо ми запустимо Spark із паралелізмом = 2 із даними навіть у 5 GB, то вилетимо з помилкою, що не можемо обробити такий великий обсяг даних!

Основна проблема в тому, що шматочки (partitions) занадто великі. В таких випадках логіка проста: весь обсяг даних / 128MB = наш parallelism.

В Spark паралелізм може бути досить високим, і це не проблема, якщо не вистачатиме workers — він буде обробляти дані поступово.
Але якщо у нас достатньо нодів і нам потрібен максимально швидкий результат, то є протилежне правило: к-сть workers * к-сть CPU * 3 = наш parallelism. Магічне число — 3, не питайте, оптимальне для того, щоб на максимум завантажити роботою ноди.
Коли ми розглядали DAG Scheduler, у нас була операція join, де partitions об’єднуються. Spark має персональний параметр для shuffle операцій (join, aggregate) — spark.sql.shuffle.partitions зі значенням 200 за замовчуванням! Тому, якщо в процесі роботи пайплайна є shuffle-операції, а даних досить багато, то потрібно також використати нашу першу формулу (весь обсяг даних / 128MB = наш parallelism) для цього параметру.

Хешування даних

Один із найефективніших інструментів у Spark — це техніка повторного використання обчислень. Суттєво збільшує продуктивність, але є деякі застереження, про які варто пам’ятати, якщо ми хочемо досягти хорошої продуктивності.

Простий приклад:

val rdd = sparkContext.parallelize(Array("Here","I","am"))

val rddLength = rdd.map(word => word.length)
rddLength.cache()
println(rddLength.count()) // Результат: 3
println(rddLength.sum())   // Результат: 7

println(rdd.map(word => word.length).count())

Як видно з прикладу, ми закешувати обчислення довжин слів. Обчислення у методі map(...) будуть обчислюватися лише один раз, навіть якщо потім ще дописати цей код:

println(rdd.map(word => word.length).count())

Spark достатньо розумний, щоб перевіряти запити на ідентичність.
Як тільки обчислення завершені, звільняйте пам’ять методом rdd.unpersist(). Якщо цього не буде виконано, Spark не зламається, але в разі нестачі пам’яті Cache Manager буде видаляти найстаріший хеш за принципом LRU (least recently used). Який у разі потреби знову переобчислиться.

Попередньо зазначалося, що Spark працює з оперативною пам’яттю, але не завжди.

Можна побачити різний спосіб збереження: в пам’яті, на диску, серіалізуючи, гібридний спосіб. Тут є багато налаштувань, щоб знайти компроміс між «залізом» і швидкістю обробки. Лише потрібно зазначити, що метод  rdd.cache() всередині — це  rdd.persist(StorageLevel.MEMORY_ONLY) . Отже, будьте уважні, коли використовуєте його. У вас може не вистачити пам’яті, і тоді дані будуть переобчислюватися в разі потреби. Рекомендую використовувати натомість метод  rdd.persist(StorageLevel.MEMORY_AND_DISK) .

Серіалізація

Коли обробляються великі обсяги даних, важливу роль відіграють процеси передачі даних між нодами. І в багатьох випадках вони передаються більше ніж один раз. Тому ефективність передачі множиться в кінцевому результаті ефективності. Spark написаний на Scala, яка, своєю чергою, запускається на Java VM. Вона має свої недоліки та переваги. Один із недоліків — те, що стандартна Java-серіалізація затратна, а байт-код — громіздкий. Spark має Kryo серіалізатор, який в 10 разів ефективніший за стандартний. Приклади роботи з Kryo.

Отже, основні рекомендації роботи з даними по серіалізації:

  • Використовуйте прості типи (String, Integer, масиви тощо) — вони автоматично під капотом серіалізуються Kryo.
  • Уникайте вкладених структур з великою кількістю дрібних об’єктів, а також колекцій (ArrayList, HashMap). Spark витрачає багато ресурсів на їх серіалізацію.
  • Передавайте класи, використовуючи Kryo Serializer, а не стандартну Java серіалізацію. Переважно це прискорює загальну ефективність роботи на 20%.
  • Не використовуйте Enum-об’єкти при передачі між операціями. Цей об’єкт може мати різний hashCode на різних нодах.

Обмеження

Як і в більшості фреймворків, тут є свої обмеження, на які потрібно звернути увагу. Опишу лише основні:

  • Не підтримує real-time processing. Як зазначалося, Spark працює з потоковими даними у вигляді micro-batch processing. Це, грубо кажучи, щоразу створюється нова RDD-колекція для певного проміжку часу. І тому велика затримка (latency) в роботі з потоковими даними.
  • Проблема малих файлів при роботі з Apache Hive. Результат зберігається у вигляді величезної кількості малих файлів.
  • MLib (Machine Learning) модуль має недостатньо алгоритмів. Напрниклад, немає Deep Learning. Але Spark дає легко під’єднати сторонні бібліотеки, такі як Databricks Spark Deep Learning.
  • Оптимізація потребує ручного налаштування пайплайну. Якщо ми хочемо правильно працювати з wide-залежностями та кешувати в Spark, це варто контролювати вручну.
  • Вартісний. Великі масиви даних потребують великої кількості оперативної пам’яті, що наразі може бути затратно.

Висновок

Spark де-факто став основним інструментом для пакетної обробки даних (batch processing). Він якісно збільшує ефективність роботи з аналітичними даними, має зручні інструменти побудови пайплайнів. Швидко розвивається, стає більш ефективним. Так, нова версія 3.0 в деяких випадках на 50% продуктивніша за попередню 2.4. Має додаткові модулі для різних потреб, легко інтегруються сторонні бібліотеки, має зручне API для створення власних Data Sources, що допомагає його зробити універсальним рішенням у цій галузі.

Дякую за увагу, надіюсь, моя публікація стане вам у пригоді.

Корисні матеріали:

Книги:

Онлайн-курси:

👍ПодобаєтьсяСподобалось25
До обраногоВ обраному21
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

гарна стаття.

У розподілених систем, окрім цього, є ще третя складова — передача даних через сітку (Network). Що ще помаліше, аніж збереження на диск, і тому цінніше потрібно його використовувати.

Останнє речення щось взагалі як з автоматичного перекладу, може:
«Що є ще повільніше аніж збереження на диск, і тому потрібно використовувати якнайменше.»

Ми якось намогались spark через .NET використовувати. Цікавий досвід був :)

У розподілених систем, окрім цього, є ще третя складова — передача даних через сітку (Network). Що ще помаліше, аніж збереження на диск,

Дуже спірне твердження.

Я з вами цілком згідний якщо порівнювати пропускну здатність мережі відносно диску, то в багатьох випадках вона більше.
В статті малося на увазі пропускна здатність Спарка в контексті wide залежностей, де передаються дані через мережу, але також накладується багато складових, таких як:
— розгрупування даних, які займають суттєвий об’єм;
— якщо не вистачає пам’яті, то частину даних зберігається на диск;
— також робить резервну копію даних в разі неуспішносні наступного worker’a;
— серіалізує;
— відправляє через мережу кожному worker’у;
— десеріалізує.
Як видно із послідовності роботи wide залежності, можна сказати що вона використовує Disk I/O, Network I/O та Serialization I/O, тому затратність більша аніж інші операції.
Дякую за коментар.

Зберіг в закладки. Кілька разів намагався вникнути у роботу Spark. Але тільки в цій статті все нормально структуровано

В мене wow-ефект! :)) Раніше сумнівався чи зможу зрозуміти матеріали з нової для себе галузі БігДати %/. Але ця стаття написана, по-1, українською, по-2, комбо з коду та кольорових схем допомагає зрозуміти концепти, по-3, побудова речень, стилістика, акценти, по-4, автор вдало пише де треба терміни англійською. Дякую за чудову статтю!:))
п.с. помітив декілька міні-помилок в тексті.

Як видно з прикладу? ми закешувати

 — зайвий знак питання

rdd.perist(StorageLevel.MEMORY_ONLY)

певно має бути persist — залишати, наполягати

Дякую за розгорнутий відгук.
п.с. описки виправлено.

Very useful article! Thank you, Dmitro, for sharing the tips and compiling the best resources together👍

Хороша стаття, автору — подяка ))

Бомбезна стаття, просто топ! В мене зара така каша в голові від нього, а тут все по поличках і дуже вчасно! Дякую

Все ж якось дуже орієнтовано на спарк, хоч і дуже толково! пайплайн це не тільки спарк, сподіваюсь буде продовження

Супер, дуже цікаво, дякую!

Підписатись на коментарі