Apache Spark to coraz bardziej popularny framework do tworzenia przetwarzań Big Data. Gdy wywalają się executory, zwiększamy ilość pamięci. Gdy job wykonuje się zbyt wolno, zwiększamy ilość executorów. Zwiększenie ilości zasobów to żadna optymalizacja i z czasem nasz klaster Hadoop jest w pełni utylizowany i nie można uruchamiać kolejnych przetwarzań. A przecież da się inaczej! Klaster Hadoop w Allegro to setki jobów uruchomionych jednocześnie, z czego większość to joby Sparkowe. Opowiemy historię kilku z nich i przemiany, które przeszły. W tym najbardziej spektakularną: od 2500 do 240GB RAM.
2. O nas
● Data Platform Engineers @ Allegro.
● Rozwijamy jeden z większych ekosystemów Big Data w Polsce.
● Piszemy joby Sparkowe i uzdrawiamy joby innych.
● W każdy piątek jemy pizzę bez warzyw.
3. Agenda
● Trzy słowa o Sparku
● Cztery problemy ze Sparkiem
● Historia pewnego joba
6. Spark Dataset & Dataset API
A Dataset is a strongly typed collection
of domain-specific objects
that can be transformed in parallel
using functional or relational operations.
// To create Dataset[Row] using SparkSession
val people = spark.read.parquet("...")
val department = spark.read.parquet("...")
7. Akcje i transformacje
● Wynikiem transformacji jest nowy dataset
● Transformacja jest wykonywana leniwie po wywołaniu akcji
● Akcja wyzwala wykonanie wszystkich transformacji
potrzebnych do uzyskania datasetu wynikowego z danych
wejściowych.
people.filter("age > 30")
.join(department, people("deptId") === department("id"))
.groupBy(department("name"), people("gender"))
.agg(avg(people("salary")), max(people("age")))
8. Wąskie transformacje Szerokie transformacje
map, filter
`
union
`
groupByKey
join with inputs not
co-opartitioned
join with inputs
co-opartitioned
14. Czasy poza Executor Computing Time powinny być możliwie najniższe.
Problemy z Executorami
15. Uczmy się na błędach
Spark History Server
Spark History Server
16. Job jest ubijany na klastrze,
yarn logi wskazują problemy z driverem.
Problemy z Driverem
17. Job jest ubijany na klastrze,
yarn logi wskazują problemy z driverem.
Potencjalne problemy:
● błąd w kodzie
● duży collect
Problemy z Driverem
18. Job jest ubijany na klastrze,
yarn logi wskazują problemy z driverem.
Potencjalne problemy:
● błąd w kodzie -> fix kodu (testy)
● duży collect -> podbicie pamięci drivera
Problemy z Driverem
19. Job jest ubijany na klastrze,
yarn logi wskazują problemy z driverem.
Potencjalne problemy:
● błąd w kodzie -> fix kodu (testy)
● duży collect -> podbicie pamięci drivera
spark.driver.memory 1G
spark.driver.memoryOverhead spark.driver.memory * 0.10 + 384M
spark.driver.maxResultSize 1G
spark.driver.cores 1
Problemy z Driverem
20. Model pamięci - executor
overhead
spark.executor.memory
spark.executor.memoryOverhead
yarn
container
memory
execution
storage
21. OverHead memory - pyspark
overhead
“... dzieje się więcej w Pythonie
niż na JVM”
Arek O.
22. Ładujemy plik CSV lub JSON do przetworzeń i ładowanie
danych trwa bardzo długo.
Schema inferring
23. def inferFromDataset(json: Dataset[String], parsedOptions: JSONOptions): StructType =
{
val sampled: Dataset[String] = JsonUtils.sample(json, parsedOptions)
val rdd: RDD[InternalRow] = sampled.queryExecution.toRdd
val rowParser = parsedOptions.encoding.map { enc =>
CreateJacksonParser.internalRow(enc, _: JsonFactory, _: InternalRow)
}.getOrElse(CreateJacksonParser.internalRow(_: JsonFactory, _: InternalRow))
SQLExecution.withSQLConfPropagated(json.sparkSession) {
JsonInferSchema.infer(rdd, parsedOptions, rowParser)
}
}
Schema inferring - Fragment kodu źródłowego Sparka
24. def sample(json: Dataset[String], options: JSONOptions): Dataset[String] = {
require(options.samplingRatio > 0,
s"samplingRatio (${options.samplingRatio}) should be greater than 0")
if (options.samplingRatio > 0.99) {
json
} else {
json.sample(withReplacement = false, options.samplingRatio, 1)
}
}
val samplingRatio =
parameters.get("samplingRatio").map(_.toDouble).getOrElse(1.0)
Schema inferring - Fragment kodu źródłowego Sparka:
26. bez schematu
%timeit df = spark.read.csv("data.csv", header=True,
inferSchema=True)
38 s ± 2.52 s per loop
ze schematem
%timeit df = spark.read.csv("data.csv", schema=schema,
header=True)
6.38 s ± 847 µs per loop
Schema inferring
27. Na każdym elemencie datasetu wykonujemy operację,
która wymaga połączenia do zewnętrznej usługi.
Map vs mapPartitions
28. Na każdym elemencie datasetu wykonujemy operację,
która wymaga połączenia do zewnętrznej usługi.
● Chcemy:
○ ograniczać ilość połączeń,
○ mieć możliwość wykonania operacji na wielu rekordach.
Map vs mapPartitions
29. Na każdym elemencie datasetu wykonujemy operację,
która wymaga połączenia do zewnętrznej usługi.
● Chcemy:
○ ograniczać ilość połączeń,
○ mieć możliwość wykonania operacji na wielu rekordach.
Rozwiązaniem jest mapParititions.
Map vs mapPartitions
30. import time
rdd = sc.parallelize([1, 2, 3, 4, 5, 6, 7, 8, 9], 3)
def connect_mock_1(element):
time.sleep(1)
return type(element)
def connect_mock_2(iterator):
time.sleep(1)
for element in iterator:
yield type(element)
% timeit rdd.map(connect_mock_1).collect()
% timeit rdd.mapPartitions(connect_mock_2).collect()
31. import time
rdd = sc.parallelize([1, 2, 3, 4, 5, 6, 7, 8, 9], 3)
def connect_mock_1(element):
time.sleep(1)
return type(element)
def connect_mock_2(iterator):
time.sleep(1)
for element in iterator:
yield type(element)
% timeit rdd.map(connect_mock_1).collect()
3.04 s ± 6.34 ms per loop
% timeit rdd.mapPartitions(connect_mock_2).collect()
1.04 s ± 5.73 ms per loop
32. Korzystam z dynamic resource allocation,
a mimo to mam problem z alokacją zasobów.
Dynamic resource allocation TTL vs cache
33. ● Job uruchomiony poprawnie,
● Dynamic resource allocation włączone,
● Parametry dostosowane do potrzeb,
● Wszystko wygląda idealnie.
Dynamic resource allocation TTL vs cache
34. ● Job uruchomiony poprawnie,
● Dynamic resource allocation włączone,
● Parametry dostosowane do potrzeb,
● Wszystko wygląda idealnie.
Hmmm….?
Dynamic resource allocation TTL vs cache
35. ● Job uruchomiony poprawnie,
● Dynamic resource allocation włączone,
● Parametry dostosowane do potrzeb,
● Wszystko wygląda idealnie,
A może to cache?
Dynamic resource allocation TTL vs cache
36. ● Job uruchomiony poprawnie,
● Dynamic resource allocation włączone,
● Parametry dostosowane do potrzeb,
● Wszystko wygląda idealnie.
A może to cache?
spark.dynamicAllocation.cachedExecutorIdleTimeout
default: infinity
Dynamic resource allocation TTL vs cache
37. Dynamic resource allocation TTL vs cache - jak to działa
Spark Job
Executor 112
Executor 2
enabled true
executorIdleTimeout 60s
cachedExecutorIdleTimeout infinity
initialExecutors 2
minExecutors 2
maxExecutors 10
38. Dynamic resource allocation TTL vs cache - jak to działa
Spark
Job
Executor 1
12
Executor 2
Executor 312
Executor 4
Executor 5
executorIdleTimeout 60s
39. Dynamic resource allocation TTL vs cache - jak to działa
Spark
Job
Executor 1
12
Executor 2
Executor 312
Executor 4
Executor 5
Po 60 sekundach bezczynne executory są zatrzymywane
40. Dynamic resource allocation TTL vs cache - jak to działa
Spark
Job
Executor 1
12
Executor 2
Executor 312
Executor 4
Executor 5
cachedExecutorIdleTimeout infinity
Cache
Cache
Cache
Cache
Cache
41. Dynamic resource allocation TTL vs cache - jak to działa
Spark
Job
Executor 1
12
Executor 2
Executor 312
Executor 4
Executor 5
cachedExecutorIdleTimeout 120s
Cache
Cache
Cache
Cache
Cache
43. Sprytny Join
30/06/201829/06/201828/06/201801/06/2018
Dataset A:
Frazy wyszukiwania
30/06/201829/06/201828/06/201801/06/2018
Dataset B:
Decyzje zakupowe
● Dane na HDFS są partycjonowane dziennie.
● Problem: Jak połączyć decyzję zakupową z ostatnią frazą wyszukania, która została użyta nie
dłużej niż X godzin przed decyzją?
A join B on A.client = B.client and B.timestamp - A.timestamp < X godzin
44. Sprytny join - cogroup
(A, 1) (D, 2) (D, [2], [])
(C, [1, 4]) cogroup (A, 3) = (C, [1,4], [])
(B, [2, 3]) (B, [1,3] (A, [1], [3])
(B, [2,3], [1,3])
Rozwiązanie naszego problemu:
● A.groupby('clientId') - dostajemy mapę clientId => lista wyszukiwań
● B.groupby('clientId') - dostajemy mapę clientId => lista decyzji zakupowych
● Wykonujemy cogroupa na powyższych i otrzymujemy mapę
○ clientId => [lista elementów zbioru A], [lista elementów zbioru B]
○ łączymy posortowane listy w posortowną listę
○ iterujemy po niej w poszukiwaniu par (wyszukanie, zakup) w odstępie X godzin
45. Sprytny join - łączenie danych partycjonowanych dziennie
30/06/201829/06/201828/06/201801/06/2018
Dataset A:
Frazy wyszukiwania
30/06/201829/06/201828/06/201801/06/2018
Dataset B:
Decyzje zakupowe
● Join na całym miesiącu był bardzo ciężki, wymagał podniesienia całego
datasetu, a dane nie mieściły się w pamięci.
● Jeśli dane nie mieszczą się w pamięci, to Spark zwalnia!
46. Sprytne joiny - iteracyjny join
for day in days:
one_day_dataset_A = fetch_one_day(dataset_A).cache()
47. Sprytne joiny - iteracyjny join
for day in days:
one_day_dataset_A = fetch_one_day(dataset_A).cache()
dailyJoinResult = joinOneDay(
one_day_before_dataset_A.union(one_day_dataset_A),
fetch_one_day(dataset_B)
).cache()
48. Sprytne joiny - iteracyjny join
for day in days:
one_day_dataset_A = fetch_one_day(dataset_A).cache()
dailyJoinResult = joinOneDay(
one_day_before_dataset_A.union(one_day_dataset_A),
fetch_one_day(dataset_B)
).cache()
result = result.union(dailyJoinResult)
one_day_before_dataset_A = one_day_dataset_A
49. Job przed i po
I ETAP
● Usunęliśmy niepotrzebnego repartition’a na początku przetwarzań
● Zmniejszyliśmy liczbę executorów z 240 do 100.
● Job nadal wykonywał się w czasie akceptowalnym biznesowo
przed zmianami po zmianach
Ilość executorów 240 100
50. Job przed i po
II ETAP
● Wczytanie danych JSON z zadanym schematem
● Zmiana joinowania na podejście iteracyjne
Dane z 10 dni przed zmianami po zmianach
Ilość executorów 50 20
Czas wykonania 98 mins 23 sec 24 mins 22 sec
YARN MB-seconds 2,768,187,887 229,664,298
YARN Vcore-seconds 270,235 22,418