160 49 9MB
Polish Pages [667] Year 2015
Spis treści
Przedmowa . ........................................................................................................................17 Wprowadzenie . ...................................................................................................................19 Kwestie porządkowe ......................................................................................................................................20 Co nowego znajdziesz w wydaniu czwartym? . ..........................................................................................20 Konwencje stosowane w tej książce . ...........................................................................................................21 Korzystanie z przykładowego kodu . ...........................................................................................................21 Podziękowania ...............................................................................................................................................22
Część I. Podstawy platformy Hadoop . .........................................................25 Rozdział 1. Poznaj platformę Hadoop . ..................................................................................27 Dane! ................................................................................................................................................................27 Przechowywanie i analizowanie danych . ...................................................................................................29 Przetwarzanie w zapytaniach wszystkich danych . ....................................................................................30 Poza przetwarzanie wsadowe .......................................................................................................................30 Porównanie z innymi systemami . ...............................................................................................................31 Systemy RDBMS .........................................................................................................................................32 Przetwarzanie sieciowe ...............................................................................................................................33 Przetwarzanie z udziałem ochotników . ...................................................................................................34 Krótka historia platformy Apache Hadoop . ..............................................................................................35 Zawartość książki ...........................................................................................................................................38
Rozdział 2. Model MapReduce ..............................................................................................41 Zbiór danych meteorologicznych . ...............................................................................................................41 Format danych .............................................................................................................................................41 Analizowanie danych za pomocą narzędzi uniksowych . .........................................................................42 Analizowanie danych za pomocą Hadoopa . ..............................................................................................44 Mapowanie i redukcja ................................................................................................................................44 Model MapReduce w Javie ........................................................................................................................45 Skalowanie ......................................................................................................................................................51 Przepływ danych .........................................................................................................................................51 Funkcje łączące ............................................................................................................................................55 Wykonywanie rozproszonego zadania w modelu MapReduce . ..........................................................56 5
Narzędzie Streaming Hadoop . .....................................................................................................................57 Ruby . .............................................................................................................................................................57 Python . ..........................................................................................................................................................59
Rozdział 3. System HDFS ......................................................................................................61 Projekt systemu HDFS . .................................................................................................................................61 Pojęcia związane z systemem HDFS . ..........................................................................................................63 Bloki ..............................................................................................................................................................63 Węzły nazw i węzły danych . ......................................................................................................................64 Zapisywanie bloków w pamięci podręcznej . ...........................................................................................65 Federacje w systemie HDFS . .....................................................................................................................65 Wysoka dostępność w systemie HDFS . ...................................................................................................66 Interfejs uruchamiany z wiersza poleceń . ..................................................................................................68 Podstawowe operacje w systemie plików . ...............................................................................................69 Systemy plików w Hadoopie . .......................................................................................................................70 Interfejsy . ......................................................................................................................................................71 Interfejs w Javie . .............................................................................................................................................74 Odczyt danych na podstawie adresu URL systemu Hadoop ................................................................74 Odczyt danych za pomocą interfejsu API FileSystem . ..........................................................................75 Zapis danych . ...............................................................................................................................................78 Katalogi . ........................................................................................................................................................80 Zapytania w systemie plików . ...................................................................................................................80 Usuwanie danych . .......................................................................................................................................84 Przepływ danych . ...........................................................................................................................................85 Anatomia odczytu pliku . ...........................................................................................................................85 Anatomia procesu zapisu danych do pliku . ............................................................................................87 Model zapewniania spójności . ..................................................................................................................90 Równoległe kopiowanie za pomocą programu distcp . ............................................................................91 Zachowywanie równowagi w klastrach z systemem HDFS ...................................................................92
Rozdział 4. System YARN ......................................................................................................95 Struktura działania aplikacji w systemie YARN . ......................................................................................96 Żądania zasobów . ........................................................................................................................................97 Czas życia aplikacji . ....................................................................................................................................97 Budowanie aplikacji systemu YARN . ......................................................................................................98 System YARN a implementacja MapReduce 1 . ........................................................................................99 Szeregowanie w systemie YARN . ..............................................................................................................101 Dostępne programy szeregujące . ............................................................................................................101 Konfigurowanie programu szeregującego Capacity . ...........................................................................103 Konfigurowanie programu szeregującego Fair . ...................................................................................105 Szeregowanie z opóźnieniem . .................................................................................................................109 Podejście Dominant Resource Fairness . ................................................................................................109 Dalsza lektura . ..............................................................................................................................................110
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop .................................................111 Integralność danych . ...................................................................................................................................111 Integralność danych w systemie HDFS . ................................................................................................112 Klasa LocalFileSystem . .............................................................................................................................112 Klasa ChecksumFileSystem . ....................................................................................................................113
6
Spis treści
Kompresja .....................................................................................................................................................113 Kodeki .........................................................................................................................................................114 Kompresja i podział danych wejściowych . ............................................................................................118 Wykorzystywanie kompresji w modelu MapReduce . .........................................................................120 Serializacja ..................................................................................................................................................122 Interfejs Writable ......................................................................................................................................123 Klasy z rodziny Writable ..........................................................................................................................125 Tworzenie niestandardowych implementacji interfejsu Writable . ...................................................132 Platformy do obsługi serializacji .............................................................................................................137 Plikowe struktury danych ...........................................................................................................................138 Klasa SequenceFile ....................................................................................................................................138 Klasa MapFile ............................................................................................................................................145 Inne formaty plików i formaty kolumnowe . .........................................................................................146
Część II. Model MapReduce . ....................................................................... 149 Rozdział 6. Budowanie aplikacji w modelu MapReduce . ......................................................151 API do obsługi konfiguracji .......................................................................................................................151 Łączenie zasobów ......................................................................................................................................152 Podstawianie wartości zmiennych ..........................................................................................................153 Przygotowywanie środowiska programowania . ...................................................................................154 Zarządzanie konfiguracją .........................................................................................................................155 GenericOptionsParser, Tool i ToolRunner . ..........................................................................................158 Pisanie testów jednostkowych za pomocą biblioteki MRUnit . .............................................................161 Mapper ........................................................................................................................................................161 Reduktor .....................................................................................................................................................164 Uruchamianie kodu lokalnie na danych testowych . ...............................................................................164 Uruchamianie zadania w lokalnym mechanizmie wykonywania zadań . .........................................165 Testowanie sterownika .............................................................................................................................166 Uruchamianie programów w klastrze . .....................................................................................................167 Tworzenie pakietu z zadaniem ................................................................................................................168 Uruchamianie zadania .............................................................................................................................169 Sieciowy interfejs użytkownika modelu MapReduce . .........................................................................171 Pobieranie wyników .................................................................................................................................174 Debugowanie zadania ...............................................................................................................................175 Dzienniki w Hadoopie ..............................................................................................................................178 Zdalne diagnozowanie ..............................................................................................................................180 Dostrajanie zadania .....................................................................................................................................181 Profilowanie operacji ................................................................................................................................181 Przepływ pracy w modelu MapReduce . ...................................................................................................182 Rozbijanie problemu na zadania w modelu MapReduce . ...................................................................183 JobControl ..................................................................................................................................................184 Apache Oozie .............................................................................................................................................185
Rozdział 7. Jak działa model MapReduce? . ..........................................................................191 Wykonywanie zadań w modelu MapReduce . ..........................................................................................191 Przesyłanie zadania ...................................................................................................................................192 Inicjowanie zadania ..................................................................................................................................193 Przypisywanie operacji do węzłów .........................................................................................................194
Spis treści
7
Wykonywanie operacji . ............................................................................................................................194 Aktualizowanie informacji o postępie i statusu . ..................................................................................196 Ukończenie zadania . .................................................................................................................................197 Niepowodzenia . ...........................................................................................................................................198 Niepowodzenie operacji . ..........................................................................................................................198 Niepowodzenie zarządcy aplikacji . ........................................................................................................199 Niepowodzenie menedżera węzła . .........................................................................................................200 Niepowodzenie menedżera zasobów . ....................................................................................................201 Przestawianie i sortowanie . ........................................................................................................................202 Etap mapowania . .......................................................................................................................................202 Etap redukcji . .............................................................................................................................................203 Dostrajanie konfiguracji . .........................................................................................................................206 Wykonywanie operacji . ..............................................................................................................................208 Środowisko wykonywania operacji . .......................................................................................................208 Wykonywanie spekulacyjne . ...................................................................................................................209 Klasy z rodziny OutputCommitter . .......................................................................................................210
Rozdział 8. Typy i formaty z modelu MapReduce .................................................................213 Typy w modelu MapReduce . .....................................................................................................................213 Domyślne zadanie z modelu MapReduce . ............................................................................................216 Formaty wejściowe . .....................................................................................................................................222 Wejściowe porcje danych i rekordy . ......................................................................................................222 Tekstowe dane wejściowe . .......................................................................................................................232 Binarne dane wejściowe . ..........................................................................................................................236 Różne dane wejściowe . .............................................................................................................................237 Dane wejściowe (i wyjściowe) z bazy . ....................................................................................................238 Formaty danych wyjściowych . ...................................................................................................................238 Tekstowe dane wyjściowe . .......................................................................................................................239 Binarne dane wyjściowe . ..........................................................................................................................239 Wiele danych wyjściowych . .....................................................................................................................240 Leniwe generowanie danych wyjściowych . ...........................................................................................243 Dane wyjściowe dla bazy . ........................................................................................................................244
Rozdział 9. Funkcje modelu MapReduce .............................................................................245 Liczniki ..........................................................................................................................................................245 Liczniki wbudowane . ................................................................................................................................245 Zdefiniowane przez użytkowników liczniki Javy . ................................................................................248 Zdefiniowane przez użytkownika liczniki narzędzia Streaming . ......................................................252 Sortowanie ....................................................................................................................................................253 Przygotowania . ..........................................................................................................................................253 Częściowe sortowanie . .............................................................................................................................254 Sortowanie wszystkich danych . ..............................................................................................................255 Sortowanie pomocnicze . ..........................................................................................................................259 Złączanie .......................................................................................................................................................264 Złączanie po stronie mapowania . ...........................................................................................................265 Złączanie po stronie redukcji . .................................................................................................................265 Rozdzielanie danych pomocniczych . ........................................................................................................268 Wykorzystanie konfiguracji zadania . .....................................................................................................268 Rozproszona pamięć podręczna . ............................................................................................................269 Klasy biblioteczne modelu MapReduce . ..................................................................................................273 8
Spis treści
Część III. Praca z platformą Hadoop ........................................................... 275 Rozdział 10. Budowanie klastra opartego na platformie Hadoop . ........................................277 Specyfikacja klastra ......................................................................................................................................278 Określanie wielkości klastra ....................................................................................................................279 Topologia sieci ...........................................................................................................................................280 Przygotowywanie i instalowanie klastra . ..................................................................................................282 Instalowanie Javy .......................................................................................................................................282 Tworzenie kont użytkowników w Uniksie . ...........................................................................................282 Instalowanie Hadoopa ..............................................................................................................................282 Konfigurowanie ustawień protokołu SSH . ............................................................................................282 Konfigurowanie Hadoopa .......................................................................................................................283 Formatowanie systemu plików HDFS . ..................................................................................................283 Uruchamianie i zatrzymywanie demonów . ..........................................................................................284 Tworzenie katalogów użytkowników . ....................................................................................................285 Konfiguracja Hadoopa ................................................................................................................................285 Zarządzanie konfiguracją .........................................................................................................................286 Ustawienia środowiskowe ........................................................................................................................287 Ważne właściwości demonów Hadoopa . ..............................................................................................289 Adresy i porty demonów Hadoopa ........................................................................................................296 Inne właściwości Hadoopa ......................................................................................................................298 Bezpieczeństwo ............................................................................................................................................299 Kerberos i Hadoop ....................................................................................................................................300 Tokeny do delegowania uprawnień .......................................................................................................302 Inne usprawnienia w zabezpieczeniach . ................................................................................................303 Testy porównawcze klastra opartego na Hadoopie . ...............................................................................305 Testy porównawcze w Hadoopie ............................................................................................................305 Zadania użytkowników ............................................................................................................................307
Rozdział 11. Zarządzanie platformą Hadoop . ......................................................................309 System HDFS ................................................................................................................................................309 Trwałe struktury danych ..........................................................................................................................309 Tryb bezpieczny ........................................................................................................................................314 Rejestrowanie dziennika inspekcji ..........................................................................................................315 Narzędzia ....................................................................................................................................................316 Monitorowanie .............................................................................................................................................320 Rejestrowanie informacji w dziennikach . ..............................................................................................320 Wskaźniki i technologia JMX ..................................................................................................................321 Konserwacja ..................................................................................................................................................322 Standardowe procedury administracyjne . .............................................................................................322 Dodawanie i usuwanie węzłów ...............................................................................................................324 Aktualizacje ................................................................................................................................................327
Spis treści
9
Część IV. Powiązane projekty . ....................................................................331 Rozdział 12. Avro . ..............................................................................................................333 Typy danych i schematy systemu Avro . ...................................................................................................334 Serializacja i deserializacja w pamięci . ......................................................................................................337 Specyficzny interfejs API . ........................................................................................................................338 Pliki danych systemu Avro . ........................................................................................................................340 Współdziałanie języków . ............................................................................................................................341 Interfejs API dla Pythona . .......................................................................................................................341 Narzędzia systemu Avro . .........................................................................................................................342 Określanie schematu . ..................................................................................................................................343 Porządek sortowania . ..................................................................................................................................344 Avro i model MapReduce . ..........................................................................................................................346 Sortowanie za pomocą modelu MapReduce i systemu Avro . ...............................................................349 Używanie systemu Avro w innych językach . ...........................................................................................351
Rozdział 13. Parquet . .........................................................................................................353 Model danych ...............................................................................................................................................354 Kodowanie struktury zagnieżdżonych danych . ...................................................................................355 Format plików Parquet . ..............................................................................................................................356 Konfiguracja dla formatu Parquet . ...........................................................................................................358 Zapis i odczyt plików w formacie Parquet . ..............................................................................................358 Avro, Protocol Buffers i Thrift . ...............................................................................................................360 Format Parquet i model MapReduce . .......................................................................................................362
Rozdział 14. Flume .............................................................................................................365 Instalowanie platformy Flume . ..................................................................................................................365 Przykład ........................................................................................................................................................366 Transakcje i niezawodność . ........................................................................................................................368 Porcje zdarzeń . ..........................................................................................................................................369 Ujścia w systemie HDFS . ............................................................................................................................369 Podział na partycje i interceptory . ..........................................................................................................370 Formaty plików . ........................................................................................................................................371 Rozsyłanie danych do wielu kanałów . ......................................................................................................372 Gwarancje dostarczenia . ..........................................................................................................................373 Selektory replikacji i rozsyłania . .............................................................................................................374 Dystrybucja — warstwy agentów . .............................................................................................................374 Gwarancje dostarczenia danych . ............................................................................................................376 Grupy ujść .....................................................................................................................................................377 Integrowanie platformy Flume z aplikacjami . .........................................................................................380 Katalog komponentów . ...............................................................................................................................381 Dalsza lektura . ..............................................................................................................................................382
10
Spis treści
Rozdział 15. Sqoop . ...........................................................................................................383 Pobieranie Sqoopa .......................................................................................................................................383 Konektory Sqoopa .......................................................................................................................................385 Przykładowa operacja importu ..................................................................................................................385 Formaty plików tekstowych i binarnych . ..............................................................................................388 Wygenerowany kod .....................................................................................................................................388 Inne systemy serializacji ...........................................................................................................................389 Importowanie — dokładne omówienie . ...................................................................................................389 Kontrolowanie procesu importu ...............................................................................................................391 Import i spójność ......................................................................................................................................392 Przyrostowy import ..................................................................................................................................392 Importowanie w trybie bezpośrednim . ..................................................................................................392 Praca z zaimportowanymi danymi ............................................................................................................393 Importowane dane i platforma Hive . .....................................................................................................394 Importowanie dużych obiektów ................................................................................................................396 Eksportowanie ..............................................................................................................................................398 Eksportowanie — dokładne omówienie ...................................................................................................399 Eksport i transakcje ...................................................................................................................................401 Eksport i pliki typu SequenceFile ...........................................................................................................401 Dalsza lektura ...............................................................................................................................................402
Rozdział 16. Pig . ................................................................................................................403 Instalowanie i uruchamianie platformy Pig . ............................................................................................404 Tryby wykonywania ..................................................................................................................................404 Uruchamianie programów platformy Pig . ............................................................................................406 Grunt ...........................................................................................................................................................406 Edytory kodu w języku Pig Latin ............................................................................................................407 Przykład .........................................................................................................................................................407 Generowanie przykładowych danych . ...................................................................................................409 Porównanie platformy Pig z bazami danych . ..........................................................................................410 Język Pig Latin ..............................................................................................................................................411 Struktura .....................................................................................................................................................411 Instrukcje ....................................................................................................................................................412 Wyrażenia ..................................................................................................................................................417 Typy ............................................................................................................................................................418 Schematy ....................................................................................................................................................419 Funkcje .......................................................................................................................................................423 Makra ..........................................................................................................................................................425 Funkcje zdefiniowane przez użytkownika . ..............................................................................................426 Funkcje UDF służące do filtrowania . .....................................................................................................426 Obliczeniowa funkcja UDF .....................................................................................................................429 Funkcje UDF służące do wczytywania danych . ....................................................................................430 Operatory używane do przetwarzania danych . .......................................................................................433 Wczytywanie i zapisywanie danych .......................................................................................................433 Filtrowanie danych ...................................................................................................................................434 Grupowanie i złączanie danych ..............................................................................................................436 Sortowanie danych ....................................................................................................................................441 Łączenie i dzielenie danych .....................................................................................................................442
Spis treści
11
Platforma Pig w praktyce . ...........................................................................................................................442 Współbieżność . .........................................................................................................................................442 Relacje anonimowe . ..................................................................................................................................443 Podstawianie wartości pod parametry . ..................................................................................................443 Dalsza lektura . ..............................................................................................................................................444
Rozdział 17. Hive ...............................................................................................................445 Instalowanie platformy Hive . ....................................................................................................................446 Powłoka platformy Hive . .........................................................................................................................446 Przykład ........................................................................................................................................................448 Uruchamianie platformy Hive . ..................................................................................................................449 Konfigurowanie platformy Hive . ............................................................................................................449 Usługi platformy Hive . .............................................................................................................................451 Magazyn metadanych . .............................................................................................................................453 Porównanie z tradycyjnymi bazami danych . ...........................................................................................456 Uwzględnianie schematu przy odczycie lub przy zapisie . ..................................................................456 Aktualizacje, transakcje i indeksy . ..........................................................................................................456 Inne silniki obsługujące język SQL w Hadoopie . .................................................................................457 HiveQL ..........................................................................................................................................................458 Typy danych . .............................................................................................................................................458 Operatory i funkcje . ..................................................................................................................................462 Tabele ............................................................................................................................................................463 Tabele zarządzane i tabele zewnętrzne . .................................................................................................463 Partycje i kubełki . ......................................................................................................................................464 Formaty przechowywania danych . .........................................................................................................468 Importowanie danych . .............................................................................................................................472 Modyfikowanie tabel . ...............................................................................................................................473 Usuwanie tabel . .........................................................................................................................................474 Pobieranie danych . ......................................................................................................................................474 Sortowanie i agregacja danych . ...............................................................................................................475 Skrypty modelu MapReduce . ..................................................................................................................475 Złączenia ....................................................................................................................................................476 Podzapytania . ............................................................................................................................................479 Widoki ........................................................................................................................................................480 Funkcje zdefiniowane przez użytkowników . ...........................................................................................481 Pisanie funkcji UDF . ................................................................................................................................482 Pisanie funkcji UDAF . .............................................................................................................................484 Dalsza lektura . ..............................................................................................................................................488
Rozdział 18. Crunch . ...........................................................................................................489 Przykład ........................................................................................................................................................490 Podstawowe interfejsy API Cruncha . .......................................................................................................493 Proste operacje ..........................................................................................................................................493 Typy ............................................................................................................................................................497 Źródłowe i docelowe zbiory danych . .....................................................................................................500 Funkcje .......................................................................................................................................................502 Materializacja ............................................................................................................................................504
12
Spis treści
Wykonywanie potoku .................................................................................................................................506 Uruchamianie potoku ..............................................................................................................................506 Zatrzymywanie potoku ............................................................................................................................507 Inspekcja planu wykonania w Crunchu . ...............................................................................................508 Algorytmy iteracyjne ................................................................................................................................511 Tworzenie punktów kontrolnych w potokach . .....................................................................................512 Biblioteki w Crunchu ..................................................................................................................................513 Dalsza lektura ...............................................................................................................................................515
Rozdział 19. Spark . ............................................................................................................517 Instalowanie Sparka .....................................................................................................................................518 Przykład .........................................................................................................................................................518 Aplikacje, zadania, etapy i operacje w Sparku . .....................................................................................520 Niezależna aplikacja w języku Scala .......................................................................................................520 Przykład napisany w Javie ........................................................................................................................521 Przykład napisany w Pythonie ................................................................................................................522 Zbiory RDD ..................................................................................................................................................523 Tworzenie zbiorów RDD .........................................................................................................................523 Transformacje i akcje ................................................................................................................................524 Utrwalanie danych ....................................................................................................................................527 Serializacja ..................................................................................................................................................529 Zmienne współużytkowane ........................................................................................................................530 Zmienne rozsyłane ....................................................................................................................................531 Akumulatory ..............................................................................................................................................531 Anatomia przebiegu zadania w Sparku . ...................................................................................................532 Przesyłanie zadań ......................................................................................................................................532 Tworzenie skierowanego grafu acyklicznego . .......................................................................................533 Szeregowanie operacji ..............................................................................................................................535 Wykonywanie operacji .............................................................................................................................536 Wykonawcy i menedżery klastra . ..............................................................................................................536 Spark i YARN ............................................................................................................................................537 Dalsza lektura ...............................................................................................................................................540
Rozdział 20. HBase . ............................................................................................................541 Podstawy .......................................................................................................................................................541 Tło historyczne ..........................................................................................................................................542 Omówienie zagadnień .................................................................................................................................542 Krótki przegląd modelu danych .............................................................................................................542 Implementacja ...........................................................................................................................................544 Instalacja ........................................................................................................................................................546 Przebieg testowy ........................................................................................................................................547 Klienty ...........................................................................................................................................................549 Java ..............................................................................................................................................................549 Model MapReduce ....................................................................................................................................552 Interfejsy REST i Thrift ............................................................................................................................553 Budowanie interaktywnej aplikacji do przesyłania zapytań . .................................................................553 Projekt schematu .......................................................................................................................................554 Wczytywanie danych ................................................................................................................................555 Zapytania interaktywne ............................................................................................................................558 Spis treści
13
Baza HBase a bazy RDBMS . .......................................................................................................................561 Historia cieszącej się powodzeniem usługi . ..........................................................................................562 Baza HBase . ................................................................................................................................................563 Bazy HBase w praktyce . ..............................................................................................................................563 System HDFS . ............................................................................................................................................564 Interfejs użytkownika . ..............................................................................................................................564 Wskaźniki ...................................................................................................................................................565 Liczniki .......................................................................................................................................................565 Dalsza lektura . ..............................................................................................................................................565
Rozdział 21. ZooKeeper ......................................................................................................567 Instalowanie i uruchamianie systemu ZooKeeper . ................................................................................568 Przykład ........................................................................................................................................................570 Przynależność do grupy w systemie ZooKeeper . .................................................................................570 Tworzenie grupy . ......................................................................................................................................571 Dołączanie członków do grupy . ..............................................................................................................573 Wyświetlanie członków grupy . ...............................................................................................................574 Usuwanie grupy . .......................................................................................................................................575 Usługa ZooKeeper . ......................................................................................................................................576 Model danych . ...........................................................................................................................................576 Operacje .....................................................................................................................................................578 Implementacja . ..........................................................................................................................................582 Spójność .....................................................................................................................................................583 Sesje .............................................................................................................................................................585 Stany ............................................................................................................................................................587 Budowanie aplikacji z wykorzystaniem ZooKeepera . ............................................................................588 Usługa do zarządzania konfiguracją . .....................................................................................................588 Odporna na błędy aplikacja ZooKeepera . .............................................................................................591 Usługa do zarządzania blokadami . .........................................................................................................594 Inne rozproszone struktury danych i protokoły . .................................................................................596 ZooKeeper w środowisku produkcyjnym . ...............................................................................................597 Odporność a wydajność . ..........................................................................................................................598 Konfiguracja . .............................................................................................................................................599 Dalsza lektura . ..............................................................................................................................................600
Część V. Studia przypadków . ......................................................................601 Rozdział 22. Integrowanie danych w firmie Cerner . .............................................................603 Od integracji procesorów do integracji semantycznej . ..........................................................................603 Poznaj platformę Crunch . ..........................................................................................................................604 Budowanie kompletnego obrazu . ..............................................................................................................604 Integrowanie danych z obszaru opieki zdrowotnej . ...............................................................................607 Możliwość łączenia danych w różnych platformach . .............................................................................610 Co dalej? . .......................................................................................................................................................611
14
Spis treści
Rozdział 23. Nauka o danych biologicznych — ratowanie życia za pomocą oprogramowania . ...............................................................613 Struktura DNA .............................................................................................................................................615 Kod genetyczny — przekształcanie liter DNA w białka . .......................................................................616 Traktowanie kodu DNA jak kodu źródłowego . ......................................................................................617 Projekt poznania ludzkiego genomu i genomy referencyjne . ................................................................619 Sekwencjonowanie i wyrównywanie DNA . .............................................................................................620 ADAM — skalowalna platforma do analizy genomu . ...........................................................................621 Programowanie piśmienne za pomocą języka IDL systemu Avro . ...................................................621 Dostęp do danych kolumnowych w formacie Parquet . ......................................................................623 Prosty przykład — zliczanie k-merów za pomocą Sparka i ADAM-a . .............................................624 Od spersonalizowanych reklam do spersonalizowanej medycyny . ......................................................626 Dołącz do projektu ......................................................................................................................................627
Rozdział 24. Cascading . ......................................................................................................629 Pola, krotki i potoki .....................................................................................................................................630 Operacje ......................................................................................................................................................632 Typy Tap, Scheme i Flow ............................................................................................................................634 Cascading w praktyce ..................................................................................................................................635 Elastyczność ...............................................................................................................................................637 Hadoop i Cascading w serwisie ShareThis . ..............................................................................................638 Podsumowanie .............................................................................................................................................642
Dodatki . .......................................................................................................643 Dodatek A. Instalowanie platformy Apache Hadoop . ..........................................................645 Wymagania wstępne ...................................................................................................................................645 Instalacja ........................................................................................................................................................645 Konfiguracja .................................................................................................................................................646 Tryb niezależny .........................................................................................................................................647 Tryb pseudorozproszony .........................................................................................................................647 Tryb rozproszony ......................................................................................................................................649
Dodatek B. Dystrybucja firmy Cloudera . ..............................................................................651 Dodatek C. Przygotowywanie danych meteorologicznych od instytucji NCDC . ......................653 Dodatek D. Dawny i nowy interfejs API Javy dla modelu MapReduce . ..................................657 Skorowidz . .......................................................................................................................661
Spis treści
15
16
Spis treści
Przedmowa
Hadoop powstał na podstawie narzędzia Nutch. W kilka osób próbowaliśmy zbudować otwartą wyszukiwarkę internetową i mieliśmy problemy z zarządzaniem obliczeniami przeprowadzanymi nawet w stosunkowo niedużej grupie komputerów. Gdy firma Google opublikowała prace na temat systemu GFS i modelu MapReduce, droga do rozwiązania stała się prosta. W Google’u opracowano systemy rozwiązujące problemy, na które natrafiliśmy w trakcie prac nad Nutchem. We dwóch zaczęliśmy w wolnym czasie odtwarzać systemy Google’a w Nutchu. Udało nam się uruchomić Nutcha na dwudziestu komputerach, jednak szybko stało się jasne, że aby poradzić sobie z ogromem internetu, będziemy potrzebować tysięcy maszyn. Ponadto zadanie stało się zbyt rozbudowane, aby dwóch programistów pracujących na pół etatu mogło sobie z nim poradzić. Mniej więcej w tym czasie projektem zainteresowała się firma Yahoo!, która szybko zbudowała zespół. Dołączyłem do niego. Wyodrębniliśmy z Nutcha aspekty związane z przetwarzaniem rozproszonym i nazwaliśmy projekt „Hadoop”. Dzięki pomocy firmy Yahoo! Hadoop szybko stał się technologią dostosowaną do potrzeb internetu. W 2006 roku Tom White zaczął wnosić swój wkład w rozwój Hadoopa. Znałem już Toma dzięki jego świetnemu artykułowi na temat Nutcha. Dlatego wiedziałem, że potrafi w przejrzysty sposób opisywać skomplikowane zagadnienia. Szybko się przekonałem, że poza tym umie pisać oprogramowanie, które czyta się równie przyjemnie jak prozę. Tom od początku pracy nad Hadoopem okazywał dbałość o użytkowników i projekt. W odróżnieniu od większości uczestników projektów o otwartym dostępie do kodu źródłowego Tom był zainteresowany nie tyle dostosowywaniem systemu do swoich potrzeb, co ułatwianiem korzystania z niego użytkownikom. Początkowo Tom zajmował się głównie zapewnianiem dobrego współdziałania Hadoopa z usługami EC2 i S3 Amazonu. Następnie zajął się bardziej różnorodnymi problemami — w tym usprawnianiem interfejsów API modelu MapReduce, rozwijaniem witryny i projektowaniem platformy do serializacji obiektów. We wszystkich tych obszarach Tom precyzyjnie prezentował swoje pomysły. Wkrótce zasłużył na miejsce w komitecie odpowiedzialnym za rozwój Hadoopa i stał się członkiem grupy Hadoop Project Management Committee.
17
Obecnie Tom jest poważanym członkiem społeczności programistów skupionej wokół Hadoopa. Choć jest ekspertem w wielu technicznych obszarach projektu, jego głównym zadaniem jest ułatwianie innym posługiwania się Hadoopem i zrozumienia tego narzędzia. Dlatego bardzo ucieszyłem się na wieść, że Tom zamierza napisać książkę na temat Hadoopa. Kto lepiej się do tego nadaje? Teraz masz możliwość poznawać Hadoopa pod kierunkiem mistrza — nie tylko technologii, ale też zdrowego rozsądku i zrozumiałego języka. — Doug Cutting, kwiecień 2009 szopa na podwórku, Kalifornia
18
Przedmowa
Wprowadzenie
Martin Gardner, matematyk i autor książek naukowych, w jednym z wywiadów powiedział: „Poza analizą matematyczną czuję się zagubiony. Z tego wynikał sukces mojej kolumny. Tyle czasu zajmowało mi zrozumienie tego, o czym pisałem, że wiedziałem, jak ująć to w słowa zrozumiałe dla większości czytelników”1.
Pod wieloma względami myślę podobnie o Hadoopie. Wewnętrzne mechanizmy Hadoopa są skomplikowane. Są oparte na teorii systemów rozproszonych, praktycznej inżynierii i zdrowym rozsądku. Dla niewtajemniczonych Hadoop może być bardzo niezrozumiały. Jednak nie musi tak być. Dostępne w Hadoopie narzędzia do pracy z wielkimi danymi (ang. big data) są w swej istocie proste. Powtarzającym się motywem jest tu podnoszenie poziomu abstrakcji. Chcemy tworzyć cegiełki dla programistów, którzy mają dużo danych do przechowywania i analizowania, ale nie posiadają czasu, umiejętności lub chęci, by stać się ekspertami od systemów rozproszonych i budować infrastrukturę potrzebną do obsługi takich danych. Hadoop udostępnia prosty i wszechstronny zestaw funkcji. Dlatego gdy tylko zacząłem posługiwać się tą platformą, stało się dla mnie oczywiste, że zasługuje ona na popularyzację. Jednak w tamtych czasach (na początku 2006 roku) opracowywanie, konfigurowanie i pisanie programów wykorzystujących Hadoopa było prawdziwą sztuką. Później sytuacja znacznie się poprawiła. Dostępnych jest więcej przykładów i bogatsza dokumentacja. Funkcjonują też aktywne listy mailingowe, na których można zadawać pytania. Mimo to dla początkujących największą przeszkodą jest zrozumienie możliwości, jakie daje omawiana technologia, w jakich obszarach jest ona wyjątkowo przydatna i jak jej używać. Dlatego napisałem tę książkę. Społeczność skupiona wokół platformy Apache Hadoop przebyła długą drogę. Od czasu pojawienia się pierwszego wydania tej książki projekt Hadoop rozkwitł. Pojęcie „big data” trafiło do powszechnego użytku2. Przez ten czas Hadoop stał się znacznie popularniejszy, wydajniejszy, bardziej niezawodny, skalowalny i łatwiejszy w zarządzaniu. Liczba rozwiązań zbudowanych i uruchamianych z wykorzystaniem Hadoopa znacznie wzrosła. Trudno jest jednej osobie śledzić wszystkie te 1
Alex Bellos, The Science of fun, „The Guardian”, 31 maja 2008 roku (http://www.theguardian.com/science/2008/ may/31/maths.science).
2
To pojęcie w 2013 roku zostało dodane do słownika Oxford English Dictionary (http://blog.oxforddictionaries.com/ 2013/06/oed-june-2013-update/).
19
rozwiązania. Aby jeszcze bardziej zwiększyć popularność tej platformy, musimy dodatkowo ułatwić korzystanie z niej. Wymaga to napisania nowych narzędzi, zintegrowania jej z kolejnymi systemami oraz opracowania nowych, usprawnionych interfejsów API. Chcę w tym uczestniczyć i mam nadzieję, że ta książka zachęci Ciebie i innych do tego samego.
Kwestie porządkowe W trakcie omawiania konkretnych klas Javy często pomijam nazwę pakietu, aby uprościć tekst. Jeśli chcesz dowiedzieć się, z którego pakietu pochodzi dana klasa, możesz łatwo to sprawdzić w dokumentacji interfejsu API Javy dla Hadoopa (odnośnik znajdziesz na stronie głównej platformy Apache Hadoop — http://hadoop.apache.org/) lub w określonym projekcie. Jeżeli korzystasz ze środowiska IDE, mechanizm automatycznego uzupełniania pomoże Ci znaleźć szukane elementy. Ponadto, choć jest to niezgodne z zaleceniami dotyczącymi stylu, na listingach, gdzie importowanych jest wiele klas z jednego pakietu, czasem używam symbolu wieloznacznego w postaci gwiazdki, aby skrócić kod (na przykład import org.apache.hadoop.io.*). Przykładowe programy z tej książki można pobrać z poświęconej książce witryny (http://hadoopbook. com/). Znajdziesz tam także instrukcje pomocne przy pobieraniu zbiorów danych używanych w przykładach w książce, jak również uwagi dotyczące uruchamiania programów z książki oraz odnośniki do aktualizacji, dodatkowych materiałów i mojego bloga.
Co nowego znajdziesz w wydaniu czwartym? Wydanie czwarte dotyczy wyłącznie wersji Hadoop 2. Edycje z rodziny Hadoop 2 to obecnie aktualne i najbardziej stabilne wersje tej platformy. Znajdziesz tu nowe rozdziały dotyczące projektów YARN (rozdział 4.), Parquet (rozdział 13.), Flume (rozdział 14.), Crunch (rozdział 18.) i Spark (rozdział 19.). Dodałem też nowy podrozdział pomagający czytelnikom poruszać się po książce („Zawartość książki”). W tym wydaniu pojawiły się dwa nowe studia przypadku (w rozdziałach 22. i 23.). Jedno dotyczy używania Hadoopa w systemach w branży opieki zdrowotnej, drugie — wykorzystywania technologii Hadoopa do przetwarzania danych związanych z genomem. Studia przypadków z wcześniejszych wydań książki są dostępne w internecie (https://github.com/oreillymedia/hadoop_the_ definitive_guide_4e). W istniejących rozdziałach wprowadziłem wiele poprawek, aktualizacji i usprawnień, aby dostosować tekst do najnowszych wersji Hadoopa i powiązanych z nim projektów.
20
Wprowadzenie
Konwencje stosowane w tej książce W książce używane są następujące konwencje typograficzne: Kursywa Reprezentuje nowe pojęcia, adresy URL, adresy e-mail, nazwy plików i rozszerzenia plików. Czcionka o stałej szerokości
Jest używana w listingach, a także w akapitach do wyróżniania instrukcji i opcji wiersza poleceń oraz elementów programu — na przykład zmiennych, nazw funkcji, baz danych, typów danych, zmiennych środowiskowych, instrukcji i słów kluczowych. Pogrubiona czcionka o stałej szerokości
Oznacza polecenia i inny tekst, który użytkownik powinien przepisać. Czcionka o stałej szerokości z kursywą
Reprezentuje tekst, który należy zastąpić wartościami podanymi przez użytkownika lub zależnymi od kontekstu. Ta ikona oznacza ogólną uwagę.
Ta ikona reprezentuje wskazówkę lub sugestię.
Ta ikona oznacza ostrzeżenie lub przestrogę.
Korzystanie z przykładowego kodu Materiały pomocnicze (kod, przykłady, ćwiczenia itd.) możesz pobrać z poświęconej książce witryny (http://hadoopbook.com/; część spolszczonych materiałów znajdziesz w witrynie wydawnictwa Helion — http://helion.pl) i z serwisu GitHub (https://github.com/tomwhite/hadoop-book/). Ta książka ma pomóc Ci w wykonywaniu zadań. Zwykle możesz korzystać z zaprezentowanego tu kodu w programach i dokumentacji. Nie musisz prosić wydawnictwa o pozwolenie, chyba że chcesz zreprodukować dużą część kodu. Na przykład napisanie programu, który wykorzystuje kilka fragmentów kodu z książki, nie wymaga pozwolenia. Sprzedaż lub dystrybucja płyt CD-ROM z przykładami z książek wydawnictwa O’Reilly wymaga zezwolenia. Do udzielenia odpowiedzi na pytanie przy użyciu cytatów z tej książki i przykładowego kodu pozwolenie nie jest konieczne. Używanie dużych fragmentów przykładowego kodu w dokumentacji produktu wymaga zezwolenia.
Korzystanie z przykładowego kodu
21
Doceniamy wskazywanie źródła kodu, jednak nie jest to wymagane. Przy podawaniu źródła zwykle określa się tytuł, autora, wydawnictwo i numer ISBN książki. Oto przykład: Hadoop: The Definitive Guide, Fourth Edition, Tom White (O’Reilly). Prawa autorskie 2015 Tom White, 978-1-491-90163-2. Jeśli uważasz, że korzystasz z przykładowego kodu w sposób wykraczający poza dozwolony użytek lub przedstawione tu pozwolenia, możesz się z nami skontaktować pod adresem permissions@ oreilly.com.
Podziękowania W trakcie pisania tej książki polegałem na wielu ludziach (bezpośrednio i pośrednio). Dziękuję całej społeczności skupionej wokół Hadoopa, od której dużo się nauczyłem (proces ten wciąż trwa). Podziękowania należą się zwłaszcza Michaelowi Stackowi i Jonathanowi Grayowi za napisanie rozdziału na temat HBase. Adrian Woodhead, Marc de Palol, Joydeep Sen Sarma, Ashish Thusoo, Andrzej Białecki, Stu Hood, Chris K. Wensel i Owen O’Malley zasłużyli na podziękowania za studia przypadków. Dziękuję też recenzentom, którzy przedstawili wiele pomocnych sugestii i poprawek do wersji roboczej tekstu. Oto te osoby: Raghu Angadi, Matt Biddulph, Christophe Bisciglia, Ryan Cox, Devaraj Das, Alex Dorman, Chris Douglas, Alan Gates, Lars George, Patrick Hunt, Aaron Kimball, Peter Krey, Hairong Kuang, Simon Maxen, Olga Natkovich, Benjamin Reed, Konstantin Shvachko, Allen Wittenauer, Matei Zaharia i Philip Zeyliger. Ajay Anand dbał o płynny przebieg procesu recenzowania. Philip („flip”) Kromer był na tyle uprzejmy, że pomógł mi z danymi meteorologicznymi organizacji NCDC wykorzystywanymi w przykładach z tej książki. Specjalne podziękowania należą się Owenowi O’Malleyowi i Arunowi C. Murthyemu za wyjaśnienie mi zawiłych mechanizmów przestawiania danych w modelu MapReduce. Za wszelkie usterki, które pozostały w tekście, odpowiadam oczywiście ja sam. Jestem bardzo wdzięczny wszystkim, którzy dokonali szczegółowej oceny i przekazali informacje zwrotne dotyczące drugiego wydania tej książki. Oto te osoby: Jeff Bean, Doug Cutting, Glynn Durham, Alan Gates, Jeff Hammerbacher, Alex Kozlov, Ken Krugler, Jimmy Lin, Todd Lipcon, Sarah Sproehnle, Vinithra Varadharajan i Ian Wrigley. Dziękuję też wszystkim czytelnikom, którzy przesłali erratę do wydania pierwszego. Podziękowania należą się też Aaronowi Kimballowi za rozdział poświęcony narzędziu Sqoop i Philipowi („flipowi”) Kromerowi za studium przypadku dotyczące przetwarzania grafów. Dziękuję też wszystkim za informacje zwrotne i sugestie na temat trzeciego wydania książki. Oto te osoby: Alejandro Abdelnur, Eva Andreasson, Eli Collins, Doug Cutting, Patrick Hunt, Aaron Kimball, Aaron T. Myers, Brock Noland, Arvind Prabhakar, Ahmed Radwan i Tom Wheeler. Rob Weltman przekazał mi bardzo szczegółowe informacje zwrotne na temat całej książki, co przyczyniło się do znacznego ulepszenia ostatecznej wersji tekstu. Dziękuję również wszystkim czytelnikom, którzy przesłali erratę do wydania drugiego. Jestem wdzięczny za bezcenne informacje zwrotne dotyczące czwartego wydania. Udzieliły mi ich następujące osoby: Jodok Batlogg, Meghan Blanchette, Ryan Blue, Jarek Jarcec Cecho, Jules Damji, Dennis Dawson, Matthew Gast, Karthik Kambatla, Julien Le Dem, Brock Noland, Sandy Ryza, 22
Wprowadzenie
Akshai Sarma, Ben Spivey, Michael Stack, Kate Ting, Josh Walter, Josh Wills i Adrian Woodhead. Ryan Brush, Micah Whitacre i Matt Massie udostępnili nowe studia przypadków do tego wydania. Ponownie dziękuję wszystkim czytelnikom za przesłanie uwag do erraty. Szczególne podziękowania należą się Dougowi Cuttingowi za zachęty, wsparcie, przyjaźń i napisanie przedmowy. Dziękuję też wszystkim innym osobom, z którymi w trakcie pisania tej książki dyskutowałem osobiście lub za pomocą poczty elektronicznej. W trakcie prac nad pierwszym wydaniem tej książki trafiłem do firmy Cloudera. Dziękuję moim współpracownikom za wsparcie i zapewnienie mi czasu na pisanie oraz szybkie ukończenie tekstu. Jestem wdzięczny redaktorom Mike’owi Loukidesowi i Meghan Blanchette, a także innym pracownikom wydawnictwa O’Reilly za pomoc w tworzeniu tej książki. Mike i Meghan zawsze byli gotowi odpowiadać na moje pytania, przeczytali pierwsze wersje robocze książki i dbali o to, abym pracował zgodnie z harmonogramem. Pisanie tej książki wymagało ode mnie dużo pracy. Nie poradziłbym sobie bez nieustającego wsparcia rodziny. Moja żona, Eliane, nie tylko dbała o dom, ale też pomagała w recenzowaniu i redakcji tekstu oraz szukaniu studiów przypadków. Córki, Emilia i Lottie, były bardzo wyrozumiałe. Cieszę się na myśl o tym, że będę mógł spędzać z nimi dużo więcej czasu.
Podziękowania
23
24
Wprowadzenie
CZĘŚĆ I
Podstawy platformy Hadoop
ROZDZIAŁ 1.
Poznaj platformę Hadoop
W dawnych latach używano wołów do transportu ciężarów. Jednak gdy jeden wół nie potrafił
�� �
pociągnąć pnia drzewa, nie próbowano wyhodować większych osobników. Dlatego my też nie powinniśmy budować większych komputerów. W zamian należy rozwijać s
e1 y maszyn.
""'
Dane!
�� �� �
Żyjemy w epoce danych. Nic jest łatwo zmierzyć łączną i
•
Giełda NYSE każdego dnia g
4,4
�owi petabajtów lub miliardowi terabajtów.
(o/>
Wielkie ilości danych napływają z wielu
wszechświat" w 2013 roku liczył
�� twardy na osobę.
to 1021 bajtów i odpowiada tysiącowi eksabajtów� Na dane potrzebny będzie więc więcej niż
ch przechowywanych w postaci
�dokrotnie - do
� zettabajta, a do roku 2020 ta wielkość ma wzrosnąć �
elektronicznej, jednak organizacja IDC szacuje, że
race Hopper
�
44
zettabajtów1• Zettabajt
Pomyśl o następujących kwestiach2:
�uj� 5 terabajtów danych.
� ��nad 240 miliardów zdjęć. Ilość zajmowanego przez zdjęcia
Na Facebooku zapisa y
�
miejsca rośnie każdeg
\ �
ąca o
7
petabajtów.
Przechowująca drz� genealogiczne witryna Ancestry.com przechowuje około 10 petabajtów danych. •
•
W archiwum internetu znajduje się około 18,5 petabajta danych. Wielki Zderzacz Hadronów pod Genewą w Szwajcarii generuje około 30 petabajtów danych rocznie.
1
Te statystyki pochodzą z badania The Digital Universe oj Opportunities: Rich Data and the Increasing Value oj the Internet oj Things (http://www.emc.com/leadershipldigital-universe/2014iview/index.htm ).
2
Wszystkie dane pochodzą z lat 2013 - 2014. Więcej informacji znajdziesz w następujących źródłach: tekście At NYSE,
The Data Deluge Overwhelms Traditional Databases Toma Groenfeldta (http://wwwjorbes.com/sites! tomgroenfeldt/2013/02/14/at-nyse-the-data-deluge-overwhelms-traditional-databases/), tekście Facebook Builds Exabyte DataCenters forCold StorageRicha Millera (http://www.datacenterknowledge.com!archives/2013/01/18/ facebook-builds-new-data-centers-for-cold-storage!), na stronieCompany Facts z serwisu Ancestry.com (http:/1 corporate.ancestry.com!press!companyjacts/), stronie Petabox z serwisu Archive.org (https:!!archive.org!web!petabox.php) i stronie powitalnej projektu Worldwide LHC Computing Grid (http://wlcg.web.cern.ch/).
27
Danych jest więc bardzo dużo. Prawdopodobnie zastanawiasz się, jaki to ma związek z Tobą. Większość danych znajduje się w największych serwisach internetowych (takich jak wyszukiwarki) oraz w instytucjach naukowych i finansowych, prawda? Czy nadejście wielkich danych wpływa na mniejsze firmy i na jednostki? Uważam, że tak. Pomyśl na przykład o zdjęciach. Dziadek mojej żony był zapalonym fotografem i robił zdjęcia przez całe dorosłe życie. Wszystkie jego fotografie (w średnim formacie, ze slajdów i w formacie 35 mm) po przeskanowaniu w wysokiej rozdzielczości zajmują około
lO GB.
Porównaj
to z cyfrowymi zdjęciami wykonanymi przez moją rodzinę w samym tylko 2008 roku, które w sumie zajmują 5 GB. Moja rodzina generuje dane fotograficzne 35 razy szybciej niż dziadek mojej żony. Tempo to każdego roku rośnie, ponieważ coraz łatwiej jest robić więcej zdjęć. Strumień danych cyfrowych generowanych przez pojedynczych ludzi szybko rośnie. Projekt MyLifeBits prowadzony przez Microsoft Research (http:!!research.microsoft.com!en-us!projects!mylifebits!
��
default.aspx) pozwala przyjrzeć się metodom archiwizowania osobistych informacji, które w bliskiej przyszłości mogą trafić do powszechnego użytku. MyLifeBits to ekspe:ry:
nt, w którego ramach
interakcje poszczególnych osób (rozmowy telefoniczne, przesyłane e-m ·
dokumenty) były re
�,��..�ęcin . Gdy koszty przecho wywania danych spadną na tyle, że akceptowalne będzie ciągłe r� �1ianie dźwięku i obrazu, ilość
jestrowane w formie elektronicznej i zapisywane. Dane obejmowały t
·
onywane co minutę
zdjęcie. Łączna ilość rejestrowanych danych wynosiła gigabajt
�c(Pł'e wzrosnąć. e �Y rośnie, jednak prawdopodobnie
danych przechowywanych w usłudze MyLifeBits może wi Tak więc ilość danych generowanych przez poszcze 'l
ważniejsze jest to, że ilość danych wytwarzanych pr będzie jeszcze większa. Dzienniki komputerów, c
�
�V
tras rejestrowane przez GPS, transakcje h �
��
góry danych.
Każdego roku rośnie też ilość pub icz ie
fJL._'o
do zarządzania własnymi danymi. jętności uzyskania wartości z
y
zyny w ramach internetu przedmiotów ·
odów kreskowych, sieci czujników, ślady
wszystko to przyczynia się do powstawania
ępnych danych. Organizacje nie mogą ograniczać się
rz szłości sukces będzie w dużym stopniu zależał od umie
ch danych.
���ts w usługach sieciowych Amazonu (http://aws.amazon.com/ �ps.org (http://www.infochimps.com!) mają prowadzić do budowania
Projekty takie jak Publi
public-data-sets!) i Inf
bibliotek informacji, w których dane będą bezpłatnie (lub za niską cenę) udostępniane do pobrania i analiz. Łączenie informacji z różnych źródeł ma nieoczekiwane i trudne do wyobrażenia zasto sowania. Pomyśl na przykład o projekcie Astrometry.net (http://astrometry.net/), który obejmuje pobiera nie nowych zdjęć nocnego nieba z grupy Astrometry z serwisu Flickr. Każde zdjęcie jest analizo wane w celu określenia części nieba, która się na nim znajduje, a także zidentyfikowania cieka wych ciał niebieskich, takich jak gwiazdy lub galaktyki. Ten projekt pokazuje, co jest możliwe, gdy dane (tu są nimi opisane zdjęcia) są udostępniane i używane do czegoś (do analiz), czego ich twórcy nie przewidzieli. Mówi się, że "większa ilość danych jest więcej warta niż lepsze algorytmy". Oznacza to, że przy nie których problemach (na przykład rekomendowanie filmów lub utworów muzycznych na podstawie
28
Rozdział 1. Poznaj platformę Hadoop
preferencji) lepsze efekty można uzyskać nie dzięki bardzo wymyślnym algorytmom, a dzięki więk szej ilości danych (nawet przy prostszych algorytmachY Dobra wiadomość jest taka, że wielkie dane są dostępne. Zła informacja dotyczy tego, że trudno jest je przechowywać i analizować.
Przechowywanie i analizowanie danych Problem jest prosty - choć pojemność dysków twardych przez lata znacznie wzrosła, czas dostę pu do danych (szybkość odczytu informacji z dysków) zmienił się w mniejszym stopniu. Typowy dysk z 1990 roku przechowywał 1370 MB danych przy szybkości transferu
4,4
MB na sekundę4•
Oznaczało to, że wszystkie dane z pełnego dysku można było wczytać w około pięć minut. Ponad 20 lat później normą są dyski terabajtowe, a szybkość transferu wynosi około 100 MB na sekundę. Dlatego wczytanie wszystkich danych z dysku zajmuje ponad dwie i pół godziny.
� zapis jest jeszcze wol �� danych z wielu dys ków jednocześnie. Wyobraź sobie, że masz 100 dysków i każd�ni reduce 14/09/16 09:48:40 INFO mapred.Task: Task 'attempt_local26392882_0001_r_000000_0' done. 14/09/16 09:48:40 INFO mapred.LocalJobRunner: Finishing task:
Analizowanie danych za pomocą Hadoopa
49
attempt_local26392882_0001_r_000000_0 14/09/16 09:48:40 INFO mapred.LocalJobRunner: reduce task executor complete. 14/09/16 09:48:41 INFO mapreduce.Job: Job job_local26392882_0001 running in uber mode : false 14/09/16 09:48:41 INFO mapreduce.Job: map 100% reduce 100% 14/09/16 09:48:41 INFO mapreduce.Job: Job job_local26392882_0001 completed successfully 14/09/16 09:48:41 INFO mapreduce.Job: Counters: 30 File System Counters FILE: Number of bytes read=377168 FILE: Number of bytes written=828464 FILE: Number of read operations=0 FILE: Number of large read operations=0 FILE: Number of write operations=0 Map-Reduce Framework Map input records=5 Map output records=5 Map output bytes=45 Map output materialized bytes=61 Input split bytes=129 Combine input records=0 Combine output records=0 Reduce input groups=2 Reduce shuffle bytes=61 Reduce input records=5 Reduce output records=2 Spilled Records=10 Shuffled Maps =1 Failed Shuffles=0 Merged Map outputs=1 GC time elapsed (ms)=39 Total committed heap usage (bytes)=226754560 File Input Format Counters Bytes Read=529 File Output Format Counters Bytes Written=29
Gdy w poleceniu hadoop pierwszym argumentem jest nazwa pliku, na potrzeby wykonania klasy uruchamiana jest maszyna wirtualna Javy (ang. Java virtual machine — JVM). Polecenie hadoop dodaje biblioteki Hadoopa (i zależności) do ścieżki do klas i pobiera konfigurację Hadoopa. Aby do ścieżki dodać klasy aplikacji, zdefiniowano zmienną środowiskową HADOOP_CLASSPATH, używaną przez skrypt hadoop. Programy z tej książki uruchamiane w trybie lokalnym (samodzielnym) wymagają ustawienia zmiennej HADOOP_CLASSPATH w ten sposób. Polecenia należy uruchamiać z poziomu katalogu, w którym zainstalowany jest przykładowy kod.
Dane wyjściowe zwrócone przez uruchomione zadanie zapewniają przydatne informacje. Widać, że zadanie otrzymało identyfikator job_local26392882_0001 i wykonało jedną operację mapowania i jedną operację redukcji (o identyfikatorach attempt_local26392882_0001_m_000000_0 i attempt_ local26392882_0001_r_000000_0). Znajomość identyfikatorów zadań i operacji jest bardzo przydatna w trakcie debugowania zadań z modelu MapReduce. W ostatniej sekcji danych wyjściowych, zatytułowanej Counters, znajdują się statystyki wygenerowane przez Hadoopa dla każdego wykonanego zadania. Pozwalają one sprawdzić, czy ilość przetworzonych danych jest zgodna z oczekiwaniami. Można na przykład ustalić liczbę rekordów
50
Rozdział 2. Model MapReduce
przetworzonych przez system. Na etapie mapowania pięć rekordów wejściowych dało pięć rekordów wyjściowych (ponieważ mapper zwraca jeden rekord wyjściowy dla każdego poprawnego rekordu wejściowego). Na etapie redukcji pięć rekordów wejściowych podzielonych na dwie grupy (po jednej dla każdego unikatowego klucza) dało dwa rekordy wyjściowe. Dane wyjściowe zostały zapisane w katalogu output, który zawiera jeden plik wyjściowy na każdy reduktor. W tym zadaniu używany jest jeden reduktor, dlatego w katalogu znajduje się jeden plik (part-r-00000). % cat output/part-r-00000 1949 111 1950 22
Jest to taki sam wynik, jaki uzyskano wcześniej ręcznie. Oznacza on, że maksymalna temperatura odnotowana w roku 1949 wynosiła 11,1°C, a w roku 1950 było to 2,2°C.
Skalowanie Zobaczyłeś już, jak model MapReduce działa dla niewielkich zbiorów danych wejściowych. Pora spojrzeć na system z ogólnej perspektywy i zobaczyć, jak wygląda przepływ danych dla dużych zbiorów informacji wejściowych. We wcześniejszych przykładach dla uproszczenia używano plików z lokalnego systemu. Jednak skalowanie wymaga zapisania danych w rozproszonym systemie plików (zwykle jest nim HDFS, który poznasz w następnym rozdziale). Dzięki temu Hadoop może przenieść obliczenia z modelu MapReduce na każdą maszynę przechowującą część danych. Używany jest do tego YARN — system zarządzania zasobami Hadoopa (zobacz rozdział 4.). Zobaczmy, jak przebiega cały proces.
Przepływ danych Warto zacząć od terminologii. Zadanie (ang. job) w modelu MapReduce to jednostka pracy, której wykonania oczekuje klient. Zadanie obejmuje dane wyjściowe, program zgodny z modelem MapReduce i informacje o konfiguracji. Hadoop uruchamia zadania podzielone na operacje (ang. task). Są dwa ich rodzaje: operacje mapowania i operacje redukowania. Operacje są szeregowane za pomocą systemu YARN w węzłach klastra. Jeśli operacja zakończy się niepowodzeniem, zostanie automatycznie zaszeregowana do innego węzła. Hadoop dzieli dane wyjściowe zadania w modelu MapReduce na fragmenty o stałej wielkości — porcje wejściowe lub po prostu porcje. Hadoop tworzy jedną operację mapowania dla każdej porcji, co prowadzi do uruchomienia zdefiniowanej przez użytkownika funkcji mapującej dla każdego rekordu z porcji. Duża liczba porcji oznacza, że czas przetwarzania każdej z nich jest niewielki w porównaniu z czasem przetwarzania całego zbioru danych wejściowych. Dlatego jeśli porcje są przetwarzane równolegle, łatwiej jest zrównoważyć obciążenie przy małych porcjach, ponieważ szybsze maszyny będą mogły w trakcie całego zadania przetworzyć proporcjonalnie więcej porcji niż wolniejsze komputery. Nawet jeśli używane są identyczne maszyny, błędy w procesach lub innych zadaniach wykonywanych równolegle powodują, że warto zadbać o równoważenie obciążenia. Im porcje są mniejsze, tym lepsze efekty daje równoważenie obciążenia. Skalowanie
51
Jeżeli jednak porcje będą zbyt małe, zarządzanie nimi i tworzenie operacji mapowania zacznie odpowiadać za dużą część łącznego czasu wykonywania zadania. W większości zadań dobrą wielkością porcji jest rozmiar bloku systemu HDFS. Domyślnie wynosi on 128 megabajtów, choć można zmienić tę wartość dla klastra (obowiązuje wtedy dla wszystkich nowych plików) lub przy tworzeniu poszczególnych plików. Hadoop próbuje uruchomić operację mapowania w węźle, w którym znajdują się dane wejściowe z systemu HDFS. To podejście nie zużywa cennej przepustowości połączeń w klastrze. Na tym polega optymalizacja oparta na lokalności danych. Jednak czasem wszystkie węzły przechowujące repliki bloków z porcją wejściową dla operacji mapowania wykonują inne prace, dlatego program szeregujący szuka wolnego węzła z tej samej szafki (ang. rack), na której dostępny jest jeden z potrzebnych bloków. Bardzo rzadko nawet to jest niemożliwe. Trzeba wtedy wykorzystać węzeł z innej szafki. Prowadzi to do transferu danych w sieci między szafkami. Trzy wymienione możliwości są przedstawione na rysunku 2.2.
Rysunek 2.2. Operacje mapowania z danymi lokalnymi (a), danymi z szafki (b) i danymi spoza szafki (c)
Powinno być już jasne, dlaczego optymalny rozmiar porcji jest równy wielkości bloku. Jest to największa część danych wyjściowych, dla której można mieć pewność, że będzie przechowywana w jednym węźle. Jeśli porcja obejmuje dwa bloki, jest mało prawdopodobne, że któryś z węzłów w systemie HDFS przechowuje je oba. Dlatego część porcji trzeba wtedy przesyłać przez sieć do węzła wykonującego operację mapowania. Jest to oczywiście mniej wydajne niż wykonywanie całej operacji za pomocą danych lokalnych. Operacje mapowania zapisują dane wyjściowe na dysku lokalnym, a nie w systemie HDFS. Dlaczego tak się dzieje? Dane wyjściowe z etapu mapowania to dane pośrednie. Są przetwarzane przez operację redukowania w celu uzyskania ostatecznych danych wyjściowych. Po zakończeniu zadania dane wyjściowe z etapu mapowania można usunąć. Dlatego zapisywanie ich w systemie HDFS z obsługą replikacji jest niepotrzebne. Jeśli węzeł wykonujący operację mapowania ulegnie awarii przed wykorzystaniem danych wyjściowych z etapu mapowania w operacji redukowania, Hadoop automatycznie ponownie uruchamia operację mapowania w innym węźle, aby utrzymać dane wyjściowe z tego etapu.
52
Rozdział 2. Model MapReduce
Operacje redukowania nie wykorzystują lokalności danych. Dane wejściowe dla jednej operacji redukowania to zwykle dane wyjściowe z wszystkich mapperów. W omawianym przykładzie wykonywana jest jedna operacja redukowania, pobierająca dane z wszystkich operacji mapowania. Dlatego posortowane dane wyjściowe z etapu mapowania trzeba przesłać przez sieć do węzła, w którym wykonywana jest operacja redukowania. Tu są one scalane, a następnie przekazywane do zdefiniowanej przez użytkownika funkcji redukującej. Dane wyjściowe z etapu redukowania zwykle zapisuje się w systemie HDFS, co zwiększa niezawodność procesu. W rozdziale 3. wyjaśniono, że w systemie HDFS dla każdego bloku danych wyjściowych z etapu redukowania pierwsza replika jest zapisywana w węźle lokalnym, a pozostałe — w węzłach spoza szafki, co zwiększa bezpieczeństwo. Dlatego zapis danych wyjściowych z etapu redukowania zużywa przepustowość sieci, ale tylko w takim stopniu jak zwykły zapis w systemie HDFS. Cały przepływ danych z jedną operacją redukowania jest pokazany na rysunku 2.3. Prostokąty z przerywanym obramowaniem oznaczają węzły, strzałki z przerywaną linią ilustrują transfer danych w węźle, a zwykłe strzałki reprezentują transfer danych między węzłami.
Rysunek 2.3. Przepływ danych w modelu MapReduce z jedną operacją redukowania
Liczba operacji redukowania nie zależy od wielkości danych wejściowych. Można ją określić niezależnie od nich. W punkcie „Domyślne zadanie z modelu MapReduce” w rozdziale 8. dowiesz się, jak dobrać liczbę operacji redukowania dla danego zadania. Gdy reduktorów jest wiele, operacje mapowania dzielą dane wyjściowe na partycje. Każda taka operacja generuje jedną partycję dla każdej operacji redukowania. Każda partycja może obejmować wiele kluczy (i powiązanych z nimi wartości), jednak wszystkie rekordy dla danego klucza znajdują się w jednej partycji. Za podział na partycje może odpowiadać funkcja zdefiniowana przez użytkownika, jednak zwykle domyślny mechanizm partycjonowania (dzielący klucze na kubełki za pomocą funkcji skrótu) sprawdza się bardzo dobrze.
Skalowanie
53
Przepływ danych dla ogólnego przypadku z wieloma operacjami redukowania jest przedstawiony na rysunku 2.4. Ten diagram wyraźnie pokazuje, dlaczego przepływ danych między operacjami mapowania i redukowania jest nazywany przestawianiem (ang. shuffle). Każda operacja redukowania otrzymuje dane z wielu operacji mapowania. Przestawianie jest bardziej skomplikowane, niż wskazuje na to diagram. Dopracowanie tej fazy ma istotny wpływ na czas wykonywania zadania. Przekonasz się o tym, czytając punkt „Przestawianie i sortowanie” w rozdziale 7.
Rysunek 2.4. Przepływ danych w modelu MapReduce z wieloma operacjami redukowania
Możliwe też, że operacja redukowania w ogóle nie jest wykonywana. Takie rozwiązanie można zastosować, gdy przestawianie nie jest potrzebne, ponieważ przetwarzanie można w całości wykonać równolegle (kilka przykładów takich sytuacji opisano w punkcie „Klasa NLineInputFormat” w rozdziale 8.). Wtedy jedyny transfer danych poza węzeł ma miejsce przy zapisie danych z operacji mapowania do systemu HDFS (zobacz rysunek 2.5).
Rysunek 2.5. Przepływ danych w modelu MapReduce bez operacji redukowania
54
Rozdział 2. Model MapReduce
Funkcje łączące Wydajność wielu zadań w modelu MapReduce jest ograniczona przepustowością połączeń w klastrze. Dlatego warto minimalizować ilość danych przekazywanych między operacjami mapowania i redukowania. Hadoop umożliwia użytkownikowi ustawienie funkcji łączącej (ang. combiner function) uruchamianej dla danych wyjściowych z etapu mapowania. Dane wyjściowe funkcji łączącej są danymi wejściowymi funkcji redukującej. Ponieważ funkcja łącząca jest używana do optymalizacji, Hadoop nie gwarantuje, ile razy wywoła ją dla konkretnego rekordu z danych wyjściowych z etapu mapowania (ani czy w ogóle ją uruchomi). Oznacza to, że wywołanie funkcji łączącej zero, jeden lub więcej razy powinno prowadzić do zwrócenia tych samych danych wyjściowych przez reduktor. Kontrakt funkcji łączącej ogranicza typ funkcji, jaką można zastosować. Najlepiej wytłumaczyć to na przykładzie. Załóżmy, że przy sprawdzaniu maksymalnej temperatury odczyty z roku 1950 były przetwarzane w dwóch operacjach mapowania (ponieważ znajdowały się w różnych porcjach). Załóżmy, że pierwsza zwróciła następujące dane wyjściowe: (1950, 0) (1950, 20) (1950, 10)
A oto dane zwrócone przez drugą: (1950, 25) (1950, 15)
Funkcja redukująca jest wywoływana dla listy z wszystkimi wartościami. (1950, [0, 20, 10, 25, 15])
Oto jej dane wyjściowe: (1950, 25)
Wartość 25 to maksimum na pokazanej liście. Można zastosować funkcję łączącą, która — podobnie jak funkcja redukująca — znajduje maksymalną temperaturę w danych wyjściowych z każdej operacji mapowania. Funkcja redukująca zostanie wtedy wywołana dla następujących danych: (1950, [20, 25])
Funkcja redukująca zwróci teraz te same dane wyjściowe co wcześniej. W zwięzłej postaci wywołania funkcji dla wartości reprezentujących temperatury można zapisać w następujący sposób: maks(0, 20, 10, 25, 15) = maks(maks(0, 20, 10), maks(25, 15)) = maks(20, 25) = 25
Nie wszystkie funkcje mają opisaną tu cechę1. Na przykład przy obliczaniu średnich temperatur nie można wykorzystać funkcji łączącej w opisany wcześniej sposób. średnia(0, 20, 10, 25, 15) = 14
Zastosowanie funkcji łączącej daje inny wynik: średnia(średnia(0, 20, 10), średnia(25, 15)) = średnia(10, 20) = 15
1
Funkcje o takiej charakterystyce są przemienne i łączne. (Czasem dla takich funkcji używa się też nazwy distributive; zobacz na przykład Jim Gray i inni, Data Cube: Relational Aggregation Operator Generalizing Group-By, Cross-Tab, and Sub-Totals, luty 1995; http://research.microsoft.com/apps/pubs/default.aspx?id=69578).
Skalowanie
55
Funkcja łącząca nie zastępuje więc funkcji redukującej. Jak miałaby to robić? Funkcja redukująca jest potrzebna do przetwarzania rekordów o tym samym kluczu z różnych operacji mapowania. Jednak funkcja łącząca pomaga ograniczyć ilość danych przekazywanych między mapperami i reduktorami. I to wystarczy, aby zawsze zastanowić się nad tym, czy można wykorzystać funkcję łączącą w zadaniu w modelu MapReduce.
Tworzenie funkcji łączącej Wróćmy do programu w modelu MapReduce w Javie. Funkcję łączącą definiuje się za pomocą klasy z rodziny Reducer. W omawianej aplikacji kod funkcji łączącej jest taki sam jak funkcji redukującej z klasy MaxTemperatureReducer. Jedyna potrzebna zmiana polega na ustawieniu klasy łączącej dla obiektu typu Job (zobacz listing 2.6). Listing 2.6. Aplikacja do wyszukiwania maksymalnej temperatury. W celu zwiększenia wydajności zastosowano funkcję łączącą public class MaxTemperatureWithCombiner { public static void main(String[] args) throws Exception { if (args.length != 2) { System.err.println("Użytkowanie: MaxTemperatureWithCombiner " + ""); System.exit(-1); } Job job = new Job(); job.setJarByClass(MaxTemperatureWithCombiner.class); job.setJobName("Maks. temperatura"); FileInputFormat.addInputPath(job, new Path(args[0])); FileOutputFormat.setOutputPath(job, new Path(args[1])); job.setMapperClass(MaxTemperatureMapper.class); job.setCombinerClass(MaxTemperatureReducer.class); job.setReducerClass(MaxTemperatureReducer.class); job.setOutputKeyClass(Text.class); job.setOutputValueClass(IntWritable.class); System.exit(job.waitForCompletion(true) ? 0 : 1); }
}
Wykonywanie rozproszonego zadania w modelu MapReduce Ten sam program zadziała (bez konieczności wprowadzania w nim zmian) dla kompletnego zbioru danych. To właśnie jest cel stosowania modelu MapReduce — skaluje się do wielkości danych i możliwości sprzętu. Oto ciekawostka: w 10-węzłowym klastrze EC2 z egzemplarzami High-CPU Extra Large wykonanie programu zajęło sześć minut2. Proces uruchamiania programów w klastrze jest opisany w rozdziale 6. 2
To siedem razy szybciej niż przy sekwencyjnym wykonywaniu kodu na jednej maszynie za pomocą narzędzia awk. Rozwiązanie współbieżne nie okazało się liniowo szybsze, ponieważ dane wyjściowe nie były podzielone na równe partycje. Dla wygody pliki wejściowe zostały skompresowane do formatu gzip na podstawie lat. W efekcie pliki dla późniejszych lat były duże, ponieważ zawierały znacznie większą liczbę rekordów.
56
Rozdział 2. Model MapReduce
Narzędzie Streaming Hadoop Hadoop udostępnia interfejs API dla modelu MapReduce, umożliwiający pisanie funkcji mapującej i redukującej w językach innych niż Java. Narzędzie Streaming Hadoop wykorzystuje standardowe strumienie uniksowe jako interfejs między Hadoopem a rozwijanym programem. Dzięki temu do napisania programu w modelu MapReduce można wykorzystać dowolny język, który potrafi wczytać standardowe wejście i zapisać informacje do standardowego wyjścia3. Narzędzie Streaming jest w naturalny sposób dostosowane do przetwarzania tekstu. Dane wejściowe dla etapu mapowania są przekazywane standardowym wejściem do funkcji mapującej, która przetwarza je wiersz po wierszu i zapisuje wiersze do standardowego wyjścia. Wyjściowe pary klucz-wartość z etapu mapowania są zapisywane jako jeden wiersz z separatorem w postaci tabulacji. Dane wyjściowe funkcji redukującej mają ten sam format (para klucz-wartość z separatorem w postaci tabulacji) i są przekazywane przez standardowe wejście. Aby zilustrować ten proces, warto pokazany wcześniej oparty na modelu MapReduce program do wyszukiwania maksymalnej temperatury przekształcić na wersję opartą na narzędziu Streaming.
Ruby W języku Ruby funkcja mapująca może mieć postać przedstawioną na listingu 2.7. Listing 2.7. Funkcja mapująca w języku Ruby używana do wyszukiwania maksymalnej temperatury #!/usr/bin/env ruby STDIN.each_line do |line| val = line year, temp, q = val[15,4], val[87,5], val[92,1] puts "#{year}\t#{temp}" if (temp != "+9999" && q =~ /[01459]/) end
Ten program przechodzi po wierszach ze standardowego wejścia i wykonuje blok kodu dla każdego wiersza z wejścia STDIN (jest to stała globalna typu IO). Kod pobiera odpowiednie pola z każdego wiersza wejściowego i, jeśli temperatura jest poprawna, zapisuje rok i temperaturę rozdzielone tabulacją (\t) do standardowego wyjścia (za pomocą instrukcji puts). Ponieważ skrypt korzysta tylko ze standardowego wejścia i wyjścia, bardzo łatwo można przetestować kod bez używania Hadoopa. Wystarczy wykorzystać potoki uniksowe. % cat input/ncdc/sample.txt | ch02-mr-intro/src/main/ruby/max_temperature_map.rb 1950 +0000 1950 +0022 1950 -0011 1949 +0111 1949 +0078
Funkcja redukująca, pokazana na listingu 2.8, jest bardziej skomplikowana.
3
Programiści języka C++ zamiast strumieni mogą używać potoków Hadoopa. Polega to na wykorzystaniu gniazd do komunikowania się z procesem wykonującym funkcję mapującą lub redukującą w języku C++.
Narzędzie Streaming Hadoop
57
Listing 2.8. Funkcja redukująca w języku Ruby służąca do wyszukiwania maksymalnej temperatury #!/usr/bin/env ruby last_key, max_val = nil, -1000000 STDIN.each_line do |line| key, val = line.split("\t") if last_key && last_key != key puts "#{last_key}\t#{max_val}" last_key, max_val = key, val.to_i else last_key, max_val = key, [max_val, val.to_i].max end end puts "#{last_key}\t#{max_val}" if last_key
Ten program także przechodzi po wierszach ze standardowego wejścia, jednak tym razem trzeba zapisać stan w trakcie przetwarzania każdej grupy wartości dla kluczy. Tu klucze reprezentują lata, a program przechowuje ostatni napotkany klucz i aktualną maksymalną temperaturę dla tego klucza. Platforma MapReduce gwarantuje, że klucze są uporządkowane. Dlatego wiadomo, że napotkanie klucza różnego od poprzedniego oznacza przejście do grupy wartości dla nowego klucza. Przy stosowaniu narzędzia Streaming (inaczej niż w interfejsie API w Javie, gdzie dostępny jest iterator dla każdej grupy wartości dla klucza) trzeba w programie wykrywać przejścia między grupami wartości dla kluczy. Warto zwrócić uwagę na różnice projektowe między narzędziem Streaming a interfejsem API Javy dla modelu MapReduce. Interfejs API Javy służy do przetwarzania w funkcji mapującej po jednym rekordzie. Platforma wywołuje metodę map() typu Mapper dla każdego rekordu z danych wyjściowych. Przy stosowaniu narzędzia Streaming to program mapujący może określić sposób przetwarzania danych wyjściowych. Może na przykład w każdym kroku wczytywać i przetwarzać po wiele wierszy, ponieważ kontroluje odczyt. Do opracowanej przez użytkownika implementacji mapowania w Javie rekordy są „wypychane”, przy czym mimo to można przetwarzać po wiele wierszy naraz, zapisując wcześniejsze wiersze w zmiennej egzemplarza typu Mapper4. Wtedy należy napisać metodę close(), aby można było wykryć wczytanie ostatniego rekordu i zakończyć przetwarzanie ostatniej grupy wierszy.
Z każdego wiersza należy pobrać klucz i wartość. Następnie, jeśli właśnie ukończono przetwarzanie grupy (last_key && last_key != key), należy zapisać ostatni klucz i maksymalną temperaturę z danej grupy (rozdzielone tabulacją) oraz zresetować maksymalną temperaturę dla nowego klucza. Jeżeli grupa wciąż jest przetwarzana, wystarczy zaktualizować maksymalną temperaturę dla bieżącego klucza. Ostatni wiersz programu gwarantuje, że zapisane zostaną wyniki dla ostatniej w danych wejściowych grupy wartości dla klucza. Teraz można zasymulować cały potok z modelu MapReduce za pomocą potoku uniksowego (jest to odpowiednik potoku uniksowego z rysunku 2.1). 4
W nowym interfejsie API dla modelu MapReduce można też wykorzystać przetwarzanie z „wyciąganiem”; zobacz dodatek D.
58
Rozdział 2. Model MapReduce
% cat input/ncdc/sample.txt | \ ch02-mr-intro/src/main/ruby/max_temperature_map.rb | \ sort | ch02-mr-intro/src/main/ruby/max_temperature_reduce.rb 1949 111 1950 22
Dane wyjściowe są takie same jak dla programu napisanego w Javie. Następny krok polega na uruchomieniu kodu za pomocą Hadoopa. Polecenie hadoop nie obsługuje opcji związanej ze strumieniami. Zamiast tego należy podać opcję jar i wskazać plik JAR służący do obsługi strumieni. W opcjach programu obsługującego strumienie należy podać ścieżki wejściową i wyjściową oraz skrypty mapujący i redukujący. Instrukcja powinna wyglądać tak: % hadoop jar $HADOOP_HOME/share/hadoop/tools/lib/hadoop-streaming-*.jar \ -input input/ncdc/sample.txt \ -output output \ -mapper ch02-mr-intro/src/main/ruby/max_temperature_map.rb \ -reducer ch02-mr-intro/src/main/ruby/max_temperature_reduce.rb
Przy uruchamianiu kodu dla dużych zbiorów danych w klastrze należy zastosować opcję -combiner i ustawić skrypt łączący: % hadoop jar $HADOOP_HOME/share/hadoop/tools/lib/hadoop-streaming-*.jar \ -files ch02-mr-intro/src/main/ruby/max_temperature_map.rb,\ ch02-mr-intro/src/main/ruby/max_temperature_reduce.rb \ -input input/ncdc/all \ -output output \ -mapper ch02-mr-intro/src/main/ruby/max_temperature_map.rb \ -combiner ch02-mr-intro/src/main/ruby/max_temperature_reduce.rb \ -reducer ch02-mr-intro/src/main/ruby/max_temperature_reduce.rb
Zwróć uwagę na opcję -files. Jest ona używana przy uruchamianiu programów z obsługą strumieni w klastrze i pozwala przekazać skrypty do klastra.
Python Narzędzie Hadoop Streaming obsługuje dowolny język programowania, który potrafi wczytywać standardowe wejście i zapisywać dane do standardowego wyjścia. Poniżej przedstawiona jest wersja tego samego przykładowego programu dla użytkowników Pythona5. Skrypt mapujący jest pokazany na listingu 2.9, a listing 2.10 przedstawia skrypt redukujący. Listing 2.9. Funkcja mapująca w Pythonie służąca do wyszukiwania maksymalnej temperatury #!/usr/bin/env python import re import sys for line in sys.stdin: val = line.strip() (year, temp, q) = (val[15:19], val[87:92], val[92:93]) if (temp != "+9999" and re.match("[01459]", q)): print "%s\t%s" % (year, temp) 5
Programiści używający Pythona mogą zamiast narzędzia Streaming rozważyć zastosowanie interfejsu API Dumbo (http://klbostee.github.io/dumbo/), który sprawia, że interfejs technologii Streaming i MapReduce zmienia postać na bardziej typową dla Pythona i staje się łatwiejszy w użyciu.
Narzędzie Streaming Hadoop
59
Listing 2.10. Funkcja redukująca w Pythonie służąca do wyszukiwania maksymalnej temperatury #!/usr/bin/env python import sys (last_key, max_val) = (None, -sys.maxint) for line in sys.stdin: (key, val) = line.strip().split("\t") if last_key and last_key != key: print "%s\t%s" % (last_key, max_val) (last_key, max_val) = (key, int(val)) else: (last_key, max_val) = (key, max(max_val, int(val))) if last_key: print "%s\t%s" % (last_key, max_val)
Można przetestować programy i uruchomić zadanie w ten sam sposób jak w języku Ruby. Poniżej pokazano, jak uruchomić test: % cat input/ncdc/sample.txt | \ ch02-mr-intro/src/main/python/max_temperature_map.py | \ sort | ch02-mr-intro/src/main/python/max_temperature_reduce.py 1949 111 1950 22
60
Rozdział 2. Model MapReduce
ROZDZIAŁ 3.
System HDFS
Gdy zbiór danych staje się na tyle duży, że nie mieści się na jednej fizycznej maszynie, konieczny jest podział go między zestaw odrębnych komputerów. Systemy plików zarządzające przechowywaniem danych w sieci maszyn to rozproszone systemy plików. Ponieważ pracują w sieci, występują w nich wszystkie komplikacje związane z programowaniem sieciowym. Dlatego rozproszone systemy plików są bardziej złożone od standardowych (dyskowych). Jednym z największych wyzwań jest zapewnienie odporności systemu plików na awarie węzłów bez utraty danych. Hadoop udostępnia rozproszony system plików HDFS (ang. Hadoop Distributed Filesystem). W nieformalnych tekstach, starszej dokumentacji lub konfiguracji możesz natrafić na nazwę „DFS” — oznacza ona to samo. HDFS to podstawowy system plików Hadoopa i temat tego rozdziału, jednak Hadoop udostępnia też abstrakcję systemu plików przeznaczoną do ogólnego użytku, dlatego dalej opisano też, jak zintegrować Hadoopa z innymi systemami przechowywania danych (na przykład z lokalnym systemem plików lub systemem S3 Amazonu).
Projekt systemu HDFS HDFS to system plików zaprojektowany do przechowywania bardzo dużych plików z dostępem do danych za pomocą strumieni i działający w klastrach zbudowanych ze standardowego sprzętu1. Warto przeanalizować ten opis. Bardzo duże pliki „»Bardzo duże« w tym kontekście oznacza pliki o wielkości mierzonej w setkach megabajtów, gigabajtów lub terabajtów. Istnieją oparte na Hadoopie klastry przechowujące petabajty danych”2.
1
Architekturę systemu HDFS opisano w pracy Roberta Chanslera i innych, „The Hadoop Distributed File System” (http://www.aosabook.org/en/hdfs.html), która ukazała się w książce The Architecture of Open Source Applications: Elegance, Evolution, and a Few Fearless Hacks pod redakcją Amy Brown i Grega Wilsona.
2
Zobacz tekst Konstantina V. Shvachko i Aruna C. Murthy’ego, Scaling Hadoop to 4000 nodes at Yahoo! (https://developer.yahoo.com/blogs/hadoop/scaling-hadoop-4000-nodes-yahoo-410.html), z 30 września 2008 roku.
61
Dostęp do danych za pomocą strumieni HDFS jest oparty na założeniu, że najwydajniejszy wzorzec przetwarzania danych polega na jednokrotnym ich zapisaniu i wielokrotnym odczycie. Zbiór danych jest zwykle generowany lub kopiowany ze źródła, a następnie na danych przeprowadza się różne analizy. Wszystkie analizy dotyczą dużej części (lub nawet całości) zbioru danych, dlatego czas odczytu zbioru jest ważniejszy niż opóźnienie odczytu pierwszego rekordu. Standardowy sprzęt Hadoop nie wymaga drogiego sprzętu o wysokiej niezawodności. Hadoop jest zaprojektowany w taki sposób, aby działał w klastrach zbudowanych ze standardowego sprzętu (powszechnie dostępnych urządzeń, które można zakupić od różnych sprzedawców)3, gdzie prawdopodobieństwo awarii węzła jest wysokie — przynajmniej jeśli klaster jest duży. Po wystąpieniu awarii HDFS ma kontynuować pracę bez zakłóceń odczuwalnych dla użytkowników. Warto też przyjrzeć się sytuacjom, w których HDFS nie sprawdza się zbyt dobrze. Choć w przyszłości może się to zmienić, obecnie HDFS nie daje dobrych wyników w następujących obszarach: Dostęp do danych z małym opóźnieniem W zastosowaniach, w których potrzebny jest szybki dostęp do danych (mierzony w dziesiątkach milisekund), HDFS nie sprawdza się dobrze. Pamiętaj, że HDFS ma zapewniać wysoką przepustowość danych nawet kosztem wyższego opóźnienia. Jeśli trzeba zapewnić szybki dostęp do danych, obecnie lepszym wyborem jest HBase (zobacz rozdział 20.). Duża liczba niewielkich plików Ponieważ węzeł nazw przechowuje metadane systemu plików w pamięci, limit liczby plików w systemie jest zależny od ilości pamięci w węźle nazw. Zgodnie z ogólną regułą każdy plik, katalog i blok zajmuje około 150 bajtów. Tak więc jeśli system obejmuje milion plików, z których każdy zajmuje jeden blok, potrzebnych jest przynajmniej 300 megabajtów pamięci. Choć przechowywanie milionów plików jest możliwe, zarządzanie miliardami wykracza poza możliwości obecnego sprzętu4. Wiele jednostek zapisujących i arbitralne modyfikacje plików Pliki w systemie HDFS mogą być zapisywane tylko przez jedną jednostkę. Zapis zawsze odbywa się na końcu pliku i wyłącznie w trybie dodawania. System nie obsługuje wielu jednostek zapisujących i nie umożliwia modyfikowania plików w dowolnym miejscu. W przyszłości obsługa tych mechanizmów może zostać wprowadzona, jednak prawdopodobnie będzie stosunkowo niewydajna.
3
Typową specyfikację komputera znajdziesz w rozdziale 10.
4
Omówienie ograniczeń systemu HDFS w zakresie skalowalności zawiera praca Konstantina V. Shvachko, HDFS Scalability: The Limits to Growth (https://www.usenix.org/publications/login/april-2010-volume-35-number-2/ hdfs-scalability-limits-growth), z kwietnia 2010 roku.
62
Rozdział 3. System HDFS
Pojęcia związane z systemem HDFS Bloki Dysk ma ustaloną wielkość bloku, określającą minimalną ilość danych, które można wczytać lub zapisać. Systemy plików przeznaczone dla pojedynczych dysków przetwarzają dane w blokach równych wielokrotności wielkości bloku dysku. Rozmiar bloku w systemie plików wynosi zwykle kilka kilobajtów, natomiast bloki dyskowe mają standardowo 512 bajtów. Nie ma to wpływu na użytkownika systemu plików, ponieważ może on wczytywać lub zapisywać pliki o dowolnej długości. Istnieją jednak narzędzia odpowiedzialne za konserwację systemu plików (na przykład df i fsck) działające na poziomie bloków systemu plików. Także w systemie HDFS występują bloki, przy czym są one znacznie większe. Domyślnie mają 128 megabajtów. Pliki w systemie HDFS (podobnie jak w systemach jednodyskowych) są podzielone na porcje o wielkości bloku, przechowywane jako niezależne jednostki. Inaczej niż w systemach jednodyskowych w systemie HDFS plik mniejszy niż pojedynczy blok nie zajmuje pamięci równej wielkości bloku. Na przykład plik o długości 1 megabajta zapisany w bloku o wielkości 128 megabajtów zajmuje tylko 1 megabajt pamięci. Gdy w książce pojawia się słowo „blok” bez dookreślenia, oznacza ono blok z systemu HDFS.
Dlaczego bloki w systemie HDFS są tak duże? Bloki systemu HDFS są duże w porównaniu z blokami dysku. Ma to minimalizować koszty wyszukiwania. Gdy blok jest duży, czas transferu danych z dysku może być znacznie dłuższy niż czas wyszukiwania początku bloku. Dlatego transfer dużych plików składających się z wielu bloków odbywa się na poziomie maksymalnej dla danego dysku szybkości przesyłania danych. Szybkie obliczenia pozwalają stwierdzić, że jeśli czas wyszukiwania wynosi około 10 ms, a szybkość transferu to 100 MB/s, to aby czas wyszukiwania wynosił 1% czasu transferu, wielkość bloku musi wynosić około 100 MB. Wartość domyślna wynosi 128 MB, choć w wielu egzemplarzach systemu HDFS używane są większe bloki. Ta wartość będzie modyfikowana wraz ze wzrostem szybkości transferu w dyskach nowej generacji. Nie należy jednak przesadzać z wielkością bloków. Operacje mapowania w modelu MapReduce zwykle są wykonywane na pojedynczych blokach. Dlatego jeśli liczba operacji jest zbyt mała (mniejsza niż liczba węzłów w klastrze), zadania będą wykonywane wolniej niż przy optymalnych ustawieniach.
Abstrakcyjne bloki w rozproszonych systemach plików dają szereg korzyści. Pierwsza z nich jest oczywista — plik może być większy niż wielkość dowolnego dysku w sieci. Bloki pliku nie muszą być przechowywane na tym samym dysku, dlatego można wykorzystać dowolne dyski z klastra. Możliwe też (choć niespotykane) jest zapisanie w klastrze z systemem HDFS jednego pliku, którego bloki znajdują się na wszystkich dyskach klastra. Po drugie, zastosowanie jako jednostki abstrakcji bloków zamiast plików upraszcza podsystem przechowywania danych. Do prostoty należy dążyć we wszystkich systemach, jednak w systemach rozproszonych, gdzie występują bardzo różnorodne błędy, ma to szczególne znaczenie. Podsystem przechowywania danych działa na blokach, co upraszcza zarządzanie pamięcią (bloki mają Pojęcia związane z systemem HDFS
63
stałą wielkość, dlatego łatwo jest obliczyć, ile ich można zapisać na dysku) i eliminuje kwestie związane z metadanymi (bloki to porcje przechowywanych danych, więc nie trzeba przechowywać razem z nimi metadanych plików, na przykład informacji o uprawnieniach; za metadane może odpowiadać odrębny podsystem). Ponadto bloki dobrze współdziałają z mechanizmem replikacji, zapewniającym odporność na błędy i wysoką dostępność. W celu ochrony przed uszkodzeniem bloków oraz awariami dysków i maszyn każdy blok jest replikowany na kilku (zwykle trzech) fizycznie niezależnych maszynach. Gdy blok stanie się niedostępny, jego kopię można wczytać z innej lokalizacji w sposób nieodczuwalny dla klienta. Blok niedostępny z powodu uszkodzenia lub awarii komputera można zreplikować na podstawie jego innych lokalizacji i umieścić w wybranych aktywnych maszynach, aby przywrócić normalną liczbę replik. Więcej informacji o zabezpieczaniu się przed uszkodzeniem danych znajdziesz w punkcie „Integralność danych” w rozdziale 5. Niektóre aplikacje tworzą dużą liczbę replik bloków z popularnego pliku, aby rozłożyć obciążenie związane z jego wczytywaniem po klastrze. Polecenie fsck w systemie HDFS (podobnie jak w dyskowych systemach plików) zwraca informacje o blokach. Na przykład poniższa instrukcja: % hdfs fsck / -files -blocks
wyświetla listę bloków tworzących poszczególne pliki z systemu plików. Zobacz też punkt „Sprawdzanie systemu plików (narzędzie fsck)” na stronie 316.
Węzły nazw i węzły danych W klastrach z systemem HDFS występują dwa rodzaje węzłów działające w modelu nadrzędnyroboczy. Są to: węzeł nazw (nadrzędny) i węzły danych (robocze). Węzeł nazw zarządza przestrzenią nazw systemu plików. Przechowuje drzewo systemu plików i metadane dla wszystkich plików i katalogów z drzewa. Te informacje są zapisywane trwale na dysku lokalnym w dwóch plikach — obrazie przestrzeni nazw i dzienniku zmian. Węzeł nazw zna węzły danych, w których znajdują się wszystkie bloki danego pliku. Nie zapisuje jednak trwale lokalizacji bloków, ponieważ te informacje są rekonstruowane na podstawie węzłów danych w momencie rozruchu systemu. Klient w imieniu użytkownika uzyskuje dostęp do systemu plików, komunikując się z węzłem nazw i węzłami danych. Klient komunikuje się z systemem plików przez interfejs podobny do POSIX-a (ang. Portable Operating System Interface), dzięki czemu w kodzie użytkownika nie są potrzebne informacje o węźle nazw i węzłach danych. Węzły danych to „woły robocze” systemu plików. Na żądanie (klienta lub węzła nazw) zapisują i pobierają bloki oraz okresowo przekazują do węzła nazw listę przechowywanych bloków. Bez węzła nazw systemu plików nie da się używać. Jeśli maszyna z węzłem nazw zostanie usunięta, wszystkie pliki w systemie plików zostaną utracone. Nie da się wtedy ustalić, jak zrekonstruować pliki na podstawie bloków z węzłów danych. Dlatego ważne jest, aby węzeł nazw był odporny na awarie. Hadoop udostępnia w tym celu dwa mechanizmy.
64
Rozdział 3. System HDFS
Pierwszy polega na zarchiwizowaniu plików z trwałym stanem metadanych systemu plików. Hadoopa można skonfigurować w taki sposób, aby węzeł nazw zapisywał trwały stan w kilku systemach plików. Te operacje zapisu są synchroniczne i atomowe. W typowej konfiguracji metadane są zapisywane na dysku lokalnym oraz w zdalnym punkcie montowania w systemie NFS. Drugie rozwiązanie to utworzenie pomocniczego węzła nazw. Mimo nazwy nie pełni on funkcji węzła nazw. Jego główna rola polega na okresowym scalaniu obrazu przestrzeni nazw z dziennikiem zmian, aby zapobiec nadmiernemu wydłużaniu się dziennika. Pomocniczy węzeł nazw zwykle znajduje się na odrębnej fizycznej maszynie, ponieważ wspomniane scalanie wymaga dużej mocy obliczeniowej i tyle samo pamięci co węzeł nazw. Pomocniczy węzeł nazw zachowuje kopię scalonego obrazu przestrzeni nazw, którą można wykorzystać, jeśli węzeł nazw ulegnie awarii. Jednak stan pomocniczego węzła nazw jest opóźniony względem głównego. Dlatego przy nieodwracalnej awarii głównego węzła nazw niemal na pewno nastąpi utrata danych. Standardowe rozwiązanie polega wtedy na skopiowaniu plików metadanych węzła nazw z systemu NFS do pomocniczego węzła nazw i wykorzystaniu węzła pomocniczego jako nowego węzła głównego. Zauważ, że można utworzyć aktywny rezerwowy węzeł nazw zamiast węzła pomocniczego. Opisano to w punkcie „Wysoka dostępność w systemie HDFS”. Więcej szczegółów znajdziesz w punkcie „Pliki obrazu i dziennika edycji systemu plików” w rozdziale 11.
Zapisywanie bloków w pamięci podręcznej Standardowo węzeł danych wczytuje bloki z dysku. Jednak dla często używanych plików bloki można jawnie zapisać w pamięci podręcznej węzła nazw, w pamięci podręcznej bloków poza stertą. Domyślnie blok jest zapisywany w pamięci podręcznej tylko jednego węzła nazw, choć dla każdego pliku można zmienić to ustawienie. Programy szeregujące zadania (w platformach MapReduce, Spark i innych) mogą wykorzystać bloki w pamięci podręcznej i uruchamiać operacje w węźle danych, w którego pamięci podręcznej znajduje się dany blok. Pozwala to zwiększyć wydajność odczytu. Do zapisania w pamięci podręcznej dobrze nadaje się na przykład niewielka tablica do wyszukiwania używana przy złączaniu. Użytkownicy lub aplikacje wskazują węzłowi nazw pliki do zapisania w pamięci podręcznej (i czas ich przechowywania w tym miejscu) za pomocą dyrektywy pamięci podręcznej dodanej do puli pamięci podręcznej. Pule pamięci podręcznej to jednostki administracyjne służące do zarządzania uprawnieniami do pamięci podręcznej i wykorzystaniem zasobów.
Federacje w systemie HDFS Węzeł nazw przechowuje w pamięci referencję do każdego pliku i bloku z systemu plików. To oznacza, że w bardzo dużych klastrach o wielu plikach pamięć ogranicza skalowanie (zobacz ramka „Ile pamięci potrzebuje węzeł nazw?” w rozdziale 10.). Federacje w systemie HDFS, wprowadzone w wersjach z rodziny 2.x, umożliwiają skalowanie klastra w wyniku dodania węzłów nazw, z których każdy zarządza fragmentem przestrzeni nazw systemu plików. Na przykład jeden węzeł nazw może zarządzać wszystkimi plikami z katalogu /user, a drugi — wszystkimi plikami z katalogu /share.
Pojęcia związane z systemem HDFS
65
W ramach federacji każdy węzeł nazw zarządza woluminem przestrzeni nazw, obejmującym metadane przestrzeni nazw i pulę bloków (zawiera ona wszystkie bloki plików z danej przestrzeni nazw). Woluminy przestrzeni nazw są niezależne od siebie. To oznacza, że węzły nazw nie komunikują się ze sobą, a uszkodzenie jednego węzła nazw nie wpływa na dostępność przestrzeni nazw zarządzanych przez inne węzły. Jednak pamięć puli bloków nie jest dzielona na partycje. Dlatego węzły danych rejestrują się w każdym węźle nazw klastra i przechowują bloki z wielu pul. By uzyskać dostęp do klastra z systemem HDFS obejmującym federacje, klienty używają przechowywanych po stronie klienta tablic montowania łączących ścieżki do plików z węzłami nazw. Do zarządzania tym procesem służą klasa ViewFileSystem i identyfikatory URI viewfs:// w konfiguracji.
Wysoka dostępność w systemie HDFS Połączenie replikacji metadanych węzłów nazw w kilku systemach plików i wykorzystania pomocniczych węzłów nazw do tworzenia punktów kontrolnych chroni przed utratą danych, ale nie zapewnia wysokiej dostępności systemu plików. Węzeł nazw nadal jest pojedynczym punktem krytycznym (ang. single point of failure — SPOF). Jeśli węzeł nazw ulegnie awarii, wszystkie klienty (w tym zadania w modelu MapReduce) nie będą mogły odczytywać, zapisywać ani wyświetlać plików, ponieważ węzeł nazw to jedyne repozytorium metadanych i wiązań plików z blokami. W takiej sytuacji cały oparty na Hadoopie system staje się niedostępny do czasu aktywowania nowego węzła nazw. Aby przywrócić stan po awarii węzła nazw, administrator uruchamia nowy główny węzeł nazw z jedną z replik metadanych systemu plików i konfiguruje węzły danych oraz klienty w taki sposób, by korzystały z nowego węzła nazw. Nowy węzeł nazw może zacząć obsługiwać żądania dopiero po tym, jak (1) wczyta obraz przestrzeni nazw do pamięci, (2) wprowadzi modyfikacje z dziennika zmian i (3) otrzyma wystarczającą liczbę raportów o blokach z węzłów danych, aby móc wyjść z trybu bezpiecznego. W dużych klastrach obejmujących wiele plików i bloków czas rozruchu węzła nazw od zera może wynosić 30 minut lub dłużej. Długi czas przywracania stanu jest problemem także w kontekście rutynowej konserwacji systemu. Ponieważ nieoczekiwane awarie węzłów nazw są bardzo rzadkie, w praktyce większe znaczenie mają planowane przestoje. W wersji Hadoop 2 zaradzono tym problemom dzięki mechanizmom zapewnienia wysokiej dostępności. Rozwiązanie polega na używaniu pary węzłów nazw w konfiguracji aktywny-rezerwowy. Gdy aktywny węzeł nazw przestanie działać, węzeł rezerwowy przejmie jego zadania i będzie obsługiwał żądania klientów bez odczuwalnych zakłóceń w pracy. Ten model wymagał wprowadzenia kilku zmian w architekturze.
66
W węzłach nazw trzeba używać wysoce dostępnej pamięci współużytkowanej, w której znajduje się dziennik zmian. Rezerwowy węzeł nazw przetwarza wszystkie elementy ze współużytkowanego dziennika zmian, aby zsynchronizować swój stan względem aktywnego węzła nazw, a następnie kontynuuje wczytywanie nowych elementów zapisywanych przez aktywny węzeł nazw.
Węzły danych muszą wysyłać raporty o blokach do obu węzłów nazw, ponieważ odwzorowania bloków są przechowywane w pamięci węzłów nazw, a nie na dysku.
Rozdział 3. System HDFS
Klienty trzeba skonfigurować w taki sposób, aby obsługiwały przełączanie awaryjne węzłów nazw za pomocą mechanizmu, który nie wpływa na przebieg pracy użytkowników.
Węzeł rezerwowy przejmuje funkcje pomocniczego węzła nazw i okresowo tworzy punkty kontrolne dla przestrzeni nazw aktywnego węzła nazw.
Wysoce dostępną współużytkowaną pamięć można utworzyć za pomocą dwóch technologii — serwera plików NFS lub menedżera QJM (ang. quorum journal manager). Menedżer QJM to wyspecjalizowane narzędzie dla systemu HDFS, zaprojektowane wyłącznie w celu zapewnienia wysokiej dostępności dziennika zmian. W większości instalacji z systemem HDFS jest to zalecane rozwiązanie. Menedżer QJM działa jako grupa węzłów dziennika, a każda zmiana musi być zapisana w większości tych węzłów. Zwykle używane są trzy węzły dziennika, dlatego system jest odporny na utratę jednego z nich. Podobnie działa narzędzie ZooKeeper, choć warto wiedzieć, że rozwiązanie z menedżerem QJM nie wykorzystuje ZooKeepera. Zauważ przy tym, że mechanizm zapewniania wysokiej dostępności systemu HDFS używa ZooKeepera do wyboru aktywnego węzła nazw, co wyjaśniono w następnym punkcie. Gdy aktywny węzeł nazw przestanie działać, węzeł rezerwowy może bardzo szybko (w kilkadziesiąt sekund) przejąć zadania, ponieważ przechowuje w pamięci najnowszy stan — zarówno ostatnie elementy z dziennika zmian, jak i aktualne odwzorowania bloków. W praktyce odczuwalny czas przełączania awaryjnego jest nieco dłuższy (wynosi około minuty), ponieważ system musi konserwatywnie oceniać, czy aktywny węzeł nazw uległ awarii. W mało prawdopodobnej sytuacji, w której węzeł rezerwowy jest niedostępny w momencie awarii aktywnego węzła, administrator może uruchomić węzeł rezerwowy od zera. Efekty nie są wtedy gorsze niż bez mechanizmów zapewniania wysokiej dostępności, a z perspektywy operacyjnej zadanie jest wygodniejsze, ponieważ cały proces jest wbudowany w Hadoopa.
Przełączanie awaryjne i odgradzanie Przełączaniem systemu z aktywnego węzła nazw na węzeł rezerwowy zarządza nowa jednostka w systemie — kontroler przełączania awaryjnego. Istnieją różne kontrolery, jednak w implementacji domyślnej używany jest ZooKeeper. Gwarantuje on, że tylko jeden węzeł nazw pozostaje aktywny. Każdy węzeł nazw obejmuje prosty proces kontrolera przełączania awaryjnego, którego zadaniem jest monitorowanie danego węzła pod kątem awarii (za pomocą prostego mechanizmu sygnałów kontrolnych) i uruchamianie przełączania awaryjnego po jej wykryciu. Przełączanie awaryjne może też zostać zainicjowane ręcznie przez administratora — na przykład w ramach rutynowej konserwacji. Następuje wtedy kontrolowane przełączanie awaryjne, ponieważ kontroler w uporządkowany sposób zamienia role obu węzłów nazw. W przypadku zwykłego przełączania awaryjnego nie da się stwierdzić, że dany węzeł nazw przestał działać. Wolna sieć lub fragmentacja sieci mogą spowodować przełączenie awaryjne nawet w sytuacji, gdy wcześniej aktywny węzeł nazw wciąż działa i przyjmuje, że pełni funkcję węzła aktywnego. Twórcy mechanizmów zapewniania wysokiej dostępności dołożyli wielu starań, by zagwarantować, że wcześniej aktywny węzeł nazw nie wyrządzi żadnych szkód. Służy do tego odgradzanie (ang. fencing).
Pojęcia związane z systemem HDFS
67
Menedżer QJM w każdym momencie umożliwia tylko jednemu węzłowi nazw zapis danych do dziennika zmian. Może się jednak zdarzyć, że wcześniej aktywny węzeł nazw obsługuje otrzymane od klientów dawne żądania odczytu, dlatego dobrym pomysłem jest wywołanie za pomocą protokołu SSH polecenia odgrodzenia, które zamknie proces tego węzła. Bardziej zdecydowane odgradzanie jest potrzebne przy używaniu serwera plików NFS dla współużytkowanego dziennika zmian, ponieważ nie da się wtedy ograniczać zapisu do jednego węzła nazw (dlatego też zaleca się stosowanie menedżera QJM). Używane mechanizmy odgradzania to na przykład blokowanie węzłowi nazw dostępu do współużytkowanego katalogu z danymi (zwykle robi się to za pomocą specyficznego dla producenta polecenia z systemu NFS) i wyłączanie portu sieciowego przy użyciu instrukcji zdalnego zarządzania. Ostatnią deską ratunku jest odgrodzenie wcześniej aktywnego węzła nazw za pomocą techniki „strzału drugiemu węzłowi w głowę”. Polega ona na wykorzystaniu specjalnej jednostki dystrybucji zasilania do wymuszonego wyłączenia maszyny z danym węzłem. Po stronie klientów przełączanie awaryjne jest obsługiwane automatycznie przez bibliotekę klienta. Najprostsza implementacja polega na kontrolowaniu przełączania awaryjnego za pomocą konfiguracji po stronie klienta. Identyfikator URI systemu HDFS obejmuje logiczną nazwę hosta wiązaną z parą adresów węzłów nazw (w pliku konfiguracyjnym). Biblioteka kliencka sprawdza każdy adres do czasu udanego nawiązania połączenia.
Interfejs uruchamiany z wiersza poleceń Przy omawianiu systemu HDFS wykorzystuję wiersz poleceń. Istnieje wiele interfejsów systemu HDFS, jednak wiersz poleceń jest jednym z najprostszych i, dla wielu programistów, najlepiej znanych. System HDFS będzie uruchamiany na jednym komputerze, dlatego najpierw zastosuj się do instrukcji z dodatku A i skonfiguruj Hadoopa w trybie pseudorozproszonym. Dalej zobaczysz, jak uruchamiać system HDFS w klastrze, aby zapewnić skalowalność i odporność na awarie. W konfiguracji pseudorozproszonej ustawiane są dwie właściwości zasługujące na dodatkowe wyjaśnienia. Pierwsza z nich to fs.defaultFS ustawiona na hdfs://localhost/. Służy ona do wskazywania domyślnego systemu plików dla Hadoopa5. Systemy plików są określane za pomocą identyfikatora URI. Tu używany jest identyfikator URI hdfs, dlatego Hadoop domyślnie będzie używał systemu HDFS. Demony systemu HDFS używają tej właściwości do określania hosta i portu węzła nazw. System działa na hoście lokalnym i w domyślnym porcie systemu HDFS (8020). Klienty systemu HDFS używają tej właściwości do ustalenia lokalizacji węzła nazw, co pozwala nawiązać z nim połączenie. Druga właściwość, dfs.replication, jest ustawiona na 1, aby system HDFS nie replikował bloków systemu plików w domyślnych trzech wersjach. Gdy używany jest jeden węzeł danych, system HDFS nie może zreplikować bloków w trzech węzłach danych, dlatego regularnie wyświetla ostrzeżenie o za małej liczbie replik. Wspomniane ustawienie rozwiązuje ten problem. 5
W Hadoopie 1 używana była właściwość o nazwie fs.default.name. W Hadoopie 2 wprowadzono wiele nowych nazw właściwości i zrezygnowano z dawnych (zobacz ramkę „Które właściwości programista może ustawić?” w rozdziale 6.). W tej książce stosowane są nowe nazwy.
68
Rozdział 3. System HDFS
Podstawowe operacje w systemie plików System plików jest gotowy do użycia i można wykonywać wszystkie standardowe operacje — wczytywać pliki, tworzyć katalogi, przenosić pliki, usuwać dane i wyświetlać listy katalogów. Aby uzyskać szczegółową pomoc na temat każdego polecenia, wpisz komendę hadoop fs -help. Zacznij od skopiowania pliku z lokalnego systemu plików do systemu HDFS. % hadoop fs -copyFromLocal input/docs/quangle.txt \ hdfs://localhost/user/tom/quangle.txt
To polecenie wywołuje instrukcję powłoki systemu plików Hadoopa, fs, udostępniającą szereg podinstrukcji. Tu używana jest podinstrukcja -copyFromLocal. Lokalny plik quangle.txt jest kopiowany do pliku /user/tom/quangle.txt w systemie HDFS działającym w hoście lokalnym. Można też pominąć schemat i hosta w identyfikatorze URI, aby użyć domyślnego ustawienia hdfs://localhost wprowadzonego w pliku core-site.xml. % hadoop fs -copyFromLocal input/docs/quangle.txt /user/tom/quangle.txt
Jeszcze inna możliwość to użycie ścieżki względnej i skopiowanie pliku do głównego katalogu użytkownika (/user/tom) w systemie HDFS. % hadoop fs -copyFromLocal input/docs/quangle.txt quangle.txt
Teraz skopiuj plik z powrotem do lokalnego systemu plików i sprawdź, czy kopia jest identyczna z oryginałem. % hadoop fs -copyToLocal quangle.txt quangle.copy.txt % md5 input/docs/quangle.txt quangle.copy.txt MD5 (input/docs/quangle.txt) = e7891a2627cf263a079fb0f18256ffb2 MD5 (quangle.copy.txt) = e7891a2627cf263a079fb0f18256ffb2
Skróty MD5 są takie same, co oznacza, że plik nie zmienił się w wyniku podróży do systemu HDFS i z powrotem. Na zakończenie zobacz, jak wyświetlać listy plików w systemie HDFS. Najpierw utwórz katalog, co pozwoli Ci zobaczyć, jak jest wyświetlany na liście. % hadoop fs -mkdir books % hadoop fs -ls . Found 2 items drwxr-xr-x - tom supergroup -rw-r--r-1 tom supergroup
0 2014-10-04 13:22 books 119 2014-10-04 13:21 quangle.txt
Zwracane dane są bardzo podobne do informacji wyświetlanych przez uniksowe polecenie ls -l, choć występują pewne drobne różnice. Pierwsza kolumna zawiera tryb pliku. Druga określa liczbę zreplikowanych egzemplarzy pliku (nie występuje to w tradycyjnym uniksowym systemie plików). Pamiętaj, że domyślną liczbę replikowanych egzemplarzy ustawiono w ogólnej konfiguracji na 1, dlatego tu widoczna jest ta wartość. Dla katalogów w tej kolumnie nie jest wyświetlana żadna wartość, ponieważ replikacja ich nie dotyczy. Katalogi są traktowane jak metadane i zapisywane w węźle nazw, a nie w węzłach danych. Kolumny trzecia i czwarta wyświetlają właściciela pliku i grupę. Piąta kolumna określa wielkość pliku w bajtach (dla katalogów wyświetlana jest wartość 0). Kolumny szósta i siódma to data i godzina ostatniej modyfikacji. Kolumna ósma zawiera nazwę pliku lub katalogu.
Interfejs uruchamiany z wiersza poleceń
69
Uprawnienia do plików w systemie HDFS W systemie HDFS używany jest model uprawnień do plików i katalogów bardzo podobny do modelu POSIX. Występują tu trzy rodzaje uprawnień: do odczytu (r), do zapisu (w) i do wykonywania (x). Uprawnienia do odczytu są niezbędne do wczytywania plików i wyświetlania zawartości katalogów. Uprawnienia do zapisu są wymagane do zapisu danych do pliku lub, w przypadku katalogu, do tworzenia lub usuwania plików oraz katalogów z danego miejsca. Uprawnienia do wykonywania dla plików są ignorowane (ponieważ w systemie HDFS, inaczej niż w standardzie POSIX, nie można wykonywać plików), natomiast dla katalogów umożliwiają dostęp do przechowywanych w nich elementów. Każdy plik i katalog ma właściciela, grupę i tryb. Tryb obejmuje uprawnienia dla właściciela, dla członków grupy i dla użytkowników, którzy nie są ani właścicielami, ani członkami grupy. Hadoop domyślnie ma wyłączone zabezpieczenia. Oznacza to, że tożsamość klientów nie jest sprawdzana. Ponieważ klient działa zdalnie, może nim być dowolny użytkownik — wystarczy, że utworzy w zdalnym systemie konto o odpowiedniej nazwie. Gdy zabezpieczenia są włączone, nie jest to możliwe (zobacz punkt „Bezpieczeństwo” w rozdziale 10.). Niezależnie od tego warto włączyć uprawnienia (domyślnie są one włączone; odpowiada za to właściwość dfs.permissions.enabled), aby uniknąć przypadkowego zmodyfikowania lub usunięcia dużych fragmentów systemu plików przez użytkowników lub automatyczne narzędzia albo programy. Gdy sprawdzanie uprawnień jest włączone, uprawnienia właściciela są uwzględniane, jeśli klient pasuje do nazwy właściciela, a uprawnienia grupy są uwzględniane, jeżeli klient jest członkiem grupy. W pozostałych sytuacjach sprawdzane są inne uprawnienia. Występuje też superużytkownik, którym jest proces węzła nazw. Dla superużytkownika uprawnienia nie są sprawdzane.
Systemy plików w Hadoopie W Hadoopie używana jest abstrakcja systemu plików. HDFS to tylko jedna z implementacji tej abstrakcji. Interfejs kliencki dla systemu plików w Hadoopie reprezentuje klasa abstrakcyjna Javy, org.apache.hadoop.fs.FileSystem. Istnieje kilka jej konkretnych implementacji. Opis podstawowych implementacji dostępnych w Hadoopie znajdziesz w tabeli 3.1. Hadoop udostępnia wiele interfejsów do systemów plików. Zwykle wybiera właściwy system plików, z którym ma się komunikować, na podstawie schematu z identyfikatora URI. Na przykład instrukcja powłoki systemu plików wspomniana we wcześniejszym punkcie współdziała z wszystkimi systemami plików Hadoopa. Aby wyświetlić pliki z katalogu głównego lokalnego systemu plików, wpisz następujące polecenie: % hadoop fs -ls file:///
Choć możliwe (i czasem bardzo wygodne) jest uruchamianie programów w modelu MapReduce używających dowolnego systemu plików, to przy przetwarzaniu dużych zbiorów danych należy wybrać rozproszony system plików z optymalizacją pod kątem lokalności danych. Takim systemem jest HDFS (zobacz punkt „Skalowanie” w rozdziale 2.).
70
Rozdział 3. System HDFS
Tabela 3.1. Systemy plików w Hadoopie System plików
Schemat w URI
Implementacja w Javie (wszystkie wymienione klasy znajdują się w pakiecie org.apache.hadoop)
Opis
Local
file
fs.LocalFileSystem
System plików dla lokalnie podłączonego dysku z sumami kontrolnymi po stronie klienta. RawLocalFileSystem to lokalny system plików bez sum kontrolnych. Zobacz punkt „Klasa LocalFileSystem” w rozdziale 5.
HDFS
hdfs
hdfs.DistributedFileSystem
Rozproszony system plików Hadoopa. HDFS jest zaprojektowany tak, aby wydajnie współdziałał z modelem MapReduce.
WebHDFS
webhdfs
hdfs.web.WebHdfsFileSystem
System plików obsługujący dostęp do odczytu i zapisu z uwierzytelnianiem do systemu HDFS z użyciem protokołu HTTP. Zobacz punkt „HTTP”.
Secure WebHDFS
swebhdfs
hdfs.web.SWebHdfsFileSystem
Wersja systemu WebHDFS używająca protokołu HTTPS.
HAR
har
fs.HarFileSystem
Używany do archiwizowania system oparty na innym systemie plików. Format Hadoop Archives jest używany do pakowania wielu plików z systemu HDFS do jednego archiwum w celu zmniejszenia ilości pamięci potrzebnej węzłowi nazw. Do tworzenia plików HAR służy polecenie hadoop archive.
View
viewfs
viewfs.ViewFileSystem
Używana po stronie klienta tabela montowania dla innych systemów plików Hadoopa. Często stosowana do tworzenia punktów montowania dla węzłów nazw z federacji (zobacz punkt „Federacje w systemie HDFS”).
FTP
ftp
fs.ftp.FTPFileSystem
System plików oparty na serwerze FTP.
S3
s3a
fs.s3a.S3AFileSystem
System plików oparty na usłudze Amazon S3. Zastępuje starszą implementację s3n (natywny S3).
Azure
wasb
fs.azure.NativeAzureFileSystem
System plików oparty na technologii Microsoft Azure.
Swift
swift
fs.swift.snative.SwiftNative FileSystem
System plików oparty na technologii OpenStack Swift.
Interfejsy Hadoop jest napisany w Javie, dlatego większość interakcji z systemem plików Hadoopa odbywa się z użyciem interfejsu API Javy. Na przykład powłoka systemu plików to aplikacja Javy używająca klasy FileSystem z tego języka do udostępniania operacji na systemie plików. W tym punkcie
Systemy plików w Hadoopie
71
omawiam pokrótce inne interfejsy systemu plików. Najczęściej stosuje się je dla systemu HDFS, ponieważ pozostałe systemy plików w Hadoopie oferują narzędzia zapewniające dostęp do poszczególnych systemów. Te narzędzia to na przykład klienty FTP dla systemów FTP, narzędzia S3 dla systemu S3 itd. Wiele rozwiązań działa dla dowolnego systemu plików Hadoopa.
HTTP Ponieważ interfejsem do systemów plików Hadoopa jest interfejs API Javy, aplikacjom w innych językach trudno jest uzyskać dostęp do systemu HDFS. Interfejs API HTTP REST z protokołu WebHDFS ułatwia interakcję z systemem HDFS w innych językach. Zauważ, że interfejs oparty na protokole HTTP jest wolniejszy niż natywny klient w Javie, dlatego w miarę możliwości należy go unikać przy transferze bardzo dużych zbiorów danych. Istnieją dwa sposoby dostępu do systemu HDFS za pomocą protokołu HTTP. Można to robić bezpośrednio, kiedy to demony systemu HDFS obsługują żądania HTTP klientów, i z użyciem pośredników komunikujących się z systemem HDFS w imieniu klientów za pomocą standardowego interfejsu API DistributedFileSystem. Oba te mechanizmy są pokazane na rysunku 3.1. W obu wykorzystywany jest protokół WebHDFS.
Rysunek 3.1. Dostęp do systemu HDFS bezpośrednio przez protokół HTTP i z wykorzystaniem pośredników systemu HDFS
W pierwszej metodzie osadzone serwery WWW z węzła nazw i węzła danych pełnią funkcję punktów końcowych systemu WebHDFS. Ten system domyślnie jest włączony, ponieważ opcja dfs.webhdfs.enabled jest ustawiona na true. Za operacje na metadanych odpowiada węzeł nazw, natomiast operacje odczytu (i zapisu) plików są przesyłane najpierw do węzła nazw, który przekierowuje żądanie HTTP do klienta i przekazuje przy tym informacje o węźle danych, z którego przesyłany będzie strumień danych (lub do którego dane mają trafić). 72
Rozdział 3. System HDFS
Drugi sposób dostępu do systemu HDFS przez protokół HTTP jest oparty na niezależnych serwerach pośredniczących. Pośrednicy są bezstanowi, dlatego mogą działać za standardowym mechanizmem równoważenia obciążenia. Wszystkie dane kierowane do klastra przepływają przez pośredników, dlatego klient nigdy nie uzyskuje bezpośredniego dostępu do węzła nazw lub węzła danych. To pozwala stosować bardziej restrykcyjne zasady zapory i ograniczania ruchu. Pośrednika często używa się do transferu danych między klastrami z Hadoopem zlokalizowanymi w różnych centrach danych, a także przy dostępie z poziomu zewnętrznej sieci do klastra z Hadoopem działającego w chmurze. Pośrednik HttpFS udostępnia ten sam interfejs HTTP (i HTTPS) co system WebHDFS. Dlatego klienty mogą korzystać z obu tych narzędzi za pomocą identyfikatorów URI webhdfs (i swebhdfs). Pośrednik HttpFS jest uruchamiany niezależnie od demonów węzłów nazw i węzłów danych. Używany jest do tego skrypt httpfs.sh. Pośrednik domyślnie oczekuje na dane w porcie numer 14000.
C Hadoop udostępnia bibliotekę języka C o nazwie libhdfs odzwierciedlającą interfejs FileSystem Javy. Ta biblioteka została napisana z myślą o dostępie do systemu HDFS, jednak mimo nazwy można jej używać do korzystania z dowolnego systemu plików z Hadoopa. Biblioteka używa interfejsu JNI (ang. Java Native Interface) do wywoływania klienta systemu plików z Javy. Istnieje też biblioteka libwebhdfs używająca opisanego w poprzednim podpunkcie interfejsu WebHDFS. Interfejs API w języku C bardzo przypomina swego odpowiednika z Javy, jednak zwykle jest rozwijany z opóźnieniem, dlatego może nie obsługiwać niektórych nowszych funkcji. Plik nagłówkowy hdfs.h znajdziesz w katalogu include binarnej dystrybucji platformy Apache Hadoop dostępnej w formacie tar. W tej dystrybucji dostępne są wstępnie zbudowane pliki binarne biblioteki libhdfs dla 64-bitowej wersji Linuksa. Dla innych platform bibliotekę musisz zbudować samodzielnie na podstawie instrukcji z pliku BUILDING.txt dostępnego na najwyższym poziomie drzewa kodu źródłowego.
NFS System HDFS można zamontować w systemie plików lokalnego klienta za pomocą bramy NFSv3 Hadoopa. Za pomocą narzędzi uniksowych (takich jak ls i cat) można komunikować się z systemem, wczytywać pliki i używać bibliotek POSIX-owych, aby uzyskać dostęp do systemu plików w dowolnym języku programowania. Dodawanie danych do plików jest możliwe, natomiast nie są dozwolone modyfikacje w dowolnym miejscu pliku, ponieważ system HDFS obsługuje wyłącznie zapis danych na końcu pliku. Z dokumentacji Hadoopa dowiesz się, jak skonfigurować i uruchomić bramę NFS oraz połączyć się z nią z poziomu klienta.
FUSE Technologia FUSE (ang. Filesystem in Userspace) umożliwia integrowanie systemów plików z przestrzeni użytkowników jako uniksowych systemów plików. Moduł Fuse-DFS z Hadoopa pozwala zamontować system HDFS (lub dowolny inny system plików z Hadoopa) jako standardowy
Systemy plików w Hadoopie
73
lokalny system plików. Fuse-DFS jest zaimplementowany w języku C, a interfejsem dla systemu HDFS jest biblioteka libhdfs. Gdy powstawała ta książka, brama NFS z Hadoopa była lepszym mechanizmem montowania systemu HDFS, dlatego należy przedkładać ją nad używanie modułu Fuse-DFS.
Interfejs w Javie W tym podrozdziale zapoznasz się z klasą FileSystem Hadoopa. Ta klasa to interfejs API służący do interakcji z jednym z systemów plików Hadoopa6. Choć tu koncentruję się głównie na implementacji dla systemu HDFS, DistributedFileSystem, zwykle powinieneś starać się pisać kod z użyciem klasy abstrakcyjnej FileSystem, aby zachować zgodność z różnymi systemami plików. Jest to bardzo przydatne na przykład w trakcie testowania programu, ponieważ pozwala szybko przeprowadzić testy na danych z lokalnego systemu plików.
Odczyt danych na podstawie adresu URL systemu Hadoop Jedną z najprostszych metod odczytu pliku z systemu plików Hadoopa jest użycie obiektu typu java.net.URL do otwarcia strumienia, z którego wczytywane są dane. Oto ogólny idiom ilustrujący to rozwiązanie: InputStream in = null; try { in = new URL("hdfs://host/path").openStream(); // Przetwarzanie obiektu in } finally { IOUtils.closeStream(in); }
Nieco więcej wysiłku trzeba włożyć, aby Java rozpoznawała używane w Hadoopie adresy URL z przedrostkiem hdfs. Wymaga to wywołania metody setURLStreamHandlerFactory() dla obiektu typu URL i przekazania do niej obiektu typu FsUrlStreamHandlerFactory. Tę metodę można wywołać tylko raz dla każdej maszyny JVM, dlatego zwykle jest uruchamiana w bloku statycznym. To ograniczenie sprawia, że gdy w innym miejscu programu (na przykład niekontrolowany przez programistę niezależny komponent) ustawiony zostanie obiekt typu URLStreamHandlerFactory, nie będzie można wykorzystać opisanego tu podejścia do odczytu danych z Hadoopa. W następnym punkcie poznasz inne rozwiązanie. Na listingu 3.1 pokazany jest program wyświetlający do standardowego wyjścia pliki z systemów plików Hadoopa. Ten program działa podobnie jak uniksowe polecenie cat. Listing 3.1. Wyświetlanie plików z systemu plików Hadoopa do standardowego wyjścia za pomocą klasy URLStreamHandler public class URLCat { static { URL.setURLStreamHandlerFactory(new FsUrlStreamHandlerFactory()); 6
W Hadoopie 2 i nowszych wersjach dostępny jest nowy interfejs dla systemu plików, FileContext, z lepszą obsługą wielu systemów plików (jeden obiekt typu FileContext obsługuje przedrostki z różnych systemów plików) oraz bardziej przejrzystym i spójniejszym interfejsem. Jednak nadal częściej używana jest klasa FileSystem.
74
Rozdział 3. System HDFS
} public static void main(String[] args) throws Exception { InputStream in = null; try { in = new URL(args[0]).openStream(); IOUtils.copyBytes(in, System.out, 4096, false); } finally { IOUtils.closeStream(in); } } }
Dostępna w Hadoopie wygodna klasa IOUtils jest tu używana do zamykania strumienia w klauzuli finally i do kopiowania bajtów między strumieniem wejściowym a strumieniem wyjściowym (tu jest nim System.out). Dwa ostatnie argumenty metody copyBytes() to wielkość bufora używanego przy kopiowaniu i wartość określająca, czy należy zamknąć strumienie po zakończeniu tej operacji. Strumień wejściowy jest zamykany ręcznie, a strumienia System.out nie trzeba zamykać. Oto przykładowy przebieg programu7. % export HADOOP_CLASSPATH=hadoop-examples.jar % hadoop URLCat hdfs://localhost/user/tom/quangle.txt On the top of the Crumpetty Tree The Quangle Wangle sat, But his face you could not see, On account of his Beaver Hat.
Odczyt danych za pomocą interfejsu API FileSystem W poprzednim punkcie wyjaśniono, że czasem nie da się ustawić w aplikacji obiektu typu URLStream HandlerFactory. Wtedy należy wykorzystać interfejs API FileSystem do otwarcia wejściowego strumienia z danymi z pliku. Plik w systemie plików Hadoopa jest reprezentowany za pomocą obiektu typu Path Hadoopa (a nie jako obiekt typu java.io.File, którego działanie jest za bardzo powiązane z lokalnym systemem plików). Obiekt typu Path możesz traktować jak identyfikator URI z systemu plików Hadoopa, na przykład hdfs://localhost/user/tom/quangle.txt. FileSystem to ogólny interfejs API dla systemów plików, dlatego pierwszy krok polega na utwo-
rzeniu egzemplarza typu właściwego dla używanego systemu plików. Tu tym systemem jest HDFS. Istnieje kilka statycznych metod fabrycznych do tworzenia egzemplarzy typu FileSystem. public static FileSystem get(Configuration conf) throws IOException public static FileSystem get(URI uri, Configuration conf) throws IOException public static FileSystem get(URI uri, Configuration conf, String user) throws IOException
Obiekt typu Configuration zawiera konfigurację klienta lub serwera ustawianą na podstawie plików konfiguracyjnych wczytywanych na podstawie ścieżki do klas (na przykład z pliku etc/hadoop/ core-site.xml). Pierwsza metoda zwraca domyślny system plików (ustawiony w pliku core-site.xml; jeśli nie określono takiego systemu domyślny lokalny system plików). Druga metoda określa
7
Tekst z pliku pochodzi z książki The Quangle Wangle’s Hat Edwarda Leara.
Interfejs w Javie
75
używany system plików na podstawie schematu z obiektu typu URI. Jeśli w podanym obiekcie schemat nie jest określony, używany jest domyślny system plików. Trzecia metoda pobiera system plików z ustawieniami podanego użytkownika, co jest istotne w kontekście zabezpieczeń (zobacz punkt „Bezpieczeństwo” w rozdziale 10.). Czasem potrzebny jest egzemplarz lokalnego systemu plików. Wtedy można wykorzystać wygodną metodę getLocal(). public static LocalFileSystem getLocal(Configuration conf) throws IOException
Po pobraniu egzemplarza typu FileSystem można wywołać metodę open(), aby otrzymać strumień wejściowy dla pliku. public FSDataInputStream open(Path f) throws IOException public abstract FSDataInputStream open(Path f, int bufferSize) throws IOException
Pierwsza metoda używa bufora o domyślnej wielkości 4 KB. Po połączeniu wszystkich informacji można zmodyfikować kod z listingu 3.1 w sposób pokazany na listingu 3.2. Listing 3.2. Wyświetlanie plików z systemu plików Hadoopa w standardowym wyjściu z bezpośrednim użyciem obiektu typu FileSystem public class FileSystemCat { public static void main(String[] args) throws Exception { String uri = args[0]; Configuration conf = new Configuration(); FileSystem fs = FileSystem.get(URI.create(uri), conf); InputStream in = null; try { in = fs.open(new Path(uri)); IOUtils.copyBytes(in, System.out, 4096, false); } finally { IOUtils.closeStream(in); } } }
Oto przebieg tego programu: % hadoop FileSystemCat hdfs://localhost/user/tom/quangle.txt On the top of the Crumpetty Tree The Quangle Wangle sat, But his face you could not see, On account of his Beaver Hat.
Klasa FSDataInputStream Metoda open() klasy FileSystem zwraca obiekt klasy FSDataInputStream, a nie standardowej klasy java.io. Klasa FSDataInputStream to wyspecjalizowana wersja klasy java.io.DataInputStream obsługująca dostęp bezpośrednio. Dzięki temu można odczytać dane z dowolnej części strumienia. package org.apache.hadoop.fs; public class FSDataInputStream extends DataInputStream implements Seekable, PositionedReadable { // Implementację pominięto }
76
Rozdział 3. System HDFS
Interfejs Seekable umożliwia wyszukiwanie pozycji w pliku i udostępnia metodę getPos() służącą do pobierania aktualnej pozycji względem początku pliku. public interface Seekable { void seek(long pos) throws IOException; long getPos() throws IOException; }
Wywołanie metody seek() z podaną pozycją wykraczającą poza koniec pliku powoduje wyjątek IOException. Metoda seek() (w odróżnieniu od metody skip() klasy java.io.InputStream) pozwala przejść do dowolnej określonej bezwzględnie pozycji w pliku. Listing 3.3 przedstawia proste rozwinięcie kodu z listingu 3.2. Nowa wersja dwukrotnie wyświetla plik w standardowym wyjściu. Po pierwszym wyświetleniu przechodzi do początku pliku i jeszcze raz przez niego przechodzi. Listing 3.3. Dwukrotne wyświetlanie pliku z systemu plików Hadoopa w standardowym wyjściu z wykorzystaniem metody seek() public class FileSystemDoubleCat {
}
public static void main(String[] args) throws Exception { String uri = args[0]; Configuration conf = new Configuration(); FileSystem fs = FileSystem.get(URI.create(uri), conf); FSDataInputStream in = null; try { in = fs.open(new Path(uri)); IOUtils.copyBytes(in, System.out, 4096, false); in.seek(0); // Powrót do początku pliku IOUtils.copyBytes(in, System.out, 4096, false); } finally { IOUtils.closeStream(in); } }
Oto efekt uruchomienia tego kodu dla krótkiego pliku. % hadoop FileSystemDoubleCat hdfs://localhost/user/tom/quangle.txt On the top of the Crumpetty Tree The Quangle Wangle sat, But his face you could not see, On account of his Beaver Hat. On the top of the Crumpetty Tree The Quangle Wangle sat, But his face you could not see, On account of his Beaver Hat.
Klasa FSDataInputStream obsługuje też interfejs PositionedReadable, umożliwiający odczyt fragmentów pliku na podstawie pozycji. public interface PositionedReadable { public int read(long position, byte[] buffer, int offset, int length) throws IOException; public void readFully(long position, byte[] buffer, int offset, int length) throws IOException; public void readFully(long position, byte[] buffer) throws IOException; }
Interfejs w Javie
77
Metoda read() wczytuje do length bajtów od podanej pozycji position z pliku i zapisuje je na pozycji position w buforze buffer. Zwracana wartość to liczba wczytanych bajtów. Jednostka wywołująca powinna sprawdzać tę wartość, ponieważ może być ona mniejsza niż length. Metody readFully() wczytują length bajtów do bufora (lub buffer.length bajtów w wersji, która przyjmuje tylko tablicę bajtów buffer), chyba że dojdą do końca pliku — wtedy zgłaszają wyjątek EOFException. Wszystkie te metody zachowują aktualną pozycję w pliku i są bezpieczne ze względu na wątki (choć klasa FSDataInputStream nie jest zaprojektowana z myślą o współbieżności, więc lepiej jest utworzyć kilka obiektów), dlatego stanowią wygodny sposób dostępu do innej części pliku (na przykład metadanych) w trakcie wczytywania głównej treści. Pamiętaj też, że wywołanie metody seek() to kosztowna operacja, dlatego nie należy wykonywać jej zbyt często. Powinieneś tak opracować wzorce dostępu w aplikacji, aby polegać na strumieniowym dostępie do danych (na przykład za pomocą modelu MapReduce), a nie na wykonywaniu dużej liczby operacji wyszukiwania.
Zapis danych Klasa FileSystem udostępnia szereg metod do tworzenia plików. Najprostsza metoda przyjmuje obiekt typu Path reprezentujący tworzony plik i zwraca strumień wyjściowy umożliwiający zapis danych. public FSDataOutputStream create(Path f) throws IOException
Istnieją przeciążone wersje tej metody, które umożliwiają określenie, czy należy siłowo zastąpić istniejące pliki, a także pozwalają na ustawienie liczby replikowanych egzemplarzy pliku, wielkości bufora używanego przy zapisie, rozmiaru bloku i uprawnień do pliku. Metody create() tworzą katalogi nadrzędne zapisywanego pliku, jeśli te jeszcze nie istnieją. Choć jest to wygodne, może prowadzić do nieoczekiwanych efektów. Jeśli chcesz, aby zapis kończył się niepowodzeniem, gdy katalog nadrzędny nie istnieje, powinieneś sprawdzać dostępność tego katalogu za pomocą metody exists(). Inna możliwość to użycie klasy FileContext, pozwalającej określić, czy katalogi nadrzędne mają być tworzone.
Istnieje też przeciążona wersja metody umożliwiająca przekazanie wywoływanego zwrotnie interfejsu, Progressable. Dzięki temu aplikacja może być powiadamiana o postępach zapisu informacji w węzłach danych. package org.apache.hadoop.util; public interface Progressable { public void progress(); }
Zamiast tworzyć nowy plik, można dodać dane do istniejącego pliku za pomocą metody append() (ma ona kilka przeciążonych wersji). public FSDataOutputStream append(Path f) throws IOException
78
Rozdział 3. System HDFS
Operacja dodawania umożliwia jednej jednostce zapisującej zmodyfikowanie istniejącego pliku. Proces ten polega na otwarciu pliku i zapisaniu danych od jego końcowej pozycji. Za pomocą tego interfejsu API aplikacje generujące nieograniczone pliki, na przykład dzienniki, mogą zapisywać dane w istniejących plikach po ich zamknięciu. Operacja dodawania jest opcjonalna i nie wszystkie systemy plików Hadoopa ją obsługują. Na przykład system HDFS obsługuje dodawanie, natomiast systemy S3 tego nie robią. Listing 3.4 pokazuje, jak skopiować lokalny plik do systemu plików Hadoopa. Postęp jest ilustrowany przez wyświetlanie kropki przy każdym wywołaniu metody progress() przez Hadoopa, czyli po zapisaniu każdego pakietu 64 KB danych do potoku węzła danych. Zauważ, że ta wartość nie jest określona przez interfejs API, dlatego może się zmienić w przyszłych wersjach Hadoopa. Omawiany interfejs API pozwala jedynie stwierdzić, że „coś się dzieje”. Listing 3.4. Kopiowanie lokalnego pliku do systemu plików Hadoopa public class FileCopyWithProgress { public static void main(String[] args) throws Exception { String localSrc = args[0]; String dst = args[1]; InputStream in = new BufferedInputStream(new FileInputStream(localSrc)); Configuration conf = new Configuration(); FileSystem fs = FileSystem.get(URI.create(dst), conf); OutputStream out = fs.create(new Path(dst), new Progressable() { public void progress() { System.out.print("."); } }); IOUtils.copyBytes(in, out, 4096, true); } }
Oto typowe zastosowanie tego kodu. % hadoop FileCopyWithProgress input/docs/1400-8.txt hdfs://localhost/user/tom/1400-8.txt .................
Obecnie żaden inny system plików Hadoopa nie wywołuje metody progress() w trakcie zapisu danych. Informacje o postępie są ważne w aplikacjach wykorzystujących model MapReduce, o czym przekonasz się w dalszych rozdziałach.
Klasa FSDataOutputStream Metoda create() klasy FileSystem zwraca obiekt klasy FSDataOutputStream, która podobnie jak klasa FSDataInputStream udostępnia metodę do sprawdzania bieżącej pozycji w pliku. package org.apache.hadoop.fs; public class FSDataOutputStream extends DataOutputStream implements Syncable { public long getPos() throws IOException { // Implementację pominięto } // Implementację pominięto }
Interfejs w Javie
79
Jednak klasa FSDataOutputStream (w odróżnieniu od FSDataInputStream) nie obsługuje wyszukiwania. Jest tak, ponieważ system HDFS dopuszcza tylko sekwencyjny zapis do otwartego pliku lub dodawanie danych do już zapisanego pliku. Oznacza to, że nie można zapisywać danych w żadnym innym miejscu oprócz końca pliku. Dlatego możliwość wyszukiwania w trakcie zapisu nie jest potrzebna.
Katalogi Klasa FileSystem udostępnia metodę do tworzenia katalogów. public boolean mkdirs(Path f) throws IOException
Ta metoda tworzy wszystkie potrzebne katalogi nadrzędne, jeśli jeszcze nie istnieją (podobnie działa metoda mkdirs() z klasy java.io.File), i zwraca wartość true, jeżeli katalog i wszystkie jego katalogi nadrzędne zostały z powodzeniem dodane. Często nie trzeba jawnie tworzyć katalogu, ponieważ przy zapisie pliku za pomocą metody create() automatycznie dodawane są katalogi nadrzędne.
Zapytania w systemie plików Metadane plików — klasa FileStatus Ważną funkcją każdego systemu plików jest możliwość poruszania się po strukturze katalogów i pobierania informacji o przechowywanych plikach i katalogach. Klasa FileStatus zawiera metadane systemu plików opisujące pliki i katalogi. Określa ona długość pliku, wielkość bloku, poziom replikacji, czas modyfikacji, właściciela i informacje o uprawnieniach. Metoda getFileStatus() klasy FileSystem pozwala uzyskać obiekt klasy FileStatus reprezentujący określony plik lub katalog. Listing 3.5 pokazuje, jak wykorzystać taki obiekt. Listing 3.5. Wyświetlanie informacji o stanie pliku public class ShowFileStatusTest { private MiniDFSCluster cluster; // Używanie do testów wewnątrzprocesowego klastra z systemem HDFS private FileSystem fs; @Before public void setUp() throws IOException { Configuration conf = new Configuration(); if (System.getProperty("test.build.data") == null) { System.setProperty("test.build.data", "/tmp"); } cluster = new MiniDFSCluster.Builder(conf).build(); fs = cluster.getFileSystem(); OutputStream out = fs.create(new Path("/dir/file")); out.write("content".getBytes("UTF-8")); out.close(); } @After public void tearDown() throws IOException { if (fs != null) { fs.close(); } if (cluster != null) { cluster.shutdown(); }
80
Rozdział 3. System HDFS
} @Test(expected = FileNotFoundException.class) public void throwsFileNotFoundForNonExistentFile() throws IOException { fs.getFileStatus(new Path("no-such-file")); } @Test public void fileStatusForFile() throws IOException { Path file = new Path("/dir/file"); FileStatus stat = fs.getFileStatus(file); assertThat(stat.getPath().toUri().getPath(), is("/dir/file")); assertThat(stat.isDirectory(), is(false)); assertThat(stat.getLen(), is(7L)); assertThat(stat.getModificationTime(), is(lessThanOrEqualTo(System.currentTimeMillis()))); assertThat(stat.getReplication(), is((short) 1)); assertThat(stat.getBlockSize(), is(128 * 1024 * 1024L)); assertThat(stat.getOwner(), is(System.getProperty("user.name"))); assertThat(stat.getGroup(), is("supergroup")); assertThat(stat.getPermission().toString(), is("rw-r--r--")); } @Test public void fileStatusForDirectory() throws IOException { Path dir = new Path("/dir"); FileStatus stat = fs.getFileStatus(dir); assertThat(stat.getPath().toUri().getPath(), is("/dir")); assertThat(stat.isDirectory(), is(true)); assertThat(stat.getLen(), is(0L)); assertThat(stat.getModificationTime(), is(lessThanOrEqualTo(System.currentTimeMillis()))); assertThat(stat.getReplication(), is((short) 0)); assertThat(stat.getBlockSize(), is(0L)); assertThat(stat.getOwner(), is(System.getProperty("user.name"))); assertThat(stat.getGroup(), is("supergroup")); assertThat(stat.getPermission().toString(), is("rwxr-xr-x")); } }
Jeśli nie istnieje żaden plik ani katalog, zgłaszany jest wyjątek FileNotFoundException. Gdy interesuje Cię tylko to, czy dany plik lub katalog istnieje, wygodniejsza jest metoda exists() klasy FileSystem. public boolean exists(Path f) throws IOException
Wyświetlanie list plików Wyszukiwanie informacji na temat pojedynczych plików lub katalogów jest przydatne, często jednak potrzebna jest możliwość wyświetlenia zawartości katalogu. Do tego służą metody listStatus() klasy FileSystem. public FileStatus[] listStatus(Path f) throws IOException public FileStatus[] listStatus(Path f, PathFilter filter) throws IOException public FileStatus[] listStatus(Path[] files) IOException public FileStatus[] listStatus(Path[] files, PathFilter filter) throws IOException
Gdy argumentem jest plik, najprostsza wersja zwraca jednoelementową tablicę obiektów klasy File Status. Jeśli argumentem jest katalog, metoda zwraca zero lub więcej obiektów klasy FileStatus reprezentujących pliki i foldery z danego katalogu. Interfejs w Javie
81
Przeciążone wersje umożliwiają podanie obiektu zgodnego z interfejsem PathFilter i definiującego pasujące pliki i katalogi. Przykład takiego rozwiązania znajdziesz w punkcie „Interfejs PathFilter”. Jeśli podasz tablicę ścieżek, instrukcja zadziała jak wywołanie metody listStatus() po kolei dla każdej ścieżki, a wszystkie wynikowe tablice obiektów klasy FileStatus zostaną zapisane w jednej tablicy. Jest to przydatne do budowania list przetwarzanych plików wejściowych z różnych miejsc drzewa systemu plików. Listing 3.6 to prosty kod ilustrujący taką sytuację. Zwróć uwagę na zastosowanie metody stat2Paths() klasy FileUtil z Hadoopa w celu przekształcenia tablicy obiektów klasy FileStatus na tablicę obiektów klasy Path. Listing 3.6. Wyświetlanie informacji o plikach na podstawie kolekcji ścieżek z systemu plików Hadoopa public class ListStatus { public static void main(String[] args) throws Exception { String uri = args[0]; Configuration conf = new Configuration(); FileSystem fs = FileSystem.get(URI.create(uri), conf); Path[] paths = new Path[args.length]; for (int i = 0; i < paths.length; i++) { paths[i] = new Path(args[i]); } FileStatus[] status = fs.listStatus(paths); Path[] listedPaths = FileUtil.stat2Paths(status); for (Path p : listedPaths) { System.out.println(p); } } }
Ten program można wykorzystać do znalezienia sumy zawartości katalogów podanych jako kolekcja ścieżek. % hadoop ListStatus hdfs://localhost/ hdfs://localhost/user/tom hdfs://localhost/user hdfs://localhost/user/tom/books hdfs://localhost/user/tom/quangle.txt
Wzorce reprezentujące pliki Często trzeba przetworzyć zbiór plików w jednej operacji. Na przykład zadanie przetwarzania dzienników w modelu MapReduce może analizować pliki z danego miesiąca zapisane w różnych katalogach. Zamiast wskazywać każdy plik i katalog, wygodniej jest zastosować symbole wieloznaczne i za pomocą jednego wyrażenia dopasować pliki. Taka operacja to dopasowywanie do wzorca (ang. globbing). Hadoop udostępnia dwie metody klasy FileSystem służące do przetwarzania wzorców. public FileStatus[] globStatus(Path pathPattern) throws IOException public FileStatus[] globStatus(Path pathPattern, PathFilter filter) throws IOException
Metoda globStatus() zwraca tablicę obiektów klasy FileStatus, których ścieżki pasują do podanego wzorca. Wyniki są posortowane według ścieżek. Aby dodatkowo ograniczyć listę pasujących elementów, można podać opcjonalny obiekt zgodny z interfejsem PathFilter. Hadoop obsługuje ten sam zestaw wzorców co uniksowa powłoka bash. Przedstawia je tabela 3.2.
82
Rozdział 3. System HDFS
Tabela 3.2. Wzorce i ich znaczenie Wzorzec
Nazwa
Pasujące znaki
*
Gwiazdka
Zero lub więcej znaków
?
Znak zapytania
Jeden znak
[ab]
Klasa znaków
Jeden znak ze zbioru {a, b}
[^ab]
Negacja klasy znaków
Jeden znak spoza zbioru {a, b}
[a-b]
Przedział znaków
Jeden znak z domkniętego przedziału [a, b], gdzie a jest słownikowo mniejsze lub równe b
[^a-b]
Negacja przedziału znaków
Jeden znak spoza domkniętego przedziału [a, b], gdzie a jest słownikowo mniejsze lub równe b
{a, b}
Jedna z możliwości
Wyrażenie a lub b
\c
Symbol poprzedzony znakiem ucieczki
Symbol c będący metaznakiem
Załóżmy, że pliki dzienników są przechowywane w strukturze katalogów uporządkowanej hierarchicznie według dat. Na przykład pliki dzienników z ostatniego dnia 2007 roku są zapisywane w katalogu /2007/12/31. Załóżmy, że tak wygląda pełna lista katalogów. / ├── 2007/ │ └── 12/ │ ├── 30/ │ └── 31/ └── 2008/ └── 01/ ├── 01/ └── 02/
Oto różne wzorce i ich rozwinięcia. Wzorzec
Rozwinięcia
/*
/2007 /2008
/*/*
/2007/12 /2008/01
/*/12/*
/2007/12/30 /2007/12/31
/200?
/2007 /2008
/200[78]
/2007 /2008
/200[7-8]
/2007 /2008
/200[^01234569]
/2007 /2008
/*/*{31,01}
/2007/12/31 /2008/01/01
/*/*3{0,1}
/2007/12/30 /2007/01/31
/*/{12/31,01/01}
/2007/12/31 /2008/01/01
Interfejs PathFilter Możliwości, jakie dają wzorce, nie zawsze wystarczają do opisania zbioru potrzebnych plików. Zwykle nie da się na przykład wykluczyć konkretnego pliku za pomocą wzorca. Metody listStatus() i globStatus() klasy FileSystem przyjmują opcjonalny obiekt zgodny z interfejsem PathFilter dający programową kontrolę nad dopasowywaniem ścieżek. Interfejs w Javie
83
package org.apache.hadoop.fs; public interface PathFilter { boolean accept(Path path); }
Interfejs PathFilter to odpowiednik interfejsu java.io.FileFilter, przeznaczony jednak dla obiektów klasy Path, a nie File. Listing 3.7 pokazuje, jak użyć interfejsu PathFilter do wykluczenia ścieżek pasujących do wyrażenia regularnego. Listing 3.7. Obiekt zgodny z interfejsem PathFilter służący do wykluczania ścieżek pasujących do wyrażenia regularnego public class RegexExcludePathFilter implements PathFilter { private final String regex; public RegexExcludePathFilter(String regex) { this.regex = regex; } public boolean accept(Path path) { return !path.toString().matches(regex); } }
Filtr przepuszcza tylko pliki, które nie pasują do wyrażenia regularnego. Po wybraniu początkowego zbioru plików na podstawie wzorca kod używa filtra do doprecyzowania wyników. Oto przykład: fs.globStatus(new Path("/2007/*/*"), new RegexExcludeFilter("^.*/2007/12/31$"))
Wyrażenie jest rozwijane do postaci /2007/12/30. Filtry działają tylko dla nazw plików reprezentowanych za pomocą obiektów klasy Path. Nie można używać w filtrach właściwości plików (na przykład daty utworzenia). Pozwalają jednak na dopasowywanie nieosiągalne ani za pomocą wzorców, ani przy użyciu wyrażeń regularnych. Możesz na przykład zapisać pliki w katalogach o strukturze opartej na datach (tak jak pokazano wcześniej) i napisać filtr PathFilter do wybierania plików o dacie utworzenia z określonego przedziału.
Usuwanie danych Do trwałego usuwania plików lub katalogów służy metoda delete() klasy FileSystem. public boolean delete(Path f, boolean recursive) throws IOException
Jeśli f to plik lub pusty katalog, wartość argumentu recursive jest ignorowana. Niepusty katalog jest usuwany razem z zawartością, gdy argument recursive to true (przy wartości false zgłaszany jest wtedy wyjątek IOException).
84
Rozdział 3. System HDFS
Przepływ danych Anatomia odczytu pliku Aby zrozumieć przepływ danych między komunikującym się z systemem HDFS klientem, węzłem nazw i węzłami danych, przyjrzyj się rysunkowi 3.2. Przedstawiona jest na nim główna sekwencja zdarzeń związanych z odczytem pliku.
Rysunek 3.2. Klient wczytuje dane z systemu HDFS
Klient, by otworzyć plik, który chce wczytać, wywołuje metodę open() obiektu typu FileSystem, którym dla systemu HDFS jest obiekt klasy DistributedFileSystem (krok 1. na rysunku 3.2). Obiekt klasy DistributedFileSystem wywołuje węzeł nazw za pomocą wywołania RPC, aby ustalić lokalizację pierwszych bloków pliku (krok 2.). Węzeł nazw zwraca adresy węzłów danych zawierających kopie każdego bloku. Ponadto węzły danych są sortowane na podstawie ich bliskości z klientem (ustalana jest ona zgodnie z topologią sieci klastra; zobacz ramkę „Topologia sieci i Hadoop”. Jeśli klient sam jest węzłem danych (może tak być na przykład w modelu MapReduce), wczytuje informacje z lokalnego węzła danych, jeżeli zawiera on kopię potrzebnego bloku (zobacz też rysunek 2.2 i punkt „Skrócony odczyt danych lokalnych” w rozdziale 10.). Obiekt klasy DistributedFileSystem zwraca klientowi obiekt klasy FSDataInputStream (jest to strumień wejściowy z obsługą wyszukiwania), z którego można wczytać dane. W obiekcie klasy FSDataInputStream dostępny jest obiekt klasy DFSInputStream zarządzający operacjami wejścia-wyjścia węzła danych i węzła nazw. Klient wywołuje następnie metodę read() dla strumienia (krok 3.). Obiekt klasy DFSInputStream, który zawiera zapisane adresy węzłów danych dla kilku pierwszych bloków pliku, łączy się wtedy z pierwszym (najbliższym) węzłem danych dla pierwszego bloku. Dane są przesyłane strumieniem z węzła danych do klienta, a ten wielokrotnie wywołuje metodę read() dla strumienia (krok 4.).
Przepływ danych
85
Po dojściu do końca bloku obiekt klasy DFSInputStream zamyka połączenie z węzłem danych i znajduje najlepszy węzeł danych dla następnego bloku (krok 5.). Jest to niezauważalne dla klienta, który może po prostu wczytywać dane z nieprzerwanego strumienia. Bloki są wczytywane po kolei. Obiekt klasy DFSInputStream otwiera nowe połączenia z węzłami danych, gdy klient wczytuje dane ze strumienia. Obiekt w razie potrzeby wywołuje też węzeł nazw, aby pobrać lokalizacje węzłów danych z następną porcją bloków. Gdy klient zakończy odczyt, wywołuje metodę close() obiektu klasy FSDataInputStream (krok 6.). Jeśli w trakcie odczytu obiekt klasy DFSInputStream natrafi na błąd w komunikacji z węzłem danych, spróbuje użyć następnego najbliższego węzła z danym blokiem. Obiekt zapamiętuje też węzły danych, które powodowały problemy, dlatego nie próbuje niepotrzebnie pobierać późniejszych bloków z tych węzłów. Obiekt klasy DFSInputStream sprawdza sumy kontrolne danych przesyłanych z węzła danych. Po wykryciu uszkodzonego bloku próbuje wczytać jego replikę z innego węzła danych oraz informuje węzeł nazw o problemie. Ważnym aspektem opisanego projektu jest to, że klient bezpośrednio kontaktuje się z węzłami danych w celu pobrania informacji i jest kierowany przez węzeł nazw do najlepszych węzłów danych z poszczególnymi blokami. Dzięki temu system HDFS potrafi obsługiwać dużą liczbę równolegle pracujących klientów, ponieważ ruch jest rozkładany między wszystkie węzły danych z klastra. Węzeł nazw musi jedynie obsługiwać żądania określenia lokalizacji bloków (lokalizacja jest zapisana w pamięci, dlatego można ją bardzo szybko zwrócić). Nie musi na przykład udostępniać danych, co mogłoby szybko spowodować zator przy wzroście liczby klientów.
Topologia sieci i Hadoop Co to znaczy, że dwa węzły w lokalnej sieci są „blisko” siebie? W kontekście przetwarzania dużych zbiorów danych ograniczeniem jest szybkość transferu informacji między węzłami. Przepustowość połączeń zawsze jest za niska. Pomysł polega na tym, aby określać odległość między węzłami na podstawie przepustowości połączeń między nimi. Jednak zamiast mierzyć przepustowość połączeń między węzłami, co w praktyce jest trudne (ponieważ wymaga minimalnej aktywności w klastrze, a liczba par węzłów w klastrze rośnie kwadratowo względem liczby węzłów), w Hadoopie zastosowano prostsze podejście. Polega ono na przedstawieniu sieci za pomocą drzewa i wyznaczaniu odległości między dwoma węzłami jako sumy ich odległości do najbliższego wspólnego przodka. Poziomy w drzewie nie są wstępnie definiowane, jednak zwykle uwzględnia się poziomy odpowiadające centrum danych, szafce i węzłowi, w którym proces działa. Założenie jest takie, że przepustowość spada w każdym z wymienionych poniżej scenariuszy. Procesy z jednego węzła. Różne węzły z tej samej szafki. Węzły z różnych szafek z jednego centrum danych. Węzły z różnych centrów danych8.
8
W czasie, gdy powstaje ta książka, Hadoop nie jest dostosowany do obsługi kilku centrów danych jednocześnie.
86
Rozdział 3. System HDFS
Przyjmijmy, że węzeł w1 w szafce s1 w centrum danych c1 jest reprezentowany jako /c1/s1/w1. Oto przedstawione z wykorzystaniem tego zapisu odległości dla czterech wymienionych wcześniej sytuacji. odległość(/c1/s1/w1, /c1/s1/w1) = 0 (procesy z tego samego węzła) odległość(/c1/s1/w1, /c1/s1/w2) = 2 (różne węzły z jednej szafki) odległość(/c1/s1/w1, /c1/s2/w3) = 4 (węzły z różnych półek z jednego centrum danych) odległość(/c1/s1/w1, /c2/s3/w4) = 6 (węzły z różnych centrów danych) Schematycznie przedstawia to rysunek 3.3. Czytelnicy zainteresowani matematyką zauważą, że używana jest tu miara odległości.
Rysunek 3.3. Odległości w sieci w Hadoopie Należy też zwrócić uwagę na to, że Hadoop nie potrafi w magiczny sposób automatycznie wykryć topologii sieci. Potrzebuje do tego pomocy (konfigurowanie topologii jest opisane w punkcie „Topologia sieci” w rozdziale 10.). Domyślnie przyjmuje jednak, że sieć jest płaska (ma strukturę jednopoziomową), a więc wszystkie węzły znajdują się w jednej szafce w tym samym centrum danych. W małych klastrach jest to możliwe. Zmiana konfiguracji nie jest wtedy potrzebna.
Anatomia procesu zapisu danych do pliku Teraz dowiesz się, jak pliki są zapisywane w systemie HDFS. Choć opis jest szczegółowy, warto zapoznać się z przepływem danych, ponieważ pomaga to zrozumieć model zachowywania spójności używany w systemie HDFS. Omawiany jest proces obejmujący tworzenie nowego pliku, zapis w nim danych i zamykanie pliku. Przedstawia to rysunek 3.4. Klient tworzy plik za pomocą wywołania metody create() obiektu klasy DistributedFileSystem (krok 1. na rysunku 3.4). Obiekt klasy DistributedFileSystem kieruje wywołanie RPC do węzła nazw, aby utworzyć nowy plik w przestrzeni nazw systemu plików; początkowo z plikiem nie są powiązane żadne bloki (krok 2.). Węzeł nazw przeprowadza różne testy, aby mieć pewność, że
Przepływ danych
87
Rysunek 3.4. Zapis danych przez klienta w systemie HDFS
plik jeszcze nie istnieje i że klient ma odpowiednie uprawnienia do utworzenia tego pliku. Jeśli testy kończą się powodzeniem, węzeł nazw generuje rekord dla nowego pliku. W przeciwnym razie tworzenie pliku kończy się porażką i do klienta przekazywany jest wyjątek IOException. Obiekt klasy DistributedFileSystem zwraca klientowi obiekt klasy FSDataOutputStream, w którym można rozpocząć zapis danych. Podobnie jak przy odczycie obiekt klasy FSDataOutputStream to nakładka na obiekt klasy DFSOutputStream, który obsługuje komunikację z węzłami danych i węzłem nazw. Gdy klient zapisuje dane (krok 3.), obiekt klasy DFSOutputStream dzieli je na pakiety zapisywane w wewnętrznej kolejce danych. Kolejka danych jest pobierana przez obiekt typu DataStreamer, który odpowiada za zażądanie od węzła nazw przydziału nowych bloków w wybranych z listy węzłach danych nadających się do przechowywania replik bloków. Węzły danych z listy tworzą potok. Tu przyjmijmy, że liczba replik to trzy, tak więc potok obejmuje trzy węzły. Obiekt typu DataStreamer przesyła strumieniem pakiety do pierwszego węzła danych z potoku, a węzeł zapisuje każdy pakiet i przekazuje go dalej do drugiego węzła danych w potoku. Drugi węzeł też zachowuje pakiet, po czym przesyła go do trzeciego (i ostatniego) węzła danych w potoku (krok 4.). Obiekt klasy DFSOutputStream przechowuje wewnętrzną kolejkę pakietów oczekujących na potwierdzenie zapisu od węzłów danych. Jest to kolejka potwierdzeń. Pakiet jest usuwany z niej dopiero po potwierdzeniu jego zapisu przez wszystkie węzły danych z potoku (krok 5.). Jeśli w węźle danych nastąpi awaria w trakcie zapisu, podejmowane są dodatkowe działania, niezauważalne dla klienta zapisującego dane. Najpierw potok zostaje zamknięty, a pakiety z kolejki potwierdzeń trafiają na początek kolejki danych, aby w węzłach danych znajdujących się za uszkodzonym węzłem nie brakowało żadnych pakietów. Bieżący blok ze sprawnych węzłów danych otrzymuje nową tożsamość, o czym poinformowany zostaje węzeł nazw. Dzięki temu częściowo zapisany blok w niedziałającym węźle danych zostanie usunięty, gdy węzeł później wznowi pracę. Niesprawny węzeł danych jest usuwany z potoku, po czym z dwóch działających węzłów danych tworzony jest nowy potok. Pozostałe dane bloku zostają zapisane w sprawnych węzłach. Węzeł nazw wykrywa, że liczba replik bloku jest za mała, i tworzy nową replikę w innym węźle. Dalsze bloki są przetwarzane w standardowy sposób. 88
Rozdział 3. System HDFS
Możliwa (choć mało prawdopodobna) jest awaria kilku węzłów danych w trakcie zapisu bloku. Jeśli zapisanych zostanie przynajmniej dfs.namenode.replication.min replik (wartość domyślna to 1), operacja zapisu kończy się powodzeniem, po czym blok jest asynchronicznie replikowany w klastrze do momentu utworzenia docelowej liczby replik (wartość dfs.replication domyślnie równa 3).
Rozmieszczanie replik W jaki sposób węzeł nazw określa, w których węzłach danych należy zapisać repliki? Wymaga to uwzględnienia niezawodności oraz przepustowości operacji zapisu i odczytu. Umieszczenie wszystkich replik w jednym węźle związane jest z najniższym wykorzystaniem przepustowości (ponieważ potok używany przy replikacji działa w pojedynczym węźle), jednak nie zapewnia nadmiarowości — gdy dany węzeł przestanie działać, dane bloku zostaną utracone. Natomiast odczyt danych spoza szafki powoduje duże obciążenie sieci. Dlatego umieszczenie replik w różnych centrach danych maksymalizuje nadmiarowość, ale wymaga wysokiej przepustowości. Nawet w ramach jednego centrum danych (obecnie wszystkie klastry z Hadoopem pracują w pojedynczych centrach danych) istnieje wiele różnych strategii rozmieszczania replik. Domyślna strategia w Hadoopie polega na umieszczaniu pierwszej repliki w węźle, w którym działa klient (gdy klient pracuje poza klastrem, węzeł jest wybierany losowo, przy czym system stara się unikać przepełnionych lub przeciążonych węzłów). Druga replika jest umieszczana w losowo wybranej innej szafce (poza pierwotną szafką). Trzecia replika trafia do tej samej szafki co druga, ale do losowo wybranego innego węzła. Kolejne repliki są umieszczane w losowych węzłach klastra, a system stara się unikać zapisywania zbyt wielu replik w jednej szafce. Po określeniu lokalizacji replik tworzony jest potok z uwzględnieniem topologii sieci. Gdy liczba replik to trzy, potok może wyglądać tak jak na rysunku 3.5.
Rysunek 3.5. Typowy potok do zapisu replik Ta strategia zapewnia dobrą równowagę między niezawodnością (bloki są zapisywane w dwóch szafkach), przepustowością przy zapisie (zapisywane dane muszą przejść tylko przez jeden przełącznik w sieci), wydajnością odczytu (możliwy jest wybór jednej z dwóch szafek) i rozkładem bloków w klastrze (klienty w lokalnej szafce mogą zapisać tylko jeden blok).
Przepływ danych
89
Klient po zakończeniu zapisu danych wywołuje metodę close() dla strumienia (krok 6.). To powoduje przesłanie wszystkich pozostałych pakietów do potoku węzłów danych i oczekiwanie na potwierdzenia. Następnie klient kontaktuje się z węzłem nazw i sygnalizuje, że plik jest kompletny (krok 7.). Węzeł nazw „wie” już, z których bloków składa się plik (ponieważ obiekt typu DataStreamer żądał przydziału bloków), dlatego musi tylko zaczekać na utworzenie minimalnej liczby replik bloków, po czym może zwrócić informacje o powodzeniu.
Model zapewniania spójności Model zapewniania spójności w systemie plików określa widoczność danych przy zapisie i odczycie plików. W systemie HDFS zrezygnowano ze spełniania niektórych warunków ze standardu POSIX, aby uzyskać wyższą wydajność. Dlatego niektóre operacje mogą przebiegać niezgodnie z oczekiwaniami. Po utworzeniu plik jest (jak można się tego spodziewać) widoczny w przestrzeni nazw systemu plików. Path p = new Path("p"); fs.create(p); assertThat(fs.exists(p), is(true));
Nie ma jednak gwarancji, że widoczne będą dane zapisane w pliku — i to nawet po opróżnieniu strumienia. Dlatego plik może wyglądać tak, jakby miał zerową długość. Path p = new Path("p"); OutputStream out = fs.create(p); out.write("content".getBytes("UTF-8")); out.flush(); assertThat(fs.getFileStatus(p).getLen(), is(0L));
Po zapisaniu więcej niż bloku danych pierwszy blok staje się widoczny dla nowych odbiorców. Dotyczy to także kolejnych bloków. Zawsze to bieżący, właśnie zapisywany blok jest niewidoczny dla pozostałych odbiorców. System HDFS umożliwia wymuszenie opróżnienia wszystkich buforów i zapisania ich zawartości w węzłach danych. Służy do tego metoda hflush() klasy FSDataOutputStream. Po udanym zwróceniu sterowania przez metodę hflush() system HDFS gwarantuje, że dane zapisane do określonego miejsca pliku znajdują się w węzłach danych z potoku używanego przy zapisie i są widoczne dla wszystkich nowych odbiorców. Path p = new Path("p"); FSDataOutputStream out = fs.create(p); out.write("content".getBytes("UTF-8")); out.hflush(); assertThat(fs.getFileStatus(p).getLen(), is(((long) "content".length())));
Zauważ, że metoda hflush() nie gwarantuje, że węzły danych zapisały informacje na dysku. Pewne jest tylko umieszczenie informacji w pamięci węzłów danych, dlatego gdy na przykład w centrum danych nastąpi awaria prądu, informacje mogą zostać utracone. Aby uzyskać dodatkowe gwarancje, zastosuj metodę hsync()9.
9
W wersjach Hadoop 1.x metoda hflush() nosiła nazwę sync(), a metoda hsync() nie istniała.
90
Rozdział 3. System HDFS
Metoda hsync() działa podobnie jak POSIX-owe wywołanie systemowe fsync(), które zapisuje dane z bufora do pliku o podanym deskryptorze. Gdy używasz standardowego interfejsu API Javy do zapisu danych w lokalnym pliku, masz gwarancję, że zobaczysz całą jego zawartość po opróżnieniu strumienia i zsynchronizowaniu stanu. FileOutputStream out = new FileOutputStream(localFile); out.write("content".getBytes("UTF-8")); out.flush(); // Przesłanie danych do systemu operacyjnego out.getFD().sync(); // Synchronizacja dysku assertThat(localFile.length(), is(((long) "content".length())));
Zamknięcie pliku w systemie HDFS oznacza niejawne wywołanie metody hflush(). Path p = new Path("p"); OutputStream out = fs.create(p); out.write("content".getBytes("UTF-8")); out.close(); assertThat(fs.getFileStatus(p).getLen(), is(((long) "content".length())))
Wpływ na projekt aplikacji Model zapewniania spójności ma wpływ na to, jak programiści projektują aplikacje. Rezygnacja z wywołań metod hflush() i hsync() oznacza narażenie się na utratę bloku danych w momencie awarii klienta lub systemu. W wielu aplikacjach jest to nieakceptowalne, dlatego należy w odpowiednich miejscach wywoływać metodę hflush() — na przykład po zapisaniu określonej liczby rekordów lub bajtów. Choć operacja hflush() jest tak zaprojektowana, aby nie obciążała nadmiernie systemu HDFS, powoduje pewne koszty (choć mniejsze niż metoda hsync()). Dlatego trzeba zachować równowagę między niezawodnością a wydajnością. Punkt równowagi zależy od aplikacji. Odpowiednie liczby należy ustalić za pomocą pomiarów wydajności programu przy różnej częstotliwości wywołań metody hflush() (lub hsync()).
Równoległe kopiowanie za pomocą programu distcp Opisane do tego miejsca wzorce dostępu do systemu HDFS dotyczą głównie operacji jednowątkowych. Możliwe jest działanie na kolekcjach plików (na przykład na podstawie wzorców reprezentujących nazwy plików), jednak aby w wydajny sposób przetwarzać te pliki równolegle, trzeba samodzielnie napisać odpowiedni kod. Hadoop udostępnia przydatny program distcp służący do równoległego kopiowania danych do systemu plików Hadoopa i z niego. Program distcp jest wydajnym zastępnikiem instrukcji hadoop fs -cp. Można na przykład skopiować jeden plik do innego10: % hadoop distcp file1 file2
Możliwe jest również kopiowanie katalogów: % hadoop distcp dir1 dir2
Jeśli katalog dir2 nie istnieje, zostanie utworzony, a zawartość katalogu dir1 zostanie do niego skopiowana. Można podać kilka ścieżek źródłowych i wszystkie one zostaną skopiowane do docelowej lokalizacji. 10
Dla dużych plików program distcp jest zalecany nawet przy kopiowaniu jednego dokumentu, ponieważ instrukcja hadoop fs –cp wymaga uruchomienia operacji kopiowania przez klienta.
Równoległe kopiowanie za pomocą programu distcp
91
Jeżeli katalog dir2 już istnieje, katalog dir1 zostanie skopiowany do niego w całości, przez co powstanie struktura katalogów dir2/dir1. Jeśli Ci to nie odpowiada, możesz dodać opcję -overwrite, aby zachować strukturę katalogów i wymusić zastępowanie plików. Można też zaktualizować tylko zmodyfikowane pliki. Służy do tego opcja -update. Najlepiej zilustrować to na przykładzie. Jeśli zmienisz plik z poddrzewa dir1, możesz zsynchronizować zmiany z katalogiem dir2 za pomocą następującej instrukcji: % hadoop distcp -update dir1 dir2
Jeśli nie jesteś pewien, jaki efekt spowoduje wykonanie operacji distcp, warto wypróbować ją najpierw na małym testowym drzewie katalogów.
Program distcp jest zaimplementowany jako zadanie w modelu MapReduce, w którym za kopiowanie odpowiadają operacje mapowania wykonywane równolegle w klastrze. Reduktory nie są tu używane. Każdy plik jest kopiowany przez jedną operację mapowania, a program distcp stara się przydzielać każdej operacji zbliżoną ilość danych, dzieląc pliki na porcje o podobnej wielkości. Domyślnie wykonywanych jest do 20 operacji mapowania. Wartość tę można zmienić za pomocą argumentu -m programu distcp. Program distcp bardzo często jest stosowany do transferu danych między dwoma klastrami z systemem HDFS. Poniższa instrukcja tworzy w drugim klastrze kopię zapasową katalogu /foo z pierwszego klastra. % hadoop distcp -update -delete -p hdfs://namenode1/foo hdfs://namenode2/foo
Opcja -delete powoduje, że program distcp usuwa z docelowej lokalizacji pliki lub katalogi niedostępne w źródle. Opcja -p oznacza, że zachowywane są atrybuty pliku (na przykład uprawnienia, wielkość bloku i informacje o replikacji). Aby wyświetlić precyzyjne instrukcje dotyczące używania programu distcp, wywołaj go bez argumentów. Jeśli w dwóch klastrach działają niezgodne wersje systemu HDFS, można wykorzystać protokół webhdfs, aby przesłać między nimi dane za pomocą programu distcp. % hadoop distcp webhdfs://namenode1:50070/foo webhdfs://namenode2:50070/foo
Inna możliwość to wykorzystanie pośrednika HttpFs jako źródłowej lub docelowej lokalizacji z instrukcji distcp (też używany jest wtedy protokół webhdfs). Ma to tę zaletę, że można ustawić zaporę lub przepustowość (zobacz punkt „HTTP”).
Zachowywanie równowagi w klastrach z systemem HDFS Przy kopiowaniu danych do systemu HDFS istotne jest uwzględnienie równowagi w klastrze. System HDFS działa najlepiej, gdy bloki plików są równomiernie rozłożone w klastrze. Dlatego warto zadbać o to, aby program distcp nie zakłócił tej równowagi. Na przykład przy włączonej opcji -m 1 kopie generuje tylko jedna operacja mapowania, co nie tylko jest powolne i powoduje nieoptymalne wykorzystanie zasobów klastra, ale też oznacza, że w węźle wykonującym tę operację umieszczana jest pierwsza replika każdego bloku (aż do momentu zapełnienia danego dysku). Repliki druga i trzecia są rozdzielane w całym klastrze, jednak wspomniany węzeł jest przeciążony.
92
Rozdział 3. System HDFS
Ten problem nie występuje, gdy operacji mapowania jest więcej niż węzłów klastra. Dlatego początkowo najlepiej jest uruchamiać program distcp z domyślną liczbą 20 operacji mapowania na węzeł. Jednak nie zawsze da się zapobiec nierównowadze w klastrze. Możliwe, że chcesz ograniczyć liczbę operacji mapowania, by wykorzystać niektóre węzły do wykonywania innych zadań. Wtedy możesz wykorzystać narzędzie balancer (zobacz punkt „Program balancer” w rozdziale 11.) do późniejszego zrównoważenia rozkładu bloków w klastrze.
Równoległe kopiowanie za pomocą programu distcp
93
94
Rozdział 3. System HDFS
ROZDZIAŁ 4.
System YARN
Apache YARN (ang. Yet Another Resource Negotiator) to używany w Hadoopie system zarządzania zasobami klastra. YARN został wprowadzony w Hadoopie 2, aby usprawnić implementację modelu MapReduce, jednak okazał się na tyle uniwersalny, że obsługuje też inne modele przetwarzania rozproszonego. YARN zapewnia interfejsy API służące do zgłaszania żądań i używania zasobów klastra. Jednak zwykle w kodzie użytkownika nie stosuje się bezpośrednio tych interfejsów. Zamiast tego użytkownicy korzystają z interfejsów API wyższego poziomu udostępnianych przez platformy przetwarzania rozproszonego, które same są oparte na systemie YARN i ukrywają szczegóły zarządzania zasobami przed użytkownikami. Tę sytuację ilustruje rysunek 4.1, który przedstawia wybrane platformy przetwarzania rozproszonego (MapReduce, Spark itd.) działające jako aplikacje systemu YARN w warstwie obliczeniowej klastra (YARN) i w warstwie przechowywania danych w klastrze (HDFS i HBase).
Rysunek 4.1. Aplikacje systemu YARN
Istnieje też warstwa aplikacji opartych na platformach z rysunku 4.1. Pig, Hive i Crunch to przykładowe platformy przetwarzania wykorzystujące technologie MapReduce, Spark lub Tez (lub wszystkie z nich) i komunikujące się z systemem YARN tylko pośrednio. W tym rozdziale znajdziesz omówienie funkcji systemu YARN i podstawy pozwalające zrozumieć rozdziały z części IV, w której omówione są platformy przetwarzania rozproszonego z Hadoopa.
95
Struktura działania aplikacji w systemie YARN YARN udostępnia podstawowe usługi za pomocą dwóch rodzajów długo działających demonów: menedżera zasobów (jeden na klaster), który zarządza wykorzystaniem zasobów w klastrze, i menedżerów węzłów, działających w każdym węźle klastra i odpowiadających za uruchamianie oraz monitorowanie kontenerów. Kontener wykonuje proces specyficzny dla aplikacji z ograniczonym zbiorem zasobów (pamięci, czasu procesora itd.). W zależności od konfiguracji systemu YARN (zobacz punkt „System YARN” w rozdziale 10.) kontenerem może być proces uniksowy lub proces cgroup w Linuksie. Rysunek 4.2 pokazuje, jak system YARN wykonuje aplikacje.
Rysunek 4.2. Wykonywanie aplikacji w systemie YARN
Aby uruchomić aplikację w systemie YARN, klient kontaktuje się z menedżerem zasobów i żąda od niego uruchomienia procesu zarządcy aplikacji (ang. application master; krok 1. na rysunku 4.2). Menedżer zasobów znajduje następnie menedżera węzła, w którym można uruchomić zarządcę aplikacji w kontenerze (kroki 2a i 2b)1. Działanie zarządcy aplikacji zależy od programu. Może on wykonywać obliczenia w kontenerze, w którym działa, i zwracać wynik do klienta. Może też zażądać dodatkowych kontenerów od menedżerów zasobów (krok 3.) i wykorzystać je do wykonania obliczeń rozproszonych (kroki 4a i 4b). Tak działają aplikacje w modelu MapReduce YARN. Ich szczegółowe omówienie znajdziesz w punkcie „Wykonywanie zadań w modelu MapReduce” w rozdziale 7. 1
Także klient może uruchomić zarządcę aplikacji, na przykład poza klastrem lub w tej samej maszynie JVM. Powstaje wtedy niezarządzany zarządca aplikacji.
96
Rozdział 4. System YARN
Na rysunku 4.2 pokazano, że system YARN nie zapewnia częściom aplikacji (klientowi, zarządcy, procesowi) mechanizmów komunikowania się między sobą. Większość (oprócz najprostszych) aplikacji systemu YARN wykorzystuje jedną z form komunikacji zdalnej, na przykład warstwę wywołań RPC Hadoopa, do przekazywania aktualizacji informacji o stanie i wyników do klienta. Jest to jednak zależne od aplikacji.
Żądania zasobów YARN zapewnia elastyczny model zgłaszania żądań zasobów. W żądaniu zbioru kontenerów można określić ilość zasobów komputera (pamięci i czasu procesora) potrzebnych w każdym kontenerze, a także ograniczenia związane z lokalnością. Lokalność jest bardzo ważna przy zapewnianiu, że algorytmy rozproszonego przetwarzania danych wydajnie wykorzystają przepustowość połączeń w klastrze2. Dlatego system YARN umożliwia aplikacji określanie związanych z lokalnością ograniczeń dla żądanych kontenerów. Takie ograniczenia pozwalają zażądać kontenera z określonego węzła lub danej szafki albo z dowolnego miejsca w klastrze (spoza szafki). Czasem ograniczeń związanych z lokalnością nie da się uwzględnić. Wtedy system nie alokuje zasobów lub, opcjonalnie, rozluźnia ograniczenia. Na przykład jeśli klient zażądał konkretnego węzła, w którym jednak nie można uruchomić kontenera (ponieważ działają w nim inne), system YARN próbuje uruchomić kontener w węźle z tej samej szafki, a gdy jest to niemożliwe, w dowolnym węźle z klastra. Kontenery często uruchamia się w celu przetwarzania bloków z systemu HDFS (na przykład aby wykonać mapowanie w modelu MapReduce). Aplikacja żąda wtedy kontenera z jednego z węzłów przechowujących jedną z trzech replik bloku (w dalszej kolejności wybierany jest węzeł z szafki zawierającej replikę lub, jeśli i on jest niedostępny, dowolny węzeł klastra). Aplikacja systemu YARN może zażądać zasobów w dowolnym momencie. Może na przykład zgłosić wszystkie żądania na początku pracy lub zastosować bardziej dynamiczne podejście i na bieżąco żądać zasobów w celu zaspokojenia zmieniających się wymagań aplikacji. W platformie Spark wykorzystano pierwsze z tych podejść — w klastrze uruchamiana jest stała liczba jednostek wykonawczych (zobacz punkt „Spark i YARN” w rozdziale 19.). Natomiast w modelu MapReduce występują dwie fazy. Kontenery dla operacji mapowania są żądane na początku pracy, natomiast kontenery dla operacji redukcji są uruchamiane dopiero później. Ponadto jeśli któraś z operacji zakończy się niepowodzeniem, zgłaszane jest żądanie dodatkowych kontenerów. Pozwala to ponownie uruchomić niewykonane operacje.
Czas życia aplikacji Czas życia aplikacji systemu YARN jest bardzo zróżnicowany. Występują zarówno aplikacje działające kilka sekund, jak i długie programy pracujące po kilka dni, a nawet miesięcy. Zamiast uwzględniać czas działania aplikacji, warto pokategoryzować programy ze względu na ich powiązania 2
Więcej na ten temat dowiesz się z punktu „Skalowanie” w rozdziale 2. i ramki „Topologia sieci i Hadoop” w rozdziale 3.
Struktura działania aplikacji w systemie YARN
97
z zadaniami uruchamianymi przez użytkowników. Najprostszy scenariusz to jedna aplikacja na jedno zadanie użytkownika. To podejście jest wykorzystywane w modelu MapReduce. Drugie podejście to uruchamianie jednej aplikacji na sesję użytkownika z (potencjalnie niepowiązanymi) zadaniami. To rozwiązanie może być wydajniejsze od pierwszego, ponieważ pozwala wykorzystać kontenery dla różnych zadań. Możliwe jest też zapisywanie między kolejnymi zadaniami pośrednich danych w pamięci podręcznej. To podejście jest używane w Sparku. Trzecie rozwiązanie to długo działające aplikacje współużytkowane przez różne osoby. Takie aplikacje często pełnią funkcję koordynatora. Na przykład w narzędziu Apache Slider (http://slider.incubator. apache.org/) używany jest długo działający zarządca aplikacji przeznaczony do uruchamiania innych aplikacji z klastra. To podejście jest też używane w Impali (zobacz punkt „Inne silniki obsługujące język SQL w Hadoopie” w rozdziale 17.) do tworzenia aplikacji pośredniczącej, z którą demony Impali się komunikują, gdy żądają zasobów klastra. Zawsze dostępny zarządca aplikacji powoduje, że czas odpowiedzi na zapytania użytkowników jest bardzo krótki, ponieważ nie trzeba ponosić kosztów uruchamiania nowego zarządcy aplikacji3.
Budowanie aplikacji systemu YARN Pisanie aplikacji systemu YARN od podstaw jest dość skomplikowane, jednak często nie jest to konieczne, ponieważ można wykorzystać odpowiednią istniejącą aplikację. Na przykład jeśli chcesz uruchamiać zadania zapisane w nieskierowanym grafie acyklicznym, odpowiednim narzędziem jest Spark lub Tez. Do przetwarzania strumieni możesz wykorzystać narzędzia Spark, Samza lub Storm4. Istnieje kilka projektów, które upraszczają proces budowania aplikacji systemu YARN. Wspomniane wcześniej narzędzie Apache Slider umożliwia uruchamianie istniejących aplikacji rozproszonych w systemie YARN. Użytkownicy mogą uruchamiać własne egzemplarze aplikacji (na przykład bazy HBase) w klastrze niezależnie od innych osób. Oznacza to, że poszczególni użytkownicy mogą korzystać z różnych wersji tej samej aplikacji. Narzędzie Slider zapewnia mechanizmy do zmiany liczby węzłów używanych przez aplikację, a także do wstrzymywania i wznawiania pracy programów. Apache Twill (http://twill.incubator.apache.org/) to narzędzie podobne do Slidera, ale dodatkowo udostępniające prosty model programowania służący do rozwijania aplikacji rozproszonych w systemie YARN. Twill pozwala zdefiniować procesy klastra jako rozszerzenia interfejsu Runnable Javy, a następnie uruchamia je w kontenerach systemu YARN w klastrze. Twill zapewnia między innymi obsługę rejestrowania zdarzeń w czasie rzeczywistym (rejestrowane zdarzenia z obiektów typu Runnable są przekazywane z powrotem do klienta) i komunikatów z instrukcjami (przesyłanych od klientów do obiektów typu Runnable). Jeśli żadne z gotowych rozwiązań nie jest wystarczająco dobre (na przykład gdy aplikacja ma skomplikowane wymagania związane z szeregowaniem zadań), jako przykład przy pisaniu aplikacji systemu YARN można wykorzystać kod powłoki rozproszonej z projektu YARN. Ten kod pokazuje, jak używać klienckich interfejsów API systemu YARN do komunikowania się klienta lub zarządcy aplikacji z demonami systemu. 3
Kod zarządcy aplikacji o krótkim czasie reakcji jest używany w projekcie Llama (http://cloudera.github.io/llama/).
4
Wszystkie te projekty są prowadzone przez organizację Apache Software Foundation.
98
Rozdział 4. System YARN
System YARN a implementacja MapReduce 1 Rozproszoną implementację modelu MapReduce z pierwotnej wersji Hadoopa (wersja 1 i starsze) czasem nazywa się „MapReduce 1”, aby odróżnić ją od MapReduce 2 — implementacji opartej na systemie YARN (stosowanej w Hadoopie 2 i nowszych wersjach). Należy zauważyć, że dawny i nowy interfejs API modelu MapReduce to nie to samo co implementacje MapReduce 1 i MapReduce 2. Interfejsy API wyznaczają funkcje dostępne dla użytkownika po stronie klienta i określają sposób pisania programów w modelu MapReduce (zobacz dodatek D). Implementacje to po prostu różne mechanizmy wykonywania programów w modelu MapReduce. Dostępne są więc wszystkie cztery kombinacje — dawnego i nowego interfejsu API z implementacjami MapReduce 1 i 2.
W implementacji MapReduce 1 istnieją dwa rodzaje demonów kontrolujących proces wykonywania zadań — jobtracker i jeden lub więcej tasktrackerów. Jobtracker koordynuje wszystkie zadania działające w systemie i szereguje operacje wykonywane przez tasktrackery. Tasktrackery uruchamiają operacje i przekazują raporty o postępach do jobtrackera, który przechowuje rejestr ogólnego postępu wykonywania każdego zadania. Jeśli operacja zakończy się niepowodzeniem, jobtracker może ponownie zaszeregować ją do wykonania w innym tasktrackerze. W implementacji MapReduce 1 jobtracker odpowiada zarówno za szeregowanie zadań (łączenie operacji z tasktrackerami), jak i monitorowanie postępów wykonania operacji (śledzenie operacji, ponowne uruchamianie nieudanych lub powolnych operacji i wykonywanie czynności administracyjnych takich jak zarządzanie licznikami). W systemie YARN za te czynności odpowiadają odrębne jednostki — menedżer zasobów i zarządca aplikacji (po jednym dla każdego zadania w modelu MapReduce). Jobtracker odpowiada też za przechowywanie historii ukończonych zadań, choć możliwe jest uruchomienie serwera z historią jako odrębnego demona w celu zmniejszenia obciążenia jobtrackera. W systemie YARN historię aplikacji przechowuje serwer historii5. W systemie YARN odpowiednikiem tasktrackera jest menedżer węzła. Odpowiadające sobie komponenty są wymienione w tabeli 4.1. Tabela 4.1. Porównanie komponentów z implementacji MapReduce 1 i systemu YARN MapReduce 1
YARN
Jobtracker
Menedżer zasobów, zarządca aplikacji, serwer historii
Tasktracker
Menedżer węzła
Slot
Kontener
System YARN zaprojektowano w celu wyeliminowania licznych ograniczeń implementacji Map Reduce 1. Oto wybrane zalety stosowania systemu YARN.
5
W wersji Hadoop 2.5.1 serwer historii systemu YARN nie przechowuje jeszcze historii zadań z modelu MapReduce. Dlatego nadal potrzebny jest demon serwera historii zadań z tego modelu (zobacz punkt „Przygotowywanie i instalowanie klastra” w rozdziale 10.).
System YARN a implementacja MapReduce 1
99
Skalowalność System YARN potrafi obsługiwać większe klastry niż implementacja MapReduce 1. W MapReduce 1 zatory zaczynają występować przy około 4000 węzłów i 40 000 operacji6, co wynika z tego, że jobtracker musi zarządzać zarówno zadaniami, jak i operacjami. W systemie YARN dzięki podziałowi architektury na menedżera zasobów i zarządcę aplikacji te ograniczenia są wyższe i wynoszą 10 000 węzłów oraz 100 000 operacji. Inaczej niż przy stosowaniu jobtrackera każdy egzemplarz aplikacji (tu jest nim zadanie w modelu MapReduce) ma dedykowanego zarządcę aplikacji, który działa przez czas jej pracy. Ten model bardziej przypomina rozwiązanie opisane w poświęconym modelowi MapReduce pierwotnym tekście firmy Google, gdzie wyjaśniono, że proces nadrzędny jest uruchamiany w celu koordynowania operacji mapowania i redukowania wykonywanych w zbiorze jednostek roboczych. Dostępność Wysoką dostępność uzyskuje się zwykle w wyniku replikacji stanu niezbędnego innemu demonowi do przejęcia zadań potrzebnych do świadczenia usługi, gdy pierwotny demon usługowy ulegnie awarii. Jednak duża liczba szybko zmieniających się informacji o skomplikowanym stanie w pamięci jobtrackera (status każdej operacji jest aktualizowany co kilka sekund) bardzo utrudnia dostosowanie mechanizmów zapewniania wysokiej dostępności do usług jobtrackera. Ponieważ w systemie YARN obowiązki jobtrackera są rozdzielone między menedżera zasobów i zarządcę aplikacji, zapewnienie wysokiej dostępności usługi staje się problemem typu „dziel i zwyciężaj”. Należy zapewnić wysoką dostępność dla każdego menedżera zasobów, a następnie dla aplikacji systemu YARN (dla każdej z osobna). Hadoop 2 obsługuje zapewnianie wysokiej dostępności zarówno menedżera zasobów, jak i zarządcy aplikacji dla zadań z modelu MapReduce. Przełączanie awaryjne w systemie YARN jest opisane szczegółowo w punkcie „Niepowodzenia” w rozdziale 7. Wykorzystanie zasobów W implementacji MapReduce 1 dla każdego tasktracera są statycznie przydzielane sloty o stałej wielkości. W czasie konfigurowania są one dzielone na sloty mapowania i sloty redukcji. Sloty mapowania mogą być używane tylko w operacjach mapowania, a sloty redukcji — tylko w operacjach redukowania. W systemie YARN menedżer węzła zarządza pulą zasobów. Nie ma tu stałej liczby przydzielonych slotów. Gdy algorytm MapReduce działa w systemie YARN, nie jest narażony na to, że operacja redukcji musi zostać wstrzymana, ponieważ w klastrze dostępne są tylko sloty mapowania (w implementacji MapReduce 1 może się tak zdarzyć). Jeśli zasoby potrzebne do wykonania operacji są dostępne, aplikacja je otrzyma. Ponadto zasoby w systemie YARN są podzielone na niewielkie porcje. Dlatego aplikacja może zażądać ich tyle, ile potrzebuje, zamiast zajmować niepodzielny slot, który może okazać się za duży (co oznacza marnotrawstwo zasobów) lub za mały (co może spowodować awarię) dla konkretnej operacji. 6
Arun C. Murthy, „The Next Generation of Apache Hadoop MapReduce”, 14 lutego 2011 (https://developer.yahoo.com/blogs/hadoop/next-generation-apache-hadoop-mapreduce-3061.html).
100
Rozdział 4. System YARN
Obsługa wielu technologii Pod niektórymi względami największą zaletą systemu YARN jest to, że umożliwia wykorzystanie w Hadoopie aplikacji rozproszonych innych niż implementacja MapReduce. MapReduce to tylko jedna z wielu aplikacji systemu YARN. Użytkownicy mogą nawet uruchamiać w tym samym klastrze z systemem YARN różne wersje implementacji MapReduce. Dzięki temu proces aktualizowania takich implementacji jest łatwiejszy. Zauważ jednak, że niektóre części implementacji MapReduce, na przykład serwer historii zadań i mechanizm obsługi fazy przestawiania, a także sam system YARN, trzeba aktualizować na poziomie całego klastra. Ponieważ Hadoop 2 to powszechnie używana i najnowsza stabilna wersja, dalej w książce określenie „MapReduce” dotyczy implementacji MapReduce 2 (chyba że jest napisane inaczej). W rozdziale 7. znajdziesz szczegółowe omówienie działania modelu MapReduce w systemie YARN.
Szeregowanie w systemie YARN W idealnych warunkach żądania zgłaszane przez aplikacje systemu YARN byłyby obsługiwane natychmiast. Jednak w praktyce zasoby są ograniczone. W obciążonym klastrze aplikacja często musi oczekiwać na obsłużenie niektórych żądań. Program szeregujący systemu YARN odpowiada za przydział zasobów aplikacjom zgodnie ze zdefiniowaną polityką. Szeregowanie to trudny problem i nie istnieje jedno najlepsze rozwiązanie. Dlatego system YARN udostępnia kilka programów szeregujących i konfigurowalnych polityk. Ich opis znajdziesz poniżej.
Dostępne programy szeregujące W systemie YARN dostępne są trzy programy szeregujące: FIFO, Capacity i Fair. Program szeregujący FIFO umieszcza aplikacje w kolejce i uruchamia je zgodnie z kolejnością zgłoszeń (pierwsza na wejściu, pierwsza na wyjściu). Najpierw obsługiwane są więc żądania pierwszej aplikacji z kolejki, po ich przetworzeniu żądania następnej aplikacji itd. Program szeregujący FIFO jest prosty do zrozumienia i nie wymaga konfigurowania, jednak nie nadaje się do stosowania we współużytkowanych klastrach. Duże aplikacje zajmują wszystkie zasoby klastra, dlatego każda aplikacja musi czekać na swoją kolej. W klastrze współużytkowanym lepiej jest korzystać z programów szeregujących Capacity lub Fair. Oba umożliwiają stosunkowo szybkie ukończenie długich zadań, a jednocześnie pozwalają w rozsądnym czasie zwracać wyniki krótszych, wykonywanych współbieżnie zapytań ad hoc od użytkowników. Różnice między programami szeregującymi przedstawia rysunek 4.3. Pokazuje on, że w programie szeregującym FIFO (1) krótkie zadania są blokowane do czasu ukończenia długich. W programie szeregującym Capacity (2 na rysunku 4.3) odrębna specjalna kolejka umożliwia rozpoczęcie krótkich zadań bezpośrednio po ich zgłoszeniu, przy czym dzieje się to kosztem ogólnego wykorzystania zasobów klastra, ponieważ zasoby są rezerwowane dla zadań z tej kolejki. To oznacza, że długie zadania są kończone później niż przy korzystaniu z programu szeregującego FIFO.
Szeregowanie w systemie YARN
101
Rysunek 4.3. Wykorzystanie zasobów klastra w trakcie wykonywania długiego i krótkiego zadania za pomocą programów szeregujących FIFO (1), Capacity (2) i Fair (3)
102
Rozdział 4. System YARN
Przy stosowaniu programu szeregującego Fair (3 na rysunku 4.3) nie trzeba rezerwować określonej ilości zasobów, ponieważ są one dynamicznie rozdzielane między wszystkie wykonywane zadania. Bezpośrednio po uruchomieniu pierwszego (długiego) zadania jest ono jedynym działającym, dlatego otrzymuje wszystkie zasoby klastra. Gdy drugie (krótkie) zadanie rozpoczyna pracę, otrzymuje połowę zasobów klastra. W ten sposób każde zadanie otrzymuje uczciwą część zasobów. Zauważ, że od momentu uruchomienia drugiego zadania do chwili przydzielenia mu zasobów upływa trochę czasu. Drugie zadanie musi oczekiwać na zwolnienie zasobów, co dzieje się po ukończeniu pracy przez kontenery używane przez pierwsze zadanie. Gdy krótkie zadanie zostanie wykonane i nie potrzebuje już zasobów, długie może ponownie wykorzystać wszystkie możliwości klastra. W efekcie zasoby klastra są wykorzystywane w dużym stopniu, a krótkie zadania szybko kończą pracę. Na rysunku 4.3 porównano podstawowy schemat pracy trzech programów szeregujących. W dwóch dalszych punktach poznasz zaawansowane opcje konfiguracyjne programów szeregujących Capacity i Fair.
Konfigurowanie programu szeregującego Capacity Program szeregujący Capacity umożliwia współużytkowanie klastra Hadoopa przez różne organizacje. Każda firma otrzymuje wtedy określoną ilość zasobów całego klastra i ma przeznaczoną dla niej kolejkę, skonfigurowaną tak, aby używała danej części zasobów. Kolejki można dodatkowo dzielić w hierarchiczny sposób, co umożliwia każdej firmie współużytkowanie przydzielonych jej zasobów klastra przez różne grupy użytkowników. W ramach kolejki aplikacje są szeregowane metodą FIFO. Na rysunku 4.3 pokazano, że jedno zadanie nie zużywa więcej zasobów, niż jest ich przydzielonych kolejce. Jeśli jednak w kolejce znajduje się więcej niż jedno zadanie i dostępne są niewykorzystane zasoby, program szeregujący Capacity może je przydzielić innym zadaniom kolejki, nawet jeśli spowoduje to przekroczenie ilości zasobów przewidzianych dla tej kolejki7. Na tym polega elastyczność kolejek. Przy normalnej pracy program szeregujący Capacity nie wywłaszcza kontenerów przez wymuszone zamykanie ich8. Dlatego jeśli kolejka nie wykorzystuje swoich zasobów (z powodu braku zapotrzebowania na nie), a następnie wymagania wzrosną, kolejka otrzyma wszystkie zasoby dopiero po ich zwolnieniu w wyniku zakończenia pracy przez kontenery z innych kolejek. Aby to zmienić, można ustawić maksymalną ilość zasobów dla kolejek, tak by nie zużywały zbyt wielu zasobów innych kolejek. Oczywiście dzieje się to kosztem elastyczności kolejek, dlatego metodą prób i błędów trzeba określić odpowiednią równowagę.
7
Jeśli właściwość yarn.scheduler.capacity..user-limit-factor ma wartość większą niż ustawienie domyślne 1, to pojedyncze zadanie może otrzymać więcej zasobów, niż przypisano ich kolejce.
8
Program szeregujący Capacity może jednak stosować wywłaszczanie bez wymuszonego kończenia pracy. Menedżer zasobów żąda wtedy od aplikacji zwrócenia kontenerów w celu zrównoważenia zużycia zasobów.
Szeregowanie w systemie YARN
103
Załóżmy, że hierarchia kolejek wygląda tak jak poniżej: root ├── prod └── dev ├── eng └── science
Listing 4.1 przedstawia przykładowy plik konfiguracyjny dla tej hierarchii (capacity-scheduler.xml) przy stosowaniu programu szeregującego Capacity. W kolejce root zdefiniowane są dwie kolejki, prod i dev, którym przydzielone jest 40% i 60% zasobów. Zauważ, że kolejkę konfiguruje się za pomocą właściwości w postaci yarn.scheduler.capacity.., gdzie to ścieżka do kolejki w hierarchii, na przykład root.prod. Listing 4.1. Podstawowy plik konfiguracyjny programu szeregującego Capacity
yarn.scheduler.capacity.root.queues prod,dev
yarn.scheduler.capacity.root.dev.queues eng,science
yarn.scheduler.capacity.root.prod.capacity 40
yarn.scheduler.capacity.root.dev.capacity 60
yarn.scheduler.capacity.root.dev.maximum-capacity 75
yarn.scheduler.capacity.root.dev.eng.capacity 50
yarn.scheduler.capacity.root.dev.science.capacity 50
Widać tu, że kolejka dev jest podzielona na kolejki eng i science o równej ilości zasobów. Aby kolejka dev nie zajmowała wszystkich zasobów klastra w czasie, gdy kolejka prod jest pusta, maksymalna ilość zasobów kolejki dev została ustawiona na 75%. Oznacza to, że kolejka prod zawsze ma do dyspozycji 25% zasobów klastra. Ponieważ dla pozostałych kolejek nie ustawiono maksymalnej ilości zasobów, może się zdarzyć, że kolejka eng lub science zajmie wszystkie zasoby kolejki dev (czyli do 75% zasobów klastra). Ponadto kolejka prod może zająć wszystkie zasoby klastra. Oprócz ustawiania hierarchii i ilości zasobów kolejek można też kontrolować maksymalną liczbę zasobów dla pojedynczego użytkownika lub określonej aplikacji, liczbę jednocześnie działających aplikacji i listy ACL kolejek. Szczegółowe informacje znajdziesz na stronie http://hadoop.apache.org/ docs/current/hadoop-yarn/hadoop-yarn-site/CapacityScheduler.html. 104
Rozdział 4. System YARN
Rozmieszczanie aplikacji w kolejkach Sposób rozmieszczania aplikacji w kolejkach zależy od sytuacji. Na przykład w modelu MapReduce można ustawić we właściwości mapreduce.job.queuename nazwę używanej kolejki. Jeśli podana kolejka nie istnieje, w momencie przydzielania do niej zadania wystąpi błąd. Jeżeli programista nie określi kolejki, aplikacje będą umieszczane w kolejce o nazwie default. W programie szeregującym Capacity jako nazwę kolejki należy podać tylko ostatni człon nazwy hierarchicznej, ponieważ pełne nazwy hierarchiczne nie są rozpoznawane. Dlatego w konfiguracji z wcześniejszego przykładu dozwolone są nazwy prod i eng, natomiast nazwy root.dev.eng i dev.eng nie zadziałają.
Konfigurowanie programu szeregującego Fair Program szeregujący Fair próbuje tak przydzielać zasoby, aby wszystkie działające aplikacje otrzymały tę samą ich ilość. Rysunek 4.3 pokazuje, jak uczciwy podział działa dla aplikacji z jednej kolejki. Dalej zobaczysz, że uczciwy podział obowiązuje także między kolejkami. W kontekście programu szeregującego Fair nazwy kolejka i pula są używane zamiennie.
Aby zrozumieć współużytkowanie zasobów przez kolejki, wyobraź sobie, że dwóch użytkowników, A i B, korzysta z odrębnych kolejek (rysunek 4.4). Użytkownik A uruchamia zadanie i otrzymuje wszystkie dostępne zasoby, ponieważ użytkownik B ich nie potrzebuje. Następnie użytkownik B uruchamia zadanie, gdy zadanie użytkownika A wciąż działa. Po chwili każde zadanie zużywa połowę zasobów. Jeśli teraz użytkownik B uruchomi drugie zadanie (wcześniejsze wciąż są kontynuowane), będzie ono współużytkować zasoby z drugim zadaniem tego użytkownika. Oba zadania użytkownika B będą więc zużywać jedną czwartą zasobów, a zadanie użytkownika A wciąż będzie miało dostęp do połowy zasobów. W efekcie zasoby są uczciwie rozdzielane między użytkowników.
Rysunek 4.4. Uczciwy podział zasobów między kolejkami użytkowników
Szeregowanie w systemie YARN
105
Włączanie programu szeregującego Fair To, który program szeregujący jest używany, zależy od ustawienia yarn.resourcemanager.scheduler. class. Domyślnie stosowany jest program szeregujący Capacity (choć w niektórych dystrybucjach Hadoopa, na przykład w CDH, domyślnie używany jest program szeregujący Fair). Można to zmienić, przypisując do właściwości yarn.resourcemanager.scheduler.class w pliku yarn-site.xml pełną nazwę klasy wybranego programu szeregującego, na przykład org.apache.hadoop.yarn.server. resourcemanager.scheduler.fair.FairScheduler.
Konfigurowanie kolejki Program szeregujący Fair jest konfigurowany za pomocą pliku alokacji fair-scheduler.xml wczytywanego na podstawie ścieżki do klas. Nazwę używanego pliku można zmienić za pomocą właściwości yarn.scheduler.fair.allocation.file. Gdy plik alokacji nie jest podany, program szeregujący Fair działa w opisany wcześniej sposób. Każda aplikacja jest umieszczana w kolejce o nazwie odpowiadającej użytkownikowi, a kolejki są tworzone dynamicznie w momencie przesłania pierwszej aplikacji przez użytkownika. Konfiguracja poszczególnych kolejek jest zapisywana w pliku alokacji. Pozwala to konfigurować kolejki hierarchiczne (takie, jakie obsługiwane są przez program szeregujący Capacity). W pliku alokacji przedstawionym na listingu 4.2 zdefiniowane są kolejki prod i dev (wcześniej używane w programie szeregującym Capacity). Listing 4.2. Plik alokacji dla programu szeregującego Fair
fair
40 fifo
60
Hierarchia kolejki jest definiowana za pomocą zagnieżdżonych elementów queue. Wszystkie kolejki to dzieci kolejki root — także wtedy, gdy nie są bezpośrednio zagnieżdżone w elemencie root. W przykładzie kolejka dev jest podzielona na kolejki eng i science. Kolejki mogą mieć wagi. Służą one do wyznaczania uczciwego podziału zasobów. W przykładzie alokacja zasobów klastra jest uważana za uczciwą, gdy są one rozdzielane w stosunku 40 do 60 między kolejki prod i dev. Kolejki eng i science nie mają określonych wag, dlatego mają przyznawaną
106
Rozdział 4. System YARN
taką samą ilość zasobów. Wagi nie są identyczne z procentami, choć w przykładzie dla uproszczenia używane są wartości sumujące się do 100. Dlatego do kolejek prod i dev można przypisać wagi 2 i 3, a efekt będzie taki sam. Przy ustawianiu wag pamiętaj, aby uwzględnić kolejkę domyślną i dynamicznie tworzone kolejki (na przykład kolejki o nazwach generowanych na podstawie nazw użytkowników). Nie są one określone w pliku alokacji, a ich waga to 1.
Dla kolejek można ustawiać różne polityki szeregowania. Politykę domyślną ustawia się w elemencie defaultQueueSchedulingPolicy z najwyższego poziomu. Jeśli jej nie podasz, użyte zostanie szeregowanie Fair. Mimo swojej nazwy program szeregujący Fair obsługuje też politykę FIFO (fifo), a także opisaną dalej w rozdziale Dominant Resource Fairness (drf). Politykę dla danej kolejki można zmienić za pomocą elementu schedulingPolicy. Tu dla kolejki prod stosowane jest szeregowanie FIFO, ponieważ każde zadanie produkcyjne powinno być uruchamiane sekwencyjnie i kończyć pracę w jak najkrótszym czasie. Zauważ, że podział zasobów między kolejki prod i dev nadal odbywa się w sposób uczciwy. To samo dotyczy kolejek eng i science. Choć w pokazanym pliku alokacji nie jest to uwzględnione, dla kolejek można ustawiać minimalną i maksymalną ilość zasobów, a także maksymalną liczbę działających aplikacji. Szczegółowe informacje znajdziesz na stronie http://hadoop.apache.org/docs/current/hadoop-yarn/hadoop-yarn-site/ FairScheduler.html. Minimalna ilość zasobów nie jest twardym limitem; służy jedynie programowi szeregującemu do określania priorytetów przy alokacji zasobów. Jeśli dwie kolejki otrzymują mniej niż uczciwą część zasobów, zasoby są przyznawane najpierw tej, dla której poziom alokacji bardziej odbiega od minimum. Poziom minimalny jest też używany przy opisanym dalej wywłaszczaniu.
Rozmieszczanie w kolejkach Program szeregujący Fair wykorzystuje oparty na regułach system do ustalania, w której kolejce należy umieścić daną aplikację. Na listingu 4.2 element queuePlacementPolicy zawiera listę reguł, z których każda jest po kolei sprawdzana do momentu wykrycia akceptowalnej. Pierwsza reguła, specified, umieszcza aplikację w podanej kolejce. Jeśli nie wskazano żadnej kolejki lub podana kolejka nie istnieje, reguła jest nieakceptowalna i należy sprawdzić następną. Reguła primaryGroup próbuje umieścić aplikację w kolejce o nazwie pasującej do nazwy głównej grupy uniksowej użytkownika. Jeżeli taka kolejka nie istnieje, nie należy jej tworzyć, tylko przejść do następnej reguły. Reguła default jest uniwersalna i zawsze umieszcza aplikację w kolejce dev.eng. Element queuePlacementPolicy można w całości pominąć. Wtedy domyślne działanie systemu jest takie jak przy ustawieniu:
Oznacza to, że jeśli nazwa kolejki nie zostanie bezpośrednio podana, wykorzystana zostanie kolejka mająca nazwę użytkownika. Jeżeli taka kolejka nie istnieje, zostanie utworzona.
Szeregowanie w systemie YARN
107
Inna prosta polityka rozmieszczania w kolejce polega na dodawaniu wszystkich aplikacji do tej samej (domyślnej) kolejki. To pozwala na uczciwy podział zasobów między aplikacje zamiast między użytkowników. To podejście działa tak jak poniższe ustawienie:
Tę politykę można też zastosować bez posługiwania się plikiem alokacji. W tym celu należy ustawić właściwość yarn.scheduler.fair.user-as-default-queue na wartość false. Aplikacje są wtedy umieszczane w kolejce domyślnej, a nie w kolejkach odpowiadających użytkownikom. Ponadto właściwość yarn.scheduler.fair.allow-undeclared-pools powinna być ustawiona na wartość false, by użytkownicy nie mogli dynamicznie tworzyć kolejek.
Wywłaszczanie Gdy zadanie jest przesyłane do pustej kolejki w obciążonym klastrze, nie może rozpocząć pracy do momentu zwolnienia zasobów przez już wykonywane w tym klastrze zadania. Aby zwiększyć przewidywalność momentu rozpoczęcia wykonywania zadania, program szeregujący Fair obsługuje wywłaszczanie. Wywłaszczanie umożliwia programowi szeregującemu zamknięcie kontenerów kolejek, które wykorzystują więcej niż uczciwą część zasobów. Dzięki temu zasoby można udostępnić kolejce, która otrzymała mniej niż uczciwy przydział zasobów. Zauważ, że wywłaszczanie zmniejsza ogólną wydajność klastra, ponieważ zadania z zamkniętych kontenerów trzeba ponownie wykonać. Aby włączyć wywłaszczanie na poziomie globalnym, ustaw właściwość yarn.scheduler.fair.preemption na wartość true. Dostępne są dwa ustawienia limitów czasu związanych z wywłaszczaniem. Jeden dotyczy minimalnej części zasobów, a drugi uczciwej części zasobów. Oba limity podaje się w sekundach. Domyślnie limity czasu nie są aktywne, dlatego musisz ustawić przynajmniej jeden z nich, aby umożliwić wywłaszczanie kontenerów. Jeśli kolejka oczekuje przez limit czasu dla minimalnej części zasobów bez otrzymania gwarantowanej minimalnej części zasobów, program szeregujący może wywłaszczyć inne kontenery. Domyślny limit czasu dla wszystkich kolejek ustawia się w pliku alokacji za pomocą elementu default MinSharePreemptionTimeout z najwyższego poziomu. Dla poszczególnych kolejek limit można ustawić przy użyciu elementu minSharePreemptionTimeout wybranej kolejki. Podobnie jeśli kolejka ma mniej niż połowę uczciwej części zasobów przez limit czasu dla uczciwej części zasobów, program szeregujący może wywłaszczyć inne kontenery. Domyślny limit czasu dla wszystkich kolejek ustawia się w pliku alokacji za pomocą elementu defaultFairSharePreemption Timeout z najwyższego poziomu. Dla poszczególnych kolejek limit można ustawić przy użyciu elementu fairSharePreemptionTimeout dla poszczególnych kolejek. Ponadto próg części zasobów aktywujący wywłaszczanie można zmienić z domyślnej wartości 0.5 za pomocą elementów default FairSharePreemptionThreshold (ogólny) i fairSharePreemptionThreshold (dla poszczególnych kolejek).
108
Rozdział 4. System YARN
Szeregowanie z opóźnieniem Wszystkie programy szeregujące z systemu YARN starają się uwzględniać żądania dotyczące lokalności. Jeśli aplikacja zażąda konkretnego węzła w obciążonym klastrze, możliwe, że w danym momencie z węzła będą korzystać inne kontenery. Oczywistym rozwiązaniem jest rozluźnienie wymagań związanych z lokalnością i przydzielenie aplikacji kontenera z tej samej szafki. Jednak w praktyce zaobserwowano, że odczekanie krótkiej chwili (wystarczy kilka sekund) znacznie podnosi prawdopodobieństwo przydzielenia kontenera z żądanego węzła, co prowadzi do wyższej wydajności klastra. Ten mechanizm to szeregowanie z opóźnieniem. Jest on obsługiwany przez programy szeregujące Capacity i Fair. Każdy menedżer węzła w klastrze w systemie YARN okresowo wysyła sygnał kontrolny do menedżera zasobów (domyślnie odbywa się to co sekundę). Sygnały kontrolne obejmują informacje o kontenerach zarządzanych przez danego menedżera węzła i o zasobach dostępnych nowym kontenerom. Dlatego każdy sygnał kontrolny oznacza dla aplikacji możliwość zaszeregowania i uruchomienia dla niej kontenera. Przy szeregowaniu z opóźnieniem program szeregujący nie ogranicza się do wykorzystania pierwszej dostępnej możliwości zaszeregowania. Zamiast tego oczekuje ustaloną maksymalną liczbę możliwości zaszeregowania, a dopiero potem rozluźnia ograniczenia związane z lokalnością i wykorzystuje następną możliwość. W programie szeregującym Capacity szeregowanie z opóźnieniem można skonfigurować w wyniku ustawienia właściwości yarn.scheduler.capacity.node-locality-delay na dodatnią liczbę całkowitą. Reprezentuje ona liczbę możliwości zaszeregowania, które można pominąć przed rozluźnieniem ograniczeń i wybraniem dowolnego węzła z tej samej szafki. Program szeregujący także wykorzystuje liczbę możliwości zaszeregowania do określenia opóźnienia, choć tu jest ono wyznaczane proporcjonalnie do wielkości klastra. Na przykład ustawienie właściwości yarn.scheduler.fair.locality.threshold.node na wartość 0.5 oznacza, że program szeregujący powinien odczekać, aż połowa węzłów klastra przedstawi możliwość zaszeregowania, i dopiero potem zaakceptować następny węzeł z tej samej szafki. Dostępna jest też właściwość yarn.scheduler.fair.locality.threshold.rack służąca do ustawiania progu, po którym program szeregujący może wybrać inną szafkę.
Podejście Dominant Resource Fairness Jeśli szeregowanie dotyczy tylko jednego rodzaju zasobów (na przykład pamięci), łatwo jest określić ich maksymalny lub uczciwy poziom. Gdy dwóch użytkowników uruchamia aplikacje, wystarczy zmierzyć ilość pamięci potrzebnej w każdej z nich, aby je porównać. Jeżeli jednak stosowane są zasoby różnego typu, sytuacja się komplikuje. Gdy aplikacja jednego użytkownika wymaga dużo czasu procesora i niewiele pamięci, a druga mało czasu procesora i dużo pamięci, jak porównać je ze sobą? Programy szeregujące w systemie YARN uwzględniają wtedy dominujący zasób dla każdego użytkownika i na tej podstawie mierzą poziom obciążenia klastra. To podejście nosi nazwę Dominant Resource Fairness — DRF9. Najlepiej zilustrować to na prostym przykładzie. 9
Podejście DRF zostało przedstawione przez Ghodsiego i innych w pracy Dominant Resource Fairness: Fair Allocation of Multiple Resource Types w marcu 2011 roku (http://static.usenix.org/event/nsdi11/tech/full_papers/Ghodsi.pdf).
Szeregowanie w systemie YARN
109
Wyobraź sobie klaster mający 100 procesorów i 10 TB pamięci. Aplikacja A żąda kontenerów udostępniających 2 procesory i 300 GB pamięci, a aplikacja B wymaga kontenerów zapewniających 6 procesorów i 100 GB pamięci. A żąda 2% i 3% różnych zasobów klastra, dlatego dominującym zasobem jest tu pamięć, ponieważ wymagany jej odsetek (3%) jest większy niż w przypadku procesorów (2%). B żąda 6% i 1% różnych zasobów, tak więc dominującym zasobem są procesory. Ponieważ kontener B żąda dwa razy więcej dominujących zasobów (6% w porównaniu z 3%), przy uczciwym podziale otrzyma o połowę mniej kontenerów. Domyślnie podejście DRF nie jest używane, dlatego w trakcie obliczania zasobów uwzględniana jest tylko pamięć (procesory są ignorowane). Podejście DRF dla programu szeregującego Capacity można włączyć w wyniku ustawienia właściwości yarn.scheduler.capacity.resource-calculator na org.apache.hadoop.yarn.util.resource.DominantResourceCalculator w pliku capacity-scheduler.xml. Aby włączyć podejście DRF dla programu szeregującego Fair, w pliku alokacji ustaw element najwyższego poziomu defaultQueueSchedulingPolicy na wartość drf.
Dalsza lektura Ten rozdział zawiera krótki przegląd systemu YARN. Więcej szczegółów znajdziesz w książce Apache Hadoop YARN Aruna C. Murthy’ego i innych (Addison-Wesley, 2014; http://yarn-book.com/).
110
Rozdział 4. System YARN
ROZDZIAŁ 5.
Operacje wejścia-wyjścia w platformie Hadoop
Hadoop jest dostępny razem z zestawem prostych mechanizmów dla operacji wejścia-wyjścia na danych. Niektóre rozwiązania (na przykład związane z integralnością i kompresją danych) dotyczą nie tylko Hadoopa, ale zasługują na specjalną uwagę w kontekście zbiorów danych obejmujących wiele terabajtów. Inne mechanizmy (na przykład platformy szeregowania i dyskowe struktury danych) to narzędzia lub interfejsy API Hadoopa, stanowiące cegiełki używane do budowania systemów rozproszonych.
Integralność danych Użytkownicy Hadoopa słusznie oczekują, że w trakcie przechowywania lub przetwarzania żadne dane nie zostaną utracone lub uszkodzone. Jednak ponieważ każda operacja wejścia-wyjścia na dysku lub w sieci jest narażona na wystąpienie błędów we wczytywanych lub zapisywanych danych, to gdy ilość danych przepływających przez system jest tak duża, jak może być w Hadoopie, prawdopodobieństwo uszkodzenia danych również jest wysokie. Standardowym sposobem wykrywania uszkodzonych danych jest obliczanie sumy kontrolnej dla danych przy pierwszym wczytywaniu ich do systemu i za każdym razem, gdy są przesyłane zawodnym kanałem i tym samym narażone na problemy. Dane są uznawane za nieprawidłowe, jeśli nowa suma kontrolna nie pasuje do pierwotnej. Ta technika nie umożliwia naprawy danych. Służy jedynie do wykrywania błędów. Jest to powód, dla którego nie należy stosować sprzętu niskiej jakości. Przede wszystkim koniecznie używaj pamięci ECC. Zauważ, że możliwa jest sytuacja, w której to suma kontrolna jest nieprawidłowa, a nie dane. Zdarza się to jednak niezwykle rzadko, ponieważ suma kontrolna jest znacznie mniejsza niż dane. Często stosowany system wykrywania błędów CRC-32 (ang. Cyclic Redundancy Check, wersja 32-bitowa) oblicza 32-bitową całkowitoliczbową sumę kontrolną dla danych wejściowych dowolnej wielkości. System CRC-32 jest używany do wyznaczania sum kontrolnych w klasie ChecksumFileSystem Hadoopa, natomiast w systemie HDFS wykorzystuje się wydajniejszą wersję CRC-32C.
111
Integralność danych w systemie HDFS System HDFS niezauważalnie dla użytkowników oblicza sumy kontrolne wszystkich zapisywanych w nim danych i domyślnie sprawdza je przy odczycie danych. Odrębna suma kontrolna jest generowana dla każdych dfs.bytes-per-checksum bajtów danych. Domyślnie jest to 512 bajtów, a ponieważ sumy kontrolne w systemie CRC-32C mają po 4 bajty, zajmują mniej niż 1% dodatkowego miejsca. Węzły danych odpowiadają za weryfikowanie otrzymywanych danych przed zapisaniem ich i sum kontrolnych. Dotyczy to danych odbieranych od klientów i z innych węzłów danych w trakcie replikacji. Klient zapisujący dane przesyła je potokiem do węzłów danych (opisano to w rozdziale 3.). Ostatni węzeł danych w potoku sprawdza sumę kontrolną. Jeśli węzeł danych wykryje błąd, klient otrzyma wyjątek klasy pochodnej od IOException, który należy obsłużyć w sposób specyficzny dla aplikacji (na przykład próbując ponowić operację). Gdy klient wczytuje dane z węzła danych, sprawdza sumy kontrolne, porównując je z zapisanymi w węźle. Każdy węzeł danych przechowuje trwały rejestr testów sum kontrolnych, dlatego zna czas ostatniego sprawdzania bloków. Gdy klient potwierdzi poprawność bloku, informuje o tym węzeł danych, a ten aktualizuje rejestr. Przechowywanie statystyk tego rodzaju jest przydatne przy wykrywaniu uszkodzonych dysków. Oprócz sprawdzania bloków przy odczycie danych przez klienta każdy węzeł danych uruchamia w wątku tła obiekt klasy DataBlockScanner, który okresowo kontroluje wszystkie bloki przechowywane w danym węźle. Zabezpiecza to przed problemami wynikającymi z uszkodzeń bitów w fizycznych nośnikach danych. Z punktu „Skaner bloków węzła danych” w rozdziale 11. dowiesz się, jak uzyskać dostęp do raportów skanera. Ponieważ system HDFS przechowuje repliki bloków, może naprawić uszkodzone bloki w wyniku skopiowania jednej z dobrych replik. Pozwala to uzyskać nową, nieuszkodzoną replikę. Jeśli klient wykryje błąd w trakcie odczytu bloku, zgłasza węzłowi nazw błędny blok i zawierający go węzeł danych, a następnie zgłasza wyjątek ChecksumException. Węzeł danych oznacza wskazaną replikę bloku jako uszkodzoną, tak by nie kierować do niej następnych klientów i jej nie kopiować. Następnie planuje replikację kopii bloku do innego węzła danych, tak aby liczba replik wróciła do oczekiwanego poziomu. Po wykonaniu tych operacji uszkodzona replika jest usuwana. Aby wyłączyć sprawdzanie sum kontrolnych, przekaż wartość false do metody setVerifyChecksum() klasy FileSystem przed użyciem metody open() do wczytania pliku. Ten sam efekt można uzyskać w powłoce, używając opcji -ignoreCrc z instrukcją -get lub -copyToLocal. Jest to przydatne, gdy plik jest uszkodzony i programista chce go zbadać, aby zdecydować, co z nim zrobić. Możliwe, że przed skasowaniem pliku chcesz sprawdzić, czy można go uratować. Do wyświetlania sumy kontrolnej pliku służy polecenie hadoop fs -checksum. Pozwala ono sprawdzić, czy dwa pliki z systemu HDFS mają tę samą zawartość. Tę operację wykonuje na przykład program distcp (zobacz punkt „Równoległe kopiowanie za pomocą programu distcp” w rozdziale 3.).
Klasa LocalFileSystem Klasa LocalFileSystem Hadoopa obsługuje sumy kontrolne po stronie klienta. To oznacza, że gdy zapisujesz plik o nazwie filename, klient systemu plików niezauważalnie tworzy w tym samym katalogu ukryty plik .filename.crc z sumami kontrolnymi dla wszystkich fragmentów pliku. Wielkość 112
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop
fragmentów jest ustawiana za pomocą właściwości file.bytes-per-checksum i domyślnie wynosi 512 bajtów. Wielkość fragmentów jest zapisana jako metadane w pliku .crc, dzięki czemu plik można poprawnie wczytać także po zmianie wspomnianej właściwości. Sumy kontrolne są sprawdzane w trakcie odczytu pliku. Po wykryciu błędu klasa LocalFileSystem zgłasza wyjątek ChecksumException. Obliczanie sum kontrolnych jest stosunkowo mało kosztowne (w Javie ta operacja jest zaimplementowana w kodzie natywnym). Zwykle powoduje wydłużenie czasu odczytu lub zapisu pliku o kilka procent. W większości zastosowań jest to akceptowalna cena, jaką trzeba zapłacić za integralność danych. Można jednak wyłączyć obliczanie sum kontrolnych. Zwykle robi się to wtedy, gdy używany system plików ma wbudowaną obsługę sum kontrolnych. Aby zrezygnować z sum kontrolnych, należy wybrać klasę RawLocalFileSystem zamiast LocalFileSystem. By zrobić to globalnie w całej aplikacji, wystarczy ustawić odpowiednią klasę dla identyfikatorów URI z rodziny file. W tym celu należy ustawić właściwość fs.file.impl na wartość org.apache.hadoop.fs.RawLocalFileSystem. Inna możliwość to bezpośrednie utworzenie egzemplarza klasy RawLocalFileSystem. Może to być przydatne, gdy programista chce wyłączyć sprawdzanie sum kontrolnych tylko w wybranych operacjach odczytu. Configuration conf = ... FileSystem fs = new RawLocalFileSystem(); fs.initialize(null, conf);
Klasa ChecksumFileSystem Klasa LocalFileSystem używa klasy ChecksumFileSystem do wykonywania zadań. Ta ostatnia umożliwia łatwe dodanie obsługi sum kontrolnych do innych systemów plików (pozbawionych tego mechanizmu), ponieważ jest nakładką na klasę FileSystem. Ogólny idiom posługiwania się klasą ChecksumFileSystem wygląda tak: FileSystem rawFs = ... FileSystem checksummedFs = new ChecksumFileSystem(rawFs);
Podstawowy system plików jest nazywany surowym (ang. raw) i można go pobrać za pomocą metody getRawFileSystem() klasy ChecksumFileSystem. Ta klasa udostępnia też inne przydatne metody do pracy z sumami kontrolnymi, takie jak getChecksumFile(), która pozwala pobrać ścieżkę do pliku z sumą kontrolną dowolnego pliku. Więcej metod znajdziesz w dokumentacji. Jeśli klasa ChecksumFileSystem w trakcie odczytu pliku wykryje błąd, wywoła metodę reportChecksum Failure(). Domyślna implementacja tej metody nie wykonuje żadnych operacji, ale klasa Local FileSystem przenosi błędny plik i jego sumę kontrolną do pomocniczego katalogu bad_files na tym samym urządzeniu. Administratorzy powinni okresowo sprawdzać ten katalog pod kątem błędnych plików i podejmować odpowiednie działania.
Kompresja Kompresja plików ma dwie podstawowe zalety — zmniejsza ilość miejsca potrzebnego na przechowywanie plików i przyspiesza transfer danych w sieci oraz dla dysków. Gdy ilość danych jest duża, oba te aspekty mogą zapewniać istotne korzyści. Dlatego warto starannie zastanowić się nad tym, jak używać kompresji w Hadoopie.
Kompresja
113
Istnieje wiele różnych formatów, narzędzi i algorytmów związanych z kompresją. Każde rozwiązanie ma inne cechy. W tabeli 5.1 wymienione są popularne mechanizmy współdziałające z Hadoopem. Tabela 5.1. Przegląd formatów kompresji Format kompresji
Narzędzie
Algorytm
Rozszerzenie
Możliwość podziału
DEFLATE
Brak
DEFLATE
.deflate
Nie
gzip
gzip
DEFLATE
.gz
Nie
a
bzip2
bzip2
bzip2
.bz2
Tak
LZO
lzop
LZO
.lzo
Nie
LZ4
Brak
LZ4
.lz4
Nie
Snappy
Brak
Snappy
.snappy
Nie
b
a
DEFLATE to algorytm kompresji, którego standardową implementacją jest zlib. Nie istnieje powszechnie dostępne narzędzie wiersza poleceń do generowania plików w formacie DEFLATE, ponieważ standardowo używany jest gzip. Zauważ, że format gzip to wzbogacona o nagłówki i stopkę wersja formatu DEFLATE. Rozszerzenie .deflate to konwencja używana w Hadoopie. b Przy czym pliki LZO można dzielić, jeśli zostały zindeksowane w ramach wstępnego przetwarzania. Zobacz punkt „Kompresja i podział danych wejściowych”.
Wszystkie algorytmy kompresji wymagają dokonania wyboru między pamięcią a czasem — szybsze kompresja i dekompresja oznaczają zwykle mniejszą oszczędność miejsca. Narzędzia wymienione w tabeli 5.1 zapewniają kontrolę nad tymi aspektami. Służy do tego dziewięć opcji. Wartość -1 oznacza optymalizację ze względu na szybkość, a -9 — ze względu na pamięć. Na przykład poniższa instrukcja tworzy skompresowany plik file.gz za pomocą najszybszej metody kompresji. % gzip -1 file
Poszczególne narzędzia mają bardzo zróżnicowane cechy. Format gzip jest najbardziej uniwersalny i znajduje się mniej więcej pośrodku, jeśli chodzi o optymalizację ze względu na szybkość lub miejsce. Format bzip2 zapewnia wyższy stopień kompresji, ale jest wolniejszy. Dekompresja w tym formacie jest szybsza niż kompresja, ale i tak wolniejsza niż w innych formatach. LZO, LZ4 i Snappy są zoptymalizowane ze względu na szybkość. Są mniej więcej o rząd wielkości szybsze niż gzip, ale oferują mniejszą oszczędność miejsca. Snappy i LZ4 są wyraźnie szybsze niż LZO w zakresie dekompresji1. Kolumna „Możliwość podziału” z tabeli 5.1 określa, czy dany format obsługuje podział, czyli czy można znaleźć w strumieniu określone miejsce i zacząć od niego odczyt. Formaty kompresji z możliwością podziału dobrze współdziałają z modelem MapReduce. Więcej informacji na ten temat znajdziesz w punkcie „Kompresja i podział danych wejściowych”.
Kodeki Kodek to implementacja algorytmu kompresji i dekompresji. W Hadoopie kodeki są reprezentowane jako implementacje interfejsu CompressionCodec. Na przykład kodek GzipCodec obejmuje algorytm kompresji i dekompresji formatu gzip. W tabeli 5.2 wymienione są kodeki dostępne w Hadoopie. 1
Kompletny zbiór testów kompresji dla bibliotek zgodnych z maszyną JVM (w tym niektórych bibliotek natywnych) znajdziesz na stronie https://github.com/ning/jvm-compressor-benchmark.
114
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop
Tabela 5.2. Kodeki kompresji z Hadoopa Format kompresji
Kodek kompresji z Hadoopa
DEFLATE
org.apache.hadoop.io.compress.DefaultCodec
gzip
org.apache.hadoop.io.compress.GzipCodec
bzip2
org.apache.hadoop.io.compress.BZip2Codec
LZO
com.hadoop.compression.lzo.LzopCodec
LZ4
org.apache.hadoop.io.compress.Lz4Codec
Snappy
org.apache.hadoop.io.compress.SnappyCodec
Biblioteki LZO są dostępne na licencji GPL, dlatego nie można ich dołączać do dystrybucji Apache’a. Z tego powodu kodeki LZO dla Hadoopa trzeba pobrać z repozytorium Google’a (http://code.google. com/a/apache-extras.org/p/hadoop-gpl-compression/?redir=1) lub z repozytorium GitHub (https:// github.com/kevinweil/hadoop-lzo), gdzie dostępne są poprawki błędów i dodatkowe narzędzia. Kodek LzopCodec, zgodny z narzędziem lzop, działa podobnie jak format LZO, przy czym ma dodatkowe nagłówki. Zwykle używany jest właśnie ten kodek. Istnieje też kodek LzoCodec przeznaczony dla czystego formatu LZO. Używa on rozszerzenia .lzo_deflate (przez analogię do formatu DEFLATE, który jest identyczny z formatem gzip pozbawionym nagłówków).
Kompresja i dekompresja strumieni za pomocą kodeka CompressionCodec Klasa CompressionCodec udostępnia dwie metody umożliwiające łatwą kompresję i dekompresję danych. Aby skompresować dane zapisywane w strumieniu wyjścia, użyj metody createOutputStream (OutputStream out). Tworzy ona strumień CompressionOutputStream, w którym można zapisać nieskompresowane dane, aby zostały zapisane w skompresowanej postaci w podstawowym strumieniu. W celu dekompresji danych wczytywanych ze strumienia wejścia wywołaj metodę createInput Stream(InputStream in). Zwraca ona strumień CompressionInputStream umożliwiający odczyt nieskompresowanych danych z podstawowego strumienia. Klasy CompressionOutputStream i CompressionInputStream są podobne do klas java.util.zip.Deflater OutputStream i java.util.zip.DeflaterInputStream, ale umożliwiają przestawianie mechanizmu kompresji i dekompresji. Jest to ważne w narzędziach, które kompresują fragmenty strumieni danych jako odrębne pliki, na przykład w klasie SequenceFile (jej omówienie znajdziesz w punkcie „Klasa SequenceFile”). Listing 5.1 pokazuje, jak wykorzystać dostępny interfejs API do kompresji danych wczytywanych ze standardowego wejścia i zapisywania ich w standardowym wyjściu. Listing 5.1. Program kompresujący dane wczytane ze standardowego wejścia i zapisujący je w standardowym wyjściu public class StreamCompressor { public static void main(String[] args) throws Exception { String codecClassname = args[0]; Class codecClass = Class.forName(codecClassname); Configuration conf = new Configuration(); CompressionCodec codec = (CompressionCodec) ReflectionUtils.newInstance(codecClass, conf); CompressionOutputStream out = codec.createOutputStream(System.out);
Kompresja
115
IOUtils.copyBytes(System.in, out, 4096, false); out.finish(); } }
Ten program wymaga, aby pierwszym argumentem podanym w wierszu poleceń była pełna nazwa implementacji kodeka CompressionCodec. Tu klasa ReflectionUtils jest używana do utworzenia nowego egzemplarza kodeka, po czym kod tworzy dla strumienia System.out nakładkę do obsługi kompresji. Potem wywoływana jest metoda narzędziowa copyBytes() klasy IOUtils, aby skopiować wejście do wyjścia. Dane są przy tym kompresowane za pomocą obiektu typu Compression OutputStream. W ostatnim kroku kod wywołuje metodę finish() tego obiektu, co nakazuje mechanizmowi kompresji zakończenie zapisu skompresowanego strumienia (strumień nie jest przy tym zamykany). Możesz wypróbować ten kod za pomocą poniższej instrukcji wiersza poleceń, która kompresuje łańcuch znaków „Text” za pomocą programu StreamCompressor i kodeka GzipCodec, a następnie dekompresuje dane ze standardowego wejścia przy użyciu narzędzia gunzip. % echo "Text" | hadoop StreamCompressor org.apache.hadoop.io.compress.GzipCodec \ | gunzip –Text Text
Określanie kodeków CompressionCodec za pomocą klasy CompressionCodecFactory Jeśli wczytujesz skompresowany plik, zwykle możesz ustalić potrzebny kodek na podstawie rozszerzenia pliku. Pliki o rozszerzeniu .gz można wczytywać za pomocą kodeka GzipCodec itd. Rozszerzenia dla poszczególnych formatów wymieniono w tabeli 5.1. Klasa CompressionCodecFactory umożliwia powiązanie rozszerzenia pliku z odpowiednim kodekiem CompressionCodec za pomocą metody getCodec(). Ta metoda przyjmuje obiekt typu Path ze ścieżką do danego pliku. Listing 5.2 przedstawia program wykorzystujący tę funkcję do dekompresji plików. Listing 5.2. Program dekompresujący skompresowany plik za pomocą kodeka ustalonego na podstawie rozszerzenia public class FileDecompressor { public static void main(String[] args) throws Exception { String uri = args[0]; Configuration conf = new Configuration(); FileSystem fs = FileSystem.get(URI.create(uri), conf); Path inputPath = new Path(uri); CompressionCodecFactory factory = new CompressionCodecFactory(conf); CompressionCodec codec = factory.getCodec(inputPath); if (codec == null) { System.err.println("Nie znaleziono kodeka dla pliku " + uri); System.exit(1); } String outputUri = CompressionCodecFactory.removeSuffix(uri, codec.getDefaultExtension()); InputStream in = null; OutputStream out = null; try { in = codec.createInputStream(fs.open(inputPath)); out = fs.create(new Path(outputUri)); IOUtils.copyBytes(in, out, conf); } finally { IOUtils.closeStream(in);
116
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop
IOUtils.closeStream(out); } } }
Po znalezieniu kodeka jest on używany do usunięcia przyrostka pliku w celu utworzenia nazwy pliku wyjściowego. Wykorzystywana jest do tego statyczna metoda removeSuffix() klasy Compression CodecFactory. W ten sposób plik file.gz jest dekompresowany do pliku file za pomocą wywołania: % hadoop FileDecompressor file.gz
Klasa CompressionCodecFactory wczytuje wszystkie kodeki wymienione w tabeli 5.2 z wyjątkiem kodeka LZO, a także kodeki zapisane we właściwości konfiguracyjnej io.compression.codecs (zobacz tabelę 5.3). Domyślnie ta właściwość jest pusta. Powinieneś zmieniać ją wyłącznie wtedy, gdy chcesz zarejestrować niestandardowy kodek (na przykład zewnętrzny kodek LZO). Każdy kodek zna domyślne rozszerzenie plików w obsługiwanym formacie. Dzięki temu klasa CompressionCodec Factory może przeszukać zarejestrowane kodeki i znaleźć ten, który pasuje do rozszerzenia (jeśli taki istnieje). Tabela 5.3. Właściwości kodeków do obsługi kompresji Nazwa właściwości
Typ
io.compression.codecs
Lista rozdzielonych przecinkami nazw klas
Wartość domyślna
Opis Lista dodatkowych klas do obsługi kompresji i dekompresji zgodnych z interfejsem CompressionCodec
Biblioteki natywne Ze względu na wydajność zaleca się używanie bibliotek natywnych do obsługi kompresji i dekompresji. W jednym z testów użycie natywnych bibliotek dla formatu gzip skróciło czas dekompresji nawet o 50%, a czas kompresji o około 10% (w porównaniu z wbudowaną implementacją z Javy). W tabeli 5.4 opisano dostępność implementacji w Javie i natywnych dla każdego formatu kompresji. Dla wszystkich formatów dostępne są implementacje natywne, natomiast dla niektórych (na przykład dla LZO) nie istnieje implementacja w Javie. Tabela 5.4. Implementacje biblioteki do obsługi kompresji Format kompresji
Implementacja w Javie
Implementacja natywna
DEFLATE
Tak
Tak
gzip
Tak
Tak
bzip2
Tak
Tak
LZO
Nie
Tak
LZ4
Nie
Tak
Snappy
Nie
Tak
Plik tarball z binariami platformy Apache Hadoop obejmuje zbudowany plik binarny z natywną implementacją kompresji dla 64-bitowego Linuksa (plik libhadoop.so). Użytkownicy innych systemów muszą samodzielnie skompilować biblioteki zgodnie z instrukcjami z pliku BUILDING.txt z najwyższego poziomu drzewa kodu źródłowego.
Kompresja
117
Biblioteki natywne są wybierane na podstawie systemowej właściwości Javy java.library.path. Skrypt hadoop z katalogu etc/hadoop automatycznie ustawia tę właściwość, jeśli jednak nie korzystasz z tego skryptu, musisz ustawić w aplikacji wspomnianą właściwość. Hadoop domyślnie szuka bibliotek natywnych dla systemu, w którym działa, i po ich znalezieniu automatycznie je wczytuje. To oznacza, że aby zastosować biblioteki natywne, nie musisz zmieniać konfiguracji. Jednak czasem programista może chcieć wyłączyć biblioteki natywne — na przykład przy debugowaniu problemów związanych z kompresją. Można to zrobić przez ustawienie właściwości io.native.lib.available na wartość false, co gwarantuje, że użyte zostaną wbudowane odpowiedniki z Javy (jeśli są dostępne). Klasa CodecPool Jeśli używasz biblioteki natywnej i wykonujesz w aplikacji dużo operacji kompresji lub dekompresji, pomyśl o wykorzystaniu klasy CodecPool. Umożliwia ona ponowne wykorzystanie obiektów służących do kompresji i dekompresji, co zmniejsza koszt ich tworzenia. Listing 5.3 pokazuje ten interfejs API, choć w tym programie tworzony jest tylko jeden obiekt typu Compressor, dlatego korzystanie z puli jest zbędne. Listing 5.3. Program kompresujący dane wczytane ze standardowego wejścia i zapisujący je do standardowego wyjścia za pomocą pobranego z puli obiektu, który przeprowadza kompresję public class PooledStreamCompressor { public static void main(String[] args) throws Exception { String codecClassname = args[0]; Class codecClass = Class.forName(codecClassname); Configuration conf = new Configuration(); CompressionCodec codec = (CompressionCodec) ReflectionUtils.newInstance(codecClass, conf); Compressor compressor = null; try { compressor = CodecPool.getCompressor(codec); CompressionOutputStream out = codec.createOutputStream(System.out, compressor); IOUtils.copyBytes(System.in, out, 4096, false); out.finish(); } finally { CodecPool.returnCompressor(compressor); } } }
Kod pobiera z puli obiekt typu Compressor dostosowany do kodeka CompressionCodec. Pobrany obiekt jest używany w przeciążonej metodzie createOutputStream() kodeka. Blok finally gwarantuje, że obiekt odpowiedzialny za kompresję zostanie zwrócony do puli także po wystąpieniu wyjątku IOException w trakcie kopiowania bajtów między strumieniami.
Kompresja i podział danych wejściowych Gdy zastanawiasz się, jak skompresować dane przeznaczone do przetwarzania w modelu MapReduce, powinieneś wiedzieć, czy określony format kompresji obsługuje podział danych. Wyobraź sobie przechowywany w systemie HDFS nieskompresowany plik o wielkości 1 GB. Gdy wielkość 118
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop
bloku w systemie HDFS wynosi 128 MB, plik jest zapisany w ośmiu blokach. Zadanie w modelu MapReduce, w którym dane wejściowe pochodzą z tego pliku, tworzy osiem fragmentów danych, a każdy z nich jest przetwarzany niezależnie w odrębnej operacji mapowania. Teraz przyjmijmy, że plik jest skompresowany w formacie gzip i zajmuje 1 GB. Tak jak w poprzedniej sytuacji system HDFS zapisuje plik w ośmiu blokach. Jednak utworzenie fragmentu na podstawie każdego bloku jest niemożliwe, ponieważ nie da się rozpocząć odczytu od dowolnego punktu strumienia danych w formacie gzip. Dlatego operacja mapowania nie może wczytać swojego fragmentu danych niezależnie od pozostałych operacji. Format gzip do przechowywania skompresowanych danych wykorzystuje format DEFLATE, w którym dane są zapisane jako serie skompresowanych bloków. Problem polega na tym, że początki bloków nie są w żaden sposób oznaczone (wyróżnienie początków bloków pozwoliłoby przy odczycie ustawić dowolny punkt w strumieniu i przejść w ten sposób do początku następnego bloku, aby zsynchronizować pracę kodu ze strumieniem). Dlatego format gzip nie obsługuje podziału danych. W tej sytuacji algorytm MapReduce wybiera właściwe rozwiązanie i nie próbuje dzielić pliku w formacie gzip, ponieważ „wie” (na podstawie rozszerzenia pliku), że dane wyjściowe są skompresowane do tego formatu i dlatego nie jest możliwy ich podział. To rozwiązanie działa, jednak kosztem uwzględniania lokalności. Jedna operacja mapowania musi wtedy przetwarzać osiem bloków z systemu HDFS, z których większość będzie znajdować się poza lokalnym węzłem. Ponadto gdy liczba operacji mapowania jest mniejsza, zadanie jest wykonywane mniej współbieżnie, dlatego może trwać dłużej.
Który format kompresji powinienem wybrać? Aplikacje w Hadoopie przetwarzają duże zbiory danych, dlatego należy starać się korzystać z kompresji. Odpowiedni format zależy od wielkości pliku, jego formatu i narzędzi używanych przy przetwarzaniu. Oto kilka zaleceń z tego obszaru (uporządkowanych od bardziej do mniej efektywnych). Stosuj format kontenerowy — na przykład pliki typu SequenceFile (dalej w tym rozdziale), pliki Avro z danymi (w rozdziale 12.), pliki ORCFiles (w tym rozdziale) lub pliki Parquet (w rozdziale 13.). Wszystkie one obsługują zarówno kompresję, jak i podział danych. Dobrym wyborem jest zwykle szybki mechanizm kompresji, na przykład LZO, LZ4 lub Snappy. Wykorzystaj format kompresji z obsługą podziału danych, na przykład bzip2 (choć jest on sto-
sunkowo powolny), lub format umożliwiający dodanie indeksów na potrzeby podziału plików, na przykład LZO. Dziel pliki na fragmenty w aplikacji i kompresuj każdy fragment osobno za pomocą dowolnego obsługiwanego formatu kompresji (obsługa podziału danych nie ma wtedy znaczenia). W tym podejściu należy tak dobrać wielkość fragmentów, aby po kompresji miały one podobny rozmiar jak bloki systemu HDFS. Przechowuj pliki w postaci nieskompresowanej. Dla dużych plików nie należy stosować formatu kompresji bez obsługi podziału całego pliku, ponieważ grozi to utratą lokalności i sprawia, że aplikacje w modelu MapReduce działają bardzo niewydajnie.
Kompresja
119
Jeśli plik ma format LZO, występuje problem, ponieważ nie umożliwia on synchronizacji jednostki wczytującej dane ze strumieniem. Można jednak wstępnie przetwarzać pliki LZO za pomocą indeksera dostępnego w bibliotekach LZO dla Hadoopa (te biblioteki znajdziesz w serwisach Google i GitHub podanych w punkcie „Kodeki”). To narzędzie buduje indeks punktów podziału, co pozwala podzielić plik, gdy używany jest on jako dane wejściowe w modelu MapReduce. Pliki bzip2 zawierają znacznik synchronizacyjny między blokami (48-bitowe przybliżenie liczby pi), dlatego obsługują podział danych. Z tabeli 5.1 dowiesz się, które formaty kompresji też obsługują tę funkcję.
Wykorzystywanie kompresji w modelu MapReduce W punkcie „Określanie kodeków CompressionCodec za pomocą klasy CompressionCodecFactory” opisano, że jeśli pliki wejściowe są skompresowane, zostaną automatycznie zdekompresowane w momencie ich odczytu w modelu MapReduce. Potrzebny kodek jest określany na podstawie rozszerzenia pliku. Aby skompresować dane wyjściowe z zadania w modelu MapReduce, należy w konfiguracji zadania ustawić właściwość mapreduce.output.fileoutputformat.compress na wartość true i właściwość mapreduce.output.fileoutputformat.compress.codec na nazwę klasy potrzebnego kodeka kompresji. Te właściwości można też ustawić za pomocą statycznych metod pomocniczych klasy FileOutput Format, co przedstawia listing 5.4. Listing 5.4. Aplikacja uruchamiająca zadanie określające maksymalną temperaturę i generująca skompresowane dane wyjściowe public class MaxTemperatureWithCompression { public static void main(String[] args) throws Exception { if (args.length != 2) { System.err.println("Użytkowanie: MaxTemperatureWithCompression " + ""); System.exit(-1); } Job job = new Job(); job.setJarByClass(MaxTemperature.class); FileInputFormat.addInputPath(job, new Path(args[0])); FileOutputFormat.setOutputPath(job, new Path(args[1])); job.setOutputKeyClass(Text.class); job.setOutputValueClass(IntWritable.class); FileOutputFormat.setCompressOutput(job, true); FileOutputFormat.setOutputCompressorClass(job, GzipCodec.class); job.setMapperClass(MaxTemperatureMapper.class); job.setCombinerClass(MaxTemperatureReducer.class); job.setReducerClass(MaxTemperatureReducer.class); System.exit(job.waitForCompletion(true) ? 0 : 1); } }
120
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop
Program jest uruchamiany dla skompresowanych danych wejściowych (w tym przykładzie dla wejścia i wyjścia używany jest ten sam format kompresji, jednak nie jest to konieczne) w następujący sposób: % hadoop MaxTemperatureWithCompression input/ncdc/sample.txt.gz output
Każdy fragment ostatecznych danych wyjściowych jest kompresowany. % gunzip -c output/part-r-00000.gz 1949 111 1950 22
Jeśli w danych wyjściowych generujesz pliki typu SequenceFile, możesz wykorzystać właściwość mapreduce.output.fileoutputformat.compress.type do kontrolowania typu kompresji. Ustawienie domyślne to RECORD, co oznacza kompresję poszczególnych rekordów. Zaleca się zmianę ustawienia na BLOCK, co powoduje efektywniejszą kompresję grup rekordów (zobacz punkt „Format SequenceFile”). Klasa SequenceFileOutputFormat udostępnia statyczną metodę pomocniczą setOutputCompressionType() służącą do ustawiania wspomnianej właściwości. Tabela 5.5 zawiera przegląd właściwości konfiguracyjnych służących do ustawiania kompresji danych wyjściowych zadań w modelu MapReduce. Jeśli sterownik MapReduce używa interfejsu Tool (opisanego w punkcie „GenericOptionsParser, Tool i ToolRunner” w rozdziale 6.), dowolną z tych właściwości możesz przekazać do programu w wierszu poleceń. Bywa to wygodniejsze niż modyfikowanie programu i zapisywanie na stałe właściwości kompresji. Tabela 5.5. Właściwości kompresji dla modelu MapReduce Nazwa właściwości
Typ
Wartość domyślna
Opis
mapreduce.output. fileoutputformat. compress
Logiczny
false
Określa, czy kompresować dane wyjściowe
mapreduce.output. fileoutputformat. compress.codec
Nazwa klasy
org.apache.hadoop.io. compress.DefaultCodec
Kodek kompresji używany dla danych wyjściowych
mapreduce.output. fileoutputformat. compress.type
String
RECORD
Typ kompresji dla plików wyjściowych typu SequenceFile — NONE, RECORD lub BLOCK
Kompresja danych wyjściowych z etapu mapowania Nawet jeśli aplikacja w modelu MapReduce wczytuje i zapisuje nieskompresowane dane, może skorzystać z kompresji pośrednich danych wyjściowych z etapu mapowania. Dane wyjściowe z tego etapu są zapisywane na dysku i przesyłane w sieci do węzłów reduktorów. Dlatego użycie szybkiego mechanizmu kompresji (takiego jak LZO, LZ4 lub Snappy) pozwala poprawić wydajność, ponieważ ilość przesyłanych danych będzie mniejsza. Tabela 5.6 przedstawia właściwości konfiguracyjne umożliwiające włączenie kompresji danych wyjściowych z etapu mapowania i ustawienie formatu kompresji.
Kompresja
121
Tabela 5.6. Właściwości kompresji danych wyjściowych z etapu mapowania Nazwa właściwości
Typ
Wartość domyślna
Opis
mapreduce.map. output.compress
Logiczny
false
Określa, czy kompresować dane wyjściowe z etapu mapowania
mapreduce.map. output.compress.codec
Nazwa klasy
org.apache.hadoop.io. compress.defaultCodec
Kodek kompresji używany dla danych wyjściowych z etapu mapowania
Oto wiersze, które należy dodać, aby włączyć kompresję w formacie gzip dla danych wyjściowych z etapu mapowania (przy użyciu nowego interfejsu API). Configuration conf = new Configuration(); conf.setBoolean(Job.MAP_OUTPUT_COMPRESS, true); conf.setClass(Job.MAP_OUTPUT_COMPRESS_CODEC, GzipCodec.class, CompressionCodec.class); Job job = new Job(conf);
W dawnym interfejsie API (zobacz dodatek D) dostępne są metody pomocnicze klasy JobConf, które pozwalają uzyskać ten sam efekt. conf.setCompressMapOutput(true); conf.setMapOutputCompressorClass(GzipCodec.class);
Serializacja Serializacja to proces przekształcania ustrukturyzowanych obiektów w strumień bajtów na potrzeby transmisji przez sieć lub zapisu w trwałym magazynie danych. Deserializacja to proces odwrotny, polegający na przekształcaniu strumienia bajtów z powrotem w ustrukturyzowane obiekty. Serializacja jest wykorzystywana w dwóch odrębnych obszarach rozproszonego przetwarzania danych — do komunikacji międzyprocesowej i do trwałego zapisu danych. W Hadoopie komunikacja międzyprocesowa między węzłami systemu odbywa się za pomocą zdalnych wywołań procedur (ang. remote procedure calls — RPC). Protokół RPC wykorzystuje serializację do przetwarzania komunikatu na strumień binarny przesyłany do zdalnego węzła, który następnie deserializuje strumień, by uzyskać pierwotny komunikat. Oto zalecane cechy formatu używanego do serializacji w wywołaniach RPC. Zwięzłość Zwięzły format pozwala najlepiej wykorzystać przepustowość sieci (jest ona najcenniejszym zasobem w centrum danych). Szybkość Komunikacja międzyprocesowa to podstawa systemu rozproszonego. Dlatego bardzo ważne jest, aby proces serializacji i deserializacji miał jak najmniejszy wpływ na wydajność. Rozszerzalność Protokoły z czasem zmieniają się ze względu na nowe wymagania. Dlatego kontrolowane modyfikowanie protokołu po stronie klienta i serwera powinno być proste. Dobrze, żeby możliwe
122
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop
było na przykład dodanie nowego argumentu do wywołania metody i jednoczesne akceptowanie przez nowe serwery komunikatów w starszym formacie (bez nowego argumentu) przesyłanych przez starsze klienty. Przenośność W niektórych systemach przydatna jest możliwość obsługi klientów napisanych w językach innych niż serwer. Dlatego format należy zaprojektować w taki sposób, aby było to możliwe. Pozornie format danych używany do trwałego przechowywania powinien mieć inne cechy niż format wybrany dla serializacji. W końcu czas życia wywołań RPC wynosi mniej niż sekundę, natomiast trwałe dane mogą być odczytywane przez lata po ich zapisaniu. Okazuje się jednak, że cztery pożądane cechy formatu używanego do serializacji w wywołaniach RPC są ważne także przy trwałym przechowywaniu danych. Format przechowywania powinien być zwięzły (aby wydajnie wykorzystać pamięć), szybki (aby zminimalizować koszty odczytu lub zapisu terabajtów danych), rozszerzalny (aby można było swobodnie wczytywać dane zapisane w starszych formatach) i przenośny (aby można było wczytywać lub zapisywać trwałe dane w różnych językach). Hadoop wykorzystuje własny format serializacji, oparty na interfejsie Writable. Jest on zwięzły i szybki, ale nie umożliwia łatwego rozszerzenia i stosowania w językach innych niż Java. Ponieważ obiekty typu Writable są istotnym elementem Hadoopa (większość programów w modelu MapReduce używa ich jako kluczy i wartości), zostały omówione szczegółowo w trzech następnych punktach. Dopiero dalej przedstawione są inne platformy serializacji obsługiwane w Hadoopie. Avro (system serializacji zaprojektowany w celu przezwyciężenia niektórych ograniczeń obiektów typu Writable) jest opisany w rozdziale 12.
Interfejs Writable Interfejs Writable definiuje dwie metody. Jedna służy do zapisu stanu do strumienia binarnego DataOutput, a druga — do odczytu stanu ze strumienia binarnego DataInput. package org.apache.hadoop.io; import java.io.DataOutput; import java.io.DataInput; import java.io.IOException; public interface Writable { void write(DataOutput out) throws IOException; void readFields(DataInput in) throws IOException; }
Przyjrzyj się konkretnemu obiektowi typu Writable, aby zobaczyć, co można z nim zrobić. Tu używana jest klasa IntWritable — nakładka na typ int Javy. Można utworzyć obiekt tej klasy i za pomocą metody set() ustawić jego wartość. IntWritable writable = new IntWritable(); writable.set(163);
Inna możliwość to użycie konstruktora przyjmującego liczbę całkowitą. IntWritable writable = new IntWritable(163);
Kompresja
123
Aby sprawdzić działanie zserializowanego obiektu typu IntWritable, można napisać krótką metodę pomocniczą, która zapisuje dane typu java.io.ByteArrayOutputStream w obiekcie typu java.io. DataOutputStream (jest to implementacja interfejsu java.io.DataOutput) w celu otrzymania bajtów serializowanego strumienia. public static byte[] serialize(Writable writable) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); DataOutputStream dataOut = new DataOutputStream(out); writable.write(dataOut); dataOut.close(); return out.toByteArray(); }
Liczba całkowita jest zapisywana za pomocą czterech bajtów (tak jak w asercjach w platformie JUnit 4). byte[] bytes = serialize(writable); assertThat(bytes.length, is(4));
Bajty są zapisywane w porządku big-endian (co oznacza, że najbardziej znaczące bajty są zapisywane w strumieniu jako pierwsze; tak działa interfejs java.io.DataOutput). Reprezentację szesnastkową tej wartości można wyświetlić za pomocą odpowiedniej metody klasy StringUtils Hadoopa. assertThat(StringUtils.byteToHexString(bytes), is("000000a3"));
Spróbujmy teraz deserializacji. Posłuży do tego metoda pomocnicza wczytująca obiekt typu Writable z tablicy bajtów. public static byte[] deserialize(Writable writable, byte[] bytes) throws IOException { ByteArrayInputStream in = new ByteArrayInputStream(bytes); DataInputStream dataIn = new DataInputStream(in); writable.readFields(dataIn); dataIn.close(); return bytes; }
Dalszy kod tworzy nowy, pozbawiony wartości obiekt klasy IntWritable, a następnie wywołuje metodę deserialize(), by wczytać zapisane wcześniej dane wyjściowe. Następnie porównuje pobraną za pomocą metody get() wartość z pierwotną liczbą (163). IntWritable newWritable = new IntWritable(); deserialize(newWritable, bytes); assertThat(newWritable.get(), is(163));
Interfejs WritableComparable i komparatory Klasa IntWritable implementuje interfejs WritableComparable (jest to interfejs pochodny od interfejsów Writable i java.lang.Comparable). package org.apache.hadoop.io; public interface WritableComparable extends Writable, Comparable { }
Porównywanie wartości danego typu jest bardzo ważne w modelu MapReduce, ponieważ występuje w nim etap sortowania, kiedy to klucze są porównywane ze sobą. Optymalizacja używana w Hadoopie to interfejs RawComparator rozszerzający interfejs Comparator z Javy.
124
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop
package org.apache.hadoop.io; import java.util.Comparator; public interface RawComparator extends Comparator { public int compare(byte[] b1, int s1, int l1, byte[] b2, int s2, int l2); }
Ten interfejs pozwala na to, aby w implementujących go klasach porównywać wczytywane ze strumienia rekordy bez deserializowania ich do postaci obiektów. Oznacza to uniknięcie kosztów tworzenia obiektów. Na przykład komparator dla obiektów typu IntWritable zawiera metodę compare() dla surowych danych, która wczytuje liczby całkowite z każdej tablicy bajtów (b1 i b2) i porównuje je bezpośrednio od pozycji początkowej (s1 i s2) z uwzględnieniem długości (l1 i l2). WritableComparator to implementacja ogólnego użytku interfejsu RawComparator, używana dla klas implementujących interfejs WritableComparable. WritableComparator pełni dwie podstawowe funk-
cje. Po pierwsze, zawiera domyślną implementację metody compare() dla surowych danych, która deserializuje porównywane obiekty ze strumienia i wywołuje ich metodę compare(). Po drugie, pełni funkcję fabryki egzemplarzy typu RawComparator (zarejestrowanych w implementacjach typu Writable). Na przykład aby otrzymać komparator dla klasy IntWritable, wystarczy zastosować poniższy kod: RawComparator comparator = WritableComparator.get(IntWritable.class);
Komparator można następnie wykorzystać do porównywania dwóch obiektów typu IntWritable. IntWritable w1 = new IntWritable(163); IntWritable w2 = new IntWritable(67); assertThat(comparator.compare(w1, w2), greaterThan(0));
Jest to możliwe także dla zserializowanych wersji takich obiektów. byte[] b1 = serialize(w1); byte[] b2 = serialize(w2); assertThat(comparator.compare(b1, 0, b1.length, b2, 0, b2.length), greaterThan(0));
Klasy z rodziny Writable Hadoop udostępnia duży wybór klas z rodziny Writable. Są one dostępne w pakiecie org.apache. hadoop.io. Hierarchia tych klas jest przedstawiona na rysunku 5.1.
Nakładki typu Writable dla typów prostych Javy Istnieją nakładki typu Writable dla wszystkich typów prostych Javy (zobacz tabelę 5.7) z wyjątkiem typu char (który można zapisać w obiektach klasy IntWritable). Wszystkie te nakładki mają metody get() i set() służące do pobierania i zapisywania podstawowej wartości.
Kompresja
125
Rysunek 5.1. Hierarchia klas z rodziny Writable
126
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop
Tabela 5.7. Nakładki typu Writable dla typów prostych Javy Typ prosty Javy
Implementacja interfejsu Writable
Rozmiar w bajtach po serializacji
boolean
BooleanWritable
1
byte
ByteWritable
1
short
ShortWritable
2
int
IntWritable
4
VIntWritable
1–5
float
FloatWritable
4
long
LongWritable
8
VLongWritable
1–9
DoubleWritable
8
double
Przy kodowaniu liczb całkowitych można wybrać formaty o stałej długości (IntWritable i LongWritable) oraz formaty o zmiennej długości (VIntWritable i VLongWritable). Formaty o zmiennej długości wykorzystują tylko jeden bajt do zakodowania wartości, jeśli jest wystarczająco mała (z przedziału od –112 do 127 włącznie). W przeciwnym razie używają pierwszego bajta do określenia, czy wartość jest dodatnia, czy ujemna, oraz ile bajtów zajmuje. Na przykład liczba 163 wymaga dwóch bajtów. byte[] data = serialize(new VIntWritable(163)); assertThat(StringUtils.byteToHexString(data), is("8fa3"));
W jaki sposób wybrać między kodowaniem ze stałą lub zmienną długością? Kodowanie ze stałą długością jest odpowiednie, gdy rozkład wartości jest stosunkowo jednolity — na przykład przy korzystaniu z (dobrze zaprojektowanej) funkcji skrótu. Większość zmiennych liczbowych ma jednak rozkład niejednolity, dlatego zwykle kodowanie ze zmienną długością pozwala zaoszczędzić pamięć. Inną zaletą kodowania ze zmienną długością jest możliwość przełączenia się z typu VIntWritable na typ VLongWritable, ponieważ używane w nich kodowanie jest takie samo. Dlatego wybór reprezentacji o zmiennej długości pozwala na zwiększenie pojemności bez decydowania się od początku na 8-bajtową reprezentację opartą na typie long.
Text Klasa Text to implementacja interfejsu Writable dla sekwencji znaków UTF-8. Można ją traktować jak odpowiednik typu java.lang.String z Javy. Klasa Text wykorzystuje typ int (z kodowaniem ze zmienną długością) do przechowywania liczby bajtów łańcucha znaków, dlatego maksymalna wartość to 2 GB. Ponadto w klasie Text używany jest standard UTF-8, dzięki czemu łatwiej jest tworzyć rozwiązania współdziałające z innymi narzędziami obsługującymi ten standard. Indeksowanie Ponieważ w klasie Text używany jest standard UTF-8, różni się ona od klasy String z Javy. Indeksowanie w klasie Text jest oparte na pozycjach w zakodowanej sekwencji bajtów, a nie na znakach Unicode z łańcucha lub na jednostkach kodowych typu char Javy (jak ma to miejsce w klasie String). W łańcuchach znaków ASCII trzy wymienione sposoby indeksowania są ze sobą zgodne. Oto przykład ilustrujący korzystanie z metody charAt(). Kompresja
127
Text t = new Text("hadoop"); assertThat(t.getLength(), is(6)); assertThat(t.getBytes().length, is(6)); assertThat(t.charAt(2), is((int) 'd')); assertThat("Poza zakresem", t.charAt(100), is(-1));
Zauważ, że metoda charAt() zwraca tu wartość typu int reprezentującą punkt kodowy Unicode (w odróżnieniu od wersji dla klasy String, gdzie zwracana jest wartość typu char). Klasa Text udostępnia też metodę find(), działającą jak metoda indexOf() klasy String. Text t = new Text("hadoop"); assertThat("Znajdowanie podłańcucha", t.find("do"), is(2)); assertThat("Znajdowanie pierwszego 'o'", t.find("o"), is(3)); assertThat("Znajdowanie 'o' od pozycji 4.", t.find("o", 4), is(4)); assertThat("Brak dopasowania", t.find("pig"), is(-1));
Unicode Gdy używasz znaków kodowanych za pomocą więcej niż jednego bajta, różnice między klasami Text i String stają się wyraźnie widoczne. Przyjrzyj się znakom Unicode przedstawionym w tabeli 5.82. Tabela 5.8. Znaki Unicode Punkt kodowy Unicode
U+0041
U+00DF
U+6771
U+10400
Nazwa
LATIN CAPITAL LETTER A
LATIN SMALL LETTER SHARP S Brak (ideogram z ujednoliconego pisma Han)
DESERET CAPITAL LETTER LONG I
Jednostki kodowe w UTF-8
41
c3 9f
e6 9d b1
f0 90 90 80
Reprezentacja w Javie
\u0041
\u00DF
\u6771
\uD801\uDC00
Wszystkie znaki z tej tabeli oprócz ostatniego (U+10400) można zapisać za pomocą jednej wartości typu char z Javy. U+10400 to znak dodatkowy reprezentowany przez dwie wartości typu char z Javy (parę zastępczą — ang. surrogate pair). Testy z listingu 5.5 pokazują różnice między typami String i Text przy przetwarzaniu łańcucha obejmującego cztery znaki z tabeli 5.8. Listing 5.5. Testy pokazujące różnice między klasami String i Text public class StringTextComparisonTest { @Test public void string() throws UnsupportedEncodingException { String s = "\u0041\u00DF\u6771\uD801\uDC00"; assertThat(s.length(), is(5)); assertThat(s.getBytes("UTF-8").length, is(10)); assertThat(s.indexOf("\u0041"), is(0)); assertThat(s.indexOf("\u00DF"), is(1)); assertThat(s.indexOf("\u6771"), is(2)); assertThat(s.indexOf("\uD801\uDC00"), is(3));
2
Ten przykład jest oparty na przykładzie z tekstu Supplementary Characters in the Java Platform (http://www.oracle. com/us/technologies/java/supplementary-142654.html) Norberta Lindenberga i Masayoshiego Okutsu z maja 2004 roku.
128
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop
assertThat(s.charAt(0), assertThat(s.charAt(1), assertThat(s.charAt(2), assertThat(s.charAt(3), assertThat(s.charAt(4),
is('\u0041')); is('\u00DF')); is('\u6771')); is('\uD801')); is('\uDC00'));
assertThat(s.codePointAt(0), assertThat(s.codePointAt(1), assertThat(s.codePointAt(2), assertThat(s.codePointAt(3),
is(0x0041)); is(0x00DF)); is(0x6771)); is(0x10400));
} @Test public void text() { Text t = new Text("\u0041\u00DF\u6771\uD801\uDC00"); assertThat(t.getLength(), is(10)); assertThat(t.find("\u0041"), is(0)); assertThat(t.find("\u00DF"), is(1)); assertThat(t.find("\u6771"), is(3)); assertThat(t.find("\uD801\uDC00"), is(6)); assertThat(t.charAt(0), assertThat(t.charAt(1), assertThat(t.charAt(3), assertThat(t.charAt(6),
is(0x0041)); is(0x00DF)); is(0x6771)); is(0x10400));
} }
Ten test potwierdza, że długość wartości typu String to liczba zawartych w nim jednostek kodowych typu char (czyli pięć — po jednej dla pierwszych trzech znaków plus para zastępcza reprezentująca ostatni znak). Natomiast długość obiektu typu Text to liczba bajtów w kodowaniu UTF-8 (10 = 1+2+3+4). Podobnie metoda indexOf() klasy String zwraca indeks w jednostkach kodowych typu char, a metoda find() klasy Text zwraca pozycję wyznaczaną na podstawie bajtów. Metoda charAt() klasy String zwraca jednostkę kodową typu char dla danego indeksu. W przypadku par zastępczych ta wartość nie reprezentuje całego znaku Unicode. Do pobrania jednego znaku Unicode reprezentowanego jako wartość typu int potrzebna jest metoda codePointAt(), indeksowana za pomocą jednostek kodowych typu char. Metoda charAt() klasy Text bardziej przypomina metodę codePointAt() niż metodę charAt() klasy String. Jedyna różnica polega na tym, że jest indeksowana za pomocą bajtów. Iteracje Iterowanie po znakach Unicode w wartościach typu Text jest skomplikowane z powodu używania pozycji bajtów przy indeksowaniu (dlatego nie wystarczy zwiększać wartości indeksu). Idiom używany do iterowania jest dość zagmatwany (zobacz listing 5.6). Należy przekształcić obiekt typu Text na obiekt typu java.nio.ByteBuffer, a następnie wielokrotnie wywołać metodę statyczną bytesToCodePoint() typu Text z buforem jako argumentem. Ta metoda pobiera następny punkt kodowy jako wartość typu int i aktualizuje pozycję w buforze. Koniec łańcucha znaków jest wykrywany, gdy metoda bytesToCodePoint() zwraca wartość -1.
Kompresja
129
Listing 5.6. Iterowanie po znakach z obiektu typu Text public class TextIterator { public static void main(String[] args) { Text t = new Text("\u0041\u00DF\u6771\uD801\uDC00");
}
ByteBuffer buf = ByteBuffer.wrap(t.getBytes(), 0, t.getLength()); int cp; while (buf.hasRemaining() && (cp = Text.bytesToCodePoint(buf)) != -1) { System.out.println(Integer.toHexString(cp)); }
}
Ten program po uruchomieniu wyświetla punkty kodowe odpowiadające czterem znakom z łańcucha. % hadoop TextIterator 41 df 6771 10400
Możliwość modyfikowania Następna różnica w porównaniu z klasą String jest taka, że klasa Text umożliwia modyfikowanie (podobnie jak wszystkie implementacje interfejsu Writable w Hadoopie z wyjątkiem klasy Null Writable, która jest singletonem). Można ponownie wykorzystać obiekt typu Text. Wystarczy wywołać w tym celu jedną z wersji metody set(). Oto przykład: Text t = new Text("hadoop"); t.set("pig"); assertThat(t.getLength(), is(3)); assertThat(t.getBytes().length, is(3));
W niektórych sytuacjach długość tablicy bajtów zwrócona przez metodę getBytes() może być większa, niż wskazuje na to wartość zwrócona przez metodę getLength(). Text t = new Text("hadoop"); t.set(new Text("pig")); assertThat(t.getLength(), is(3)); assertThat("Długość w bajtach nie jest zmniejszana", t.getBytes().length, is(6));
To pokazuje, dlaczego zawsze należy wywoływać metodę getLength() przy korzystaniu z metody getBytes() — pozwala to stwierdzić, jak dużą część tablicy bajtów zajmują poprawne dane.
Uciekanie się do typu String Klasa Text nie ma tak bogatego interfejsu API do manipulowania łańcuchami znaków jak klasa java.lang.String. Dlatego w wielu sytuacjach trzeba przekształcić obiekt typu Text na typ String. Odbywa się to w standardowy sposób, za pomocą metody toString(). assertThat(new Text("hadoop").toString(), is("hadoop"));
Klasa BytesWritable BytesWritable to nakładka na tablicę danych binarnych. Po serializacji zajmuje cztery bajty z liczbą całkowitą określającą występującą dalej liczbę bajtów danych. Na przykład tablica bajtów zawierająca
130
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop
dwa elementy 3 i 5 ma postać 4-bajtowej liczby całkowitej (00000002), po której następują dwa bajty z tablicy (03 i 05). BytesWritable b = new BytesWritable(new byte[] { 3, 5 }); byte[] bytes = serialize(b); assertThat(StringUtils.byteToHexString(bytes), is("000000020305"));
Obiekty klasy BytesWritable można modyfikować za pomocą jej metody set(). Podobnie jak w klasie Text długość tablicy bajtów zwracana przez metodę getBytes() (określa ona pojemność tablicy) klasy BytesWritable może być niezgodna z ilością danych przechowywanych w obiekcie tej klasy. Aby ustalić rzeczywistą ilość danych w obiekcie klasy BytesWritable, należy wywołać metodę get Length(). Oto przykład: b.setCapacity(11); assertThat(b.getLength(), is(2)); assertThat(b.getBytes().length, is(11));
Klasa NullWritable Klasa NullWritable to specjalna implementacja interfejsu Writable, ponieważ przy serializacji ma zerową długość. Żadne bajty nie są zapisywane do strumienia ani z niego wczytywane. Ta klasa jest używana jako wypełniacz. Na przykład w modelu MapReduce klucz lub wartość można zadeklarować za pomocą klasy NullWritable, gdy dana pozycja nie jest potrzebna. W efekcie zapisywana jest pusta stała wartość. Klasa NullWritable jest też przydatna jako klucz w plikach typu SequenceFile, gdy zapisywana jest lista samych wartości (a nie par klucz-wartość). NullWritable to niemodyfikowalny singleton. Egzemplarz tej klasy można pobrać za pomocą wywołania NullWritable.get().
Klasy ObjectWritable i GenericWritable Klasa ObjectWritable to nakładka ogólnego użytku na następujące elementy: typy proste Javy, typ String, typ enum, typ Writable, wartość null i tablice elementów tych typów. Jest używana w wywołaniach RPC w Hadoopie do serializowania i deserializowania typów argumentów i zwracanych wartości metod. Klasa ObjectWritable jest przydatna, gdy pole może być różnego typu. Na przykład jeśli wartości w pliku typu SequenceFile mają różne typy, można zadeklarować typ wartości za pomocą klasy ObjectWritable i opakować każdy typ w obiekt tej klasy. ObjectWritable to klasa ogólnego użytku, dlatego powoduje marnowanie dużej ilości miejsca, ponieważ przy serializacji wymaga zapisania nazwy opakowywanego typu. Gdy liczba typów jest niewielka i z góry znana, można utworzyć statyczną tablicę typów i wykorzystać indeks z tej tablicy do określania typów. To podejście zastosowano w klasie GenericWritable. Aby określić używane typy, należy utworzyć klasę pochodną od niej.
Kolekcje z rodziny Writable Pakiet org.apache.hadoop.io obejmuje sześć typów kolekcji implementujących interfejs Writable. Te typy to: ArrayWritable, ArrayPrimitiveWritable, TwoDArrayWritable, MapWritable, SortedMapWritable i EnumSetWritable. ArrayWritable i TwoDArrayWritable to implementacje reprezentujące tablice i tablice dwuwymiarowe (tablice tablic) z elementami typu Writable. Wszystkie elementy tych tablic muszą być egzempla-
rzami tej samej klasy, określanej w momencie tworzenia obiektu w pokazany poniżej sposób. ArrayWritable writable = new ArrayWritable(Text.class);
Kompresja
131
Gdy implementacja interfejsu Writable jest definiowana za pomocą typu, tak jak w przypadku kluczy lub wartości w plikach typu SequenceFile albo w danych wejściowych w modelu MapReduce, trzeba utworzyć klasę pochodną od ArrayWritable (lub TwoDArrayWritable) i statycznie ustawić typ. Oto przykład: public class TextArrayWritable extends ArrayWritable { public TextArrayWritable() { super(Text.class); } }
Klasy ArrayWritable i TwoDArrayWritable udostępniają metody get() i set(), a także metodę toArray() tworzącą płytką kopię tablicy (lub tablicy dwuwymiarowej). ArrayPrimitiveWritable to nakładka na tablice elementów typów prostych Javy. Typ elementów jest wykrywany przy wywołaniach metody set(), dlatego w celu określenia typu nie trzeba tworzyć
klasy pochodnej. MapWritable to implementacja interfejsu java.util.Map, a SortedMapWritable
to implementacja interfejsu java.util.SortedMap. Typ każdego pola klucza i wartości jest określony w formacie serializacji. Typ jest zapisywany jako jeden bajt, który stanowi indeks do tablicy typów. Tablica jest zapełniana standardowymi typami z pakietu org.apache.hadoop.io, przy czym uwzględniane są też niestandardowe typy Writable. Żeby je dodać, należy utworzyć nagłówek reprezentujący tablicę niestandardowych typów. W klasach MapWritable i SortedMapWritable dodatnie wartości typu byte reprezentują typy niestandardowe, dlatego w każdym egzemplarzu tych klas można użyć do 127 różnych niestandardowych klas z rodziny Writable. Oto przykład zastosowania klasy MapWritable z różnymi typami kluczy i wartości. MapWritable src = new MapWritable(); src.put(new IntWritable(1), new Text("cat")); src.put(new VIntWritable(2), new LongWritable(163)); MapWritable dest = new MapWritable(); WritableUtils.cloneInto(dest, src); assertThat((Text) dest.get(new IntWritable(1)), is(new Text("cat"))); assertThat((LongWritable) dest.get(new VIntWritable(2)), is(new LongWritable(163)));
Zauważalny jest brak implementacji kolekcji reprezentujących zbiory i listy elementów typu Writable. Ogólny zbiór można przedstawić za pomocą klasy MapWritable (lub SortedMapWritable w przypadku zbiorów uporządkowanych) z wartościami typu NullWritable. Istnieje też klasa EnumSetWritable reprezentująca zbiory elementów typu wyliczeniowego. Do tworzenia list elementów jednego typu z rodziny Writable można wykorzystać klasę ArrayWritable, natomiast aby na jednej liście zapisać elementy różnych typów z tej rodziny, należy zastosować klasę GenericWritable i opakować elementy w klasę ArrayWritable. Jeszcze inna możliwość to utworzenie ogólnej klasy ListWritable na podstawie rozwiązań z klasy MapWritable.
Tworzenie niestandardowych implementacji interfejsu Writable Hadoop udostępnia przydatny zestaw implementacji interfejsu Writable obsługujących większość potrzeb użytkowników. Jednak czasem trzeba napisać własną niestandardową implementację. W ten sposób można uzyskać pełną kontrolę nad binarną reprezentacją danych i porządkiem 132
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop
sortowania. Ponieważ obiekty typu Writable są ważnym elementem w ścieżce danych w modelu MapReduce, dostrojenie binarnej reprezentacji może mieć istotny wpływ na wydajność kodu. Dostępne w Hadoopie gotowe implementacje interfejsu Writable są dobrze dopracowane, jednak w przypadku skomplikowanych struktur często lepiej jest utworzyć nowy typ Writable, zamiast łączyć ze sobą wbudowane typy. Jeśli rozważasz napisanie niestandardowej implementacji interfejsu Writable, przydatna może być inna platforma serializacji, na przykład Avro, pozwalająca deklaratywnie definiować niestandardowe typy. Zobacz punkt „Platformy do obsługi serializacji” i rozdział 12.
Aby zademonstrować tworzenie niestandardowych implementacji interfejsu Writable, poniżej pokazano klasę TextPair reprezentującą parę łańcuchów znaków. Podstawową wersję tej klasy przedstawia listing 5.7. Listing 5.7. Implementacja interfejsu Writable przechowująca parę obiektów typu Text import java.io.*; import org.apache.hadoop.io.*; public class TextPair implements WritableComparable { private Text first; private Text second; public TextPair() { set(new Text(), new Text()); } public TextPair(String first, String second) { set(new Text(first), new Text(second)); } public TextPair(Text first, Text second) { set(first, second); } public void set(Text first, Text second) { this.first = first; this.second = second; } public Text getFirst() { return first; } public Text getSecond() { return second; } @Override public void write(DataOutput out) throws IOException { first.write(out); second.write(out); } @Override
Kompresja
133
public void readFields(DataInput in) throws IOException { first.readFields(in); second.readFields(in); } @Override public int hashCode() { return first.hashCode() * 163 + second.hashCode(); } @Override public boolean equals(Object o) { if (o instanceof TextPair) { TextPair tp = (TextPair) o; return first.equals(tp.first) && second.equals(tp.second); } return false; } @Override public String toString() { return first + "\t" + second; } @Override public int compareTo(TextPair tp) { int cmp = first.compareTo(tp.first); if (cmp != 0) { return cmp; } return second.compareTo(tp.second); } }
Pierwsza część implementacji jest prosta. Tworzone są dwie zmienne typu Text, first i second, oraz powiązane konstruktory, gettery i settery. Wszystkie implementacje interfejsu Writable muszą mieć domyślny konstruktor, aby platforma MapReduce mogła tworzyć obiekty danego typu, a następnie zapełniać pola obiektów za pomocą wywołania metody readFields(). Obiekty typu Writable są modyfikowalne i często wielokrotnie używane. Dlatego należy unikać alokowania obiektów w metodach write() i readFields(). Metoda write() klasy TextPair serializuje każdy obiekt typu Text do strumienia wyjścia, delegując tę operację do tych właśnie obiektów. Podobnie metoda readFields() deserializuje bajty ze strumienia wejścia, delegując operację do obiektów typu Text. Interfejsy DataOutput i DataInput udostępniają bogaty zestaw metod do serializowania i deserializowania typów prostych Javy, dlatego zwykle programista ma pełną kontrolę nad formatem, w jakim obiekty typu Writable są przesyłane. Podobnie jak w przypadku każdego obiektu wartości pisanego w Javie, tak i tu należy przesłonić metody hashCode(), equals() i toString() z klasy java.lang.Object. Metoda hashCode() jest używana przez klasę HashParitioner (domyślny mechanizm partycjonowania w modelu MapReduce) do wyboru partycji na etapie redukcji, dlatego koniecznie opracuj skuteczną funkcję skrótu, która dobrze dzieli dane (funkcja powinna gwarantować, że partycje na etapie redukcji będą miały podobną wielkość).
134
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop
Jeśli planujesz stosować niestandardowe implementacje interfejsu Writable razem z formatem TextOutputFormat, musisz zaimplementować metodę toString(). Klasa TextOutputFormat wywołuje tę metodę dla kluczy i wartości, aby uzyskać ich reprezentacje wyjściowe. W klasie TextPair zwracane są łańcuchy znaków z obiektów typu Text rozdzielone znakiem tabulacji.
Klasa TextPair implementuje interfejs WritableComparable, dlatego udostępnia implementację metody compareTo(), która porządkuje dane w oczekiwany sposób — umieszcza pierwszy łańcuch znaków przed drugim. Zauważ, że klasa TextPair różni się od (opisanej we wcześniejszym podpunkcie) klasy TextArrayWritable nie tylko liczbą przechowywanych elementów. Klasa TextPair implementuje interfejs WritableComparable, a klasa TextArrayWritable — tylko interfejs Writable.
Implementowanie interfejsu RawComparator z myślą o szybkości Kod klasy TextPair z listingu 5.7 działa poprawnie, można go jednak zoptymalizować. W punkcie „Interfejs WritableComparable i komparatory” wyjaśniono, że gdy obiekty typu TextPair są używane jako klucze w modelu MapReduce, muszą być deserializowane do postaci obiektów w celu wywołania metody compareTo(). A gdyby tak umożliwić porównywanie dwóch obiektów typu TextPair na podstawie ich zserializowanej reprezentacji? Okazuje się, że jest to możliwe, ponieważ obiekt typu TextPair łączy dwa obiekty typu Text, a binarna reprezentacja obiektu typu Text to liczba całkowita zmiennej długości zawierająca liczbę bajtów w reprezentacji łańcucha w kodowaniu UTF-8, po którym następują bajty znaków w tym kodowaniu. Sztuka polega na tym, aby wczytać długość łańcucha. Dzięki temu wiadomo, jak długa jest bajtowa reprezentacja pierwszego obiektu typu Text. Następnie można oddelegować operację do komparatora RawComparator z klasy Text i wywołać go z odpowiednimi pozycjami pierwszego i drugiego łańcucha znaków. Szczegóły przedstawia listing 5.8 (ten kod należy zagnieździć w klasie TextPair). Listing 5.8. Komparator surowych danych używany do porównywania bajtowych reprezentacji obiektów typu TextPair public static class Comparator extends WritableComparator { private static final Text.Comparator TEXT_COMPARATOR = new Text.Comparator(); public Comparator() { super(TextPair.class); } @Override public int compare(byte[] b1, int s1, int l1, byte[] b2, int s2, int l2) { try { int firstL1 = WritableUtils.decodeVIntSize(b1[s1]) + readVInt(b1, s1); int firstL2 = WritableUtils.decodeVIntSize(b2[s2]) + readVInt(b2, s2); int cmp = TEXT_COMPARATOR.compare(b1, s1, firstL1, b2, s2, firstL2); if (cmp != 0) { return cmp; } return TEXT_COMPARATOR.compare(b1, s1 + firstL1, l1 - firstL1, b2, s2 + firstL2, l2 - firstL2); } catch (IOException e) { throw new IllegalArgumentException(e); }
Kompresja
135
} } static { WritableComparator.define(TextPair.class, new Comparator()); }
Tu zamiast bezpośrednio pisać implementację interfejsu RawComparator, utworzono klasę pochodną od WritableComparator, ponieważ udostępnia ona metody pomocnicze i domyślną implementację. Istotnym aspektem przedstawionego kodu jest wyznaczanie wartości firstL1 i firstL2, czyli długości pierwszych pól typu Text w każdym strumieniu bajtów. Każda wartość to suma długości liczby całkowitej o zmiennej długości (zwracana przez metodę decodeVIntSize() klasy WritableUtils) i długości zakodowanego tekstu (zwracana przez metodę readVInt()). Komparator surowych danych jest zarejestrowany w statycznym bloku, dlatego gdy w modelu MapReduce używana jest klasa TextPair, wiadomo, że należy zastosować zarejestrowany komparator jako domyślny.
Niestandardowe komparatory Klasa TextPair jest dowodem na to, że pisanie komparatorów surowych danych wymaga staranności, ponieważ trzeba uwzględniać szczegóły na poziomie bajtów. Jeśli potrzebujesz własnego komparatora, warto zapoznać się z rozwiązaniami z implementacji interfejsu Writable z pakietu org.apache.hadoop.io. Bardzo przydatna jest też analiza metod narzędziowych z klasy WritableUtils. Niestandardowe komparatory należy też w miarę możliwości pisać w taki sposób, aby implementowały interfejs RawComparator. Tego typu komparatory wykorzystują inny porządek sortowania niż naturalna kolejność zdefiniowana w komparatorze domyślnym. Listing 5.9 przedstawia komparator FirstComparator dla klasy TextPair, uwzględniający tylko pierwszy łańcuch znaków z pary. Zauważ, że przesłonięta jest tu metoda compare() przyjmująca obiekty, dlatego obie metody compare() działają według tych samych zasad. Listing 5.9. Niestandardowy komparator surowych danych używany do porównywania pierwszych pól z bajtowych reprezentacji obiektów typu TextPair public static class FirstComparator extends WritableComparator { private static final Text.Comparator TEXT_COMPARATOR = new Text.Comparator(); public FirstComparator() { super(TextPair.class); } @Override public int compare(byte[] b1, int s1, int l1, byte[] b2, int s2, int l2) { try { int firstL1 = WritableUtils.decodeVIntSize(b1[s1]) + readVInt(b1, s1); int firstL2 = WritableUtils.decodeVIntSize(b2[s2]) + readVInt(b2, s2); return TEXT_COMPARATOR.compare(b1, s1, firstL1, b2, s2, firstL2); } catch (IOException e) { throw new IllegalArgumentException(e); } }
136
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop
@Override public int compare(WritableComparable a, WritableComparable b) { if (a instanceof TextPair && b instanceof TextPair) { return ((TextPair) a).first.compareTo(((TextPair) b).first); } return super.compare(a, b); } }
Pokazany tu komparator zostanie użyty w rozdziale 9. w kontekście złączania i sortowania dodatkowego w modelu MapReduce (zobacz punkt „Złączanie”).
Platformy do obsługi serializacji Choć większość programów w modelu MapReduce wykorzystuje dla kluczy i wartości typy Writable, nie jest to wymagane przez interfejs API tego modelu. Można zastosować dowolny typ. Jedynym wymogiem jest dostępność mechanizmu, który przekształca dane na postać binarną i z powrotem. Aby ułatwić tworzenie takich typów, Hadoop udostępnia interfejs API dla dołączanych platform do obsługi serializacji. Taką platformę reprezentuje implementacja interfejsu Serialization (znajduje się on w pakiecie org.apache.hadoop.io.serializer). Na przykład klasa WritableSerialization to implementacja interfejsu Serialization dla typów Writable. Interfejs Serialization definiuje powiązania typów z obiektami typów Serializer (służą do przekształcania obiektów na strumienie bajtów) i Deserializer (przetwarzają strumienie bajtów na obiekty). Do właściwości io.serializations należy przypisać rozdzieloną przecinkami listę nazw klas, aby zarejestrować implementacje interfejsu Serialization. Wartość domyślna obejmuje klasę org.apache. hadoop.io.serializer.WritableSerialization oraz klasy dla rodzin Specific i Reflect z platformy Avro (zobacz punkt „Typy danych i schematy systemu Avro” w rozdziale 12.). To oznacza, że domyślnie obsługiwana jest serializacja i deserializacja tylko obiektów platformy Avro i obiektów typu Writable. Hadoop udostępnia klasę JavaSerialization, która używa mechanizmu Javy Object Serialization. Choć jest to wygodne, ponieważ pozwala stosować w programach w modelu MapReduce standardowe typy Javy (na przykład Integer i String), mechanizm Java Object Serialization jest mniej wydajny od obiektów typu Writable, dlatego nie zaleca się używania go (zobacz ramkę „Dlaczego nie warto używać mechanizmu Java Object Serialization? ”).
Język IDL dla serializacji Istnieją różne platformy serializacji, w których zastosowano odmienne podejście. Zamiast definiować typy w kodzie, określa się je w niezależny od języka deklaratywny sposób, za pomocą języka IDL (ang. interface description language). System może wtedy generować typy dla różnych języków, co jest korzystne ze względu na przenośność. W językach IDL zwykle definiuje się schemat do kontroli wersji, co pozwala na proste modyfikowanie typów. Apache Thrift (http://thrift.apache.org/) i Google Protocol Buffers (https://github.com/google/protobuf) to popularne platformy do obsługi serializacji, często używane do przechowywania trwałych danych
Kompresja
137
binarnych. Ich obsługa jako formatów dla modelu MapReduce jest ograniczona3, używa się ich jednak wewnętrznie w mechanizmach Hadoopa związanych z wywołaniami RPC i wymianą danych. Avro to oparta na języku IDL platforma do obsługi serializacji, zaprojektowana pod kątem przetwarzania dużych zbiorów danych w Hadoopie. Jej omówienie znajdziesz w rozdziale 12.
Dlaczego nie warto używać mechanizmu Java Object Serialization? Java udostępnia własny mechanizm serializacji Java Object Serialization (nazywany też „serializacją z Javy”). Jest on ściśle zintegrowany z językiem, dlatego nasuwa się pytanie, dlaczego nie wykorzystano go w Hadoopie. Oto co na ten temat ma do powiedzenia Doug Cutting: „Dlaczego nie zastosowałem serializacji z Javy, gdy zacząłem prace nad Hadoopem? Ponieważ mechanizm wyglądał na rozbudowany i skomplikowany. Szukałem czegoś prostego i oszczędnego, z precyzyjną kontrolą nad zapisem i odczytem obiektów, ponieważ ten mechanizm pełni bardzo ważną funkcję w Hadoopie. Serializacja z Javy daje pewną kontrolę, ale jej uzyskanie nie jest łatwe. Z podobnych powodów zrezygnowałem z wywołań RMI (ang. Remote Method Invocation). Efektywna, wydajna komunikacja międzyprocesowa jest w Hadoopie bardzo istotna. Czułem, że potrzebuję precyzyjnej kontroli nad obsługą aspektów takich jak połączenia, limity czasu i bufory. Wywołania RMI zapewniają bardzo niewielką kontrolę w tym obszarze”. Problem polega na tym, że serializacja z Javy nie spełnia wymienionych wcześniej kryteriów formatu serializacji — zwięzłości, szybkości, rozszerzalności i przenośności.
Plikowe struktury danych W niektórych aplikacjach potrzebne są specjalne struktury do przechowywania danych. W przetwarzaniu opartym na modelu MapReduce umieszczenie każdej porcji danych binarnych w odrębnym pliku nie zapewnia skalowalności, dlatego na potrzeby takich sytuacji w Hadoopie opracowano kilka kontenerów wyższego poziomu.
Klasa SequenceFile Wyobraź sobie plik dziennika, w którym każdy rekord znajduje się w nowym wierszu. Jeśli chcesz zapisywać w nim dane binarne, zwykły tekst nie jest odpowiednim formatem. Klasa SequenceFile z Hadoopa lepiej się do tego nadaje i zapewnia trwałą strukturę danych dla binarnych par kluczwartość. Aby wykorzystać tę klasę jako format pliku dziennika, wybierz klucz (na przykład znacznik czasu reprezentowany za pomocą typu LongWritable), a jako wartość zastosuj obiekt typu Writable reprezentujący rejestrowane informacje. Klasa SequenceFile dobrze sprawdza się także jako kontener dla mniejszych plików. System HDFS i model MapReduce są zoptymalizowane pod kątem dużych plików, dlatego umieszczenie mniej-
3
Projekt Elephant Bird (https://github.com/twitter/elephant-bird) rozwijany przez firmę Twitter obejmuje narzędzia do pracy z platformami Thrift i Protocol Buffers w Hadoopie.
138
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop
szych w pliku typu SequenceFile zwiększa wydajność ich przechowywania i przetwarzania (punkt „Przetwarzanie całego pliku jako rekordu” w rozdziale 8. zawiera program ilustrujący umieszczanie plików w obiekcie typu SequenceFile)4.
Zapis danych w plikach typu SequenceFile Aby utworzyć plik typu SequenceFile, użyj jednej z jego metod statycznych createWriter(), zwracających egzemplarz typu SequenceFile.Writer. Istnieje kilka przeciążonych wersji tej metody. Wszystkie wymagają podania docelowego strumienia (typu FSDataOutputStream lub pary obiektów typu FileSystem i Path), obiektu typu Configuration oraz typów klucza i wartości. Opcjonalne argumenty to typ i kodek kompresji, wywołanie zwrotne typu Progressable służące do informowania o postępach operacji zapisu, a także egzemplarz typu Metadata zapisywany w nagłówku pliku klasy SequenceFile. Klucze i wartości przechowywane w plikach klasy SequenceFile nie muszą być typu Writable. Dozwolone są wszystkie typy umożliwiające serializację i deserializację za pomocą obiektów typu Serialization. Po otrzymaniu obiektu typu SequenceFile.Writer należy zapisać pary klucz-wartość za pomocą metody append(). Po wykonaniu tej operacji wystarczy wywołać metodę close() (klasa SequenceFile. Writer implementuje interfejs java.io.Closeable). Listing 5.10 przedstawia krótki program zapisujący pary klucz-wartość w pliku typu SequenceFile za pomocą opisanego wcześniej interfejsu API. Listing 5.10. Zapis danych w pliku typu SequenceFile public class SequenceFileWriteDemo { private static final String[] DATA = { "One, two, buckle my shoe", "Three, four, shut the door", "Five, six, pick up sticks", "Seven, eight, lay them straight", "Nine, ten, a big fat hen" }; public static void main(String[] args) throws IOException { String uri = args[0]; Configuration conf = new Configuration(); FileSystem fs = FileSystem.get(URI.create(uri), conf); Path path = new Path(uri); IntWritable key = new IntWritable(); Text value = new Text(); SequenceFile.Writer writer = null; try { writer = SequenceFile.createWriter(fs, conf, path, key.getClass(), value.getClass()); for (int i = 0; i < 100; i++) { key.set(100 - i); value.set(DATA[i % DATA.length]); 4
Zamieszczony na blogu tekst A Million Little Files (http://stuartsierra.com/2008/04/24/a-million-little-files) Stuarta Sierry zawiera kod umożliwiający konwersję plików tar na obiekt klasy SequenceFile.
Plikowe struktury danych
139
System.out.printf("[%s]\t%s\t%s\n", writer.getLength(), key, value); writer.append(key, value); } } finally { IOUtils.closeStream(writer); } } }
Klucze w używanym pliku typu SequenceFile to liczby całkowite od 100 do 1 reprezentowane jako obiekty typu IntWritable. Wartościami są obiekty typu Text. Przed dodaniem każdego rekordu do obiektu typu SequenceFile.Writer wywoływana jest metoda getLength() w celu ustalenia bieżącej pozycji w pliku (uzyskana w ten sposób informacja o granicach rekordów jest wykorzystywana w następnym punkcie, przy niesekwencyjnym odczycie pliku). Pozycje są wyświetlane w konsoli razem z parami klucz-wartość. Oto efekt uruchomienia przedstawionego kodu. % hadoop SequenceFileWriteDemo numbers.seq [128] 100 One, two, buckle my shoe [173] 99 Three, four, shut the door [220] 98 Five, six, pick up sticks [264] 97 Seven, eight, lay them straight [314] 96 Nine, ten, a big fat hen [359] 95 One, two, buckle my shoe [404] 94 Three, four, shut the door [451] 93 Five, six, pick up sticks [495] 92 Seven, eight, lay them straight [545] 91 Nine, ten, a big fat hen ... [1976] 60 One, two, buckle my shoe [2021] 59 Three, four, shut the door [2088] 58 Five, six, pick up sticks [2132] 57 Seven, eight, lay them straight [2182] 56 Nine, ten, a big fat hen ... [4557] 5 One, two, buckle my shoe [4602] 4 Three, four, shut the door [4649] 3 Five, six, pick up sticks [4693] 2 Seven, eight, lay them straight [4743] 1 Nine, ten, a big fat hen
Odczyt danych z plików typu SequenceFile Odczyt plików typu SequenceFile od początku do końca wymaga tylko utworzenia egzemplarza typu SequenceFile.Reader i przejścia po rekordach za pomocą wywołań jednej z metod next(). Używana wersja tej metody zależy od zastosowanej platformy do obsługi serializacji. Jeśli korzystasz z typów Writable, możesz wykorzystać metodę next() przyjmującą jako argumenty zmienne na klucz i wartość oraz wczytującą następny klucz i następną wartość ze strumienia do tych zmiennych. public boolean next(Writable key, Writable val)
Zwracana wartość to true, jeśli para klucz-wartość została wczytana, a false, jeżeli kod doszedł do końca pliku. W platformach do obsługi serializacji, które nie są oparte na typach Writable (czyli na przykład w platformie Apache Thrift), należy stosować dwie poniższe metody. public Object next(Object key) throws IOException public Object getCurrentValue(Object val) throws IOException
140
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop
W tej sytuacji należy się upewnić, że pożądaną metodę serializacji ustawiono we właściwości io.serializations (zobacz punkt „Platformy do obsługi serializacji”). Jeśli metoda next() zwraca obiekt różny od null, oznacza to, że ze strumienia wczytano parę klucz-wartość. By pobrać wartość, należy użyć metody getCurrentValue(). Jeżeli metoda next() zwróciła wartość null, oznacza to dojście do końca pliku. Program z listingu 5.11 pokazuje, jak wczytać dane z pliku typu SequenceFile z kluczami i wartościami typu Writable. Zauważ, że typy są ustalane na podstawie pliku typu SequenceFile.Reader za pomocą wywołań getKeyClass() i getValueClass(). Następnie program używa klasy ReflectionUtils do utworzenia obiektów na klucz i wartość. Ta technika pozwala zastosować program do dowolnego pliku typu SequenceFile z kluczami i wartościami typu Writable. Listing 5.11. Odczyt pliku typu SequenceFile public class SequenceFileReadDemo { public static void main(String[] args) throws IOException { String uri = args[0]; Configuration conf = new Configuration(); FileSystem fs = FileSystem.get(URI.create(uri), conf); Path path = new Path(uri); SequenceFile.Reader reader = null; try { reader = new SequenceFile.Reader(fs, path, conf); Writable key = (Writable) ReflectionUtils.newInstance(reader.getKeyClass(), conf); Writable value = (Writable) ReflectionUtils.newInstance(reader.getValueClass(), conf); long position = reader.getPosition(); while (reader.next(key, value)) { String syncSeen = reader.syncSeen() ? "*" : ""; System.out.printf("[%s%s]\t%s\t%s\n", position, syncSeen, key, value); position = reader.getPosition(); // Początek następnego rekordu } } finally { IOUtils.closeStream(reader); } } }
Inną cechą tego programu jest to, że wyświetla pozycje punktów synchronizacji z pliku typu SequenceFile. Punkt synchronizacji to miejsce w strumieniu pozwalające na ponowną synchronizację programu z granicami rekordów, jeśli jednostka wczytująca się zgubi (na przykład po wyszukaniu wskazanej pozycji w strumieniu). Punkty synchronizacji są zapisywane przez obiekt typu SequenceFile.Writer, który co kilka rekordów stawia specjalne znaczniki w trakcie zapisu pliku typu SequenceFile. Te znaczniki są na tyle małe, że w niewielkim stopniu zwiększają ilość zajmowanej pamięci (poniżej 1%). Punkty synchronizacji zawsze pokrywają się z granicami rekordów. Gdy uruchomisz program z listingu 5.11, zobaczysz punkty synchronizacji z pliku typu SequenceFile wyróżnione gwiazdką. Pierwszy taki punkt znajduje się na pozycji 2021 (drugi pojawia się na niewidocznej w poniższych danych wyjściowych pozycji 4075).
Plikowe struktury danych
141
% hadoop SequenceFileReadDemo numbers.seq [128] 100 One, two, buckle my shoe [173] 99 Three, four, shut the door [220] 98 Five, six, pick up sticks [264] 97 Seven, eight, lay them straight [314] 96 Nine, ten, a big fat hen [359] 95 One, two, buckle my shoe [404] 94 Three, four, shut the door [451] 93 Five, six, pick up sticks [495] 92 Seven, eight, lay them straight [545] 91 Nine, ten, a big fat hen [590] 90 One, two, buckle my shoe ... [1976] 60 One, two, buckle my shoe [2021*] 59 Three, four, shut the door [2088] 58 Five, six, pick up sticks [2132] 57 Seven, eight, lay them straight [2182] 56 Nine, ten, a big fat hen ... [4557] 5 One, two, buckle my shoe [4602] 4 Three, four, shut the door [4649] 3 Five, six, pick up sticks [4693] 2 Seven, eight, lay them straight [4743] 1 Nine, ten, a big fat hen
Istnieją dwa sposoby wyszukiwania konkretnej pozycji w plikach typu SequenceFile. Pierwszy polega na użyciu metody seek(), która przenosi kod do podanego punktu w danym pliku. Wyszukiwanie pozycji odpowiadającej granicy rekordu działa w oczekiwany sposób. reader.seek(359); assertThat(reader.next(key, value), is(true)); assertThat(((IntWritable) key).get(), is(95));
Jeśli jednak pozycja nie odpowiada granicy rekordu, przy wywołaniu metody next() zgłoszony zostanie wyjątek. reader.seek(360); reader.next(key, value); // Zgłoszenie wyjątku IOException
Drugi sposób wyszukiwania granic rekordów polega na użyciu punktów synchronizacji. Metoda sync(long position) klasy SequenceFile.Reader przenosi kod do następnego punktu synchronizacji po pozycji position. Jeśli po podanej pozycji nie występują żadne punkty synchronizacji, kod jest przenoszony do końca pliku. Można więc wywołać metodę sync() dla dowolnej pozycji w strumieniu (niekoniecznie dla granicy rekordu), a kod znajdzie się w następnym punkcie synchronizacji i będzie mógł kontynuować odczyt. reader.sync(360); assertThat(reader.getPosition(), is(2021L)); assertThat(reader.next(key, value), is(true)); assertThat(((IntWritable) key).get(), is(59));
Klasa SequenceFile.Writer udostępnia metodę sync() przeznaczoną do wstawiania punktów synchronizacji w bieżącej pozycji w strumieniu. Nie należy mylić tej metody z metodą hsync() interfejsu Syncable, przeznaczoną do synchronizowania buforów z używanym urządzeniem (zobacz punkt „Model zapewniania spójności” w rozdziale 3.).
142
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop
Punkty synchronizacji są przydatne, gdy pliki typu SequenceFile są używane jako dane wejściowe w modelu MapReduce. Punkty synchronizacji pozwalają podzielić pliki i przetwarzać ich fragmenty niezależnie przez odrębne operacje mapowania (zobacz punkt „Klasa SequenceFileInputFormat” w rozdziale 8.).
Wyświetlanie zawartości plików typu SequenceFile z poziomu wiersza poleceń Instrukcja hadoop fs ma opcję -text pozwalającą wyświetlać zawartość plików typu SequenceFile w postaci tekstowej. Kod sprawdza wtedy „magiczny kod” pliku, aby wykryć jego typ i we właściwy sposób przekształcić dane na tekst. Rozpoznawane są pliki w formacie gzip, pliki typu Sequence File i pliki danych platformy Avro. Dla innych plików kod przyjmuje, że dane wejściowe to zwykły tekst. Dla plików typu SequenceFile wspomniane polecenie jest przydatne tylko wtedy, gdy klucze i wartości mają sensowną reprezentację w postaci łańcuchów znaków (zdefiniowaną za pomocą metody toString()). Jeśli używasz własnych klas dla kluczy lub wartości, upewnij się, że są one wskazane w ścieżce do klas Hadoopa. Uruchomienie omawianej instrukcji dla pliku typu SequenceFile utworzonego w poprzednim punkcie da następujące dane wyjściowe: % hadoop fs -text numbers.seq | head 100 One, two, buckle my shoe 99 Three, four, shut the door 98 Five, six, pick up sticks 97 Seven, eight, lay them straight 96 Nine, ten, a big fat hen 95 One, two, buckle my shoe 94 Three, four, shut the door 93 Five, six, pick up sticks 92 Seven, eight, lay them straight 91 Nine, ten, a big fat hen
Sortowanie i scalanie plików typu SequenceFile Jedna z najbardziej przydatnych metod sortowania (i scalania) plików typu SequenceFile polega na wykorzystaniu modelu MapReduce. Jest on z natury równoległy i umożliwia określenie liczby używanych reduktorów, co określa liczbę wyjściowych partycji (na przykład ustawienie jednego reduktora oznacza uzyskanie jednego pliku wyjściowego). Można wykorzystać dostępny w Hadoopie przykładowy kod do sortowania. W tym celu należy określić, że wejście i wyjście to pliki typu SequenceFile, a także ustawić typy kluczy i wartości. % hadoop jar \ $HADOOP_HOME/share/hadoop/mapreduce/hadoop-mapreduce-examples-*.jar \ sort -r 1 \ -inFormat org.apache.hadoop.mapreduce.lib.input.SequenceFileInputFormat \ -outFormat org.apache.hadoop.mapreduce.lib.output.SequenceFileOutputFormat \ -outKey org.apache.hadoop.io.IntWritable \ -outValue org.apache.hadoop.io.Text \ numbers.seq sorted % hadoop fs -text sorted/part-r-00000 | head 1 Nine, ten, a big fat hen 2 Seven, eight, lay them straight 3 Five, six, pick up sticks 4 Three, four, shut the door
Plikowe struktury danych
143
5 6 7 8 9 10
One, two, buckle my shoe Nine, ten, a big fat hen Seven, eight, lay them straight Five, six, pick up sticks Three, four, shut the door One, two, buckle my shoe
Szczegółowe omówienie sortowania znajdziesz w punkcie „Sortowanie” w rozdziale 9. Zamiast sortowania i scalania za pomocą modelu MapReduce można wykorzystać klasę Sequence File.Sorter, udostępniającą zestaw metod sort() i merge(). Te metody są starsze niż model MapReduce i działają na niższym poziomie (na przykład w celu zapewnienia współbieżności trzeba ręcznie dzielić dane na fragmenty), dlatego zwykle zaleca się, aby do sortowania i scalania plików typu SequenceFile używać właśnie modelu MapReduce.
Format SequenceFile Plik typu SequenceFile obejmuje nagłówek, po którym następują rekordy (zobacz rysunek 5.2). Trzy pierwsze bajty to bajty SEQ, pełniące funkcję „magicznego kodu”. Po nich następuje jeden bajt reprezentujący numer wersji. Nagłówek obejmuje też inne pola, w tym nazwy klas kluczy i wartości, informacje o kompresji, metadane zdefiniowane przez użytkownika i znacznik synchronizacji5. Pamiętaj, że znacznik synchronizacji umożliwia w kodzie wczytującym dane synchronizację granic rekordów na podstawie dowolnej pozycji w pliku. Każdy plik ma losowo wygenerowany znacznik synchronizacji, którego wartość jest zapisywana w nagłówku. Znaczniki synchronizacji pojawiają się między rekordami pliku typu SequenceFile. Są tak zaprojektowane, aby nie zajmowały więcej niż 1% dodatkowego miejsca, dlatego nie zawsze występują między każdą parą rekordów (gdy rekordy są krótkie, znaczniki pojawiają się rzadziej).
Rysunek 5.2. Wewnętrzna struktura pliku typu SequenceFile bez kompresji i z kompresją rekordów
5
Szczegółowe informacje o formacie pól znajdziesz w dokumentacji klasy SequenceFile (http://hadoop.apache.org/ docs/current/api/org/apache/hadoop/io/SequenceFile.html) i w kodzie źródłowym.
144
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop
Wewnętrzny format rekordów zależy od tego, czy kompresja jest włączona, a także od tego, czy stosowana jest kompresja rekordów, czy bloków. Bez kompresji (jest to ustawienie domyślne) każdy rekord zawiera długość rekordu (w bajtach), długość klucza, klucz i wartość. Pola określające długość to 4-bajtowe liczby całkowite zgodne z metodą writeInt() klasy java.io.DataOutput. Klucze i wartości są serializowane za pomocą obiektu typu Serialization zdefiniowanego dla klasy zapisywanej w pliku typu SequenceFile. Format plików z kompresją rekordów jest prawie identyczny jak przy braku kompresji. Różnica polega na tym, że bajty wartości są skompresowane za pomocą zdefiniowanego w nagłówku kodeka. Zauważ, że klucze nie są kompresowane. Kompresja bloków (rysunek 5.3) polega na wspólnej kompresji wielu rekordów. Ta technika pozwala zaoszczędzić więcej miejsca i zwykle należy ją przedkładać nad kompresję rekordów, ponieważ umożliwia wykorzystanie podobieństw między rekordami. Rekordy są dodawane do bloku do momentu osiągnięcia minimalnej wielkości mierzonej w bajtach. Ta wartość jest zdefiniowana we właściwości io.seqfile.compress.blocksize i domyślnie wynosi milion bajtów. Przed początkiem każdego bloku zapisywany jest znacznik synchronizacji. Format bloku wygląda następująco — pole określające liczbę rekordów w bloku, po czym następują cztery skompresowane pola (z długościami kluczy, kluczami, długościami wartości i wartościami).
Rysunek 5.3. Wewnętrzna struktura pliku typu SequenceFile z kompresją bloków
Klasa MapFile Plik typu MapFile to posortowany plik typu SequenceFile z indeksem umożliwiającym wyszukiwanie na podstawie kluczy. Sam indeks to plik typu SequenceFile zawierający część kluczy z odwzorowania (domyślnie co 128. klucz). Technika polega na tym, że indeks można wczytać do pamięci, aby umożliwić szybkie wyszukiwanie w głównym pliku danych, którym jest inny plik typu SequenceFile, zawierający wszystkie klucze z odwzorowania w posortowanej kolejności. Klasa MapFile udostępnia bardzo podobny interfejs do odczytu i zapisu danych jak klasa Sequence File. Najważniejsze, o czym trzeba pamiętać przy zapisie danych za pomocą obiektu klasy MapFile. Writer, to że elementy odwzorowania trzeba podawać we właściwej kolejności. W przeciwnym razie zgłoszony zostanie wyjątek IOException.
Plikowe struktury danych
145
Odmiany klasy MapFile Hadoop udostępnia kilka odmian ogólnego interfejsu MapFile przeznaczonego dla kluczy i wartości. Oto te odmiany:
Klasa SetFile to specjalna wersja klasy MapFile przeznaczona do przechowywania zbiorów kluczy typu Writable. Klucze trzeba dodawać zgodnie z porządkiem sortowania.
Klasa ArrayFile to wersja klasy MapFile, w której klucze to liczby całkowite reprezentujące indeksy elementów tablicy, a wartości to elementy typu Writable.
Klasa BloomMapFile to wersja klasy MapFile udostępniająca szybką metodę get(), odpowiednia zwłaszcza dla rzadko zapełnionych plików. W implementacji używany jest dynamiczny filtr Blooma do sprawdzania, czy odwzorowanie zawiera dany klucz. Sprawdzanie trwa bardzo krótko, ponieważ odbywa się w pamięci. Jest jednak narażone na błędne zgłaszanie dostępności nieistniejących kluczy. Tylko wtedy, gdy test zakończy się powodzeniem (czyli jeśli klucz istnieje), wywoływana jest zwykła metoda get().
Inne formaty plików i formaty kolumnowe Choć typy SequenceFile i MapFile to najstarsze formaty plików binarnych w Hadoopie, istnieją też inne. Dostępne są też lepsze rozwiązania, które warto rozważyć w czasie pracy nad nowymi projektami. Pliki danych platformy Avro (opisane w punkcie „Typy danych i schematy systemu Avro” w rozdziale 12.) przypominają pliki typu SequenceFile, ponieważ też są zaprojektowane z myślą o przetwarzaniu dużych zbiorów danych — dlatego zajmują mało miejsca i można je dzielić. Jednak dodatkowo są przenośne między różnymi językami programowania. Obiekty zapisane w plikach danych platformy Avro są opisywane za pomocą schematu, a nie za pomocą napisanej w Javie implementacji interfejsu Writable (jak w przypadku plików SequenceFile, przez co są one przeznaczone głównie dla Javy). Pliki danych platformy Avro są powszechnie obsługiwane przez komponenty z ekosystemu Hadoop, dlatego są dobrym domyślnym wyborem, gdy potrzebujesz formatu binarnego. Pliki typów SequenceFile i MapFile oraz pliki danych platformy Avro to wierszowe formaty plików. To oznacza, że wartości z poszczególnych wierszy są zapisywane w pliku obok siebie. W formacie kolumnowym wiersze z pliku (lub tabele z bazy Hive) są dzielone na porcje zapisywane według kolumn. Najpierw zapisywane są wartości z pierwszej kolumny każdego wiersza, następnie wartości z drugiej kolumny każdego wiersza itd. Schematycznie przedstawia to rysunek 5.4. Układ kolumnowy umożliwia pominięcie kolumn, które nie są używane w zapytaniu. Wyobraź sobie zapytanie do tabeli z rysunku 5.4, które przetwarza tylko drugą kolumnę. W układzie wierszowym (na przykład w pliku typu SequenceFile) do pamięci trzeba przenieść cały wiersz (zapisany w rekordzie z pliku), choć odczytywana jest tylko druga kolumna. W modelu leniwej deserializacji można zmniejszyć liczbę potrzebnych cykli procesora, deserializując tylko potrzebne kolumny. Nie pozwala to jednak uniknąć kosztów wczytywania z dysku bajtów każdego wiersza. W układzie kolumnowym do pamięci trzeba przenieść tylko fragmenty pliku z kolumny drugiej (wyróżnione na rysunku). Ogólnie formaty kolumnowe dobrze się sprawdzają, gdy zapytania używają tylko niewielkiej liczby kolumn tabeli. Natomiast formaty wierszowe są odpowiednie, gdy przy przetwarzaniu jednocześnie potrzebna jest duża liczba kolumn wiersza.
146
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop
Rysunek 5.4. Układ wierszowy a układ kolumnowy
Formaty kolumnowe wymagają więcej pamięci przy odczycie i zapisie, ponieważ zapisują w pamięci wycinki wierszy, a nie pojedyncze wiersze. Ponadto zwykle nie jest możliwe kontrolowanie momentu zapisu (za pomocą synchronizacji lub operacji opróżniania bufora), dlatego formaty kolumnowe nie nadają się do strumieniowego zapisu danych, ponieważ nie można odzyskać bieżącego pliku, gdy proces zapisujący zawiedzie. Formaty wierszowe (na przykład pliki typu SequenceFile i pliki danych platformy Avro) mogą zostać wczytane do ostatniego punktu synchronizacji po niepowodzeniu procesu zapisującego. To dlatego w usłudze Flume (zobacz rozdział 14.) używane są formaty wierszowe. Pierwszym formatem kolumnowym w Hadoopie był RCFile (ang. Record Columnar File) z bazy Hive. Później został on zastąpiony przez formaty ORCFile (ang. Optimized Record Columnar File) z bazy Hive i omówiony w rozdziale 13. Parquet. Parquet to format kolumnowy ogólnego użytku oparty na Dremelu Google’a, z bogatą obsługą komponentów w Hadoopie. Także w Avro dostępny jest format kolumnowy (Trevni).
Plikowe struktury danych
147
148
Rozdział 5. Operacje wejścia-wyjścia w platformie Hadoop
CZĘŚĆ II
Model MapReduce
ROZDZIAŁ 6.
Budowanie aplikacji w modelu MapReduce
W rozdziale 2. znajduje się wprowadzenie do modelu MapReduce. W tym rozdziale zapoznasz się z praktycznymi aspektami budowania w Hadoopie aplikacji w modelu MapReduce. Pisanie programu w modelu MapReduce odbywa się zgodnie z określonym wzorcem. Najpierw należy przygotować funkcje mapującą i redukującą (najlepiej z testami jednostkowymi sprawdzającymi, czy funkcje działają zgodnie z oczekiwaniami). Następnie trzeba dodać program sterujący, który uruchamia zadanie. Ten program można wywołać w środowisku IDE dla małego podzbioru danych, aby ustalić, czy działa poprawnie. Jeśli wystąpią problemy, należy wykorzystać debuger ze środowiska IDE do wykrycia ich źródła. Na podstawie uzyskanych informacji można rozwinąć testy jednostkowe z uwzględnieniem danej sytuacji i usprawnić mapper lub reduktor, by zapewnić poprawną obsługę używanych danych wejściowych. Jeżeli program działa prawidłowo dla małego zbioru danych, jest gotowy do uruchomienia w klastrze. Wykonanie programu dla pełnego zbioru danych prawdopodobnie pozwoli odkryć inne problemy. Można je rozwiązać w opisany wcześniej sposób, rozbudowując testy i modyfikując mapper lub reduktor pod kątem obsługi nowych przypadków. Debugowanie błędnych programów w klastrze jest trudne, dlatego dalej opisanych jest kilka popularnych technik, które to ułatwiają. Gdy program już działa, możesz go dopracować. Najpierw wykonaj standardowe testy związane z przyspieszaniem pracy programów w modelu MapReduce, a następnie przeprowadź profilowanie zadania. Profilowanie programów rozproszonych to skomplikowane zadanie, jednak Hadoop udostępnia mechanizmy ułatwiające ten proces. Przed rozpoczęciem pisania programu w modelu MapReduce należy zainstalować i skonfigurować środowisko programowania. W tym celu musisz zrozumieć, jak Hadoop przetwarza konfigurację.
API do obsługi konfiguracji Komponenty w Hadoopie są konfigurowane za pomocą dostępnego w tej platformie specjalnego interfejsu API. Obiekt klasy Configuration (z pakietu org.apache.hadoop.conf) reprezentuje kolekcję właściwości konfiguracyjnych i ich wartości. Każda właściwość ma nazwę (typu String), a jako wartości używane są różne typy, w tym typy proste Javy (na przykład boolean, int, long i float), typy String, Class i java.io.File oraz kolekcje wartości typu String.
151
Obiekt typu Configuration wczytuje właściwości z zasobów — plików XML o prostej strukturze definiujących pary nazwa-wartość (zobacz listing 6.1). Listing 6.1. Prosty plik konfiguracyjny configuration-1.xml
color yellow Color
size 10 Size
weight heavy true Weight
size-weight ${size},${weight} Size and weight
Jeśli dane obiektu typu Configuration znajdują się w pliku configuration-1.xml, dostęp do właściwości można uzyskać za pomocą następującego kodu: Configuration conf = new Configuration(); conf.addResource("configuration-1.xml"); assertThat(conf.get("color"), is("yellow")); assertThat(conf.getInt("size", 0), is(10)); assertThat(conf.get("breadth", "wide"), is("wide"));
Warto zwrócić uwagę na kilka kwestii. W pokazanym pliku XML nie są przechowywane informacje o typie. Właściwości można interpretować jako wartość danego typu w momencie ich wczytywania. Ponadto metody get() umożliwiają określenie wartości domyślnej, używanej, jeśli dana właściwość nie jest zdefiniowana w pliku XML (tak jest z właściwością breadth w przykładzie).
Łączenie zasobów Sytuacja staje się ciekawa, gdy do definiowania obiektu typu Configuration używany jest więcej niż jeden zasób. To podejście jest stosowane w Hadoopie do wyodrębniania właściwości domyślnych systemu (zdefiniowanych wewnętrznie w pliku core-default.xml) od właściwości specyficznych dla danej jednostki (zdefiniowanych w pliku core-site.xml). W pliku z listingu 6.2 zdefiniowane są właściwości size i weight.
152
Rozdział 6. Budowanie aplikacji w modelu MapReduce
Listing 6.2. Drugi plik konfiguracyjny — configuration-2.xml
size 12
weight light
Zasoby są dodawane do obiektu typu Configuration po kolei: Configuration conf = new Configuration(); conf.addResource("configuration-1.xml"); conf.addResource("configuration-2.xml");
Właściwości zdefiniowane w później dodawanych zasobach zastępują wcześniejsze definicje. Dlatego właściwość size ostatecznie przyjmuje wartość z drugiego pliku konfiguracyjnego, configuration-2.xml. assertThat(conf.getInt("size", 0), is(12));
Jednak właściwości oznaczonych jako final nie można zastępować w dalszych definicjach. Właściwość weight w pierwszym pliku konfiguracyjnym ma modyfikator final, dlatego próba zastąpienia jej wartości w drugim pliku kończy się niepowodzeniem (zachowana zostaje wartość z pierwszego pliku). assertThat(conf.get("weight"), is("heavy"));
Próba przesłonięcia właściwości z modyfikatorem final zwykle oznacza błąd konfiguracji. Skutkuje to zarejestrowaniem komunikatu ostrzegawczego, co ułatwia późniejszą diagnozę. Administratorzy oznaczają modyfikatorem final właściwości w lokalnych plikach demonów, gdy nie chcą, aby użytkownicy zmieniali te właściwości w plikach po stronie klienta lub za pomocą parametrów przekazywanych do zadania.
Podstawianie wartości zmiennych Właściwości konfiguracyjne można definiować za pomocą innych właściwości i właściwości systemowych. Na przykład właściwość size-weight z pierwszego pliku konfiguracyjnego jest zdefiniowana jako ${size},${weight}. Za te zmienne podstawiane są wartości znalezione w konfiguracji. assertThat(conf.get("size-weight"), is("12,heavy"));
Właściwości systemowe są traktowane priorytetowo względem właściwości zdefiniowanych w plikach zasobów. System.setProperty("size", "14"); assertThat(conf.get("size-weight"), is("14,heavy"));
Ten mechanizm jest przydatny do zastępowania właściwości w wierszu poleceń za pomocą argumentów maszyny JVM -Dwłaściwość=wartość. Zauważ, że choć właściwości konfiguracyjne można definiować w kategoriach właściwości systemowych, to jeśli właściwości systemowe nie zostaną zredefiniowane we właściwościach konfiguracyjnych, nie będą dostępne za pomocą interfejsu API do obsługi konfiguracji. Dlatego: API do obsługi konfiguracji
153
System.setProperty("length", "2"); assertThat(conf.get("length"), is((String) null));
Przygotowywanie środowiska programowania Pierwszy krok polega na utworzeniu projektu. Pozwoli to budować programy w modelu MapReduce i uruchamiać je w trybie lokalnym (niezależnym) z poziomu wiersza poleceń lub środowiska IDE. Plik POM (ang. Project Object Model) Mavena z listingu 6.3 pokazuje zależności potrzebne do budowania i testowania programów w modelu MapReduce. Listing 6.3. Plik POM Mavena potrzebny do budowania i testowania aplikacji w modelu MapReduce
4.0.0 com.hadoopbook hadoop-book-mr-dev 4.0
UTF-8 2.5.1
junit junit 4.11 test
org.apache.mrunit mrunit 1.1.0 hadoop2 test
fs.defaultFS 5
Więcej informacji na temat wpływu kluczy hostów w protokole SSH na bezpieczeństwo znajdziesz w artykule „SSH Host Key Protection” (http://www.symantec.com/connect/articles/ssh-host-key-protection) Briana Hatcha.
6
Zauważ, że nie pokazano tu pliku site dla modelu MapReduce. Wynika to z tego, że jedynym demonem modelu MapReduce jest serwer historii zadań, dla którego ustawienia domyślne są odpowiednie.
Konfiguracja Hadoopa
289
hdfs://namenode/
Listing 10.2. Typowa zawartość pliku konfiguracyjnego hdfs-site.xml
yarn.resourcemanager.hostname resourcemanager
yarn.nodemanager.local-dirs /disk1/nm-local-dir,/disk2/nm-local-dir
yarn.nodemanager.aux-services mapreduce.shuffle
yarn.nodemanager.resource.memory-mb 16384
yarn.nodemanager.resource.cpu-vcores 16
System HDFS Aby używać systemu HDFS, należy ustawić jedną maszynę jako węzeł nazw. Wtedy właściwość fs.defaultFS określa identyfikator URL systemu pliku HDFS, gdzie jako host jest podana nazwa hosta albo adres IP węzła nazw, a jako port jest ustawiony port, w którym węzeł nazw odbiera wywołania RPC. Jeśli port nie jest określony, używany jest port domyślny 8020. 290
Rozdział 10. Budowanie klastra opartego na platformie Hadoop
Właściwość fs.defaultFS określa też domyślny system plików. Jest on używany do interpretowania względnych ścieżek, przydatnych, ponieważ pozwalają zmniejszyć ilość wpisywanego kodu (i uniknąć zapisywania na stałe adresu konkretnego węzła nazw). Na przykład dla domyślnego systemu plików zdefiniowanego na listingu 10.1 względny identyfikator URI /a/b jest interpretowany jako hdfs://namenode/a/b. Jeśli korzystasz z systemu HDFS, to, że właściwość fs.defaultFS jest używana do określania zarówno węzła nazw systemu HDFS, jak i domyślnego systemu nazw, oznacza, iż HDFS musi być domyślnym systemem plików w konfiguracji serwera. Pamiętaj jednak, że w konfiguracji klienta dla wygody można ustawić inny system plików jako domyślny. Jeśli na przykład korzystasz jednocześnie z systemów HDFS i S3, w konfiguracji klienta możesz ustawić dowolny z nich jako domyślny. Pozwala to używać domyślnego systemu plików za pomocą względnych identyfikatorów URI i drugiego systemu przy użyciu bezwzględnych identyfikatorów URI.
Jest też kilka innych właściwości konfiguracyjnych, które należy ustawić dla systemu HDFS. Określają one katalogi dla węzła nazw i węzłów danych. Właściwość dfs.namenode.name.dir określa listę katalogów, w których węzeł nazw przechowuje trwałe metadane systemu plików (dziennik edycji i obraz systemu plików). W celu zapewnienia redundancji w każdym katalogu przechowywane są kopie plików metadanych. Właściwość dfs.namenode.name.dir często ustawia się w taki sposób, aby metadane węzła danych były zapisywane na jednym lub dwóch dyskach lokalnych i dodatkowo na dysku zdalnym (na przykład w katalogu zamontowanym w systemie NFS). Taka konfiguracja chroni przed awarią dysku lokalnego i całego węzła nazw, ponieważ pliki można odtworzyć i wykorzystać do uruchomienia nowego węzła nazw. Pomocniczy węzeł nazw tylko okresowo tworzy punkty kontrolne dla głównego węzła nazw, dlatego nie zapewnia aktualnej kopii zapasowej danych. Ponadto należy ustawić właściwość dfs.datanode.data.dir, określającą listę katalogów węzła danych, w których przechowywane są bloki. Inaczej niż w węźle nazw, gdzie dla zapewnienia redundancji dane umieszcza się w wielu katalogach, węzeł nazw po kolei zapisuje dane w dostępnych katalogach. Dlatego w celu zwiększenia wydajności należy określić katalog dla każdego dysku lokalnego. Przechowywanie danych na kilku dyskach jest korzystne także ze względu na wydajność odczytu, ponieważ bloki są wtedy rozproszone i współbieżny odczyt różnych bloków może się odbywać z wykorzystaniem różnych dysków. Aby uzyskać maksymalną wydajność, przeznaczony na dane dysk należy zamontować przy użyciu opcji noatime. To ustawienie oznacza, że przy odczycie plików nie jest zapisywany czas ostatniego dostępu. Zapewnia to istotny wzrost wydajności.
Ponadto należy skonfigurować miejsce przechowywania w systemie plików punktów kontrolnych pomocniczego węzła nazw. Właściwość dfs.namenode.checkpoint.dir określa listę katalogów przechowujących punkty kontrolne. W celu zapewnienia redundancji każdy z tych katalogów zawiera obraz systemu plików z punktem kontrolnym (podobnie każdy katalog na dane węzła nazw przechowuje dodatkową kopię metadanych). Ważne właściwości konfiguracyjne systemu HDFS są wymienione w tabeli 10.2.
Konfiguracja Hadoopa
291
Tabela 10.2. Istotne właściwości demona systemu HDFS Nazwa właściwości
Typ
Wartość domyślna
Opis
fs.defaultFS
Identyfikator URI
file:///
Domyślny system plików. Identyfikator URI definiuje nazwę hosta i port serwera wywołań RPC węzła nazw. Domyślny port to 8020. Ta właściwość jest ustawiana w pliku core-site.xml.
dfs.namenode. name.dir
Rozdzielone przecinkami nazwy katalogów
file://${hadoop. tmp.dir}/dfs/name
Lista katalogów, w których węzeł nazw przechowuje trwałe metadane. W każdym katalogu z listy węzeł nazw przechowuje kopię metadanych.
dfs.datanode. data.dir
Rozdzielone przecinkami nazwy katalogów
file://${hadoop. tmp.dir}/dfs/data
Lista katalogów, w których węzeł danych przechowuje bloki. Każdy blok jest zapisywany tylko w jednym z tych katalogów.
dfs.namenode. checkpoint.dir
Rozdzielone przecinkami nazwy katalogów
file://${hadoop.tmp .dir}/dfs/ namesecondary
Lista katalogów, w których pomocniczy węzeł nazw przechowuje punkty kontrolne. Kopia punktu kontrolnego znajduje się w każdym katalogu z listy.
Zauważ, że katalogi na dane systemu HDFS domyślnie znajdują się w tymczasowym katalogu Hadoopa (ustawianym za pomocą właściwości hadoop.tmp.dir; jej wartość domyślna to /tmp/hadoop-${user.name}). Dlatego ważne jest, aby odpowiednio ustawić potrzebne właściwości, ponieważ w przeciwnym razie opróżnienie katalogów tymczasowych doprowadzi do utraty danych w systemie.
System YARN System YARN wymaga do działania ustawienia jednej maszyny jako menedżera zasobów. Najprościej można zrobić to, ustawiając właściwość yarn.resourcemanager.hostname na nazwę hosta lub adres IP maszyny menedżera zasobów. Wiele adresów serwera menedżera zasobów jest określanych na podstawie tej właściwości. Na przykład właściwość yarn.resourcemanager.address ma postać pary host-port, gdzie host to domyślnie wartość właściwości yarn.resourcemanager.hostname. W konfiguracji klienta modelu MapReduce ta właściwość służy do nawiązywania połączenia z menedżerem zasobów za pomocą wywołań RPC. W zadaniu w modelu MapReduce dane pośrednie i pliki robocze są zapisywane w tymczasowych plikach lokalnych. Ponieważ te dane obejmują potencjalnie bardzo duże porcje danych wyjściowych z operacji mapowania, trzeba zadbać o właściwe skonfigurowanie właściwości yarn.nodemanager.localdirs (określa ona lokalizację lokalnej tymczasowej pamięci dyskowej dla kontenerów systemu YARN), tak by używane były wystarczająco duże partycje dysku. Ta właściwość przyjmuje rozdzieloną przecinkami listę nazw katalogów. Należy wykorzystać wszystkie dostępne dyski lokalne, aby rozdzielić między nie dyskowe operacje wejścia-wyjścia (wskazane katalogi są używane po kolei). Dla lokalnej pamięci dyskowej systemu YARN zwykle używane są te same dyski i partycje (choć różne katalogi) co dla bloków węzła nazw (katalogi dla bloków określa opisana wcześniej właściwość dfs.datanode.data.dir).
292
Rozdział 10. Budowanie klastra opartego na platformie Hadoop
Inaczej niż model MapReduce 1 system YARN nie używa tasktrackerów do udostępniania danych wyjściowych z etapu mapowania do operacji redukcji. Zamiast tego stosowane są mechanizmy obsługi przestawiania w postaci długo działających usług pomocniczych pracujących w menedżerach węzłów. Ponieważ YARN to usługa ogólnego użytku, trzeba włączyć mechanizmy obsługi przestawiania dla modelu MapReduce. W tym celu należy w pliku yarn-site.xml ustawić właściwość yarn.nodemanager. aux-services na wartość mapreduce_shuffle. Tabela 10.3 przedstawia ważne właściwości konfiguracyjne systemu YARN. Ustawienia związane z zasobami są opisane szczegółowo w dalszych punktach. Tabela 10.3. Ważne właściwości demona systemu YARN Nazwa właściwości
Typ
Wartość domyślna
Opis
yarn.resourcemanager. hostname
Nazwa hosta
0.0.0.0
Nazwa hosta maszyny, w której działa menedżer zasobów. Dalej skrótowo podawana jako ${y.rm.hostname}.
yarn.resourcemanager. address
Nazwa hosta i port
${y.rm.hostname} :8032
Nazwa hosta i port serwera wywołań RPC menedżera zasobów.
yarn.nodemanager. local-dirs
Rozdzielone przecinkami nazwy katalogów
${hadoop.tmp.dir}/ nm-local-dir
Lista katalogów, w których menedżery węzłów umożliwiają kontenerom przechowywanie danych pośrednich. Te dane są usuwane po zakończeniu pracy przez aplikację.
yarn.nodemanager. aux-services
Rozdzielone przecinkami nazwy usług
yarn.nodemanager. resource.memory-mb
int
8192
Ilość pamięci fizycznej (w megabajtach), jaką można przydzielić kontenerom uruchamianym przez menedżer węzła.
yarn.nodemanager. vmem-pmem-ratio
float
2.1
Stosunek przeznaczonej dla kontenerów pamięci wirtualnej do fizycznej. Wartość określa, ile razy pamięć wirtualna może przekraczać przydzieloną pamięć fizyczną.
yarn.nodemanager. resource.cpu-vcores
int
8
Liczba rdzeni procesora, które można przydzielić kontenerom uruchamianym przez menedżer węzła.
Lista usług pomocniczych uruchamianych przez menedżer węzła. Usługa jest określana jako klasa zdefiniowana przez właściwość yarn.nodemanager. aux-services.nazwa-usługi.class. Domyślnie nie są używane żadne usługi pomocnicze.
Ustawienia pamięci w systemie YARN i modelu MapReduce System YARN zarządza pamięcią w bardziej precyzyjny sposób niż w opartym na slotach podejściu z modelu MapReduce 1. Zamiast określać maksymalną liczbę używanych jednocześnie w węźle slotów dla mapowania i redukcji, system YARN umożliwia aplikacjom zażądanie dla operacji dowolnej ilości pamięci (w ramach ustalonych limitów). W systemie YARN menedżery węzłów przydzielają pamięć z puli, dlatego liczba operacji uruchamianych w danym węźle zależy od sumy ich wymagań pamięciowych, a nie od stałej liczby slotów.
Konfiguracja Hadoopa
293
Obliczenia ilości pamięci przyznawanej menedżerowi węzła na potrzeby uruchamiania kontenerów są oparte na ilości fizycznej pamięci dostępnej w maszynie. Każdy demon Hadoopa wykorzystuje 1000 MB, tak więc węzeł danych i menedżer węzła w sumie potrzebują 2000 MB. Do tego należy doliczyć pamięć potrzebną dla innych procesów działających w danej maszynie, a resztę można przeznaczyć na kontenery menedżera węzła. Tę wartość należy podać w megabajtach we właściwości konfiguracyjnej yarn.nodemanager.resource.memory-mb. Domyślnie przydzielane są 8192 MB, co w większości sytuacji jest zbyt małą ilością. Następny krok wymaga określenia wartości opcji pamięci dla poszczególnych zadań. Używane są dwa główne ustawienia. Jedno dotyczy wielkości kontenera przydzielanego przez system YARN, a drugie — wielkości sterty dla uruchamianego w kontenerze procesu Javy. Wszystkie opcje pamięci w modelu MapReduce są ustawiane przez klienta w konfiguracji zadania. Ustawienia z systemu YARN są ustawieniami z poziomu klastra i nie mogą być modyfikowane przez klienty.
Wielkość kontenerów jest określana na podstawie właściwości mapreduce.map.memory.mb i mapreduce. reduce.memory.mb. Wartość obu to domyślnie 1024 MB. Te ustawienia są wykorzystywane przez zarządcę aplikacji przy żądaniu zasobów klastra, a także przez menedżer węzła, który uruchamia i monitoruje kontenery operacji. Wielkość sterty procesu Javy jest ustawiana za pomocą właściwości mapred.child.java.opts i domyślnie wynosi 200 MB. Opcje Javy można też ustawiać osobno dla operacji mapowania i redukcji (zobacz tabelę 10.4). Tabela 10.4. Właściwości związane z pamięcią dla zadań w modelu MapReduce (ustawiane przez klienta) Nazwa właściwości
Typ
Wartość domyślna
Opis
mapreduce.map. memory.mb
int
1024
Ilość pamięci dla kontenerów operacji mapowania.
mapreduce.reduce. memory.mb
int
1024
Ilość pamięci dla kontenerów operacji redukcji.
mapred.child. java.opts
String
-Xmx200m
Opcje maszyny JVM używane do uruchamiania procesu kontenera, w którym działają operacje mapowania i redukcji. Oprócz ustawień pamięci można dodać na przykład właściwości maszyny JVM dotyczące diagnozowania.
mapreduce.map. java.opts
String
-Xmx200m
Opcje maszyny JVM używane dla procesu podrzędnego wykonującego operacje mapowania.
mapreduce.reduce. java.opts
String
-Xmx200m
Opcje maszyny JVM używane dla procesu podrzędnego wykonującego operacje redukcji.
Załóżmy, że właściwość mapred.child.java.opts jest ustawiona na wartość -Xmx800m, a dla właściwości mapreduce.map.memory.mb pozostawiono wartość domyślną 1024 MB. W momencie uruchomienia operacji mapowania menedżer węzła przydziela kontener o pojemności 1024 MB (zmniejsza przy tym wielkość dostępnej puli o tę wartość na czas wykonywania operacji) i uruchamia maszynę JVM ze stertą o pojemności 800 MB. Zauważ, że proces maszyny JVM zajmuje więcej pamięci niż ustawiona wielkość sterty. Ilość dodatkowego miejsca zależy na przykład od używanych bibliotek natywnych, rozmiaru pamięci PermGen itd. Istotne jest to, aby fizyczna pamięć zajmowana przez proces JVM (włącznie z uruchomionymi przez niego procesami, na przykład procesami narzędzia Streaming) nie przekraczała przydzielonego obszaru 1024 MB. Jeśli kontener zajmie więcej pa294
Rozdział 10. Budowanie klastra opartego na platformie Hadoop
mięci, proces może zostać zakończony przez menedżer węzła i oznaczony jako zakończony niepowodzeniem. Programy szeregujące w systemie YARN określają minimalną i maksymalną ilość przydzielanej pamięci. Domyślnie minimum wynosi 1024 MB (ustawiane we właściwości yarn.scheduler.minimum-allocation-mb), a maksimum — 8192 MB (ustawiane we właściwości yarn.scheduler.maximum-allocation-mb). Obowiązują też ograniczenia pamięci wirtualnej, które kontener musi spełniać. Jeśli ilość pamięci wirtualnej przekroczy określoną wielokrotność przydzielonej pamięci fizycznej, menedżer węzła może zakończyć proces. Ta wielokrotność jest ustawiana we właściwości yarn.nodemanager.vmem-pmemratio i domyślnie wynosi 2.1. We wcześniej przedstawionym przykładzie progowa ilość pamięci wirtualnej, która może prowadzić do zakończenia operacji, wynosi 2150 MB (2,1 × 1024 MB). Przy konfigurowaniu parametrów pamięci bardzo przydatna jest możliwość monitorowania rzeczywistej ilości pamięci zajmowanej przez zadanie. Można ją ustalić za pomocą liczników operacji modelu MapReduce. Liczniki PHYSICAL_MEMORY_BYTES, VIRTUAL_MEMORY_BYTES i COMMITTED_HEAP_BYTES (opisane w tabeli 9.2) udostępniają chwilową ilość zajmowanej pamięci, dlatego nadają się do prowadzenia obserwacji w trakcie prób wykonywania operacji. Hadoop udostępnia też ustawienia do kontrolowania ilości pamięci używanej w różnych fazach modelu MapReduce. Można je ustawiać dla poszczególnych zadań. Omówienie tych właściwości znajdziesz w punkcie „Przestawianie i sortowanie” w rozdziale 7.
Ustawienia systemu YARN i modelu MapReduce dotyczące procesora System YARN traktuje jako zasoby zarządzane zarówno pamięć, jak i procesory. Aplikacje mogą zażądać potrzebnej liczby rdzeni. Liczba rdzeni, jaką menedżer węzła może przydzielić kontenerom, jest ustawiana za pomocą właściwości yarn.nodemanager.resource.cpu-vcores. Należy ją ustawić na łączną liczbę rdzeni maszyny minus jeden rdzeń na każdy działający w komputerze proces demona (węzła danych, menedżera węzła i innych długich procesów). W zadaniach w modelu MapReduce można kontrolować liczbę przydzielanych kontenerom operacji mapowania i redukcji rdzeni za pomocą właściwości mapreduce.map.cpu.vcores i mapreduce. reduce.cpu.vcores. Obie domyślnie mają wartość 1, co jest odpowiednim ustawieniem dla zwykłych jednowątkowych operacji w modelu MapReduce, które potrafią wykorzystać tylko jeden rdzeń. Choć liczba rdzeni jest sprawdzana w trakcie szeregowania (na przykład po to, aby system nie przydzielił kontenera w maszynie, w której żaden rdzeń nie jest dostępny), menedżer węzła domyślnie nie ogranicza zasobów procesora używanych przez działające kontenery. To oznacza, że kontener może zająć więcej rdzeni, niż mu przydzielono, i tym samym zablokować dostęp do nich innym kontenerom z tego samego hosta. System YARN umożliwia wymuszanie limitów wykorzystania zasobów procesora za pomocą mechanizmu cgroups Linuksa. Właściwość określającą wykonawcę kontenera dla menedżera węzła (yarn.nodemanager.containerexecutor.class) trzeba ustawić na klasę LinuxContainerExecutor. Ponadto klasę należy tak skonfigurować, by używała mechanizmu cgroups (zobacz właściwości z rodziny yarn. nodemanager.linux-container-executor).
Konfiguracja Hadoopa
295
Adresy i porty demonów Hadoopa Demony Hadoopa działają zwykle na serwerze wywołań RPC używanym do komunikacji między demonami (tabela 10.5) i na serwerze HTTP, gdzie udostępniają strony użytkownikom (tabela 10.6). W konfiguracji każdego serwera należy określić adres sieciowy i numer portu. Numer portu 0 oznacza, że serwer ma wykorzystać niezajęty port. Nie zaleca się stosowania tego podejścia, ponieważ jest niezgodne z politykami zapory ustawianymi na poziomie klastra. Tabela 10.5. Właściwości serwera RPC Nazwa właściwości
Wartość domyślna
Opis
fs.defaultFS
file:///
Gdy właściwość jest ustawiona na identyfikator URI z systemu HDFS, określa adres i port serwera RPC węzła nazw. Port domyślny to 8020.
dfs.namenode. rpc-bind-host
Adres, z którym wiązany jest serwer RPC węzła nazw. Jeśli nie jest określony (jest to ustawienie domyślne), ustala się go na podstawie właściwości fs.defaultFS. Ustawienie 0.0.0.0 powoduje, że węzeł nazw oczekuje na żądania we wszystkich interfejsach.
dfs.datanode.ipc.address
0.0.0.0:50020
Adres i port serwera RPC węzła danych.
mapreduce.jobhistory. address
0.0.0.0:10020
Adres i port serwera RPC serwera historii zadań. Używany przez klienty (zwykle spoza klastra) do pobierania danych z historii zadań.
mapreduce.jobhistory. bind-host yarn.resourcemanager. hostname
Adres, z którym wiązane są serwery RPC i HTTP serwera historii zadań. 0.0.0.0
yarn.resourcemanager. bind-host
Nazwa hosta maszyny, w której działa menedżer zasobów. Dalej używany jest skrótowy zapis ${y.rm.hostname}. Adres, z którym wiązane są serwery RPC i HTTP menedżera zasobów.
yarn.resourcemanager. address
${y.rm.hostname} :8032
Adres i port serwera RPC menedżera zasobów. Używane przez klienty (zwykle spoza klastra) do komunikowania się z menedżerem zasobów.
yarn.resourcemanager. admin.address
${y.rm.hostname} :8033
Adres i port administracyjnego serwera RPC menedżera zasobów. Używane przez kliencką aplikację administracyjną (wywoływaną za pomocą instrukcji yarn rmadmin, zwykle poza klastrem) do komunikowania się z menedżerem zasobów.
yarn.resourcemanager. scheduler.address
${y.rm.hostname} :8030
Adres i port serwera RPC programu szeregującego menedżera zasobów. Używane przez zarządców aplikacji z klastra do komunikowania się z menedżerem zasobów.
yarn.resourcemanager. resource-tracker.address
${y.rm.hostname} :8031
Adres i port serwera RPC trackera menedżera zasobów. Używane przez menedżery węzłów z klastra do komunikowania się z menedżerem zasobów.
yarn.nodemanager.hostname
0.0.0.0
Nazwa hosta maszyny, w której działa menedżer węzła. Dalej używany jest skrótowy zapis ${y.nm.hostname}.
yarn.nodemanager.bindhost
Adres, z którym wiązane są serwery RPC i HTTP menedżera węzła.
yarn.nodemanager. address
${y.nm.hostname} :0
Adres i port serwera RPC menedżera węzła. Używane przez zarządców aplikacji z klastra do komunikowania się z menedżerami węzłów.
yarn.nodemanager. localizer.address
${y.nm.hostname} :8040
Adres i port serwera RPC lokalizatora menedżera węzła.
296
Rozdział 10. Budowanie klastra opartego na platformie Hadoop
Tabela 10.6. Właściwości serwera HTTP Nazwa właściwości
Wartość domyślna
Opis
dfs.namenode.http-address
0.0.0.0:50070
Adres i port serwera HTTP węzła nazw.
dfs.namenode.http-bind-host
Adres, z którym wiązany jest serwer HTTP węzła nazw.
dfs.namenode. secondary.httpaddress
0.0.0.0:50090
Adres i port serwera HTTP pomocniczego węzła nazw.
dfs.datanode. http.address
0.0.0.0:50075
Adres i port serwera HTTP węzła danych (nazwa tej właściwości jest niespójna z nazwami właściwości węzła nazw).
mapreduce.jobhistory.
0.0.0.0:19888
Adres i port serwera historii zadań modelu MapReduce. Są ustawiane w pliku mapred-site.xml.
mapreduce.shuffle. port
13562
Numer portu HTTP mechanizmu obsługi przestawiania. Służy do udostępniania danych wyjściowych z etapu mapowania i nie stanowi sieciowego interfejsu dostępnego użytkownikom. Ta właściwość jest ustawiana w pliku mapred-site.xml.
yarn.resourcemanager. webapp.ad
${y.rm.hostname}: 8088
Adres i port serwera HTTP menedżera zasobów.
${y.rm.hostname}: 8042
Adres i port serwera HTTP menedżera węzła.
webapp.address
dress yarn.nodemanager. webapp.address yarn.web-proxy.address
Adres i port serwera HTTP serwera pośredniczącego aplikacji sieciowej. Jeśli właściwość nie jest podana (jest to ustawienie domyślne), serwer pośredniczący aplikacji sieciowej działa w procesie menedżera zasobów.
Właściwości dotyczące adresów serwerów RPC i HTTP mają dwie funkcje — określają interfejs sieciowy serwera oraz są używane przez klienty i inne maszyny w klastrze do łączenia się z serwerem. Na przykład menedżery węzłów używają właściwości yarn.resourcemanager.resource-tracker.address do ustalania adresu menedżera zasobów. Często zaleca się powiązanie serwerów z kilkoma interfejsami sieciowymi. Jednak ustawienie adresu sieciowego na 0.0.0.0, co pozwala skonfigurować serwer, nie spełnia drugiej funkcji, ponieważ adres nie jest zrozumiały dla klientów i innych maszyn w klastrze. Jedną z możliwości jest utworzenie odrębnych konfiguracji dla klientów i serwerów, jednak lepsze rozwiązanie polega na ustawieniu właściwości bind-host dla serwera. Ustawienie właściwości yarn.resourcemanager. hostname na (zrozumiałe dla zewnętrznych jednostek) nazwę hosta lub adres IP oraz właściwości yarn.resourcemanager.bind-host na 0.0.0.0 gwarantuje, że menedżer zasobów zostanie powiązany z wszystkimi adresami z danej maszyny, a także zapewni zrozumiały adres menedżerom węzłów i klientom. Oprócz serwera RPC węzły nazw uruchamiają serwer TCP/IP na potrzeby przesyłania bloków. Adres i port serwera są ustawiane za pomocą właściwości dfs.datanode.address. Jej wartość domyślna to 0.0.0.0:50010. Dostępne jest też ustawienie do kontrolowania interfejsów sieciowych używanych przez węzły danych jako adresy IP (dla serwerów HTTP i RPC). Służy do tego właściwość dfs.datanode.dns. interface. Wartość default oznacza używanie domyślnego interfejsu sieciowego. Właściwość można ustawić, by określić adres konkretnego interfejsu (na przykład eth0).
Konfiguracja Hadoopa
297
Inne właściwości Hadoopa W tym punkcie opisane są inne właściwości, które mogą być przydatne.
Przynależność do klastra Aby ułatwić późniejsze dodawanie i usuwanie węzłów, możesz wskazać plik z listą maszyn uprawnionych do dołączania do klastra jako węzły danych lub menedżery węzłów. Ten plik ustawia się za pomocą właściwości dfs.hosts (dla węzłów danych) i yarn.resourcemanager.nodes.include-path (dla menedżerów węzłów). Powiązane właściwości dfs.hosts.exclude i yarn.resourcemanager.nodes. exclude-path pozwalają wskazać pliki używane do usuwania maszyn z klastra. Więcej informacji na ten temat zawiera punkt „Dodawanie i usuwanie węzłów” w rozdziale 11.
Wielkość bufora Hadoop w operacjach wejścia-wyjścia używa bufora o wielkości 4 KB (4096 bajtów). Jest to ostrożne ustawienie. Jeśli używasz nowego sprzętu i najnowszych systemów operacyjnych, zwiększenie tej wartości pozwala uzyskać wzrost wydajności. Często stosowany jest poziom 128 KB (131 072 bajty). Tę wartość można podać (w bajtach) we właściwości io.file.buffer.size w pliku core-site.xml.
Wielkość bloku w systemie HDFS Wielkość bloku w systemie HDFS domyślnie wynosi 128 MB. Jednak w wielu klastrach używana jest większa wartość (na przykład 256 MB, co daje 268 435 456 bajtów), by zmniejszyć zapotrzebowanie węzła nazw na pamięć i zapewnić mapperom więcej danych. Wielkość bloku podaje się w bajtach we właściwości dfs.blocksize w pliku hdfs-site.xml.
Zarezerwowane miejsce Węzły danych domyślnie próbują wykorzystać całą dostępną pamięć z katalogów na dane. Jeśli w woluminach przeznaczonych na dane chcesz zarezerwować część miejsca na użytek komponentów innych niż HDFS, we właściwości dfs.datanode.du.reserved podaj ilość rezerwowanej pamięci w bajtach.
Kosz W systemach plików Hadoopa dostępny jest kosz, do którego przenoszone są usuwane pliki. Pliki pozostają w koszu przez określony minimalny czas, po czym są trwale kasowane przez system. Ten minimalny czas jest ustawiany w minutach we właściwości konfiguracyjnej fs.trash.interval w pliku core-site.xml. Domyślna wartość właściwości to zero, co oznacza, że kosz nie jest używany. Kosz w Hadoopie (podobnie jak w wielu systemach operacyjnych) to funkcja z poziomu użytkownika. Oznacza to, że w koszu umieszczane są wyłącznie pliki usunięte z poziomu powłoki systemu plików. Pliki usuwane programowo są natychmiast kasowane. Z kosza można jednak korzystać w programach. Wymaga to utworzenia obiektu typu Trash i wywołania metody moveTo Trash() ze ścieżką (obiektem typu Path) prowadzącą do usuwanego pliku. Ta metoda zwraca wartość informującą o sukcesie lub niepowodzeniu. Wartość false oznacza, że kosz jest niedostępny lub że dany plik już się w nim znajduje. 298
Rozdział 10. Budowanie klastra opartego na platformie Hadoop
Gdy kosz jest dostępny, każdy użytkownik ma w katalogu głównym własny podkatalog z zawartością kosza, .Trash. Odzyskiwanie plików jest proste. Wystarczy poszukać pliku w podkatalogu .Trash i przenieść go poza kosz. System HDFS automatycznie kasuje pliki z katalogów kosza, jednak inne systemy plików tego nie robią. Dlatego trzeba zadbać o okresowe wykonywanie tej czynności. W powłoce systemu plików możesz użyć opcji -expunge, która powoduje usunięcie plików pozostających w koszu dłużej niż określony minimalny czas. % hadoop fs -expunge
Klasa Trash udostępnia metodę expunge(), której wywołanie ma ten sam efekt.
Program szeregujący zadania Zwłaszcza w konfiguracji wielodostępnej warto rozważyć zmianę konfiguracji kolejki programu szeregującego zadania, aby uwzględnić wymagania firmy. Możesz na przykład utworzyć kolejkę dla każdej grupy użytkowników klastra. Więcej dowiesz się z punktu „Szeregowanie w systemie YARN” w rozdziale 4.
Opóźniony start operacji redukcji Program szeregujący domyślnie czeka, aż 5% operacji mapowania w zadaniu ukończy pracę, i dopiero potem szereguje operacje redukcji z tego samego zadania. W dużych zadaniach może to prowadzić do problemów z wykorzystaniem klastra, ponieważ kontenery dla operacji redukcji są zajmowane jeszcze w trakcie oczekiwania na ukończenie operacji mapowania. Ustawienie właściwości mapreduce.job.reduce.slowstart.completedmaps na wyższą wartość, na przykład 0.80 (80%), może poprawić wydajność.
Skrócony odczyt danych lokalnych Przy odczycie pliku z systemu HDFS klient kontaktuje się z węzłem danych i dane są przesyłane do klienta za pomocą połączenia TCP. Jeśli wczytywany blok znajduje się w tym samym węźle, w którym działa klient, wydajniejsze jest pominięcie sieci i wczytanie danych z bloku bezpośrednio z dysku. Na tym polega skrócony odczyt danych lokalnych. Ta technika może przyczynić się do poprawy wydajności aplikacji takich jak HBase. Skrócony odczyt danych lokalnych można włączyć w wyniku ustawienia właściwości dfs.client. read.shortcircuit na wartość true. Skrócony odczyt jest zaimplementowany za pomocą uniksowych gniazd domeny, które wykorzystują ścieżkę lokalną do komunikacji między klientem a węzłem danych. Ta ścieżka jest ustawiana za pomocą właściwości dfs.domain.socket.path. Należy używać ścieżek, które może utworzyć tylko użytkownik węzła danych (zwykle jest to użytkownik hdfs) lub administrator. Oto przykładowa ścieżka: /var/run/hadoop-hdfs/dn_socket.
Bezpieczeństwo W starszych wersjach Hadoopa przyjmowano, że klastry z systemem HDFS i platformą MapReduce będą używane przez grupę współpracujących użytkowników w bezpiecznym środowisku. Środki ograniczenia dostępu zaprojektowano z myślą o zapobieganiu przypadkowej utracie danych, a nie Bezpieczeństwo
299
w celu blokowania nieuprawnionego korzystania z danych. Na przykład uprawnienia do plików w systemie HDFS zapobiegają przypadkowemu wykasowaniu całego systemu plików z powodu błędu w programie lub pomyłkowego wprowadzenia instrukcji fs -rmr /, ale nie chronią przed celowym przejęciem tożsamości administratora w celu uzyskania dostępu do danych z klastra lub ich usunięcia. Brakowało więc bezpiecznego mechanizmu uwierzytelniania, dzięki któremu Hadoop mógłby stwierdzić, że użytkownik chcący wykonać określone operacje w klastrze rzeczywiście jest tym, za kogo się podaje, dlatego można mu zaufać. Uprawnienia do plików w systemie HDFS zapewniały jedynie mechanizm autoryzacji, określający, co dana osoba może zrobić z konkretnym plikiem. Na przykład plik może być dostępny do odczytu tylko dla określonej grupy użytkowników, dlatego osoby spoza tej grupy nie są autoryzowane do wczytywania jego zawartości. Jednak sama autoryzacja to za mało, ponieważ system jest narażony na podszywanie się pod innych użytkowników w celu uzyskania z poziomu sieci dostępu do klastra. Często dostęp do danych osobowych (na przykład imienia i nazwiska lub adresu IP użytkownika końcowego) przyznaje się w firmie tylko niewielkiej grupie użytkowników klastra, którzy są uprawnieni do korzystania z takich informacji. Mniej poufne (lub anonimowe) dane mogą być dostępne dla większej grupy użytkowników. Wygodne jest przechowywanie w jednym klastrze zbiorów danych o różnych poziomach zabezpieczeń (jedną z przyczyn jest to, że zbiory danych wymagające mniejszych zabezpieczeń można wtedy współużytkować). Jednak aby spełnić prawne wymogi z zakresu ochrony danych, we współużytkowanych klastrach trzeba stosować bezpieczny mechanizm uwierzytelniania. Z taką sytuacją zmierzyła się firma Yahoo! w 2009 roku. Zaowocowało to zaimplementowaniem przez zespół inżynierów z tej firmy mechanizmów bezpiecznego uwierzytelniania w Hadoopie. W opracowanym projekcie Hadoop sam nie zarządza danymi uwierzytelniającymi użytkowników. Zamiast tego do uwierzytelniania wykorzystywany jest Kerberos — dojrzały sieciowy protokół uwierzytelniania o otwartym dostępie do kodu źródłowego. Jednak Kerberos nie zarządza uprawnieniami. Informuje tylko, że użytkownik jest tym, za kogo się podaje. To Hadoop musi określić, czy dany użytkownik ma uprawnienia potrzebne do wykonywania określonych czynności. Bezpieczeństwo w Hadoopie to obszerne zagadnienie. W tym podrozdziale opisano tylko najważniejsze aspekty tej dziedziny. Więcej informacji znajdziesz w książce Hadoop Security Bena Spiveya i Joeya Echeverrii (O’Reilly, 2014).
Kerberos i Hadoop Na ogólnym poziomie są trzy kroki, które klient musi wykonać, aby uzyskać dostęp do usługi, gdy posługuje się Kerberosem. Każdy krok wymaga wymiany komunikatów z serwerem. Oto te kroki: 1. Uwierzytelnianie. Klient uwierzytelnia się przed serwerem uwierzytelniania i otrzymuje bilet TGT (ang. Ticket-Granting Ticket) ze znacznikiem czasu. 2. Autoryzacja. Klient używa biletu TGT do zażądania biletu usługi od serwera TGS (ang. Ticket-Granting Server). 3. Żądanie usługi. Klient używa biletu usługi do uwierzytelnienia się przed serwerem, który udostępnia usługę używaną przez klienta. W Hadoopie funkcję serwera pełni węzeł nazw lub menedżer zasobów. 300
Rozdział 10. Budowanie klastra opartego na platformie Hadoop
Serwer uwierzytelniania i serwer TGS wspólnie tworzą centrum dystrybucji kluczy (ang. Key Distribution Center — KDC). Cały proces graficznie przedstawiono na rysunku 10.2.
Rysunek 10.2. Trzyetapowy protokół wymiany biletów w Kerberosie
Kroki autoryzacji i żądania usługi nie odbywają się na poziomie użytkownika. To klient wykonuje je na rzecz użytkownika. Jednak etap uwierzytelniania zwykle jest bezpośrednio wykonywany przez użytkownika za pomocą polecenia kinit, które żąda podania hasła. Nie oznacza to, że hasło trzeba wprowadzać każdorazowo przy uruchamianiu zadania lub dostępie do systemu HDFS. Bilety TGT domyślnie są aktualne przez 10 godzin, a czas ich ważności może zostać przedłużony aż do tygodnia. Często uwierzytelnianie jest przeprowadzane automatycznie na etapie logowania się do systemu operacyjnego. Pozwala to udostępnić mechanizm pojedynczego logowania w platformie Hadoop. Jeśli nie chcesz, aby system wyświetlał żądania hasła (na przykład przy wykonywaniu nienadzorowanych zadań w modelu MapReduce), możesz za pomocą polecenia ktutil utworzyć plik keytab Kerberosa. Taki plik przechowuje hasła i można go wskazać w poleceniu kinit za pomocą opcji -t.
Przykład Przyjrzyj się teraz przykładowemu przebiegowi tego procesu. Pierwszy krok polega na włączeniu uwierzytelniania za pomocą protokołu Kerberos. Służy do tego właściwość hadoop.security.authentication z pliku core-site.xml, którą należy ustawić na wartość kerberos7. Ustawienie domyślne simple oznacza, że stosowane jest zgodne wstecz (ale niezapewniające bezpieczeństwa) rozwiązanie polegające na określaniu tożsamości na podstawie nazwy użytkownika z systemu operacyjnego. 7
Aby używać w Hadoopie uwierzytelniania opartego na protokole Kerberos, trzeba zainstalować, skonfigurować i uruchomić centrum KDC (Hadoop nie udostępnia takiej jednostki). Możliwe, że firma ma już centrum KDC, które może wykorzystać (na przykład instalację usługi Active Directory). W przeciwnym razie można zainstalować narzędzie MIT Kerberos 5 KDC.
Bezpieczeństwo
301
Należy też włączyć uwierzytelnianie na poziomie usług. Wymaga to ustawienia właściwości hadoop. security.authorization na wartość true we wspomnianym wcześniej pliku. W pliku konfiguracyjnym hadoop-policy.xml można ustawić listy kontroli dostępu (ang. Access Control List — ACL), aby kontrolować, którzy użytkownicy i które grupy mają prawo nawiązywać połączenie z poszczególnymi usługami Hadoopa. Usługi są definiowane na poziomie protokołu, dlatego konfigurowane są usługi do przekazywania zadań w modelu MapReduce, komunikacji z węzłem nazw itd. Domyślnie wszystkie listy ACL są ustawione na wartość *, co oznacza, że wszyscy użytkownicy mają prawo dostępu do każdej usługi. Jednak w praktyce w klastrze należy za pomocą list ACL zezwolić na dostęp tylko odpowiednim użytkownikom i grupom. Format list ACL to rozdzielona przecinkami lista nazw użytkowników, po których następuje spacja i rozdzielona przecinkami lista nazw grup. Na przykład lista ACL preston,howard directors,inventors zapewnia dostęp użytkownikom o nazwach preston i howard oraz osobom z grup directors i inventors. Załóżmy, że uwierzytelnianie za pomocą protokołu Kerberos jest już włączone. Zobaczmy, co się stanie przy próbie skopiowania lokalnego pliku do systemu HDFS. % hadoop fs -put quangle.txt . 10/07/03 15:44:58 WARN ipc.Client: Exception encountered while connecting to the server: javax.security.sasl.SaslException: GSS initiate failed [Caused by GSSException: No valid credentials provided (Mechanism level: Failed to find any Kerberos tgt)] Bad connection to FS. command aborted. exception: Call to localhost/ 127.0.0.1:8020 failed on local exception: java.io.IOException: javax.security.sasl.SaslException: GSS initiate failed [Caused by GSSException: No valid credentials provided (Mechanism level: Failed to find any Kerberos tgt)]
Operacja zakończy się niepowodzeniem, ponieważ użytkownik nie ma biletu Kerberosa. Można go uzyskać w wyniku uwierzytelnienia się w centrum KDC za pomocą instrukcji kinit. % kinit Password for hadoop-user@LOCALDOMAIN: password % hadoop fs -put quangle.txt . % hadoop fs -stat %n quangle.txt quangle.txt
Teraz plik jest z powodzeniem zapisywany w systemie HDFS. Zauważ, że choć wykonywane są dwa polecenia systemu plików, instrukcję kinit wystarczy uruchomić raz, ponieważ bilet w Kerberosie jest ważny przez 10 godzin (za pomocą polecenia klist możesz sprawdzić czas wygasania biletów, a instrukcja kdestroy pozwala je unieważnić). Po uzyskaniu biletu wszystkie operacje można wykonywać w standardowy sposób.
Tokeny do delegowania uprawnień W rozproszonym systemie, takim jak HDFS lub MapReduce, następuje wiele interakcji między klientem i serwerem, a każda z nich wymaga uwierzytelnienia jednostek. Na przykład odczyt w systemie HDFS obejmuje liczne wywołania kierowane do węzła nazw oraz do jednego lub kilku węzłów danych. Zamiast uwierzytelniać każde wywołanie za pomocą trzyetapowego protokołu Kerberos z wymianą biletów, co w aktywnym klastrze mocno obciążałoby centrum KDC, Hadoop stosuje tokeny do delegowania uprawnień, umożliwiające późniejszy dostęp z uwierzytelnieniami 302
Rozdział 10. Budowanie klastra opartego na platformie Hadoop
bez konieczności ponownego kontaktu z centrum KDC. Takie tokeny są tworzone i stosowane w Hadoopie w sposób niezauważalny dla użytkowników. Dlatego użytkownik nie musi nic robić, oprócz zalogowania się za pomocą instrukcji kinit. Warto jednak wiedzieć, jak używane są omawiane tokeny. Token do delegowania uprawnień jest generowany przez serwer (tu jest nim węzeł nazw). Można traktować go jak klucz współużytkowany przez klienta i serwer. Przy pierwszym wywołaniu RPC skierowanym do węzła nazw klient nie ma tokena, dlatego używa Kerberosa do uwierzytelnienia się. Razem z odpowiedzią otrzymuje od węzła nazw token. W późniejszych wywołaniach dodaje token, który węzeł nazw może sprawdzić (ponieważ sam wygenerował go za pomocą prywatnego klucza). W ten sposób klient jest uwierzytelniany na serwerze. Gdy klient chce wykonać operacje na blokach systemu HDFS, używa specjalnego rodzaju tokena — tokena dostępu do bloku. Węzeł nazw przekazuje ten token klientowi wraz z odpowiedzią na żądanie metadanych. Klient wykorzystuje token dostępu do bloku do uwierzytelnienia się w węźle danych. Jest to możliwe dzięki temu, że węzeł nazw przekazuje (w komunikatach kontrolnych) węzłom danych prywatny klucz użyty do wygenerowania tokena. Dzięki temu węzły danych mogą sprawdzić poprawność tokena. Blok systemu HDFS jest więc dostępny tylko klientom posiadającym poprawny token otrzymany od węzła nazw. To zamyka lukę w niezabezpieczonym Hadoopie, ponieważ wcześniej uzyskanie dostępu do bloku wymagało tylko znajomości jego identyfikatora. Aby włączyć obsługę tokenów dostępu do bloków, ustaw właściwość dfs.block.access.token.enable na wartość true. W modelu MapReduce zasoby i metadane zadań (na przykład pliki JAR, wejściowe porcje danych i pliki konfiguracyjne) są współużytkowane w systemie HDFS, aby zarządza aplikacji miał do nich dostęp. Kod użytkownika działa w menedżerze węzła i korzysta z plików z systemu HDFS (ten proces wyjaśniono w punkcie „Wykonywanie zadań w modelu MapReduce” w rozdziale 7.). Wymienione komponenty używają tokenów do delegowania uprawnień, aby uzyskać dostęp do systemu HDFS w trakcie wykonywania zadania. Po jego ukończeniu tokeny są unieważniane. Tokeny do delegowania uprawnień dla domyślnego egzemplarza systemu HDFS są generowane automatycznie. Jeśli jednak zadanie potrzebuje dostępu do innych klastrów z systemem HDFS, można wczytać niezbędne tokeny przez ustawienie we właściwości mapreduce.job.hdfs-servers zadania listy rozdzielonych przecinkami identyfikatorów URI systemów HDFS.
Inne usprawnienia w zabezpieczeniach W stosie narzędzi Hadoopa wzmocniono zabezpieczenia, aby chronić zasoby przed dostępem ze strony nieuwierzytelnionych jednostek. Oto istotne zmiany:
Operacje można uruchamiać z poziomu konta systemu operacyjnego należącego do użytkownika, który przesłał zadanie, a nie z poziomu konta, gdzie działa menedżer węzła. To oznacza, że system operacyjny jest wykorzystywany do izolowania działających operacji, dzięki czemu nie mogą one przesyłać między sobą sygnałów (powodujących na przykład zamknięcie operacji innego użytkownika). W ten sposób lokalne informacje, na przykład dane operacji, są prywatne. Odpowiadają za to uprawnienia z lokalnego systemu plików.
Bezpieczeństwo
303
Tę funkcję można włączyć, ustawiając właściwość yarn.nodemanager.container-executor.class na wartość org.apache.hadoop.yarn.server.nodemanager.LinuxContainerExecutor8. Ponadto administratorzy muszą się upewnić, że każdy użytkownik ma konto w każdym węźle klastra (zwykle do zarządzania kontami wykorzystuje się tu protokół LDAP).
Gdy operacje są uruchamiane przez użytkownika, który przesłał zadanie, rozproszona pamięć podręczna (zobacz punkt „Rozproszona pamięć podręczna” w rozdziale 9.) jest bezpieczna. Pliki dostępne dla wszystkich do odczytu są umieszczane we współużytkowanej pamięci podręcznej (jest to niezabezpieczone domyślne ustawienie). Pozostałe pliki trafiają do prywatnej pamięci podręcznej, dostępnej tylko ich właścicielowi.
Użytkownicy mogą wyświetlać i modyfikować tylko własne zadania. Aby włączyć ten mechanizm, należy ustawić właściwość mapreduce.cluster.acls.enabled na wartość true. Wartości dwóch właściwości konfiguracyjnych zadań, mapreduce.job.acl-view-job i mapreduce.job. acl-modify-job, można ustawić na rozdzieloną przecinkami listę użytkowników, aby kontrolować, kto może wyświetlać i modyfikować konkretne zadania.
Faza przestawiania jest obecnie zabezpieczona. Napastnik nie może już zażądać danych wyjściowych z operacji mapowania innego użytkownika.
Gdy konfiguracja jest odpowiednia, napastnik nie może już uruchomić szkodliwego pomocniczego węzła nazw, węzła danych lub menedżera węzła, który dołączy do klastra i przejmie przechowywane w nim dane. Zabezpieczenie polega na tym, że demony muszą się uwierzytelniać w nadrzędnym węźle, z którym nawiązują połączenie. Aby włączyć tę funkcję, najpierw trzeba skonfigurować Hadoopa, by używał pliku keytab wygenerowanego wcześniej za pomocą polecenia ktutil. Na przykład w węźle danych należy ustawić właściwość dfs.datanode.keytab.file na nazwę pliku keytab i właściwość dfs.datanode. kerberos.principal na nazwę użytkownika korzystającego z tego węzła. Listę ACL dla implementacji interfejsu DataNodeProtocol (używanej przez węzły danych do komunikacji z węzłem nazw) należy ustawić w pliku hadoop-policy.xml; w tym celu pozostaw we właściwości security. datanode.protocol.acl tylko nazwę użytkownika danego węzła danych.
8
Węzeł nazw można uruchomić w uprzywilejowanym porcie (o numerze mniejszym niż 1024), by klient mógł bezpiecznie przyjąć, że węzeł został bezpiecznie uruchomiony.
Operacja może się komunikować tylko z własnym zarządcą aplikacji. Dzięki temu napastnik nie może przejąć danych z modelu MapReduce z zadania innego użytkownika.
Różne elementy w Hadoopie można skonfigurować w taki sposób, by szyfrowały dane w sieci. Dotyczy to wywołań RPC (hadoop.rpc.protection), transferów bloków w systemie HDFS (dfs.encrypt.data.transfer), przestawiania danych w modelu MapReduce (mapreduce.shuffle.ssl. enabled) i sieciowych interfejsów użytkownika (hadoop.ssl.enabled). Trwają prace nad umożliwieniem szyfrowania także danych „w spoczynku”, co pozwoli na zapis bloków systemu HDFS w zaszyfrowanej postaci.
Klasa LinuxTaskController używa dostępnego w pliku bin pliku wykonywalnego o nazwie container-executor z ustawionym bitem setuid. Należy się upewnić, że ten plik binarny należy do administratora i ma ustawiony bit setuid (za pomocą polecenia chmod +s).
304
Rozdział 10. Budowanie klastra opartego na platformie Hadoop
Testy porównawcze klastra opartego na Hadoopie Czy klaster jest prawidłowo skonfigurowany? Aby odpowiedzieć na to pytanie, najlepiej jest posłużyć się podejściem empirycznym — uruchomić zadania i sprawdzić wyniki. Testy porównawcze są przydatne, ponieważ pozwalają uzyskać wartości, które można porównać z wynikami z innych klastrów w celu ustalenia, czy nowy klaster działa zgodnie z oczekiwaniami. Na podstawie wyników testów porównawczych można też dostroić klaster w celu zmaksymalizowania jego wydajności. Często używane są przy tym systemy monitorowania (zobacz punkt „Monitorowanie” w rozdziale 11). Pozwalają one stwierdzić, jak w klastrze wykorzystywane są zasoby. By uzyskać wiarygodne wyniki, testy porównawcze należy przeprowadzić w klastrze, który w danym momencie nie jest używany przez inne osoby. W praktyce jest to czas tuż przed udostępnieniem klastra użytkownikom. Gdy użytkownicy zaplanują już okresowe wykonywanie zadań w klastrze, zwykle nie da się znaleźć czasu, w którym z klastra nikt nie korzysta (chyba że uzgodnisz z użytkownikami przestój). Dlatego testy porównawcze warto przeprowadzić przed udostępnieniem klastra. Z doświadczenia wynika, że większość awarii sprzętu w nowych systemach to wina usterek dysków twardych. Dzięki uruchomieniu testów porównawczych z wieloma operacjami wejścia-wyjścia (właśnie takie testy są opisane dalej) możesz „dotrzeć” klaster przed jego udostępnieniem.
Testy porównawcze w Hadoopie Hadoop udostępnia kilka testów porównawczych, które możesz przeprowadzić bardzo łatwo przy minimalnych kosztach przygotowań. Te testy znajdują się w specjalnym pliku JAR. Wywołanie tego pliku bez argumentów pozwala zapoznać się z listą testów wraz z ich opisami. % hadoop jar $HADOOP_HOME/share/hadoop/mapreduce/hadoop-mapreduce-*-tests.jar
Większość testów porównawczych wywołanych bez argumentów wyświetla instrukcje pokazujące, jak je przeprowadzać. Oto przykład: % hadoop jar $HADOOP_HOME/share/hadoop/mapreduce/hadoop-mapreduce-*-tests.jar \ TestDFSIO TestDFSIO.1.7 Missing arguments. Usage: TestDFSIO [genericOptions] -read [-random | -backward | -skip [-skipSize Size]] | -write | -append | -clean [-compression codecClassName] [-nrFiles N] [-size Size[B|KB|MB|GB|TB]] [-resFile resultFileName] [-bufferSize Bytes] [-rootDir]
Przeprowadzanie testów porównawczych na modelu MapReduce za pomocą programu TeraSort Hadoop udostępnia oparty na modelu MapReduce program TeraSort, który przeprowadza globalne sortowanie danych wejściowych9. Jest on bardzo przydatny do jednoczesnego testowania systemu HDFS i modelu MapReduce, ponieważ cały wejściowy zbiór danych przechodzi przez proces przestawiania. Testy obejmują trzy kroki: generowanie losowych danych, sortowanie ich i sprawdzanie poprawności wyników. 9
W 2008 roku program TeraSort pobił rekord świata w sortowaniu 1 TB danych. Zobacz punkt „Krótka historia platformy Apache Hadoop” w rozdziale 1.
Testy porównawcze klastra opartego na Hadoopie
305
Najpierw należy wygenerować losowe dane za pomocą instrukcji teragen (jest ona dostępna w pliku JAR z przykładami, a nie w pliku z testami). Uruchamia ona zadanie obejmujące tylko etap mapowania i generujące określoną liczbę wierszy danych binarnych. Każdy wiersz ma 100 bajtów, dlatego aby wygenerować terabajt danych w 1000 operacji mapowania, wywołaj następujące polecenie (10t to skrótowy zapis 10 trylionów): % hadoop jar \ $HADOOP_HOME/share/hadoop/mapreduce/hadoop-mapreduce-examples-*.jar \ teragen -Dmapreduce.job.maps=1000 10t random-data
Następnie wywołaj program terasort: % hadoop jar \ $HADOOP_HOME/share/hadoop/mapreduce/hadoop-mapreduce-examples-*.jar \ terasort random-data sorted-data
Ważny jest tu łączny czas sortowania, jednak warto też przyjrzeć się za pomocą sieciowego interfejsu użytkownika (http://resource-manager-host:8088/) postępom w wykonywaniu zadania. Pozwala to zrozumieć, jak dużo czasu zajmują poszczególne fazy zadania. Cennym ćwiczeniem jest też dostosowanie parametrów wymienionych w punkcie „Dostrajanie zadania” w rozdziale 6. W ramach ostatniego testu poprawności należy sprawdzić, czy dane z pliku sorted-data są prawidłowo posortowane. % hadoop jar \ $HADOOP_HOME/share/hadoop/mapreduce/hadoop-mapreduce-examples-*.jar \ teravalidate sorted-data report
To polecenie uruchamia krótkie zadanie w modelu MapReduce, które wykonuje na posortowanych danych serię testów w celu sprawdzenia, czy sortowanie jest poprawne. Wszelkie błędy można znaleźć w pliku wyjściowym report/part-r-00000.
Inne testy porównawcze W Hadoopie dostępnych jest wiele testów porównawczych. Poniżej wymienione są często używane z nich:
TestDFSIO testuje wydajność operacji wejścia-wyjścia w systemie HDFS. W tym celu używane jest zadanie w modelu MapReduce jako wygodny sposób równoległego odczytu i zapisu plików.
MRBench (wywoływany za pomocą instrukcji mrbench) uruchamia krótkie zadanie określoną liczbę razy. Jest dobrym uzupełnieniem testu TeraSort, ponieważ sprawdza, jak system reaguje przy wykonywaniu wielu krótkich zadań.
NNBench (wywoływany za pomocą instrukcji nnbench) służy do przeprowadzania testów obciążeniowych sprzętu węzła nazw.
Gridmix to zestaw testów porównawczych zaprojektowany tak, by realistycznie odzwierciedlać obciążenie klastra. Te testy symulują różne stosowane w praktyce wzorce dostępu do danych. Informacje o uruchamianiu testów Gridmix znajdziesz w dokumentacji dystrybucji Hadoopa.
SWIM (ang. Statistical Workload Injector for MapReduce; https://github.com/SWIMProjectUCB/ SWIM/wiki) to repozytorium zarejestrowanych w praktyce przepływów pracy z modelu Map-Reduce, które można wykorzystać do wygenerowania reprezentatywnych testowych przepływów pracy na potrzeby własnego systemu.
306
Rozdział 10. Budowanie klastra opartego na platformie Hadoop
TPCx-HS (http://www.tpc.org/tpcx-hs/) to oparty na programie TeraSort ustandaryzowany test porównawczy opracowany przez organizację Transaction Processing Performance Council.
Zadania użytkowników Przy dostrajaniu systemu najlepiej jest wykorzystać kilka zadań reprezentatywnych dla prac wykonywanych przez użytkowników. Dzięki temu klaster zostanie ustawiony pod kątem takich zadań (a nie tylko z uwzględnieniem standardowych testów porównawczych). Jeśli budujesz pierwszy klaster oparty na Hadoopie i nie masz jeszcze żadnych zadań użytkowników, dobrym ich zastępnikiem będą testy Gridmix lub SWIM. Gdy w testach porównawczych stosujesz własne zadania, powinieneś wybrać określony zbiór danych dla zadań użytkowników i posługiwać się nim we wszystkich testach. Pozwoli to na porównywanie poszczególnych przebiegów testów. Gdy zbudujesz nowy klaster lub zaktualizujesz używany, będziesz mógł zastosować ten sam zbiór danych w celu porównania wydajności systemu z wcześniejszymi przebiegami testów.
Testy porównawcze klastra opartego na Hadoopie
307
308
Rozdział 10. Budowanie klastra opartego na platformie Hadoop
ROZDZIAŁ 11.
Zarządzanie platformą Hadoop
Poprzedni rozdział poświęcono budowaniu klastrów opartych na platformie Hadoop. W tym rozdziale poznasz procedury pozwalające na zapewnienie płynnej pracy klastra.
System HDFS Trwałe struktury danych Dla administratora bardzo ważne jest zrozumienie procesu zarządzania trwałymi danymi na dysku przez komponenty systemu HDFS (węzeł nazw, pomocniczy węzeł nazw i węzły danych). Wiedza o przeznaczeniu różnych plików pomaga diagnozować problemy i wykrywać nietypowe sytuacje.
Struktura katalogów węzła nazw Pracujący węzeł nazw posługuje się przedstawioną poniżej strukturą katalogów: ${dfs.namenode.name.dir}/ ── current ── VERSION ── edits_0000000000000000001-0000000000000000019 ── edits_inprogress_0000000000000000020 ── fsimage_0000000000000000000 ── fsimage_0000000000000000000.md5 ── fsimage_0000000000000000019 ── fsimage_0000000000000000019.md5 ── seen_txid ── in_use.lock
W rozdziale 10. wyjaśniono, że właściwość dfs.namenode.name.dir reprezentuje listę katalogów, a jej zawartość jest powielana w każdym katalogu. Ten mechanizm zapewnia odporność na awarie — zwłaszcza gdy (zgodnie z zaleceniami) jeden z katalogów jest zamontowany w systemie NFS. VERSION to plik właściwości Javy zawierający informacje na temat używanej wersji systemu HDFS. Oto typowa zawartość tego pliku: #Mon Sep 29 09:54:36 BST 2014 namespaceID=1342387246 clusterID=CID-01b5c398-959c-4ea8-aae6-1e0d9bd8b142 cTime=0 storageType=NAME_NODE blockpoolID=BP-526805057-127.0.0.1-1411980876842 layoutVersion=-57
309
Wartość właściwości layoutVersion to ujemna liczba całkowita, określająca wersję trwałych struktur danych z systemu HDFS. Ten numer nie jest powiązany z numerem wersji dystrybucji Hadoopa. Gdy wersja struktur danych się zmienia, jej numer jest zmniejszany (na przykład po wersji -57 pojawi się wersja -58). W takiej sytuacji należy zaktualizować system HDFS, ponieważ nowy węzeł nazw (lub węzeł danych) nie będzie działał, gdy używane są starsze wersje struktur danych. Aktualizowanie systemu HDSF omówiono w punkcie „Aktualizacje”. Właściwość namespaceID reprezentuje unikatowy identyfikator przestrzeni nazw systemu plików. Identyfikator jest generowany w trakcie pierwszego formatowania węzła nazw. Właściwość clusterID to unikatowy identyfikator całego klastra z systemem HDFS. Ta właściwość jest istotna w kontekście federacji z systemu HDFS (zobacz punkt „Federacje w systemie HDFS” w rozdziale 3.), kiedy to klaster składa się z wielu przestrzeni nazw, a każdą z nich zarządza jeden węzeł nazw. Właściwość blockpoolID określa unikatowy identyfikator puli bloków zawierającej wszystkie pliki z przestrzeni nazw zarządzanej przez dany węzeł nazw. Właściwość cTime określa czas utworzenia pamięci dyskowej węzła nazw. Dla nowo sformatowanej pamięci dyskowej wartość tej właściwości zawsze wynosi zero. Zostaje ona zaktualizowana za pomocą znacznika czasu w momencie aktualizacji systemu plików. Właściwość storageType informuje tu, że dany katalog zawiera struktury danych węzła nazw. Plik in_use.lock to plik blokady używany przez węzeł nazw do blokowania katalogu. Zapobiega to uruchomieniu w tym samym czasie innego egzemplarza węzła nazw korzystającego z tego samego katalogu (co doprowadziłoby do uszkodzenia zawartości tego katalogu). W katalogu węzła nazw znajdują się też pliki edits, fsimage i seen_txid. Aby zrozumieć, do czego one służą, należy bliżej przyjrzeć się pracy węzła nazw.
Pliki obrazu i dziennika edycji systemu plików Gdy klient systemu plików zapisuje dane (na przykład przy tworzeniu lub przenoszeniu plików), transakcja jest najpierw rejestrowana w dzienniku edycji. Węzeł nazw przechowuje w pamięci reprezentację metadanych systemu plików, aktualizowaną po zmodyfikowaniu dziennika edycji. Metadane z pamięci służą do obsługiwania żądań odczytu. Dziennik edycji jest traktowany jak jeden element, jednak na dysku jest reprezentowany za pomocą zbioru plików. Każdy taki plik jest nazywany segmentem oraz ma przedrostek edits i przyrostek określający przechowywane identyfikatory transakcji. W każdym momencie do zapisu otwarty jest tylko jeden plik (w przykładzie jest to plik edits_inprogress_0000000000000000020). Jest on opróżniany i synchronizowany po każdej transakcji przed zwróceniem do klienta kodu informującego o powodzeniu. W węzłach nazw, które zapisują dane w wielu katalogach, dziennik musi zostać opróżniony i zsynchronizowany dla każdej kopii danych; dopiero potem zwracana jest informacja o sukcesie. To gwarantuje, że transakcje nie zostaną utracone z powodu awarii maszyny. Każdy plik fsimage zawiera kompletny trwały punkt kontrolny z metadanymi systemu plików. Przyrostek oznacza tu ostatnią transakcję z obrazu. Ten plik nie jest aktualizowany po każdym zapisie w systemie plików. Jest tak, ponieważ plik fsimage może zajmować nawet kilka gigabajtów, dlatego jego zapis wymaga dużo czasu. To rozwiązanie nie prowadzi jednak do braku odporności
310
Rozdział 11. Zarządzanie platformą Hadoop
danych, ponieważ jeśli węzeł nazw ulegnie awarii, najnowszy stan metadanych można odtworzyć w wyniku wczytania pliku fsimage z dysku do pamięci i zastosowania wszystkich transakcji z dziennika edycji od określonego momentu do aktualnego czasu. Te czynności węzeł nazw wykonuje w momencie rozruchu (zobacz punkt „Tryb bezpieczny”). Każdy plik fsimage zawiera w zserializowanej postaci wszystkie i-węzły katalogów i plików z systemu plików. I-węzeł to wewnętrzna reprezentacja metadanych pliku lub katalogów. Zawiera takie informacje jak liczba replik pliku, czas jego ostatnich modyfikacji i dostępu, uprawnienia dostępu, wielkość bloku i bloki plików. Dla katalogów zapisywane są czas ostatniej modyfikacji, uprawnienia i metadane dotyczące limitów. Plik fsimage nie rejestruje węzłów danych, w których zapisywane są bloki. To węzeł nazw przechowuje w pamięci takie informacje. Zbiera je, żądając od węzłów danych list bloków w momencie dołączania takich węzłów do klastra. Następnie okresowo ponawia żądanie, aby listy bloków w węźle nazw były aktualne.
W opisanym podejściu dziennik edycji może rozrastać się bez ograniczeń (nawet jeśli jest rozbity na grupę fizycznych plików edits). Choć nie wpływa to na system w trakcie pracy węzła nazw, to po ponownym uruchomieniu węzła zastosowanie wszystkich transakcji z (bardzo długiego) dziennika edycji wymagałoby dużo czasu. W tym okresie system plików byłby niedostępny, co jest niepożądane. Rozwiązanie polega na używaniu pomocniczego węzła nazw, którego zadaniem jest generowanie punktów kontrolnych z metadanymi systemu plików z pamięci głównego węzła nazw1. Proces tworzenia punktu kontrolnego opisano poniżej (schematycznie jest on pokazany na rysunku 11.1 uwzględniającym omówione wcześniej pliki dziennika edycji i obrazu). 1. Pomocniczy węzeł nazw żąda od głównego zastąpienia bieżącego pliku edits i rozpoczęcia zapisu nowych edycji w nowym pliku. Główny węzeł nazw aktualizuje też plik seen_txid we wszystkich katalogach z danymi. 2. Pomocniczy węzeł nazw pobiera z głównego najnowsze pliki fsimage i edits (za pomocą żądania HTTP GET). 3. Pomocniczy węzeł nazw wczytuje plik fsimage do pamięci, stosuje każdą transakcję z pliku edits, po czym tworzy nowy scalony plik fsimage. 4. Pomocniczy węzeł nazw przesyła nowy plik fsimage z powrotem do węzła głównego (za pomocą żądania HTTP PUT), a węzeł główny zachowuje otrzymane dane jako tymczasowy plik .ckpt. 5. Główny węzeł nazw zmienia nazwę tymczasowego pliku fsimage, aby go udostępnić.
1
Możliwe jest uruchomienie węzła nazw z opcją -checkpoint, która sprawia, że dany węzeł tworzy punkty kontrolne dla innego (głównego) węzła. Efekt jest taki sam jak przy uruchomieniu pomocniczego węzła nazw, przy czym w czasie, gdy powstawała ta książka, to podejście nie dawało żadnych korzyści w porównaniu z używaniem węzła pomocniczego (a węzeł pomocniczy to lepiej sprawdzone i przetestowane rozwiązanie). W środowisku zapewniającym wysoką dostępność (zobacz punkt „Wysoka dostępność w systemie HDFS” w rozdziale 3.) za tworzenie punktów kontrolnych odpowiada węzeł rezerwowy.
System HDFS
311
Rysunek 11.1. Proces tworzenia punktu kontrolnego
Na zakończenie tego procesu główny węzeł nazw zawiera aktualny plik fsimage i krótki bieżący plik edits (nie musi on być pusty, ponieważ od momentu utworzenia punktu kontrolnego mogły nastąpić już pewne zmiany). Administrator może uruchomić cały opisany proces ręcznie, gdy węzeł nazw działa w trybie bezpiecznym. Służy do tego instrukcja hdfs dfsadmin -saveNamespace. Ta procedura pokazuje, że pomocniczy węzeł nazw wczytuje plik fsimage do pamięci, dlatego ma podobne wymagania pamięciowe co węzeł główny. Z tego powodu w dużych klastrach pomocniczy węzeł nazw wymaga odrębnej maszyny. Plan tworzenia punktów kontrolnych wynika z dwóch parametrów konfiguracyjnych. Pomocniczy węzeł nazw tworzy punkt kontrolny co godzinę (zgodnie z podawaną w sekundach wartością właściwości dfs.namenode.checkpoint.period) lub wcześniej, jeśli w dzienniku edycji od czasu utworzenia ostatniego punktu kontrolnego zapisano milion transakcji (tę wartość określa właściwość dfs. namenode.checkpoint.txns). Liczba transakcji sprawdzana jest co minutę (ten czas można ustawić w sekundach we właściwości dfs.namenode.checkpoint.check.period).
312
Rozdział 11. Zarządzanie platformą Hadoop
Struktura katalogów pomocniczego węzła nazw Struktura katalogu z punktem kontrolnym (dfs.namenode.checkpoint.dir) w pomocniczym węźle nazw jest taka sama jak w węźle głównym. Jest to celowe, ponieważ w przypadku całkowitej awarii węzła nazw (gdy nawet w systemie NFS nie ma potrzebnych kopii zapasowych) możliwe jest przywrócenie stanu za pomocą węzła pomocniczego. W tym celu można albo skopiować odpowiedni katalog do nowego węzła danych, albo (jeśli węzeł pomocniczy staje się nowym węzłem głównym) użyć opcji -importCheckpoint przy uruchamianiu demona węzła nazw. Opcja -importCheckpoint powoduje wczytanie metadanych węzła nazw z ostatniego punktu kontrolnego z katalogu ustawionego we właściwości dfs.namenode.checkpoint.dir. Metadane wczytywane są jednak tylko wtedy, jeżeli nie ma ich w katalogu podanym we właściwości dfs.namenode.name.dir. To rozwiązanie chroni przed zastąpieniem istotnych metadanych.
Struktura katalogów węzła danych Węzły danych (w odróżnieniu od węzłów nazw) nie wymagają jawnego formatowania, ponieważ tworzą katalogi na dane automatycznie w momencie rozruchu. Oto najważniejsze pliki i katalogi: ${dfs.datanode.data.dir}/ ── current ── BP-526805057-127.0.0.1-1411980876842 ── current ── VERSION ── finalized ── blk_1073741825 ── blk_1073741825_1001.meta ── blk_1073741826 ── blk_1073741826_1002.meta ── rbw ── VERSION ── in_use.lock
Bloki systemu HDFS są zapisywane w plikach z przedrostkiem blk_ i zawierają surowe bajty przechowywanego fragmentu pliku. Każdy blok jest powiązany z plikiem metadanych z rozszerzeniem .meta. Plik metadanych zawiera nagłówek z informacjami o wersji i typie, po którym następuje seria sum kontrolnych dla sekcji bloku. Każdy blok należy do puli bloków, a każda pula ma własny katalog o nazwie obejmującej identyfikator puli (jest to identyfikator puli bloków używany w pliku VERSION węzła nazw). Gdy liczba bloków w katalogu osiągnie określony poziom, węzeł danych tworzy nowy podkatalog, w którym umieszcza nowe bloki i powiązane z nimi metadane. Nowy podkatalog jest generowany za każdym razem, gdy liczba bloków w katalogu osiąga wartość 64 (określa ją właściwość konfiguracyjna dfs.datanode.numblocks). W efekcie powstaje drzewo o dużej liczbie rozgałęzień. Dlatego nawet w systemach z dużą liczbą bloków katalogi są zagłębione jedynie na kilka poziomów. Dzięki temu węzeł danych gwarantuje, że liczba plików na katalog jest akceptowalna. Pozwala to uniknąć problemów powstających w większości systemów operacyjnych, gdy w jednym katalogu znajduje się duża liczba plików (rzędu dziesiątek lub setek tysięcy). Jeśli właściwość konfiguracyjna dfs.datanode.data.dir wskazuje zestaw katalogów z różnych dysków, bloki są zapisywane po kolei w poszczególnych lokalizacjach. Zauważ, że bloki nie są replikowane w każdym dysku jednego węzła danych. Przy replikacji bloków używane są osobne węzły danych. System HDFS
313
Tryb bezpieczny Gdy węzeł nazw rozpoczyna pracę, najpierw wczytuje plik obrazu (fsimage) do pamięci i wprowadza zmiany zapisane w dzienniku edycji. Po odtworzeniu w pamięci spójnego obrazu metadanych systemu plików tworzy nowy plik fsimage (w ten sposób sam wykonuje punkt kontrolny, bez udziału pomocniczego węzła nazw) i pusty dziennik edycji. W tym procesie węzeł nazw pracuje w trybie bezpiecznym. To oznacza, że umożliwia klientom dostęp do systemu plików w trybie tylko do odczytu. W trybie bezpiecznym jedyne operacje w systemie plików, które mają gwarancję wykonania, to dostęp do metadanych systemu plików (na przykład zwrócenie zawartości katalogu). Odczyt pliku jest możliwy tylko wtedy, jeśli bloki są dostępne w bieżącym zestawie węzłów danych klastra. Próby modyfikacji plików (zapisu, usunięcia, zmiany nazwy) na tym etapie zawsze kończą się niepowodzeniem.
Warto przypomnieć, że lokalizacja bloków w systemie nie jest określana przez węzeł nazw. To węzły danych zawierają potrzebne informacje w postaci listy bloków przechowywanych w każdym z nich. W trakcie normalnej pracy systemu węzeł nazw przechowuje w pamięci odwzorowanie z lokalizacjami bloków. Tryb bezpieczny jest potrzebny, aby zapewnić węzłom danych czas na przekazanie węzłowi nazw listy przechowywanych bloków. Dzięki temu węzeł nazw otrzymuje informacje potrzebne do efektywnej pracy systemu plików. Jeśli węzeł nazw nie poczeka na przekazanie informacji przez węzły danych, zacznie replikować bloki do nowych węzłów danych. Zwykle jest to niepotrzebne (ponieważ wystarczy zaczekać na zgłoszenia od kolejnych węzłów danych) i mocno obciąża zasoby klastra. Dlatego w trybie bezpiecznym węzeł nazw nie przekazuje do węzłów danych żadnych instrukcji replikacji lub usuwania bloków. System wychodzi z trybu bezpiecznego, gdy minie 30 sekund od momentu spełnienia warunku minimalnego poziomu replikacji. Ten warunek zostaje spełniony, gdy 99,9% bloków z całego systemu plików osiąga minimalny poziom replikacji (domyślnie wynosi on 1, a można go ustawić za pomocą właściwości dfs.namenode.replication.min; zobacz tabelę 11.1). Tabela 11.1. Właściwości związane z trybem bezpiecznym Nazwa właściwości
Typ
Wartość domyślna
Opis
dfs.namenode. replication.min
int
1
Minimalna liczba replik, które muszą zostać utworzone, aby zapis został uznany za udany.
dfs.namenode. safemode.threshold-pct
float
0.999
Część bloków w systemie, które muszą osiągnąć minimalny poziom replikacji zdefiniowany we właściwości dfs.namenode.replication.min. Dopiero przekroczenie tej wartości powoduje wyjście węzła nazw z trybu bezpiecznego. Ustawienie wartości zero lub mniejszej sprawia, że węzeł nie wchodzi w tryb bezpieczny. Wartość większa niż jeden powoduje, że węzeł nigdy nie wychodzi z trybu bezpiecznego.
dfs.namenode. safemode.extension
int
30000
Czas w milisekundach, o jaki wydłużana jest praca w trybie bezpiecznym po spełnieniu warunku minimalnego poziomu replikacji zdefiniowanego we właściwości dfs.namenode.safemode.threshhold-pct. W małych klastrach (do kilkudziesięciu węzłów) można tę wartość ustawić na zero.
314
Rozdział 11. Zarządzanie platformą Hadoop
Gdy uruchamiasz świeżo sformatowany klaster z systemem HDFS, węzeł nazw nie przechodzi w tryb bezpieczny, ponieważ system nie zawiera żadnych bloków.
Włączanie i wyłączanie trybu bezpiecznego Aby sprawdzić, czy węzeł nazw pracuje w trybie bezpiecznym, można wykorzystać polecenie dfsadmin. % hdfs dfsadmin -safemode get Safe mode is ON
Strona główna sieciowego interfejsu użytkownika systemu HDFS też zawiera informację o tym, czy węzeł nazw działa w trybie bezpiecznym. Czasem programista chce zaczekać, aż węzeł nazw wyjdzie z tryb bezpiecznego, a dopiero potem uruchomić polecenie (dotyczy to zwłaszcza instrukcji ze skryptów). Ten efekt można osiągnąć za pomocą opcji wait. % hdfs dfsadmin -safemode wait # Instrukcja odczytu lub zapisu pliku
Administrator może w dowolnym momencie włączyć lub wyłączyć tryb bezpieczny w węźle nazw. Czasem jest to konieczne w trakcie konserwacji klastra lub po jego aktualizacji. Pozwala to sprawdzić, czy dane wciąż są dostępne do odczytu. Aby włączyć tryb bezpieczny, wywołaj poniższe polecenie: % hdfs dfsadmin -safemode enter Safe mode is ON
Możesz je uruchomić, gdy węzeł nazw wciąż pracuje w trybie bezpiecznym w trakcie rozruchu. Wtedy węzeł nigdy sam nie wyjdzie z trybu bezpiecznego. Inny sposób na sprawienie, aby węzeł nazw na stałe pozostał w trybie bezpiecznym, to ustawienie właściwości dfs.namenode.safemode. threshold-pct na wartość większą niż jeden. Tryb bezpieczny w węźle nazw można wyłączyć przy użyciu następującego polecenia: % hdfs dfsadmin -safemode leave Safe mode is OFF
Rejestrowanie dziennika inspekcji System HDFS może rejestrować wszystkie żądania dostępu do systemu plików. Niektóre organizacje potrzebują tej funkcji w celu przeprowadzania inspekcji. Rejestrowanie dziennika inspekcji jest zaimplementowane za pomocą biblioteki log4j z ustawionym poziomem INFO. W konfiguracji domyślnej dziennik inspekcji jest wyłączony, można jednak łatwo włączyć go, dodając do pliku hadoop-env.sh poniższy wiersz: export HDFS_AUDIT_LOGGER="INFO,RFAAUDIT"
Dla każdego zdarzenia z systemu HDFS w dzienniku inspekcji (hdfs-audit.log) zapisywany jest wiersz. Oto przykładowy wiersz dotyczący polecenia listStatus wywołanego dla katalogu /user/tom. 2014-09-30 21:35:30,484 INFO FSNamesystem.audit: allowed=true ugi=tom (auth:SIMPLE) ip=/127.0.0.1 cmd=listStatus src=/user/tom dst=null perm=null proto=rpc
System HDFS
315
Narzędzia dfsadmin Jest to narzędzie o wielu funkcjach, przeznaczone do wyszukiwania informacji o stanie systemu HDFS, a także do wykonywania operacji administracyjnych w tym systemie. Jest wywoływane za pomocą instrukcji hdfs dfsadmin i wymaga uprawnień superużytkownika. Wybrane polecenia narzędzia dfsadmin opisano w tabeli 11.2. Aby uzyskać więcej informacji, wywołaj instrukcję -help. Tabela 11.2. Polecenia narzędzia dfsadmin Polecenie
Opis
-help
Wyświetla pomoc dla podanego polecenia lub (jeśli nie określono żadnej instrukcji) wszystkie polecenia.
-report
Wyświetla statystyki systemu plików (podobne do danych z sieciowego interfejsu użytkownika) i informacje o podłączonych węzłach danych.
-metasave
Zapisuje do pliku z katalogu dzienników Hadoopa informacje o replikowanych lub usuwanych blokach, a także listę podłączonych węzłów danych.
-safemode
Zmienia lub pobiera stan trybu bezpiecznego. Zobacz punkt „Tryb bezpieczny”.
-saveNamespace
Zapisuje bieżący obraz systemu plików z pamięci do nowego pliku fsimage i resetuje plik edits. Te czynności można wykonywać tylko w trybie bezpiecznym.
-fetchImage
Pobiera ostatni plik fsimage z węzła nazw i zapisuje go do lokalnego pliku.
-refreshNodes
Aktualizuje grupę węzłów danych, które mogą łączyć się z danym węzłem nazw. Zobacz punkt „Dodawanie i usuwanie węzłów”.
-upgradeProgress
Pobiera informacje o postępie aktualizacji systemu HDFS lub wymusza aktualizację. Zobacz punkt „Aktualizacje”.
-finalizeUpgrade
Usuwa wcześniejszą wersję katalogów węzła nazw i węzła danych. Używana po zainstalowaniu aktualizacji, gdy klaster korzysta już z nowej wersji oprogramowania. Zobacz punkt „Aktualizacje”.
-setQuota
Ustawia limity dla katalogów. Ograniczają one liczbę nazw (plików lub katalogów) w drzewie katalogu. Limity dla katalogów przydają się do uniemożliwiania użytkownikom tworzenia dużej liczby małych plików. Pomaga to oszczędzać pamięć węzła nazw (ponieważ informacje o każdym pliku, katalogu i bloku z systemu plików są przechowywane w pamięci tego węzła).
-clrQuota
Usuwa wskazane limity dla katalogów.
-setSpaceQuota
Ustawia limity miejsca dla katalogów. Limity miejsca ograniczają łączny rozmiar plików, jakie można zapisać w drzewie katalogu. Przydają się do przyznawania użytkownikom ograniczonej ilości miejsca.
-clrSpaceQuota
Usuwa wskazane limity miejsca.
-refreshServiceAcl
Odświeża plik polityki uwierzytelniania na poziomie usługi w węźle nazw.
-allowSnapshot
Umożliwia tworzenie snapshotów dla wskazanego katalogu.
-disallowSnapshot
Blokuje tworzenie snapshotów dla wskazanego katalogu.
Sprawdzanie systemu plików (narzędzie fsck) Hadoop udostępnia narzędzie fsck do sprawdzania stanu plików w systemie HDFS. Narzędzie szuka brakujących bloków, a także bloków, które mają za mało lub za dużo replik. Oto przykład sprawdzania całego systemu plików w małym klastrze.
316
Rozdział 11. Zarządzanie platformą Hadoop
% hdfs fsck / ......................Status: HEALTHY Total size: 511799225 B Total dirs: 10 Total files: 22 Total blocks (validated): 22 (avg. block size 23263601 B) Minimally replicated blocks: 22 (100.0 %) Over-replicated blocks: 0 (0.0 %) Under-replicated blocks: 0 (0.0 %) Mis-replicated blocks: 0 (0.0 %) Default replication factor: 3 Average block replication: 3.0 Corrupt blocks: 0 Missing replicas: 0 (0.0 %) Number of data-nodes: 4 Number of racks: 1 The filesystem under path '/' is HEALTHY
Narzędzie fsck rekurencyjnie przechodzi przez przestrzeń nazw systemu plików, rozpoczynając od podanej ścieżki (tu jest to katalog główny), i sprawdza znalezione pliki. Po sprawdzeniu każdego pliku wyświetla kropkę. Aby sprawdzić plik, narzędzie fsck pobiera metadane bloków pliku i wyszukuje problemy lub niespójności. Zauważ, że narzędzie fsck wszystkie informacje pobiera z węzła nazw — nie komunikuje się z żadnymi węzłami danych, by pobrać dane z bloków. Większość danych wyjściowych narzędzia fsck jest zrozumiała. Oto kilka zjawisk, na jakie narzędzie zwraca uwagę: Bloki o za dużej liczbie replik (ang. over-replicated blocks) Są to bloki o liczbie replik przekraczającej poziom ustawiony dla plików, do których należą. Za duża liczba replik zwykle nie stanowi problemu, ponieważ system HDFS automatycznie usuwa nadmiarowe kopie. Bloki o za małej liczbie replik (ang. under-replicated blocks) Są to bloki o liczbie replik mniejszej niż poziom ustawiony dla plików, do których należą. System HDFS automatycznie tworzy dla takich bloków nowe repliki do momentu osiągnięcia do-celowego poziomu. Informacje na temat replikowanych (lub oczekujących na replikację) bloków zwraca polecenie hdfs dfsadmin -metasave. Błędnie zreplikowane bloki (ang. mis-replicated blocks) Są to bloki niezgodne z polityką rozmieszczania replik (zobacz ramkę „Rozmieszczanie replik” w rozdziale 3.). Jeśli na przykład docelowa liczba replik to trzy i klaster obejmuje wiele szafek, umieszczenie wszystkich trzech kopii bloku w jednej szafce oznacza błędną replikację. Jest tak, ponieważ repliki powinny być rozdzielone przynajmniej między dwie szafki, co zwiększa odporność danych na awarie. System HDFS automatycznie ponownie replikuje takie bloki, by były zgodne z polityką rozmieszczania kopii w szafkach. Uszkodzone bloki (ang. corrupt blocks) Są to bloki, których wszystkie repliki są uszkodzone. Bloki mające przynajmniej jedną poprawną replikę nie są zaliczane do tej grupy. Węzeł nazw tworzy repliki nieuszkodzonej kopii do momentu uzyskania docelowej liczby replik.
System HDFS
317
Brakujące repliki (ang. missing replicas) Są to bloki, dla których w klastrze nie istnieją żadne repliki. Największym powodem do obaw są uszkodzone bloki lub brak bloków, ponieważ wykrycie ich może oznaczać, że dane zostały utracone. Narzędzie fsck domyślnie nie modyfikuje uszkodzonych bloków i bloków z brakującymi replikami. Można jednak nakazać wykonanie dla takich bloków następujących operacji:
Przeniesienie problematycznych plików do katalogu /lost+found w systemie HDFS za pomocą opcji -move. Pliki są dzielone na łańcuchy przyległych bloków, co ułatwia podejmowanie prób ich odzyskania.
Usunięcie problematycznych plików za pomocą opcji -delete. Usuniętych plików nie można odzyskać.
Wyszukiwanie bloków pliku Narzędzie fsck udostępnia też łatwy sposób wyszukiwania bloków należących do określonego pliku. Oto przykład: % hdfs fsck /user/tom/part-00007 -files -blocks -racks /user/tom/part-00007 25582428 bytes, 1 block(s): OK 0. blk_-3724870485760122836_1035 len=25582428 repl=3 [/default-rack/10.251.43.2: 50010,/default-rack/10.251.27.178:50010, /default-rack/10.251.123.163:50010]
Zwracane są informacje, że plik /user/tom/part-00007 składa się z jednego bloku. Wskazane są też węzły danych, w których dany blok się znajduje. Oto użyte tu opcje narzędzia fsck:
Opcja -files powoduje wyświetlenie wiersza z nazwą i wielkością pliku, liczbą bloków i ich stanem (pojawiają się tu informacje o brakujących replikach).
Opcja -blocks pozwala wyświetlić informacje o każdym bloku pliku po jednym wierszu na blok.
Opcja -racks wyświetla lokalizację szafki i adresy węzłów danych każdego bloku.
Uruchomienie polecenia hdfs fsck bez argumentów powoduje wyświetlenie kompletnych instrukcji używania omawianego narzędzia.
Skaner bloków węzła danych Każdy węzeł danych uruchamia skaner bloków, który okresowo sprawdza wszystkie bloki przechowywane w określonym węźle. Pozwala to wykryć i naprawić błędne bloki przed ich wczytaniem przez klienty. Skaner przechowuje listę bloków do sprawdzenia i kontroluje je jeden po drugim pod kątem błędów sum kontrolnych. Stosowany jest przy tym mechanizm ograniczania szybkości skanowania w celu zmniejszenia obciążenia dysku węzła danych. Bloki są sprawdzane co trzy tygodnie w celu zabezpieczenia się przed pojawiającymi się z czasem błędami dysków. Ten czas można zmienić za pomocą właściwości dfs.datanode.scan.period.hours. Jej wartość domyślna to 504 godziny. Wykryte uszkodzone bloki są zgłaszane węzłowi nazw w celu ich naprawienia.
318
Rozdział 11. Zarządzanie platformą Hadoop
Aby wyświetlić raport ze sprawdzania bloków węzła nazw, wystarczy otworzyć sieciowy interfejs węzła, dostępny pod adresem http://datanode:50075/blockScannerReport. Oto przykładowy raport (jego punkty powinny być zrozumiałe): Total Blocks Verified in last hour Verified in last day Verified in last week Verified in last four weeks Verified in SCAN_PERIOD Not yet verified Verified since restart Scans since restart Scan errors since restart Transient scan errors Current scan rate limit KBps Progress this period Time left in cur period
: : : : : : : : : : : : : :
21131 70 1767 7360 20057 20057 1074 35912 6541 0 0 1024 109% 53.08%
Jeśli dodasz parametr listblocks (http://datanode:50075/blockScannerReport?listblocks), raport zostanie poprzedzony listą wszystkich bloków z węzła danych i ich ostatnim ustalonym stanem. Oto fragment listy bloków (wiersze zostały podzielone, by zmieściły się na stronie): blk_6035596358209321442 : status : ok type : none 0 not yet verified blk_3065580480714947643 : status : ok type : remote 1215755306400 2008-07-11 05:48:26,400 blk_8729669677359108508 : status : ok type : local 1215755727345 2008-07-11 05:55:27,345
scan time : scan time : scan time :
Pierwsza kolumna zawiera identyfikator bloku. Dalej następują pary klucz-wartość. Pole status przyjmuje wartości failed i ok — w zależności od tego, czy przy ostatnim skanowaniu bloku wykryto błąd sumy kontrolnej. Skanowanie typu local jest wykonywane przez wątek pracujący w tle, a typu remote — przez klienta lub zdalny węzeł danych. Typ none oznacza, że blok nie został jeszcze przeskanowany. Ostatnia porcja informacji to czas skanowania. Jest wyświetlany jako liczba milisekund od północy 1 stycznia 1970 roku oraz w bardziej zrozumiałej postaci.
Program balancer Z czasem rozkład bloków w węzłach danych może stać się nierównomierny. Niezrównoważony klaster utrudnia lokalne wykonywanie operacji w modelu MapReduce i powoduje większe obciążenie często używanych węzłów danych, dlatego należy unikać takich sytuacji. Program balancer to demon Hadoopa, który zmienia rozkład bloków i przenosi je z nadmiernie wykorzystanych węzłów danych do niedostatecznie zajętych węzłów. Uwzględnia przy tym politykę rozmieszczania replik bloków (zmniejszającą ryzyko utraty danych), dlatego zapisuje repliki w różnych szafkach (zobacz ramkę „Rozmieszczanie replik” w rozdziale 3.). Program balancer przenosi bloki do momentu uznania klastra za zrównoważony. Klaster jest zrównoważony, gdy poziom wykorzystania każdego węzła danych (stosunek zajętej przestrzeni węzła do jego całkowitej pojemności) różni się od poziomu wykorzystania całego klastra (stosunek zajętej przestrzeni klastra do jego łącznej pojemności) o nie więcej niż ustalony progowy poziom. Do uruchamiania programu balancer służy poniższa instrukcja: % start-balancer.sh
System HDFS
319
Argument -threshold pozwala ustawić progowy poziom określający, kiedy klaster jest uznawany za zrównoważony. Ten argument jest opcjonalny. Jeśli go nie podasz, domyślnie używany jest poziom 10%. W danym momencie w klastrze może działać tylko jeden program balancer. Program balancer kończy pracę, gdy klaster staje się zrównoważony, gdy nie można przenieść żadnych bloków lub gdy nastąpi utrata kontaktu z węzłem nazw. Program generuje plik dziennika w standardowym katalogu dzienników. W pliku zapisywany jest wiersz dla każdej przeprowadzonej serii przenoszenia bloków. Oto dane wyjściowe z krótkiego przebiegu programu w małym klastrze (formatowanie danych zostało zmienione, aby informacje zmieściły się na stronie): Time Stamp Iteration# Bytes Already Moved Mar 18, 2009 5:23:42 PM 0 0 KB Mar 18, 2009 5:27:14 PM 1 195.24 MB The cluster is balanced. Exiting... Balancing took 6.072933333333333 minutes
...Left To Move ...Being Moved 219.21 MB 150.29 MB 22.45 MB 150.29 MB
Program balancer działa w tle, by niepotrzebnie nie obciążać klastra i nie zakłócać pracy innych korzystających z klastra klientów. Program ogranicza szybkość kopiowania bloków między węzłami. Domyślnie przenosi dane z szybkością 1 MB/s. Wartość tę (jest ona podawana w bajtach) można zmienić we właściwości dfs.datanode.balance.bandwidthPerSec z pliku hdfs-site.xml.
Monitorowanie Monitorowanie to ważny aspekt zarządzania systemem. W tym podrozdziale opisano mechanizmy monitorowania z Hadoopa oraz ich podłączanie do zewnętrznych systemów monitorowania. Celem monitorowania jest wykrywanie sytuacji, w których klaster nie zapewnia oczekiwanego poziomu usług. Najważniejsze jest monitorowanie nadrzędnych demonów — węzłów nazw (głównego i pomocniczego) i menedżera zasobów. Awarie węzłów danych i menedżerów węzłów nie są niczym niespodziewanym (zwłaszcza w dużych klastrach), dlatego należy zapewnić dodatkowe zasoby, by klaster mógł działać także wtedy, gdy niewielki procent węzłów przestanie pracować. Niektórzy administratorzy oprócz stosowania opisanych dalej mechanizmów regularnie uruchamiają zadania testowe, aby sprawdzić stan klastra.
Rejestrowanie informacji w dziennikach Wszystkie demony Hadoopa generują pliki dzienników, które są bardzo przydatne przy ustalaniu, co dzieje się w systemie. W punkcie „Pliki dzienników systemowych” w rozdziale 10. wyjaśniono, jak skonfigurować te pliki.
Ustawianie poziomu rejestrowania informacji W trakcie diagnozowania problemu bardzo wygodna jest możliwość tymczasowej zmiany poziomu rejestrowania informacji dla wybranego komponentu systemu. Demony Hadoopa udostępniają stronę WWW, na której można zmienić poziom rejestrowania informacji dla dowolnego dziennika technologii log4j. Ta strona jest dostępna po wpisaniu końcówki /logLevel w sieciowym interfejsie użytkownika poszczególnych demonów. Przyjęło się, że
320
Rozdział 11. Zarządzanie platformą Hadoop
nazwy dzienników w Hadoopie odpowiadają nazwom klas odpowiedzialnych za rejestrowanie informacji. Występują jednak wyjątki od tej reguły, dlatego powinieneś poszukać nazw dzienników w kodzie źródłowym. Rejestrowanie informacji można włączyć dla wszystkich pakietów rozpoczynających się od określonego przedrostka. Na przykład w celu włączenia rejestrowania informacji dla wszystkich klas powiązanych z menedżerem zasobów należy przejść do sieciowego interfejsu użytkownika http:// resource-manager-host:8088/logLevel i ustawić dla przedrostka org.apache.hadoop.yarn.server. resourcemanager poziom DEBUG. Ten sam efekt można uzyskać z poziomu wiersza poleceń: % hadoop daemonlog -setlevel resource-manager-host:8088 \ org.apache.hadoop.yarn.server.resourcemanager DEBUG
Poziom rejestrowania informacji zmieniony w ten sposób jest resetowany w momencie ponownego rozruchu demona. Zwykle jest to pożądane rozwiązanie. Jeśli jednak chcesz trwale zmienić poziom, możesz zmodyfikować zawartość pliku log4j.properties w katalogu konfiguracyjnym. Należy wtedy dodać poniższy wiersz: log4j.logger.org.apache.hadoop.yarn.server.resourcemanager=DEBUG
Pobieranie śladu stosu Demony Hadoopa udostępniają stronę WWW (z końcówką /stacks w sieciowych interfejsach użytkownika), która generuje zrzut stosu wątku dla wszystkich wątków działających w maszynie JVM danego demona. Na przykład zrzut stosu wątku dla menedżera zasobów można uzyskać pod adresem http://resource-manager-host:8088/stacks.
Wskaźniki i technologia JMX Demony Hadoopa zbierają informacje o zdarzeniach i wskaźnikach. Te informacje ogólnie nazywa się wskaźnikami (ang. metrics). Na przykład węzły danych rejestrują następujące wskaźniki (obok wielu innych): liczbę zapisanych bajtów, liczbę zreplikowanych bloków i liczbę żądań odczytów od klientów (lokalnych i zdalnych). System wskaźników z Hadoopa 2 i nowszych wersji tej platformy nazywany jest czasem metrics2. Pozwala to odróżnić go od starszych (i obecnie wycofanych) systemów wskaźników z wcześniejszych wersji Hadoopa.
Wskaźniki należą do kontekstu. Przykładowe konteksty to: „dfs”, „mapred”, „yarn” i „rpc”. Demony Hadoopa zwykle rejestrują wskaźniki z kilku kontekstów. Na przykład węzły danych rejestrują wskaźniki dla kontekstów „dfs” i „rpc”. Wszystkie wskaźniki Hadoopa są przekazywane do technologii JMX (ang. Java Management Extensions), dlatego można je wyświetlać za pomocą standardowych narzędzi tej technologii, takich jak JConsole (to narzędzie jest dostępne razem z pakietem JDK). Na potrzeby zdalnego monitorowania trzeba ustawić właściwość com.sun.management.jmxremote.port systemu JMX (i inne w celu zapewnienia bezpieczeństwa), aby umożliwić dostęp do maszyny. Jeśli chcesz pozwolić na dostęp do węzła nazw, dodaj poniższy wiersz do pliku hadoop-env.sh: HADOOP_NAMENODE_OPTS="-Dcom.sun.management.jmxremote.port=8004"
Monitorowanie
321
Jakie są różnice między wskaźnikami a licznikami? Główna różnica polega na zasięgu działania tych mechanizmów. Wskaźniki są rejestrowane przez demony Hadoopa, natomiast liczniki (zobacz punkt „Liczniki” w rozdziale 9.) są zbierane przez operacje w modelu MapReduce i agregowane dla całego zadania. Różni są też odbiorcy danych obu rodzajów. Wskaźniki są zwykle używane przez administratorów, a liczniki — przez użytkowników modelu MapReduce. Odmienny jest również sposób zbierania i agregowania danych. Liczniki to funkcja modelu MapReduce i to system MapReduce gwarantuje, że wartości zostaną przesłane z maszyn JVM operacji (gdzie są generowane) do zarządcy aplikacji, a następnie do klienta uruchamiającego określone zadanie. Wartości liczników są przekazywane w sygnałach kontrolnych protokołu RPC; zobacz punkt „Aktualizowanie informacji o postępie i statusu” w rozdziale 7. Agregacja jest wykonywana zarówno w procesie operacji, jak i w zarządcy aplikacji. Mechanizm rejestrowania wskaźników jest niezależny od komponentu, który otrzymuje aktualizacje. Dostępne są różne rodzaje danych wyjściowych, w tym pliki lokalne oraz dane w formatach technologii Ganglia i JMX. Demon rejestrujący wskaźniki przeprowadza ich agregację przed przesłaniem danych wyjściowych.
Wskaźniki z technologii JMX (w formacie JSON) zebrane w określonym demonie Hadoopa można zobaczyć po przejściu na stronę WWW /jmx. Jest to przydatne w trakcie diagnozowania. Na przykład wskaźniki węzła nazw są dostępne pod adresem http://namenode-host:50070/jmx. Hadoop udostępnia wiele źródeł wskaźników służących do publikowania informacji w zewnętrznych systemach. Dostępne są na przykład pliki lokalne i dane w systemie monitorowania Ganglia. Źródła są konfigurowane w pliku hadoop-metrics2.properties. Przykładowe ustawienia konfiguracyjne znajdziesz właśnie w tym pliku.
Konserwacja Standardowe procedury administracyjne Kopie zapasowe metadanych Jeśli trwałe metadane z węzła nazw zostaną utracone lub uszkodzone, cały system plików stanie się nieprzydatny. Dlatego niezbędne jest utworzenie kopii zapasowych takich danych. Powinieneś przechowywać wiele kopii z różnego czasu (na przykład sprzed godziny, z poprzedniego dnia, z poprzedniego tygodnia i z poprzedniego miesiąca), aby zabezpieczyć się przed uszkodzeniem danych. Wersje z różnego czasu można przechowywać w samych kopiach lub w aktywnych plikach z węzła nazw. Prosty sposób tworzenia kopii zapasowych polega na użyciu polecenia dfsadmin i pobraniu kopii najnowszego pliku fsimage z węzła nazw: % hdfs dfsadmin -fetchImage fsimage.backup
322
Rozdział 11. Zarządzanie platformą Hadoop
Można napisać skrypt, by uruchamiać to polecenie z zewnętrznej lokalizacji w celu zapisywania kopii archiwalnych pliku fsimage. Skrypt powinien dodatkowo sprawdzać poprawność kopii. Można to zrobić, uruchamiając lokalnego demona węzła nazw i sprawdzając, czy z powodzeniem wczytał pliki fsimage i edits do pamięci (informują o tym odpowiednie komunikaty o powodzeniu w dzienniku węzła nazw)2.
Kopie zapasowe danych Choć system HDFS jest zaprojektowany z myślą o niezawodnym przechowywaniu danych, utrata danych jest w nim możliwa (podobnie jak w każdym innym systemie plików). Dlatego bardzo ważna jest strategia generowania kopii zapasowych. Ponieważ Hadoop może przechowywać bardzo duże zbiory danych, trudno jest zdecydować, co umieszczać w kopiach zapasowych i gdzie je zapisywać. Najważniejsze jest określenie priorytetowych danych. Najwyższy priorytet mają informacje, których nie da się odtworzyć i które są niezbędne dla firmy. Najniższy priorytet należy przyznać danym, które można łatwo odtworzyć lub które nie są konieczne z powodu ich ograniczonej przydatności biznesowej. Dla danych o niskim priorytecie można zrezygnować z tworzenia kopii zapasowych. Nie przyjmuj błędnie, że replikacja w systemie HDFS zwalnia z obowiązku tworzenia kopii zapasowych. Błędy w systemie HDFS i usterki sprzętu mogą spowodować utratę replik. Choć Hadoop jest celowo zaprojektowany w taki sposób, by zminimalizować ryzyko utraty danych w wyniku awarii sprzętu, nie można wykluczyć takiego zagrożenia (zwłaszcza w obliczu błędów oprogramowania lub pomyłek użytkowników). W kontekście kopii zapasowych system HDFS należy traktować w taki sam sposób jak macierz RAID. Choć dane przetrwają awarię jednego dysku z macierzy, mogą zostać utracone w wyniku awarii lub błędu kontrolera (który na przykład zastąpi niektóre dane) albo uszkodzenia całej macierzy.
Często stosowana jest określona polityka dotycząca katalogów użytkowników w systemie HDFS. Możliwe, że obowiązują limity miejsca i co noc zapisywane są kopie zapasowe. Niezależnie od stosowanej polityki upewnij się, że użytkownicy ją znają i wiedzą, czego mogą oczekiwać. Narzędzie distcp doskonale nadaje się do tworzenia kopii zapasowych w innych klastrach z systemem HDFS (najlepiej z inną wersją oprogramowania, co chroni przed utratą danych z powodu błędów w systemie) lub w innych systemach plików Hadoopa (takich jak S3), ponieważ potrafi równolegle kopiować pliki. Dla kopii zapasowych można też zastosować zupełnie inny system plików, używając jednej z metod eksportowania danych z systemu HDFS opisanych w punkcie „Systemy plików w Hadoopie” w rozdziale 3. System HDFS umożliwia administratorom i użytkownikom tworzenie snapshotów systemu plików. Snapshot to przeznaczona tylko do odczytu kopia poddrzewa systemu plików z określonego czasu. Snapshoty są bardzo wydajne, ponieważ nie wymagają kopiowania danych. Obejmują jedynie metadane i listę bloków każdego pliku, co wystarcza do odtworzenia zawartości systemu plików z czasu wykonania snapshota. 2
Hadoop udostępnia przeglądarki Offline Image Viewer i Offline Edits Viewer, które pozwalają sprawdzić poprawność plików fsimage i edits. Zauważ, że obie przeglądarki obsługują starsze formaty plików. Dlatego możesz wykorzystać przeglądarki do diagnozy problemów w plikach wygenerowanych przez starsze wersje Hadoopa. Do wywoływania tych narzędzi służą instrukcje hdfs oiv i hdfs oev.
Konserwacja
323
Snapshoty nie zastępują kopii zapasowych danych, są jednak przydatnym narzędziem do przywracania stanu do określonego punktu w czasie (na przykład w celu odzyskania plików pomyłkowo usuniętych przez użytkowników). Firma może wdrożyć politykę okresowego wykonywania snapshotów i przechowywania ich przez określony czas w zależności od ich wieku. Można na przykład przechowywać cogodzinne snapshoty z poprzedniego dnia i codzienne snapshoty z ostatniego miesiąca.
Sprawdzanie systemu plików (narzędzie fsck) Zaleca się regularne (na przykład codzienne) stosowanie narzędzia fsck z systemu HDFS dla całego systemu plików w celu proaktywnego wyszukiwania brakujących replik lub uszkodzonych bloków. Zobacz punkt „Sprawdzanie systemu plików (narzędzie fsck)” na stronie 316.
Używanie programu balancer w systemie plików Regularnie uruchamiaj program balancer (zobacz punkt „Program balancer”), aby zapewnić równy podział danych między węzły danych systemu plików.
Dodawanie i usuwanie węzłów Administrator klastra opartego na Hadoopie musi czasem dodawać lub usuwać węzły. Na przykład aby zwiększyć ilość miejsca dostępnego w klastrze, należy dodać nowe węzły. Czasem trzeba też zmniejszyć węzeł i w związku z tym usunąć węzły. W jeszcze innych sytuacjach konieczne jest usunięcie węzła z powodu problemów z jego pracą. Możliwe, że dany węzeł zbyt często się psuje lub ma zbyt niską wydajność. Węzły zwykle obejmują węzeł nazw i menedżer węzła. Przeważnie oba te komponenty są dodawane lub usuwane jednocześnie.
Dodawanie nowych węzłów Choć dodanie nowego węzła może być bardzo proste i wymagać tylko wskazania węzła nazw w pliku hdfs-site.xml, wskazania menedżera zasobów w pliku yarn-site.xml i uruchomienia demonów węzła danych i menedżera zasobów, zwykle najlepiej jest używać listy węzłów o odpowiednich uprawnieniach. Zezwalanie dowolnej maszynie na łączenie się z węzłem nazw i działanie jako węzeł danych powoduje zagrożenia z obszaru bezpieczeństwa, ponieważ maszyna może uzyskać nieuprawniony dostęp do danych. Ponadto ponieważ taka maszyna w rzeczywistości nie jest węzłem danych, administrator nie ma nad nią kontroli. Taka maszyna może zakończyć pracę w dowolnym momencie, powodując utratę danych. Wyobraź sobie, co się stanie, jeśli w systemie działa grupa tego rodzaju węzłów i blok danych jest dostępny tylko w takich „obcych” węzłach. Ten scenariusz — z powodu możliwości błędów w konfiguracji — powoduje zagrożenie nawet przy korzystaniu z zapory. Dlatego we wszystkich produkcyjnych klastrach węzłami danych (i menedżerami węzłów) należy zarządzać jawnie. Węzły danych mające uprawnienia do łączenia się z określonym węzłem nazw są wymienione w pliku, którego nazwa jest zapisana we właściwości dfs.hosts. Ten plik znajduje się w lokalnym systemie plików węzła nazw i zawiera wiersze reprezentujące odpowiednie węzły danych, określone za pomocą adresu sieciowego (podanego przez węzeł danych; ten adres jest widoczny w sieciowym interfejsie użytkownika węzła nazw). Jeśli chcesz ustawić kilka adresów sieciowych dla węzła danych, umieść je wszystkie w jednym wierszu. Poszczególne adresy należy wtedy rozdzielić spacjami. 324
Rozdział 11. Zarządzanie platformą Hadoop
Podobnie menedżery węzłów mogące łączyć się z menedżerem zasobów są wymienione w pliku o nazwie ustawionej we właściwości yarn.resourcemanager.nodes.include-path. Zwykle stosowany jest jeden współużytkowany plik (plik dołączania — ang. include file) wskazywany we właściwościach dfs.hosts i yarn.resourcemanager.nodes.include-path, ponieważ węzły w klastrze obejmują demony zarówno węzła nazw, jak i menedżera węzła. Plik (lub pliki) wskazany we właściwościach dfs.hosts i yarn.resourcemanager.nodes. include-path to nie plik slaves. Plik omawiany w tym miejscu jest używany przez węzeł nazw i menedżer zasobów do ustalania, które węzły robocze mają uprawnienia do łączenia się z tymi komponentami. Natomiast plik slaves jest używany przez skrypty sterujące Hadoopa do przeprowadzania operacji z poziomu klastra (na przykład przy ponownym rozruchu klastra) i nigdy nie jest wykorzystywany przez demony Hadoopa.
Oto co trzeba zrobić, by dodać nowe węzły do klastra: 1. Dodaj do pliku dołączania adresy sieciowe nowych węzłów. 2. Zaktualizuj węzeł nazw przy użyciu nowego zbioru węzłów danych uprawnionych do nawiązywania połączenia. W tym celu wywołaj poniższe polecenie: % hdfs dfsadmin -refreshNodes
3. Zaktualizuj menedżer zasobów za pomocą nowego zbioru menedżerów węzłów uprawnionych do nawiązywania połączenia. Posłuż się następującą instrukcją: % yarn rmadmin -refreshNodes
4. Zaktualizuj plik slaves. Dodaj do niego nowe węzły, by były uwzględniane w operacjach wykonywanych przez skrypty sterujące Hadoopa. 5. Uruchom nowe węzły danych i menedżery węzłów. 6. Sprawdź, czy nowe węzły danych i menedżery węzłów są widoczne w sieciowym interfejsie użytkownika. System HDFS nie przenosi bloków ze starszych węzłów nazw do nowych w celu zrównoważenia klastra. Aby przenieść bloki, uruchom program balancer opisany w punkcie „Program balancer”.
Usuwanie węzłów Choć system HDFS jest tak zaprojektowany, by był odporny na awarie węzłów danych, wyłączenie dużej grupy węzłów może prowadzić do problemów. Gdy docelowa liczba replik to trzy, istnieje bardzo duże prawdopodobieństwo utraty danych, gdy jednocześnie wyłączone zostaną trzy węzły danych z różnych szafek. Usuwanie węzłów danych polega na poinformowaniu węzła nazw o wyłączeniu określonych węzłów. Dzięki temu węzeł nazw może zreplikować bloki w innych węzłach danych przed wyłączeniem wskazanych węzłów. W przypadku menedżerów węzłów Hadoop zapewnia większą swobodę. Po zamknięciu menedżera węzła, w którym działają operacje w modelu MapReduce, zarządca aplikacji wykryje awarię i zaszereguje operacje do wykonania w innych węzłach.
Konserwacja
325
Proces usuwania jest kontrolowany za pomocą pliku odłączania (ang. exclude file). Dla systemu HDFS ustawia się go za pomocą właściwości dfs.hosts.exclude, a dla systemu YARN — przy użyciu właściwości yarn.resourcemanager.nodes.exclude-path. Obie wymienione właściwości często prowadzą do tego samego pliku. Plik odłączania zawiera listę węzłów, które nie są uprawnione do łączenia się z klastrem. Reguły określające, czy menedżer węzła może łączyć się z menedżerem zasobów, są proste. Menedżer węzła może nawiązać połączenie tylko wtedy, jeśli jest wymieniony w pliku dołączania i nie występuje w pliku odłączania. Jeśli plik dołączania nie jest wskazany lub jest pusty, uznaje się, że znajdują się w nim wszystkie węzły. W systemie HDFS obowiązują nieco odmienne reguły. Jeśli węzeł danych pojawia się zarówno w pliku dołączania, jak i w pliku odłączania, może nawiązać połączenie, ale wyłącznie w celu usunięcia go z klastra. W tabeli 11.3 opisano różne kombinacje wystąpień węzłów danych w obu plikach. Jeżeli plik dołączania nie jest wskazany lub jest pusty, to (podobnie jak w przypadku menedżerów węzłów) przyjmuje się, że występują w nim wszystkie węzły. Tabela 11.3. Występowanie węzłów z systemem HDFS w plikach dołączania i odłączania Węzeł występuje w pliku dołączania
Węzeł występuje w pliku odłączania
Znaczenie
Nie
Nie
Węzeł nie może nawiązać połączenia
Nie
Tak
Węzeł nie może nawiązać połączenia
Tak
Nie
Węzeł może nawiązać połączenie
Tak
Tak
Węzeł może nawiązać połączenie i zostanie usunięty
W celu usunięcie węzłów z klastra: 1. Dodaj do pliku odłączania adresy sieciowe usuwanych węzłów. Na razie nie aktualizuj pliku dołączania. 2. Zaktualizuj węzeł nazw na podstawie nowego zbioru węzłów danych. Użyj do tego następującego polecenia: % hdfs dfsadmin -refreshNodes
3. Zaktualizuj menedżer zasobów na podstawie nowego zbioru menedżerów węzłów. Użyj poniższej instrukcji: % yarn rmadmin -refreshNodes
4. Otwórz sieciowy interfejs użytkownika i sprawdź, czy stan usuwanych węzłów danych zmienił się na Decommission in progress. Rozpocznie się kopiowanie bloków z tych węzłów do innych węzłów danych z klastra. 5. Gdy stan wszystkich usuwanych węzłów nazw zmieni się na Decommissioned, będzie to oznaczać, że wszystkie bloki zostały zreplikowane. Można wtedy zamknąć usuwane węzły. 6. Usuń węzły z pliku dołączania i wywołaj poniższe polecenia: % hdfs dfsadmin -refreshNodes % yarn rmadmin -refreshNodes
7. Usuń węzły z pliku slaves. 326
Rozdział 11. Zarządzanie platformą Hadoop
Aktualizacje Aktualizowanie klastra opartego na Hadoopie wymaga starannie przygotowanego planu. Najważniejszym aspektem jest aktualizacja systemu HDFS. Jeśli zmienia się wersja struktury systemu plików, aktualizacja prowadzi do automatycznej migracji danych i metadanych systemu plików do formatu zgodnego z nową wersją. Grozi to utratą danych (jak w każdej procedurze obejmującej migrację danych). Dlatego należy się upewnić, że dla danych i metadanych utworzono kopie zapasowe (zobacz punkt „Standardowe procedury administracyjne”). W ramach planowania aktualizacji należy przeprowadzić próbny przebieg w niewielkim klastrze testowym z kopią danych, którą można utracić. Przebieg próbny pozwala zapoznać się z procesem i dostosować go do konfiguracji klastra oraz używanego zestawu narzędzi, a także zlikwidować ewentualne przeszkody przed przeprowadzeniem aktualizacji w klastrze produkcyjnym. Klaster testowy przydaje się też do testowania aktualizacji aplikacji klienckich. W poniższej ramce opisano ogólne zagadnienia dotyczące zgodności klientów z klastrem. Aktualizowanie klastra w sytuacji, gdy struktura systemu plików się nie zmieniła, jest stosunkowo proste. Wystarczy zainstalować w klastrze (i w maszynach klienckich) nową wersję Hadoopa, zamknąć starsze demony, zaktualizować pliki konfiguracyjne, uruchomić nowe demony i zmodyfikować klienty w taki sposób, by korzystały z nowych bibliotek. Ten proces jest odwracalny, dlatego łatwo można anulować aktualizację. Po udanej aktualizacji należy wykonać kilka końcowych kroków w celu uporządkowania systemu: 1. Usuń z klastra dawne pliki instalacyjne i konfiguracyjne. 2. Na podstawie ostrzeżeń o przestarzałych mechanizmach wprowadź w kodzie i konfiguracji odpowiednie poprawki. W procesie aktualizacji swoją wartość pokazują narzędzia do zarządzania klastrami opartymi na Hadoopie, na przykład Cloudera Manager i Apache Ambari. Upraszczają one proces aktualizacji, a także pozwalają na łatwe przeprowadzanie aktualizacji bez wyłączania systemu (węzły są wtedy modyfikowane w grupach, a węzły nadrzędne — pojedynczo). Dzięki temu klienty nie są narażone na przerwy w pracy usług.
Aktualizowanie danych i metadanych w systemie HDFS Jeśli zastosujesz opisane procedury do zaktualizowania systemu HDFS do nowej wersji, w której używana jest inna struktura systemu plików, węzeł nazw przestanie działać. W dzienniku węzła nazw pojawi się wtedy następujący komunikat: File system image contains an old layout version -16. An upgrade to version -18 is required. Please restart NameNode with -upgrade option.
Najpewniejszy sposób ustalenia, czy potrzebna jest aktualizacja systemu plików, polega na przeprowadzeniu prób w klastrze testowym. W wyniku aktualizacji systemu HDFS tworzone są kopie metadanych i danych z wcześniejszej wersji. Przeprowadzenie aktualizacji nie powoduje jednak podwojenia wymagań pamięci dyskowej w klastrze, ponieważ w węzłach danych używane są dowiązania twarde do przechowywania dwóch
Konserwacja
327
referencji (dla nowej i starszej wersji) do tego samego bloku danych. Ten projekt umożliwia łatwy powrót do wcześniejszej wersji systemu plików, jeśli jest to konieczne. Warto przy tym zauważyć, że wszelkie zmiany wprowadzone w danych w zaktualizowanym systemie zostaną w wyniku anulowania aktualizacji utracone.
Zgodność Przy zmianie wersji należy się zastanowić, jakie etapy aktualizacji będą potrzebne. Należy uwzględnić tu kilka aspektów — zgodność interfejsu API, zgodność danych i zgodność połączeń. Zgodność interfejsu API dotyczy kontraktu między kodem użytkownika a publicznymi interfejsami API Hadoopa (na przykład interfejsami API Javy z modelu MapReduce). Wprowadzenie nowej głównej wersji (na przykład przejście od wersji 1.x.y do 2.0.0) może naruszać zgodność interfejsu API, dlatego czasem konieczna jest modyfikacja i ponowna kompilacja programów użytkownika. Pojawienie się nowych podwersji (na przykład zmiana z wersji 1.0.x na 1.1.0) i aktualizacji (na przykład przejście od wersji 1.0.1 do 1.0.2) nie powinno naruszać zgodności interfejsu API. W Hadoopie dla elementów interfejsu API używane są kategorie określające ich stabilność. Opisane reguły zgodności interfejsu API dotyczą elementów z kategorii InterfaceStability. Stable. Niektóre elementy z publicznych interfejsów API Hadoopa należą jednak do kategorii InterfaceStability.Evolving lub InterfaceStability.Unstable (wszystkie adnotacje określające kategorie znajdują się w pakiecie org.apache.hadoop.classification), co oznacza, że interfejs API może zostać naruszony w podwersjach (elementy z adnotacją Evolving) lub aktualizacjach (elementy z adnotacją Unstable). Zgodność danych dotyczy formatów trwałych danych i metadanych — na przykład formatu, w jakim przechowywane są trwałe dane węzła nazw z systemu HDFS. Formaty mogą się zmieniać między wersjami i podwersjami, jednak modyfikacje są nieodczuwalne dla użytkowników, ponieważ w ramach aktualizacji następuje automatyczna migracja danych. Czasem występują pewne ograniczenia w kolejności aktualizacji. Są one opisane w uwagach do poszczególnych wersji. Konieczne może być przeprowadzenie najpierw aktualizacji do wersji pośredniej zamiast aktualizowania systemu w jednym kroku od razu do późniejszej, ostatecznej wersji. Zgodność połączeń związana jest ze współdziałaniem klientów i serwerów za pomocą protokołów takich jak RPC i HTTP. Zgodnie z regułami z tego obszaru klient musi mieć ten sam numer głównej wersji co serwer, natomiast może mieć inny numer podwersji i aktualizacji. Na przykład klient o wersji 2.0.2 będzie współdziałał z serwerami o wersjach 2.0.1 i 2.1.0, ale już niekoniecznie z serwerem o wersji 3.0.0. Reguły zgodności połączeń są inne niż we wcześniejszych wersjach Hadoopa, gdzie klienty wewnętrzne (na przykład węzły danych) trzeba było aktualizować razem z serwerem. Ponieważ w Hadoopie 2 odmienne wersje wewnętrznych klientów i serwerów współdziałają ze sobą, możliwe jest przeprowadzanie aktualizacji bez wyłączania systemu. Pełny zestaw obowiązujących w Hadoopie reguł zapewniania zgodności jest udokumentowany w witrynie fundacji Apache (http://hadoop.apache.org/docs/current/hadoop-project-dist/hadoop-common/ Compatibility.html).
328
Rozdział 11. Zarządzanie platformą Hadoop
Można zachować tylko ostatnią wersję systemu plików, co oznacza, że nie da się cofnąć o kilka wersji. Dlatego aby przeprowadzić kolejną aktualizację danych i metadanych systemu HDFS, trzeba usunąć ich wcześniejszą wersję. Jest to proces finalizowania aktualizacji. Po sfinalizowaniu aktualizacji nie jest możliwe cofnięcie się do dawnej wersji. Zwykle można przeskakiwać wersje w trakcie aktualizacji, jednak czasem konieczne jest zainstalowanie wersji pośrednich. Jest to opisane w uwagach do poszczególnych wersji. Aktualizację należy przeprowadzać tylko w sprawnym systemie plików. Przed rozpoczęciem aktualizacji sprawdź system przy użyciu narzędzia fsck — zobacz punkt „Sprawdzanie systemu plików (narzędzie fsck)” na stronie 316. Jako dodatkowy środek bezpieczeństwa możesz zachować kopię danych wyjściowych narzędzia fsck z listą wszystkich plików i bloków systemu. Pozwoli Ci to porównać tę listę z danymi wyjściowymi narzędzia fsck uzyskanymi po aktualizacji. Przed aktualizacją należy usunąć pliki tymczasowe — zarówno lokalne, jak i te z katalogu modelu MapReduce w systemie HDFS. Po wykonaniu opisanych czynności wstępnych można przejść do przedstawionej poniżej ogólnej procedury aktualizowania klastra w sytuacji, gdy niezbędna jest modyfikacja struktury systemu plików. 1. Przed rozpoczęciem nowej aktualizacji upewnij się, że wszystkie wcześniejsze aktualizacje zostały sfinalizowane. 2. Zamknij demony systemu YARN i modelu MapReduce. 3. Zamknij system HDFS i utwórz kopię zapasową katalogów węzła nazw. 4. Zainstaluj nową wersję Hadoopa w klastrze i w maszynach klienckich. 5. Uruchom system HDFS z opcją -upgrade. 6. Poczekaj do momentu zakończenia aktualizacji. 7. Przeprowadź podstawowe testy poprawności w systemie HDFS. 8. Uruchom demony systemu YARN i modelu MapReduce. 9. Opcjonalnie wycofaj lub sfinalizuj aktualizację. W trakcie aktualizacji warto usunąć skrypty Hadoopa ze zmiennej środowiskowej PATH. Zmusza to do jawnego określania wersji używanych skryptów. Wygodnym rozwiązaniem jest zdefiniowanie dwóch zmiennych środowiskowych dla katalogów z instalacjami różnych wersji. W dalszych instrukcjach używane są zmienne środowiskowe OLD_HADOOP_HOME i NEW_HADOOP_HOME. Rozpoczynanie aktualizacji Aby przeprowadzić aktualizację, uruchom poniższe polecenie (jest to krok 5. w ogólnej procedurze aktualizacji): % $NEW_HADOOP_HOME/bin/start-dfs.sh -upgrade
Ta instrukcja powoduje aktualizację metadanych węzła nazw i umieszczenie ich wcześniejszej wersji w nowym podkatalogu previous w katalogu ustawionym we właściwości dfs.namenode.name.dir. Podobnie węzły danych aktualizują katalogi z danymi i zachowują kopię wcześniejszych wersji w podkatalogu previous.
Konserwacja
329
Oczekiwanie na zakończenie procesu aktualizacji Proces aktualizacji wymaga czasu. Postępy możesz sprawdzić za pomocą narzędzia dfsadmin (krok 6.; zdarzenia związane z aktualizacją są też rejestrowane w plikach dzienników demonów). % $NEW_HADOOP_HOME/bin/hdfs dfsadmin -upgradeProgress status Upgrade for version -18 has been completed. Upgrade is not finalized.
Sprawdzanie aktualizacji Ten etap pozwala stwierdzić, że aktualizacja została ukończona. Należy przeprowadzić proste testy poprawności w systemie plików (krok 7.) — na przykład sprawdzić pliki i bloki za pomocą narzędzia fsck oraz przetestować podstawowe operacje na plikach. W trakcie przeprowadzania niektórych testów (w trybie tylko do odczytu) można włączyć dla systemu HDFS tryb bezpieczny, aby uniemożliwić innym użytkownikom wprowadzanie zmian. Zobacz punkt „Tryb bezpieczny”. Opcjonalne wycofywanie aktualizacji Jeśli odkryjesz, że nowa wersja nie działa prawidłowo, możesz przywrócić wcześniejszą wersję (krok 9.). Jest to możliwe tylko do czasu sfinalizowania aktualizacji. Wycofywanie powoduje przywrócenie stanu systemu plików do momentu sprzed aktualizacji. Dlatego wszystkie zmiany wprowadzone po aktualizacji zostają utracone. Oznacza to, że proces polega na wycofaniu zmian i przywróceniu wcześniejszego stanu systemu plików, a nie na „zaktualizowaniu” systemu plików w bieżącym stanie do wcześniejszej wersji.
Najpierw zamknij nowe demony: % $NEW_HADOOP_HOME/bin/stop-dfs.sh
Następnie uruchom wcześniejszą wersję systemu HDFS z opcją -rollback: % $OLD_HADOOP_HOME/bin/start-dfs.sh -rollback
To polecenie powoduje, że węzeł nazw i węzły danych zastępują obecne katalogi z danymi ich wcześniejszymi kopiami. W efekcie przywracany jest poprzedni stan systemu plików. Opcjonalne finalizowanie aktualizacji Jeśli zadowala Cię praca nowej wersji systemu HDFS, możesz sfinalizować aktualizację (krok 9.) i usunąć wcześniejsze katalogi z danymi. Po sfinalizowaniu aktualizacji nie ma możliwości powrotu do wcześniejszej wersji systemu.
Ten krok jest niezbędny przed wykonaniem następnej aktualizacji. % $NEW_HADOOP_HOME/bin/hdfs dfsadmin -finalizeUpgrade % $NEW_HADOOP_HOME/bin/hdfs dfsadmin -upgradeProgress status There are no upgrades in progress.
Teraz system HDFS jest w pełni zaktualizowany do nowej wersji.
330
Rozdział 11. Zarządzanie platformą Hadoop
CZĘŚĆ IV
Powiązane projekty
ROZDZIAŁ 12.
Avro
Apache Avro1 (http://avro.apache.org/) to współdziałający z wieloma językami system serializacji danych. Projekt został zainicjowany przez Douga Cuttinga (twórcę Hadoopa) w celu wyeliminowania poważnej wady obiektów z rodziny Writable z Hadoopa — braku przenośności między językami. Utworzenie formatu danych możliwego do przetwarzania w wielu językach (obecnie obsługiwane są: C, C++, C#, Java, JavaScript, Perl, PHP, Python i Ruby) ułatwia udostępnianie danych większym grupom odbiorców w porównaniu z formatem ograniczonym do jednego języka. Uniwersalny format stanowi też zabezpieczenie na przyszłość, ponieważ dane mogą dzięki temu przetrwać dłużej niż język używany pierwotnie do ich zapisu i odczytu. Po co jednak tworzyć nowy system serializacji danych? Avro posiada zestaw cech, które w połączeniu odróżniają ten system od innych (takich jak Apache Thrift lub Protocol Buffers firmy Google)2. Dane w systemie Avro (podobnie jak w innych systemach) są opisywane za pomocą schematu niezależnego od języka. Jednak inaczej niż w innych systemach w Avro generowanie kodu jest opcjonalne. Oznacza to, że odczyt i zapis danych zgodnych z określonym schematem jest możliwy także w sytuacji, jeśli kod nigdy wcześniej nie napotkał określonego schematu. Aby to uzyskać, w Avro przyjęto, że schemat zawsze jest dostępny (w czasie odczytu i zapisu). Dzięki temu zakodowane dane są bardzo zwięzłe, ponieważ kodowanych wartości nie trzeba opisywać za pomocą identyfikatorów pól. Schematy systemu Avro są zwykle zapisywane w formacie JSON, a dane mają postać binarną, choć istnieją też inne możliwości. Istnieje język wyższego poziomu, Avro IDL, służący do pisania schematu w języku przypominającym C, wyglądającym bardziej znajomo dla programistów. Dostępny jest też oparty na formacie JSON mechanizm kodowania danych. Ponieważ wyniki jego pracy są czytelne dla ludzi, mechanizm ten jest przydatny do tworzenia prototypów i diagnozowania problemów w danych systemu Avro. Specyfikacja systemu Avro (http://avro.apache.org/docs/current/spec.html) precyzyjnie definiuje format binarny, który wszystkie implementacje muszą obsługiwać. Określa też wiele innych funkcji systemu, które należy udostępniać w specyfikacjach. Specyfikacja nie wyznacza jednak interfejsów API. Twórcy implementacji mają pełną swobodę w budowaniu interfejsów API udostępnianych na potrzeby pracy z danymi w systemie Avro, ponieważ interfejsy te są specyficzne dla języków. 1
Nazwa pochodzi od angielskiego producenta samolotów z XX wieku.
2
Avro ma też wyższą wydajność niż inne biblioteki serializacji, co stwierdzono na podstawie testów porównawczych (http://code.google.com/p/thrift-protobuf-compare/).
333
Ważne jest używanie tylko jednego formatu binarnego. Ułatwia to dodawanie obsługi systemu w nowych językach i pozwala uniknąć gwałtownego wzrostu liczby języków i formatów utrudniającego komunikację między różnymi programami. Avro ma też rozbudowane możliwości w zakresie określania schematów. W ramach starannie zdefiniowanych ograniczeń schemat używany do odczytu danych nie musi być identyczny ze schematem wykorzystanym do ich zapisu. Dzięki temu mechanizmowi Avro umożliwia modyfikowanie schematów. Do rekordu można na przykład dodać nowe opcjonalne pole, deklarując je w schemacie używanym do odczytu dawnych danych. Nowe i starsze klienty mogą odczytać dawne dane, natomiast nowe klienty mogą zapisywać nowe dane z nowym polem. Gdy starszy klient natrafi na dane w nowym formacie, pominie nowe pole i będzie kontynuował przetwarzanie tak, jakby używał danych w dawnym formacie. Avro udostępnia format kontenerowy dla obiektów. Służy on do przechowywania sekwencji obiektów i jest podobny do plików typu SequenceFile Hadoopa. Plik danych systemu Avro obejmuje sekcję metadanych z zapisanym schematem. Dzięki temu plik jest samoopisowy. Pliki danych systemu Avro obsługują kompresję i umożliwiają podział (są to niezbędne cechy formatów danych wejściowych w modelu MapReduce). Avro współdziała nie tylko z modelem MapReduce. Wszystkie opisane w tej książce platformy przetwarzania danych (Pig, Hive, Crunch i Spark) potrafią wczytywać oraz zapisywać pliki danych systemu Avro. System Avro można wykorzystać także na potrzeby wywołań RPC, choć to zagadnienie nie jest tu opisane. Więcej informacji znajdziesz w specyfikacji systemu.
Typy danych i schematy systemu Avro W systemie Avro zdefiniowana jest niewielka liczba prostych typów danych, które można wykorzystać do budowania specyficznych dla aplikacji struktur danych, pisząc schematy. Aby zapewnić zgodność między językami, implementacje muszą obsługiwać wszystkie typy systemu Avro. Typy proste systemu Avro są wymienione w tabeli 12.1. Każdy typ prosty można też zapisać w bardziej rozbudowanej postaci za pomocą atrybutu type: { "type": "null" }
Tabela 12.1. Typy proste systemu Avro Typ
Opis
Schemat
null
Brak wartości
"null"
boolean
Wartość binarna
"boolean"
int
32-bitowa liczba całkowita ze znakiem
"int"
long
64-bitowa liczba całkowita ze znakiem
"long"
float
Liczba zmiennoprzecinkowa pojedynczej precyzji (32-bitowa) ze standardu IEEE 754
"float"
double
Liczba zmiennoprzecinkowa podwójnej precyzji (64-bitowa) ze standardu IEEE 754
"double"
bytes
Sekwencja 8-bitowych bajtów bez znaku
"bytes"
string
Sekwencja znaków Unicode
"string"
334
Rozdział 12. Avro
W Avro zdefiniowane są też typy złożone wymienione w tabeli 12.2 (razem z przykładowym schematem dla każdego z nich). Tabela 12.2. Typy złożone systemu Avro Typ
Opis
Przykładowy schemat
array
Uporządkowana kolekcja obiektów. Wszystkie obiekty z tablicy muszą mieć ten sam schemat.
{ "type": "array", "items": "long" }
map
record
Nieuporządkowana kolekcja par klucz-wartość. Klucze muszą być łańcuchami znaków, a wartości mogą być dowolnego typu (przy czym w jednym odwzorowaniu wszystkie wartości muszą mieć ten sam schemat).
{
Kolekcja nazwanych pól dowolnego typu.
{
"type": "map", "values": "string" } "type": "record", "name": "WeatherRecord", "doc": "Odczyt pogody.", "fields": [ {"name": "year", "type": "int"}, {"name": "temperature", "type": "int"}, {"name": "stationId", "type": "string"} ] }
enum
Zbiór nazwanych wartości.
{ "type": "enum", "name": "Cutlery", "doc": "Sztućce.", "symbols": ["KNIFE", "FORK", "SPOON"] }
fixed
Stała liczba 8-bitowych bajtów bez znaku.
{ "type": "fixed", "name": "Md5Hash", "size": 16 }
union
Suma schematów, reprezentowana jako tablica w formacie JSON, w której każdy element to schemat. Dane reprezentowane przez typ union muszą pasować do jednego z wymienionym w nim schematów.
[ "null", "string", {"type": "map", "values": "string"} ]
W każdym języku w interfejsie API systemu Avro występuje reprezentacja wszystkich typów systemu Avro w postaci typowej dla danego języka. Na przykład typ double z systemu Avro jest reprezentowany w językach C, C++ i Java za pomocą typu double, w Pythonie za pomocą typu float, a w Ruby przy użyciu typu Float. Typy danych i schematy systemu Avro
335
Ponadto w języku używana może być więcej niż jedna reprezentacja typu. Wszystkie języki obsługują dynamiczne reprezentacje, które można stosować także wtedy, gdy schemat nie jest znany przed uruchomieniem programu. W Javie są to reprezentacje generyczne. Implementacje z języków Java i C++ potrafią generować kod reprezentujący dane ze schematu z systemu Avro. Reprezentacje oparte na generowaniu kodu (w Javie nazywa się je reprezentacjami specyficznymi) to optymalizacja przydatna w sytuacjach, gdy przed odczytem lub zapisem danych dostępna jest kopia schematu. Wygenerowane klasy udostępniają kodowi użytkownika interfejs API bardziej dostosowany do dziedziny niż reprezentacje generyczne. W Javie używane są też reprezentacje trzeciego rodzaju, refleksyjne, odwzorowujące typy systemu Avro na istniejące typy Javy za pomocą mechanizmu refleksji. Ten mechanizm jest wolniejszy niż reprezentacje generyczne i specyficzne, bywa jednak wygodną techniką definiowania typów, ponieważ system Avro potrafi automatycznie określać schematy. Reprezentacje używane w Javie wymieniono w tabeli 12.3. Reprezentacje specyficzne są takie same jak generyczne, chyba że napisano inaczej. Podobnie reprezentacje refleksyjne są identyczne z reprezentacjami specyficznymi, chyba że podano inaczej. Reprezentacje specyficzne są inne od generycznych tylko dla typów record, enum i fixed. Dla tych typów generowane są klasy, których nazwy można kontrolować za pomocą atrybutów name i (opcjonalnego) namespace. Tabela 12.3. Używane w Javie reprezentacje typów systemu Avro Typ systemu Avro
Generyczna reprezentacja w Javie
null
null
boolean
boolean
int
int
long
long
float
float
double
double
bytes
java.nio.ByteBuffer
Tablica bajtów
string
org.apache.avro.util.Utf8 lub java.lang.String
java.lang.String
array
org.apache.avro. generic.GenericArray
Array lub java.util.Collection
map
java.util.Map
record
org.apache.avro. generic.GenericRecord
Wygenerowana klasa z rodziny
enum
java.lang.String
Wygenerowane wyliczenie Javy
Dowolne wyliczenie Javy
fixed
org.apache.avro. generic.GenericFixed
Wygenerowana klasa z rodziny
org.apache.avro. generic.GenericFixed
union
336
java.lang.Object
Rozdział 12. Avro
Specyficzna reprezentacja w Javie
Refleksyjna reprezentacja w Javie
byte, short, int lub char
org.apache.avro. specific.SpecificRecord
org.apache.avro. specific.SpecificFixed
Dowolna klasa użytkownika z konstruktorem bezargumentowym. Używane są wszystkie odziedziczone trwałe pola egzemplarza
Typ string systemu Avro może być reprezentowany za pomocą typu String Javy lub przeznaczonego dla systemu Avro typu Utf8 Javy. Typ Utf8 jest stosowany ze względu na wydajność. Jest to typ zmienny, dlatego jeden jego egzemplarz można wielokrotnie wykorzystać przy odczycie lub zapisie serii wartości. Ponadto typ String Javy dekoduje dane w formacie UTF-8 w trakcie tworzenia obiektu, natomiast typ Utf8 robi to w trybie leniwym, co w niektórych sytuacjach prowadzi do poprawy wydajności kodu. Typ Utf8 implementuje interfejs java.lang.CharSequence Javy, który w pewnym zakresie zapewnia współdziałanie z bibliotekami Javy. W nieobsługiwanych obszarach konieczne może być przekształcenie obiektów typu Utf8 na obiekty typu String za pomocą metody toString(). Utf8 to domyślny typ używany jako reprezentacja generyczna i specyficzna. Jednak w konkretnych sytuacjach można użyć typu String. Istnieje kilka sposobów na osiągnięcie tego celu. Pierwszy polega na ustawieniu właściwości avro.java.string w schemacie na wartość String: { "type": "string", "avro.java.string": "String" }
Ponadto na potrzeby reprezentacji specyficznej można wygenerować klasy z getterami i setterami opartymi na typie String. Gdy używana jest wtyczka Avro Maven, należy w tym celu ustawić właściwość konfiguracyjną stringType na wartość String (przykład zastosowania tej techniki przedstawia punkt „Specyficzny interfejs API”). Warto też zauważyć, że jako reprezentacja refleksyjna zawsze używane są obiekty typu String, ponieważ ta reprezentacja jest zaprojektowana pod kątem zgodności z Javą, a nie z myślą o wydajności.
Serializacja i deserializacja w pamięci System Avro udostępnia interfejsy API służące do serializacji i deserializacji, przydatne, gdy programista chce zintegrować Avro z istniejącym systemem (na przykład z systemem komunikatów, w którym format ramek jest już zdefiniowany). W innych sytuacjach należy rozważyć zastosowanie plików danych systemu Avro. Napiszmy teraz program w Javie wczytujący dane systemu Avro ze strumienia i zapisujący je w strumienia. Oto prosty schemat systemu Avro reprezentujący rekord z parą łańcuchów znaków: { "type": "record", "name": "StringPair", "doc": "Para łańcuchów znaków.", "fields": [ {"name": "left", "type": "string"}, {"name": "right", "type": "string"} ] }
Jeśli ten schemat zapiszesz w dostępnym w ścieżce do klas pliku StringPair.avsc (.avsc to zwyczajowe rozszerzenie schematów systemu Avro), będziesz mógł wczytać go za pomocą dwóch wierszy kodu. Schema.Parser parser = new Schema.Parser(); Schema schema = parser.parse( getClass().getResourceAsStream("StringPair.avsc"));
Serializacja i deserializacja w pamięci
337
Egzemplarz rekordu systemu Avro można utworzyć za pomocą generycznego interfejsu API w następujący sposób: GenericRecord datum = new GenericData.Record(schema); datum.put("left", "L"); datum.put("right", "R");
Następnie program serializuje rekord do strumienia wyjściowego: ByteArrayOutputStream out = new ByteArrayOutputStream(); DatumWriter writer = new GenericDatumWriter(schema); Encoder encoder = EncoderFactory.get().binaryEncoder(out, null); writer.write(datum, encoder); encoder.flush(); out.close();
Występują tu dwa ważne obiekty — typu DatumWriter i typu Encoder. Obiekt typu DatumWriter przekształca obiekty z danymi na typy zrozumiałe dla obiektu typu Encoder, który zapisuje dane do strumienia wyjściowego. Tu używana jest klasa GenericDatumWriter, która przekazuje pola rekordu typu GenericRecord do obiektu typu Encoder. Do fabryki obiektów typu Encoder przekazywana jest wartość null, ponieważ program nie wykorzystuje wcześniej utworzonego obiektu tego typu. W tym przykładzie do strumienia zapisywany jest tylko jeden obiekt. Można jednak wywołać metodę write() dla kolejnych obiektów przed zamknięciem strumienia. Do klasy GenericDatumWriter należy przekazać schemat, ponieważ na jego podstawie klasa określa, które wartości z obiektu z danymi należy zapisać do strumienia wyjściowego. Po wywołaniu metody write() można opróżnić obiekt typu Encoder, a następnie zamknąć strumień. Następnie można odwrócić cały proces i wczytać obiekt z bufora bajtów. DatumReader reader = new GenericDatumReader(schema); Decoder decoder = DecoderFactory.get().binaryDecoder(out.toByteArray(), null); GenericRecord result = reader.read(null, decoder); assertThat(result.get("left").toString(), is("L")); assertThat(result.get("right").toString(), is("R"));
W wywołaniach metod binaryDecoder() i read() przekazywana jest wartość null, ponieważ kod nie wykorzystuje ponownie obiektów (dekodera i rekordu). Obiekty zwracane przez wywołania result.get("left") i result.get("right") są typu Utf8, dlatego kod przekształca je na obiekty typu String Javy, wywołując metodę toString().
Specyficzny interfejs API Spójrz teraz na analogiczny kod oparty na specyficznym interfejsie API. Klasę StringPair można wygenerować na podstawie pliku schematu za pomocą wtyczki Mavena dla systemu Avro, służącej do kompilowania schematów. Poniżej pokazano ważny w tym kontekście fragment pliku POM (ang. Project Object Model) Mavena.
...
338
Rozdział 12. Avro
org.apache.avro avro-maven-plugin ${avro.version}
schemas generate-sources
schema
StringPair.avsc
String src/main/resources ${project.build.directory}/generated-sources/java
...
Zamiast Mavena do wygenerowania kodu Javy dla schematu możesz wykorzystać zadanie Anta dla systemu Avro (org.apache.avro.specific.SchemaTask) lub uruchamiane w wierszu poleceń narzędzia systemu Avro3. W kodzie odpowiedzialnym za serializację i deserializację tworzony jest obiekt typu StringPair zamiast obiektu typu GenericRecord. Obiekt jest zapisywany do strumienia za pomocą obiektu typu SpecificDatumWriter i wczytywany za pomocą obiektu typu SpecificDatumReader. StringPair datum = new StringPair(); datum.setLeft("L"); datum.setRight("R"); ByteArrayOutputStream out = new ByteArrayOutputStream(); DatumWriter writer = new SpecificDatumWriter(StringPair.class); Encoder encoder = EncoderFactory.get().binaryEncoder(out, null); writer.write(datum, encoder); encoder.flush(); out.close(); DatumReader reader = new SpecificDatumReader(StringPair.class); Decoder decoder = DecoderFactory.get().binaryDecoder(out.toByteArray(), null); StringPair result = reader.read(null, decoder); assertThat(result.getLeft(), is("L")); assertThat(result.getRight(), is("R"));
3
System Avro można pobrać jako kod źródłowy i w postaci binarnej (http://avro.apache.org/releases.html). Polecenie java –jar avro-tools-*.jar wyświetla instrukcje użytkowania narzędzi systemu Avro.
Serializacja i deserializacja w pamięci
339
Pliki danych systemu Avro Kontenerowy format plików systemu Avro służy do przechowywania sekwencji obiektów tego systemu. Projekt tego formatu jest bardzo podobny jak w plikach typu SequenceFile Hadoopa, opisanych w punkcie „Klasa SequenceFile” w rozdziale 5. Główna różnica polega na tym, że pliki danych systemu Avro są przenośne między językami, dlatego można na przykład zapisać plik w Pythonie i wczytać go w języku C (właśnie takie rozwiązanie pokazano w następnym punkcie). Plik danych ma nagłówek z metadanymi (obejmują one schemat danych systemu Avro i znacznik synchronizacji), po którym następuje seria (opcjonalnie skompresowanych) bloków obejmujących zserializowane obiekty systemu Avro. Bloki są rozdzielane unikatowym dla pliku znacznikiem synchronizacji (znacznik dla danego pliku znajduje się w nagłówku), co umożliwia szybką ponowną synchronizację kodu z granicami bloków po przejściu do dowolnego punktu w pliku (na przykład do granicy bloku systemu HDFS). Pliki danych systemu Avro umożliwiają podział, dlatego nadają się do wydajnego przetwarzania w modelu MapReduce. Zapisywanie obiektów systemu Avro do pliku danych odbywa się podobnie jak zapis danych do strumienia. Można wykorzystać obiekt typu DatumWriter, jednak zamiast stosować obiekt typu Encoder, kod za pomocą obiektu typu DatumWriter tworzy obiekt typu DataFileWriter. Następnie można utworzyć nowy plik danych (takie pliki zwyczajowo mają rozszerzenie .avro) i zacząć dodawać do niego obiekty. File file = new File("data.avro"); DatumWriter writer = new GenericDatumWriter(schema); DataFileWriter dataFileWriter = new DataFileWriter(writer); dataFileWriter.create(schema, file); dataFileWriter.append(datum); dataFileWriter.close();
Obiekty zapisywane do pliku danych muszą być zgodne ze schematem pliku. W przeciwnym razie w momencie wywołania metody append() zgłoszony zostanie wyjątek. Ten przykład ilustruje zapis do pliku lokalnego (typ java.io.File). Jednak za pomocą przeciążonej metody create()klasy DataFileWriter można zapisywać dane do dowolnego strumienia typu java.io.OutputStream. Na przykład by zapisać plik w systemie HDFS, należy utworzyć strumień typu OutputStream za pomocą wywołania metody create() klasy FileSystem (zobacz punkt „Zapis danych” w rozdziale 3). Wczytywanie obiektów z pliku danych przebiega podobnie jak odczyt ze strumienia przechowywanego w pamięci. Występuje jednak jedna ważna różnica — nie trzeba określać schematu, ponieważ jest on wczytywany z metadanych pliku. Za pomocą metody getScheme() można pobrać schemat z obiektu typu DataFileReader i sprawdzić, czy identyczny schemat wykorzystano przy zapisie obiektu. DatumReader reader = new GenericDatumReader(); DataFileReader dataFileReader = new DataFileReader(file, reader); assertThat("Schemat jest taki sam", schema, is(dataFileReader.getSchema()));
340
Rozdział 12. Avro
DataFileReader to zwykły iterator Javy. Dlatego można przejść po obiektach z danymi za pomocą
metod hasNext() i next() iteratora. Poniższy fragment kodu sprawdza, czy istnieje tylko jeden rekord i czy jego pola mają wartości zgodne z oczekiwaniami. assertThat(dataFileReader.hasNext(), is(true)); GenericRecord result = dataFileReader.next(); assertThat(result.get("left").toString(), is("L")); assertThat(result.get("right").toString(), is("R")); assertThat(dataFileReader.hasNext(), is(false));
Jednak zamiast stosować standardową metodę next(), lepiej jest wykorzystać jej przeciążoną wersję, która przyjmuje zwracany obiekt (tu jest to obiekt typu GenericRecord). Powoduje to ponowne wykorzystanie obiektu, dlatego w plikach zawierających wiele obiektów można w ten sposób uniknąć ponoszenia kosztów tworzenia obiektów i przywracania pamięci. Oto wzorcowy kod: GenericRecord record = null; while (dataFileReader.hasNext()) { record = dataFileReader.next(record); // Przetwarzanie rekordu }
Jeśli ponowne wykorzystanie obiektu nie jest istotne, można zastosować krótszy zapis. for (GenericRecord record : dataFileReader) { // Przetwarzanie rekordu }
Do odczytu pliku w systemie plików Hadoopa można użyć klasy FsInput systemu Avro i wskazać plik wejściowy za pomocą obiektu typu Path Hadoopa. Klasa DataFileReader umożliwia dostęp swobodny do plików danych systemu Avro (za pomocą metod seek() i sync()). Jednak w wielu sytuacjach wystarczający jest sekwencyjny dostęp do strumienia. Wtedy należy używać klasy Data FileStream (umożliwia ona odczyt z dowolnego obiektu typu InputStream Javy).
Współdziałanie języków By zademonstrować współdziałanie języków obsługiwanych przez system Avro, można napisać plik danych za pomocą jednego języka (Pythona) i wczytać go w innym (w Javie).
Interfejs API dla Pythona Program z listingu 12.1 wczytuje rozdzielone przecinkami łańcuchy znaków ze standardowego wejścia i zapisuje je jako rekordy typu StringPair w pliku danych systemu Avro. Podobnie jak w kodzie Javy służącym do zapisu pliku danych, tak i tu tworzone są obiekty typów DatumWriter i DataFileWriter. Zauważ, że schemat systemu Avro jest określony w kodzie, choć równie dobrze można go wczytać z pliku. W Pythonie rekordy systemu Avro są reprezentowane jako słowniki. Każdy wiersz wczytywany ze standardowego wejścia zostaje przekształcony w obiekt słownika i dodany do obiektu typu Data FileWriter.
Współdziałanie języków
341
Listing 12.1. Program w Pythonie zapisujący rekordy z parami słów do pliku danych import os import string import sys from avro import schema from avro import io from avro import datafile if __name__ == '__main__': if len(sys.argv) != 2: sys.exit('Użytkowanie: %s' % sys.argv[0]) avro_file = sys.argv[1] writer = open(avro_file, 'wb') datum_writer = io.DatumWriter() schema_object = schema.parse("\ { "type": "record", "name": "StringPair", "doc": "Para łańcuchów znaków.", "fields": [ {"name": "left", "type": "string"}, {"name": "right", "type": "string"} ] }") dfw = datafile.DataFileWriter(writer, datum_writer, schema_object) for line in sys.stdin.readlines(): (left, right) = string.split(line.strip(), ',') dfw.append({'left':left, 'right':right}); dfw.close()
Przed uruchomieniem programu trzeba zainstalować system Avro dla Pythona. % easy_install avro
W celu wykonania programu określ nazwę pliku na dane wyjściowe (pairs.avro) i podaj wejściowe pary do standardowego wejścia. Koniec pliku oznacz za pomocą kombinacji Ctrl+D. % python ch12-avro/src/main/py/write_pairs.py pairs.avro a,1 c,2 b,3 b,2 ^D
Narzędzia systemu Avro Następnie można wykorzystać narzędzia systemu Avro (napisane w Javie) do wyświetlenia zawartości pliku pairs.avro. Plik JAR z narzędziami jest dostępny w witrynie systemu Avro. Tu przyjęto, że plik znajduje się już w katalogu lokalnym $AVRO_HOME. Polecenie tojson przekształca plik danych systemu Avro na format JSON i wyświetla go w konsoli. % java -jar $AVRO_HOME/avro-tools-*.jar tojson pairs.avro {"left":"a","right":"1"} {"left":"c","right":"2"} {"left":"b","right":"3"} {"left":"b","right":"2"}
W ten sposób udało się z powodzeniem przesłać złożone dane między dwiema implementacjami systemu Avro (z języków Python i Java).
342
Rozdział 12. Avro
Określanie schematu Przy wczytywaniu danych można zastosować inny schemat (schemat odbiorcy) niż przy zapisie (schemat nadawcy). Jest to cenne narzędzie, ponieważ pozwala modyfikować schemat. Przyjrzyj się teraz nowemu schematowi par łańcuchów znaków, do którego dodano pole description. { "type": "record", "name": "StringPair", "doc": "Para łańcuchów znaków z dodatkowym polem.", "fields": [ {"name": "left", "type": "string"}, {"name": "right", "type": "string"}, {"name": "description", "type": "string", "default": ""} ] }
Ten schemat można wykorzystać do wczytania zserializowanych wcześniej danych, ponieważ — co niezwykle ważne — dla pola description ustawiono wartość domyślną (pusty łańcuch znaków)4, używaną przez system Avro, gdy wczytywane rekordy nie obejmują danego pola. Pominięcie atrybutu default spowodowałoby błąd przy próbie odczytu danych w starszym formacie. Aby jako wartości domyślne zastosować null zamiast pustego łańcucha znaków, można zdefiniować pole description, używając sumy z typem null systemu Avro. {"name": "description", "type": ["null", "string"], "default": null}
Gdy schemat odbiorcy różni się od schematu nadawcy, należy zastosować konstruktor klasy Generic DatumReader przyjmujący dwa obiekty schematu (w kolejności schemat nadawcy – schemat odbiorcy). DatumReader reader = new GenericDatumReader(schema, newSchema); Decoder decoder = DecoderFactory.get().binaryDecoder(out.toByteArray(), null); GenericRecord result = reader.read(null, decoder); assertThat(result.get("left").toString(), is("L")); assertThat(result.get("right").toString(), is("R")); assertThat(result.get("description").toString(), is(""));
Gdy w pliku danych w metadanych znajduje się schemat nadawcy, wystarczy jawnie wskazać schemat odbiorcy. W tym celu można przekazać null jako schemat nadawcy. DatumReader reader = new GenericDatumReader(null, newSchema);
Nowy schemat odczytu często stosuje się też do pomijania pól z rekordu (jest to mechanizm projekcji). Jest to przydatne, gdy rekord zawiera dużą liczbę pól, a program ma wczytywać tylko wybrane z nich. Poniższy schemat można wykorzystać do pobrania z obiektu typu StringPair tylko pola right. { "type": "record", "name": "StringPair", "doc": "Prawe pole z pary łańcuchów znaków.", 4
Wartości domyślne pól są kodowane w formacie JSON. Specyfikację tego kodowania dla poszczególnych typów danych znajdziesz w specyfikacji systemu Avro.
Określanie schematu
343
"fields": [ {"name": "right", "type": "string"} ] }
Reguły określania schematu mają bezpośredni wpływ na możliwość modyfikowania schematów między wersjami. Opis tych reguł dla wszystkich typów znajdziesz w specyfikacji systemu Avro. Przegląd reguł modyfikowania rekordów z perspektywy nadawcy i odbiorcy (lub serwerów i klientów) przedstawia tabela 12.4. Tabela 12.4. Określanie schematów z rekordami Nowy schemat
Nadawca
Odbiorca
Skutki
Dodano pole
Dawny
Nowy
Odbiorca używa wartości domyślnej nowego pola, ponieważ nie została ona zapisana przez nadawcę.
Nowy
Dawny
Odbiorca nie zna nowego pola zapisanego przez nadawcę, dlatego je pomija (projekcja).
Dawny
Nowy
Odbiorca ignoruje usunięte pole (projekcja).
Nowy
Dawny
Usunięte pole nie jest zapisywane przez nadawcę. Jeśli w dawnym schemacie zdefiniowana była wartość domyślna tego pola, odbiorca ją wykorzystuje. W przeciwnym razie występuje błąd. Dlatego najlepiej jest zaktualizować schemat odbiorcy (albo przed zmianą schematu po stronie nadawcy, albo jednocześnie z nią).
Usunięto pole
Inna przydatna technika modyfikowania schematów systemu Avro polega na używaniu aliasów nazw. Aliasy pozwalają zastosować w schemacie używanym do odczytu inne nazwy niż w schemacie, który posłużył do zapisu danych. Poniższy schemat odbiorcy można wykorzystać do wczytania danych typu StringPair z wykorzystaniem nowych nazw (first i second) zamiast nazw użytych przy zapisie (left i right). { "type": "record", "name": "StringPair", "doc": "Para łańcuchów znaków z nowymi nazwami pól.", "fields": [ {"name": "first", "type": "string", "aliases": ["left"]}, {"name": "second", "type": "string", "aliases": ["right"]} ] }
Zauważ, że aliasy są używane do przekształcania (w trakcie wczytywania danych) schematu nadawcy na schemat odbiorcy. Pierwotne nazwy nie są wtedy dostępne odbiorcy. W tym przykładzie odbiorca nie może używać nazw left i right, ponieważ już zostały one przekształcone na nazwy first i second.
Porządek sortowania System Avro wyznacza porządek sortowania obiektów. Dla większości typów używany jest naturalny porządek. Na przykład typy liczbowe są sortowane rosnąco według wartości. Jednak dla niektórych typów stosowane są bardziej skomplikowane techniki. Na przykład wyliczenia są porównywane według kolejności występowania symboli w definicji, a nie na podstawie wartości tych symboli. 344
Rozdział 12. Avro
Wszystkie typy z wyjątkiem typu record mają wbudowane reguły sortowania. Te zasady są opisane w specyfikacji systemu Avro, a użytkownicy nie mogą ich zmieniać. W rekordach porządek sortowania określa się za pomocą atrybutów order pól. Ten atrybut przyjmuje trzy wartości: ascending (porządek domyślny), descending (porządek odwrotny do domyślnego) i ignore (pole jest pomijane przy porównaniach). Na przykład w poniższym schemacie (SortedStringPair.avsc) zdefiniowane jest porządkowanie rekordów typu StringPair malejąco według pola right. Pole left przy porządkowaniu jest pomijane, ale pozostaje dostępne po projekcji. { "type": "record", "name": "StringPair", "doc": "Para łańcuchów znaków sortowana malejąco według pola right.", "fields": [ {"name": "left", "type": "string", "order": "ignore"}, {"name": "right", "type": "string", "order": "descending"} ] }
Pola rekordów są porównywane parami w kolejności wyznaczanej przez schemat odbiorcy. Dlatego za pomocą odpowiedniego schematu odbiorcy można wymusić dowolne uporządkowanie rekordów z danymi. Poniższy schemat (SwitchedStringPair.avsc) definiuje sortowanie, w którym najpierw uwzględniane jest pole right, a następnie left. { "type": "record", "name": "StringPair", "doc": "Para łańcuchów znaków sortowana wg pola right i potem wg pola left.", "fields": [ {"name": "right", "type": "string"}, {"name": "left", "type": "string"} ] }
System Avro udostępnia też wydajny mechanizm porównywania binarnego. Oznacza to, że Avro nie musi deserializować danych binarnych do postaci obiektów, aby przeprowadzić porównanie. Zamiast tego może bezpośrednio pracować na strumieniach bajtów5. Na przykład dla pierwotnego schematu StringPair (bez atrybutów order) system Avro przeprowadza porównywanie binarne w opisany poniżej sposób. Pierwsze pole, left, to łańcuch znaków UTF-8, dla którego Avro może porównywać bajty leksykograficznie. Jeśli bajty są różne, kolejność zostaje ustalona, a system Avro może zakończyć porównywanie. Jeżeli obie sekwencje bajtów są identyczne, następuje porównanie drugich pól (right). Także to porównanie odbywa się leksykograficznie na poziomie bajtów, ponieważ pola right również są łańcuchami znaków UTF-8. Zauważ, że działanie funkcji porównującej jest identyczne z pracą mechanizmu porównywania napisanego dla obiektów typu Writable w punkcie „Implementowanie interfejsu RawComparator z myślą o szybkości” w rozdziale 5. Dużą zaletą jest to, że system Avro udostępnia gotowy mechanizm 5
Wygodnym skutkiem tego rozwiązania jest to, że można wyznaczać skrót dla jednostki danych z systemu Avro zarówno dla obiektów, jak i na podstawie reprezentacji binarnej (przy użyciu statycznej metody hashCode() klasy BinaryData) i w obu sytuacjach uzyskać ten sam wynik.
Porządek sortowania
345
porównywania, dzięki czemu nie trzeba pisać ani konserwować jego kodu. Ponadto można łatwo zmienić porządek sortowania, modyfikując schemat odbiorcy. Dla schematów SortedStringPair.avsc i SwitchedStringPair.avsc mechanizm porównywania używany przez system Avro działa tak, jak opisano. Różnice dotyczą tylko uwzględnianych pól, kolejności ich sprawdzania i porządku sortowania (rosnąco lub malejąco). Dalej w rozdziale mechanizm sortowania z systemu Avro jest używany razem z modelem MapReduce do równoległego sortowania plików danych systemu Avro.
Avro i model MapReduce Avro udostępnia liczne klasy, które umożliwiają łatwe wykonywanie programów w modelu MapReduce dla danych z systemu Avro. Tu używane są klasy nowego interfejsu API modelu MapReduce, dostępne w pakiecie org.apache.avro.mapreduce. Pakiet org.apache.avro.mapred zawiera klasy z dawnego interfejsu API tego modelu. Poniżej pokazano, jak zmodyfikować program w modelu MapReduce służący do wyszukiwania w zbiorze danych meteorologicznych maksymalnej temperatury z każdego roku. Tym razem użyty zostanie interfejs API modelu MapReduce i system Avro. Do reprezentowania rekordów z danymi meteorologicznymi posłuży następujący schemat: { "type": "record", "name": "WeatherRecord", "doc": "Dane meteorologiczne.", "fields": [ {"name": "year", "type": "int"}, {"name": "temperature", "type": "int"}, {"name": "stationId", "type": "string"} ] }
Program z listingu 12.2 wczytuje wejściowe dane tekstowe (w formacie przedstawionym we wcześniejszych rozdziałach) i jako dane wyjściowe generuje pliki danych systemu Avro z rekordami z danymi meteorologicznymi. Listing 12.2. Program w modelu MapReduce wyszukujący maksymalną temperaturę i generujący dane wyjściowe w formacie z systemu Avro public class AvroGenericMaxTemperature extends Configured implements Tool { private static final Schema SCHEMA = new Schema.Parser().parse( "{" + " \"type\": \"record\"," + " \"name\": \"WeatherRecord\"," + " \"doc\": \"Odczyt pogody.\"," + " \"fields\": [" + " {\"name\": \"year\", \"type\": \"int\"}," + " {\"name\": \"temperature\", \"type\": \"int\"}," + " {\"name\": \"stationId\", \"type\": \"string\"}" + " ]" + "}" );
346
Rozdział 12. Avro
public static class MaxTemperatureMapper extends Mapper { private NcdcRecordParser parser = new NcdcRecordParser(); private GenericRecord record = new GenericData.Record(SCHEMA); @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { parser.parse(value.toString()); if (parser.isValidTemperature()) { record.put("year", parser.getYearInt()); record.put("temperature", parser.getAirTemperature()); record.put("stationId", parser.getStationId()); context.write(new AvroKey(parser.getYearInt()), new AvroValue(record)); } } } public static class MaxTemperatureReducer extends Reducer {
}
@Override protected void reduce(AvroKey key, Iterable values, Context context) throws IOException, InterruptedException { GenericRecord max = null; for (AvroValue value : values) { GenericRecord record = value.datum(); if (max == null || (Integer) record.get("temperature") > (Integer) max.get("temperature")) { max = newWeatherRecord(record); } } context.write(new AvroKey(max), NullWritable.get()); } private GenericRecord newWeatherRecord(GenericRecord value) { GenericRecord record = new GenericData.Record(SCHEMA); record.put("year", value.get("year")); record.put("temperature", value.get("temperature")); record.put("stationId", value.get("stationId")); return record; }
@Override public int run(String[] args) throws Exception { if (args.length != 2) { System.err.printf("Użytkowanie: %s [opcje] \n", getClass().getSimpleName()); ToolRunner.printGenericCommandUsage(System.err); return -1; } Job job = new Job(getConf(), "Max temperature"); job.setJarByClass(getClass()); job.getConfiguration().setBoolean( Job.MAPREDUCE_JOB_USER_CLASSPATH_FIRST, true); FileInputFormat.addInputPath(job, new Path(args[0])); FileOutputFormat.setOutputPath(job, new Path(args[1]));
Avro i model MapReduce
347
AvroJob.setMapOutputKeySchema(job, Schema.create(Schema.Type.INT)); AvroJob.setMapOutputValueSchema(job, SCHEMA); AvroJob.setOutputKeySchema(job, SCHEMA); job.setInputFormatClass(TextInputFormat.class); job.setOutputFormatClass(AvroKeyOutputFormat.class); job.setMapperClass(MaxTemperatureMapper.class); job.setReducerClass(MaxTemperatureReducer.class); return job.waitForCompletion(true) ? 0 : 1; } public static void main(String[] args) throws Exception { int exitCode = ToolRunner.run(new AvroGenericMaxTemperature(), args); System.exit(exitCode); } }
W tym programie wykorzystano reprezentację generyczną z systemu Avro. Zwalnia to programistę z konieczności generowania kodu do reprezentowania rekordów, przy czym dzieje się to kosztem bezpieczeństwa ze względu na typ (nazwy pól są określane za pomocą łańcuchów znaków, na przykład temperature)6. Schemat rekordów z danymi meteorologicznymi jest dla wygody podany wewnątrzwierszowo w kodzie (i wczytywany do stałej SCHEMA). W praktyce łatwiejszym ze względu na konserwację rozwiązaniem jest wczytywanie schematu z pliku lokalnego w kodzie sterownika. Następnie schemat można przekazać do mappera i reduktora za pomocą konfiguracji zadania Hadoopa. Używane do tego techniki opisano w punkcie „Rozdzielanie danych pomocniczych” w rozdziale 9. W Avro występuje kilka różnic w porównaniu ze standardowym interfejsem API modelu MapReduce z Hadoopa. Pierwsza dotyczy nakładek na typy Javy związane z systemem Avro. W przedstawionym programie w modelu MapReduce kluczem jest rok (liczba całkowita), a wartością — rekord z danymi meteorologicznymi reprezentowany za pomocą typu GenericRecord z systemu Avro. W danych wyjściowych z etapu mapowania (i danych wejściowych na etapie redukcji) odpowiadają im typy AvroKey (typ klucza) i AvroValue (typ wartości). Klasa MaxTemperatureReducer przechodzi po rekordach powiązanych z każdym kluczem (rokiem) i wyszukuje rekord z maksymalną temperaturą. Konieczne jest skopiowanie rekordu o najwyższej dotychczas znalezionej temperaturze, ponieważ iterator w celu zwiększenia wydajności ponownie wykorzystuje obiekt (i tylko aktualizuje jego pola). Druga ważna różnica w porównaniu ze standardowym modelem MapReduce polega na użyciu klasy AvroJob do skonfigurowania zadania. AvroJob to klasa pomocnicza służąca do określania schematów systemu Avro dla danych wejściowych, danych wyjściowych z etapu mapowania i ostatecznych danych wyjściowych. W tym programie schemat danych wejściowych nie jest ustawiany, ponieważ kod wczytuje zawartość pliku tekstowego. Jako schemat klucza danych wyjściowych z etapu mapowania używany jest typ int systemu Avro, a schemat wartości to rekord z danymi meteorologicznymi. Schemat klucza ostatecznych danych wyjściowych to rekord z danymi meteorologicznymi, a format danych wyjściowych określa klasa AvroKeyOutputFormat, która zapisuje klucze do plików danych systemu Avro i ignoruje przy tym wartości (typu NullWritable). 6
W przykładowym kodzie znajduje się klasa AvroSpecificMaxTemperature ilustrująca wykorzystanie reprezentacji specyficznej z wygenerowanymi klasami.
348
Rozdział 12. Avro
Poniższe polecenie uruchamia przedstawiony program dla małego zbioru przykładowych danych. % export HADOOP_CLASSPATH=avro-examples.jar % export HADOOP_USER_CLASSPATH_FIRST=true # Użycie bibliotek z Avro w Hadoopie % hadoop jar avro-examples.jar AvroGenericMaxTemperature \ input/ncdc/sample.txt output
Po zakończeniu pracy programu można zapoznać się z danymi wyjściowymi za pomocą pliku JAR z narzędziami systemu Avro i wyświetlić plik danych systemu Avro przy użyciu formatu JSON po jednym rekordzie na wiersz. % java -jar $AVRO_HOME/avro-tools-*.jar tojson output/part-r-00000.avro {"year":1949,"temperature":111,"stationId":"012650-99999"} {"year":1950,"temperature":22,"stationId":"011990-99999"}
Ten program wczytuje plik tekstowy i zwraca plik danych systemu Avro. Możliwe są też inne kombinacje, co pozwala przekształcać dane z formatów systemu Avro na inne formaty (na przykład na pliki typu SequenceFile). Szczegółowe informacje znajdziesz w dokumentacji pakietu związanego z używaniem modelu MapReduce w systemie Avro.
Sortowanie za pomocą modelu MapReduce i systemu Avro W tym podrozdziale zobaczysz, jak wykorzystać mechanizmy sortowania z systemu Avro i połączyć je z modelem MapReduce w celu utworzenia programu sortującego plik danych systemu Avro (listing 12.3). Listing 12.3. Program w modelu MapReduce sortujący plik danych systemu Avro public class AvroSort extends Configured implements Tool { static class SortMapper extends Mapper { @Override protected void map(AvroKey key, NullWritable value, Context context) throws IOException, InterruptedException { context.write(key, new AvroValue(key.datum())); } } static class SortReducer extends Reducer { @Override protected void reduce(AvroKey key, Iterable values, Context context) throws IOException, InterruptedException { for (AvroValue value : values) { context.write(new AvroKey(value.datum()), NullWritable.get()); } } } @Override public int run(String[] args) throws Exception { if (args.length != 3) { System.err.printf( "Użytkowanie: %s [opcje] \n", getClass().getSimpleName()); ToolRunner.printGenericCommandUsage(System.err); return -1;
Sortowanie za pomocą modelu MapReduce i systemu Avro
349
} String input = args[0]; String output = args[1]; String schemaFile = args[2]; Job job = new Job(getConf(), "Avro sort"); job.setJarByClass(getClass()); job.getConfiguration().setBoolean( Job.MAPREDUCE_JOB_USER_CLASSPATH_FIRST, true); FileInputFormat.addInputPath(job, new Path(input)); FileOutputFormat.setOutputPath(job, new Path(output)); AvroJob.setDataModelClass(job, GenericData.class); Schema schema = new Schema.Parser().parse(new File(schemaFile)); AvroJob.setInputKeySchema(job, schema); AvroJob.setMapOutputKeySchema(job, schema); AvroJob.setMapOutputValueSchema(job, schema); AvroJob.setOutputKeySchema(job, schema); job.setInputFormatClass(AvroKeyInputFormat.class); job.setOutputFormatClass(AvroKeyOutputFormat.class); job.setOutputKeyClass(AvroKey.class); job.setOutputValueClass(NullWritable.class); job.setMapperClass(SortMapper.class); job.setReducerClass(SortReducer.class); return job.waitForCompletion(true) ? 0 : 1; } public static void main(String[] args) throws Exception { int exitCode = ToolRunner.run(new AvroSort(), args); System.exit(exitCode); } }
Ten program używa reprezentacji generycznej z systemu Avro, dlatego nie wymaga generowania kodu. Program potrafi sortować rekordy systemu Avro dowolnego typu, reprezentowane w Javie za pomocą parametru K dla typu generycznego. Wartość jest tu taka sama jak klucz, dlatego po pogrupowaniu wartości według klucza można zwrócić wszystkie wartości w sytuacji, gdy więcej niż jedna z nich ma ten sam klucz (według funkcji sortującej). To chroni przed utratą rekordów7. Mapper jedynie zwraca klucz wejściowy umieszczony w obiektach typów AvroKey oraz AvroValue. Reduktor działa jak funkcja tożsamościowa i przekazuje wartości jako klucze danych wyjściowych. Są one zapisywane w pliku danych systemu Avro. Sortowanie odbywa się w fazie przestawiania modelu MapReduce. Funkcja sortująca jest określana na podstawie przekazanego do programu schematu systemu Avro. Przedstawiony program można wykorzystać do posortowania utworzonego wcześniej pliku pairs.avro i zastosować schemat 7
Użycie tożsamościowego mappera i tożsamościowego reduktora spowoduje jednoczesne sortowanie kluczy i usuwanie ich duplikatów. Zagadnienie duplikacji informacji z klucza w wartości opisano też w punkcie „Sortowanie pomocnicze” w rozdziale 9.
350
Rozdział 12. Avro
SortedStringPair.avsc do sortowania danych według pola right w porządku malejącym. Najpierw zbadaj dane wyjściowe za pomocą pliku JAR z narzędziami systemu Avro. % java -jar $AVRO_HOME/avro-tools-*.jar tojson input/avro/pairs.avro {"left":"a","right":"1"} {"left":"c","right":"2"} {"left":"b","right":"3"} {"left":"b","right":"2"}
Następnie można uruchomić sortowanie. % hadoop jar avro-examples.jar AvroSort input/avro/pairs.avro output \ ch12-avro/src/main/resources/SortedStringPair.avsc
W ostatnim kroku należy sprawdzić, czy dane wyjściowe są prawidłowo posortowane. % java -jar $AVRO_HOME/avro-tools-*.jar tojson output/part-r-00000.avro {"left":"b","right":"3"} {"left":"b","right":"2"} {"left":"c","right":"2"} {"left":"a","right":"1"}
Używanie systemu Avro w innych językach W językach i platformach innych niż Java danych systemu Avro można używać na kilka sposobów. Klasa AvroAsTextInputFormat umożliwia programom w technologii Hadoop Streaming odczyt plików danych systemu Avro. Każda jednostka danych z pliku jest przekształcana na łańcuch znaków (reprezentację jednostki danych w formacie JSON) lub — jeśli typ danych to bytes z systemu Avro — na surowe bajty. Na potrzeby odwrotnej operacji można ustawić klasę AvroTextOutputFormat jako format wyjściowy zadania z technologii Streaming, aby uzyskać plik danych systemu Avro o schemacie bytes, gdzie każda jednostka danych to rozdzielona tabulacjami para klucz-wartość otrzymana w danych wyjściowych z technologii Streaming. Obie wymienione klasy należą do pakietu org.apache.avro.mapred. Do przetwarzania danych systemu Avro można też zastosować inne platformy, takie jak Pig, Hive, Crunch i Spark. Wszystkie wymienione platformy potrafią wczytywać i zapisywać pliki danych systemu Avro za pomocą odpowiednich formatów. Szczegółowe informacje znajdziesz w rozdziałach książki poświęconych tym platformom.
Używanie systemu Avro w innych językach
351
352
Rozdział 12. Avro
ROZDZIAŁ 13.
Parquet
Apache Parquet (http://parquet.apache.org/) to kolumnowy format przechowywania danych umożliwiający wydajne zapisywanie danych zagnieżdżonych. Formaty kolumnowe są atrakcyjne, ponieważ pozwalają uzyskać wyższą wydajność zarówno ze względu na wielkość plików, jak i szybkość przetwarzania zapytań. Wielkość plików jest zwykle mniejsza niż w formatach wierszowych, ponieważ w formacie kolumnowym wartości z jednej kolumny są zapisywane obok siebie, co zwykle pozwala na bardzo wydajne kodowanie danych. Na przykład dla kolumny ze znacznikiem czasu można zapisać pierwszą wartość, a następnie same różnice między kolejnymi wartościami (które zwykle są niewielkie dzięki temu, że rekordy z podobnego czasu przeważnie są zapisywane obok siebie). Poprawia się też szybkość przetwarzania zapytań, ponieważ używany do tego silnik może pominąć zbędne kolumny (ilustruje to rysunek 5.4). W tym rozdziale szczegółowo opisany jest format Parquet, jednak istnieją też inne formaty kolumnowe współdziałające z Hadoopem. Ważnym z nich jest ORCFile (ang. Optimized Record Columnar File), będący częścią projektu Hive. Główną zaletą formatu Parquet jest możliwość przechowywania danych o wysoce zagnieżdżonej strukturze w sposób czysto kolumnowy. Jest to ważne, ponieważ w praktyce często występują schematy o kilku poziomach zagnieżdżenia. W formacie Parquet zastosowano nowatorską technikę przechowywania zagnieżdżonych struktur w płaskim formacie kolumnowym przy niskich kosztach dodatkowych. Technika została przedstawiona przez inżynierów z firmy Google w pracy poświęconej systemowi Dremel1. Za pomocą formatu Parquet nawet zagnieżdżone pola można wczytywać niezależnie od pozostałych pól, co pozwala na znaczną poprawę wydajności. Inną cechą formatu Parquet jest duża liczba współdziałających z nim narzędzi. Inżynierowie z firm Twitter i Cloudera, którzy opracowali ten format, chcieli, aby pozwalał on na łatwe wypróbowywanie nowych narzędzi do przetwarzania istniejących danych. Dlatego podzielili projekt na specyfikację (parquet-format), w której format plików jest zdefiniowany w sposób niezależny od języków, i implementacje tej specyfikacji przeznaczone dla różnych języków (Java i C++), które umożliwiają narzędziom łatwy odczyt i zapis plików w omawianym formacie. Większość z opisywanych w książce komponentów do przetwarzania danych obsługuje format Parquet. Dotyczy to na przykład technologii MapReduce, Pig, Hive, Cascading, Crunch i Spark. Ta wszechstronność rozciąga się też na 1
Sergey Melnik i inni, „Dremel: Interactive Analysis of Web-Scale Datasets”, Proceedings of the 36th International Conference on Very Large Data Bases, 2010 (http://research.google.com/pubs/pub36632.html).
353
reprezentację danych w pamięci. Implementacja w Javie nie jest ograniczona do jednej reprezentacji, dlatego przy odczycie i zapisie plików w formacie Parquet można stosować w pamięci modele danych z technologii Avro, Thrift lub Protocol Buffers.
Model danych W formacie Parquet zdefiniowanych jest kilka typów prostych. Ich listę przedstawia tabela 13.1. Tabela 13.1. Typy proste z formatu Parquet Typ
Opis
boolean
Wartość binarna
int32
32-bitowa liczba całkowita ze znakiem
int64
64-bitowa liczba całkowita ze znakiem
int96
96-bitowa liczba całkowita ze znakiem
float
Liczba zmiennoprzecinkowa pojedynczej precyzji (32-bitowa) ze standardu IEEE 754
double
Liczba zmiennoprzecinkowa pojedynczej precyzji (64-bitowa) ze standardu IEEE 754
binary
Sekwencja 8-bitowych bajtów bez znaku
fixed_len_byte_array
Stała liczba 8-bitowych bajtów bez znaku
Dane przechowywane w pliku w formacie Parquet są opisywane za pomocą schematu, którego element główny to message (obejmuje on grupę pól). Każde pole ma określony sposób występowania (required, optional lub repeated), typ i nazwę. Oto prosty schemat w formacie Parquet dla rekordu z danymi meteorologicznymi: message WeatherRecord { required int32 year; required int32 temperature; required binary stationId (UTF8); }
Zauważ, że nie istnieje tu typ prosty dla łańcuchów znaków. Zamiast tego w formacie Parquet zdefiniowane są typy logiczne, określające sposób interpretacji typów prostych. Następuje więc rozdział między zserializowaną reprezentacją (typ prosty) a działaniem aplikacji (typ logiczny). Łańcuchy znaków są reprezentowane za pomocą typu prostego binary z adnotacją UTF8. Niektóre typy logiczne zdefiniowane w formacie Parquet wymieniono w tabeli 13.2 razem z reprezentatywnymi przykładowymi schematami. W tabeli nie opisano m.in. typów logicznych dla liczb całkowitych ze znakiem, liczb całkowitych bez znaku, kilku typów dla dat i czasu, a także typów powiązanych z dokumentami w formatach JSON i BSON. Szczegółowe informacje znajdziesz w specyfikacji formatu Parquet. Typy złożone w formacie Parquet tworzy się za pomocą typu group, który pozwala dodać następny poziom zagnieżdżenia2. Grupa bez adnotacji oznacza zagnieżdżony rekord.
2
To rozwiązanie jest oparte na modelu z technologii Protocol Buffers (https://developers.google.com/protocol-buffers/), w którym do definiowania typów złożonych (takich jak listy i odwzorowania) używane są grupy.
354
Rozdział 13. Parquet
Tabela 13.2. Typy logiczne z formatu Parquet Adnotacja typu logicznego
Opis
Przykładowy schemat
UTF8
Łańcuch znaków UTF-8. Adnotacja dla typu binary.
message m { required binary a (UTF8); }
ENUM
Zbiór nazwanych wartości. Adnotacja dla typu binary.
message m { required binary a (ENUM); }
DECIMAL (precyzja,skala)
DATE
LIST
Liczby dziesiętne dowolnej precyzji ze znakiem. Adnotacja dla typów int32, int64, binary lub fixed_len_byte_array.
message m {
Data bez czasu. Adnotacja dla typu int32. Typ reprezentowany przez liczbę dni od początku epoki Uniksa (1 stycznia 1970 roku).
message m {
Uporządkowana kolekcja wartości. Adnotacja dla typu group.
message m {
required int32 a (DECIMAL(5,2)); } required int32 a (DATE); } required group a (LIST) { repeated group list { required int32 element; } } }
MAP
Nieuporządkowana kolekcja par klucz-wartość. Adnotacja dla typu group.
message m { required group a (MAP) { repeated group key_value { required binary key (UTF8); optional int32 value; } } }
Listy i odwzorowania są tworzone za pomocą grup o określonej, dwupoziomowej strukturze, co pokazano w tabeli 13.2. Lista jest reprezentowana jako grupa z adnotacją LIST z zagnieżdżoną powtarzającą się grupą (o nazwie list), która zawiera pole z elementem. W przykładzie dla listy 32-bitowych liczb całkowitych używane jest wymagane pole z elementem typu int32. W odwzorowaniach zewnętrzna grupa (z adnotacją MAP) obejmuje wewnętrzną powtarzającą się grupę key_value, która zawiera pola klucza i wartości. W przykładzie wartości są oznaczone jako opcjonalne (optional), dlatego w odwzorowaniu mogą występować wartości null.
Kodowanie struktury zagnieżdżonych danych W formacie kolumnowym wartości z poszczególnych kolumn są przechowywane razem. W płaskiej tabeli (bez zagnieżdżania i powtórzeń), takiej jak dla schematu z rekordami z danymi meteorologicznymi, sytuacja jest prosta, ponieważ każda kolumna zawiera tę samą liczbę wartości. Dlatego można łatwo ustalić, do którego wiersza należy każda wartość. Model danych
355
Jednak gdy zagnieżdżanie lub powtórzenia są możliwe (na przykład w schemacie odwzorowań), zadanie staje się trudniejsze, ponieważ trzeba zakodować także sposób zagnieżdżania. Niektóre formaty kolumnowe pozwalają uniknąć problemu, ponieważ spłaszczają strukturę. W efekcie w sposób kolumnowy są przechowywane tylko kolumny z najwyższego poziomu (to podejście zastosowano na przykład w formacie RCFile z platformy Hive). Odwzorowanie z zagnieżdżonymi kolumnami zapisywane byłoby wtedy z przeplatającymi się kluczami i wartościami. Dlatego wczytanie na przykład samych kluczy bez wczytywania do pamięci także wartości byłoby niemożliwe. W formacie Parquet używane jest kodowanie z systemu Dremel, gdzie każde pole typu prostego ze schematu jest zapisywane w odrębnej kolumnie, a dla każdej wartości struktura jest kodowana za pomocą dwóch liczb całkowitych — poziomu definicji i poziomu powtórzeń. Szczegóły tego kodowania są skomplikowane3. Sposób przechowywania poziomów definicji i powtórzeń możesz traktować jak uogólnioną postać używania pola bitowego do kodowania wartości null w płaskim rekordzie, gdy wartości różne od null są zapisywane jedna po drugiej. W opisanym kodowaniu każdą kolumnę (także zagnieżdżoną) można odczytać niezależnie od pozostałych. Na przykład w odwzorowaniu w formacie Parquet klucze można wczytywać bez uzyskiwania dostępu do wartości, co pozwala uzyskać znaczną poprawę wydajności (zwłaszcza wtedy, gdy wartości są duże — na przykład są zagnieżdżonymi rekordami o wielu polach).
Format plików Parquet Plik w formacie Parquet składa się z nagłówka, po którym następują bloki i stopka. Nagłówek zawiera tylko 4-bajtową „magiczną liczbę”, PAR1, która informuje, że plik jest w formacie Parquet. Wszystkie metadane pliku są zapisane w stopce. Metadane obejmują wersję formatu, schemat, dodatkowe pary klucz-wartość i metadane dla każdego bloku z pliku. Dwa ostatnie pola ze stopki to 4-bajtowe pole z zakodowaną długością metadanych i ponowne wystąpienie „magicznej liczby” PAR1. Ponieważ metadane są zapisane w stopce, przy odczycie pliku w formacie Parquet najpierw trzeba przejść do miejsca osiem bajtów przed końcem pliku, aby wczytać długość metadanych ze stopki, a następnie cofnąć się o tę długość w celu rozpoczęcia odczytu metadanych. W plikach w formacie Parquet (inaczej niż w plikach typu SequenceFile i plikach danych z systemu Avro, gdzie metadane są przechowywane w nagłówku, a do rozdzielania bloków służą znaczniki synchronizacji) nie są potrzebne znaczniki synchronizacji, ponieważ granice bloków są opisane w metadanych. Jest to możliwe, ponieważ metadane są zapisywane po zakończeniu zapisu wszystkich bloków. Dzięki temu do momentu zamknięcia pliku można zachować w pamięci lokalizacje granic bloków. Pliki w formacie Parquet można więc dzielić, ponieważ bloki da się zlokalizować po wczytaniu stopki. Później można przetwarzać bloki równolegle (na przykład w modelu MapReduce). Każdy blok w pliku w formacie Parquet przechowuje grupę wierszy, składającą się z porcji kolumn, które zawierają dane z odpowiednich kolumn tych wierszy. Dane każdej porcji kolumn są zapisywane na stronach. Ilustruje to rysunek 13.1.
3
Julien Le Dem napisał doskonały artykuł na ten temat (https://blog.twitter.com/2013/dremel-made-simple-with-parquet).
356
Rozdział 13. Parquet
Rysunek 13.1. Wewnętrzna struktura pliku w formacie Parquet
Każda strona zawiera wartości z tej samej kolumny, a ponieważ takie wartości zwykle są podobne do siebie, dlatego strona bardzo dobrze nadaje się do kompresji. Pierwszy poziom kompresji można osiągnąć dzięki odpowiedniemu zakodowaniu wartości. Najprostsze kodowanie (kodowanie zwykłe) polega na zapisywaniu całych wartości (na przykład wartość typu int32 jest zapisywana za pomocą 4-bajtowej reprezentacji w formacie little endian), co jednak samo w sobie nie zapewnia żadnej kompresji. Format Parquet obsługuje też bardziej zwięzłe metody kodowania — w tym kodowanie delta (polega na zapisywaniu różnic między wartościami), kodowanie długości serii (gdzie serie identycznych wartości są zapisywane jako jedna wartość i liczba jej powtórzeń) i kodowanie słownikowe (budowany i kodowany jest słownik wartości, a następnie same wartości są kodowane jako liczby całkowite reprezentujące indeksy ze słownika). W większości sytuacji stosowane są też techniki takie jak pakowanie bitów, co pozwala zaoszczędzić miejsce przez zapisanie kilku małych wartości w jednym bajcie. Przy zapisie plików w formacie Parquet odpowiednie kodowanie jest wybierane automatycznie na podstawie typu kolumny. Na przykład wartości logiczne są zapisywane przy użyciu kodowania długości serii i z pakowaniem bitów. Dla większości typów domyślnie używa się kodowania słownikowego. Jednak gdy słownik staje się za duży, jako rozwiązanie rezerwowe stosowane jest kodowanie zwykłe. Progowa wielkość słownika (wielkość strony słownika) domyślnie jest równa wielkości strony. Oznacza to, że słownik musi mieścić się na jednej stronie. Zauważ, że używane kodowanie jest zapisane w metadanych plików. Daje to gwarancję, że odbiorca posłuży się odpowiednim kodowaniem. Oprócz kodowania można dodać drugi poziom kompresji, stosując standardowy algorytm kompresji do bajtów zakodowanej strony. Domyślnie kompresja na tym poziomie nie jest stosowana, jednak obsługiwane są algorytmy Snappy, gzip i LZO. Dla danych zagnieżdżonych każda strona przechowuje też poziomy definicji i powtórzeń dla wszystkich wartości. Ponieważ poziomy to małe liczby całkowite (maksymalna wartość zależy od liczby zagnieżdżonych poziomów w schemacie), można je bardzo wydajnie zapisać za pomocą kodowania długości serii z pakowaniem bitów.
Format plików Parquet
357
Konfiguracja dla formatu Parquet Właściwości plików w formacie Parquet są zapisywane w czasie zapisu danych. Właściwości wymienione w tabeli 13.3 są odpowiednie, jeśli tworzysz pliki w formacie Parquet za pomocą technologii MapReduce (z wykorzystaniem formatów omówionych w punkcie „Format Parquet i model MapReduce”), Crunch, Pig lub Hive. Tabela 13.3. Właściwości klasy ParquetOutputFormat Nazwa właściwości
Typ
Wartość domyślna
Opis
parquet.block.size
int
134217728 (128 MB)
Wielkość bloku (grupy wierszy) w bajtach.
parquet.page.size
int
1048576 (1 MB)
Wielkość strony w bajtach.
parquet.dictionary. page.size
int
1048576 (1 MB)
Maksymalna wielkość słownika, której przekroczenie powoduje użycie dla strony kodowania zwykłego.
parquet.enable. dictionary
boolean
true
Określa, czy stosować kodowanie słownikowe.
parquet.compression
String
UNCOMPRESSED
Typ kompresji używany dla plików w formacie Parquet: UNCOMPRESSED, SNAPPY, GZIP lub LZO. Używana zamiast właściwości mapreduce.output.fileoutputformat. compress.
Przy określaniu wielkości bloku należy zadbać o równowagę między wydajnością skanowania a wykorzystaniem pamięci. Skanowanie większych bloków jest wydajniejsze, ponieważ zawierają one więcej wierszy, co przyspiesza sekwencyjne operacje wejścia-wyjścia (koszty przygotowywania każdej porcji kolumn są mniejsze). Jednak każdy blok przy odczycie i zapisie jest zapisywany w pamięci, co ogranicza wielkość bloków. Domyślnie wynosi ona 128 MB. Wielkość bloku pliku w formacie Parquet nie powinna być większa niż wielkość bloku w systemie HDFS. Dzięki temu każdy blok pliku można wczytać z jednego bloku systemu HDFS (a tym samym z jednego węzła danych). Często wielkości bloków obu tych rodzajów ustawia się na tę samą wartość. Domyślnie w obu przypadkach jest ona równa 128 MB. Strona to najmniejsza jednostka przechowywania danych w plikach w formacie Parquet. Dlatego pobranie dowolnego wiersza (na potrzeby omówienia niech będzie to wiersz z jedną kolumną) wymaga dekompresji i odkodowania strony z tym wierszem. Dlatego przy wyszukiwaniu pojedynczych stron wydajniejsze są mniejsze strony, ponieważ przed dojściem do docelowego elementu trzeba wczytać mniej wartości. Jednak mniejsze strony oznaczają wyższe koszty przechowywania i przetwarzania danych z powodu dodatkowych metadanych (pozycji, słowników) niezbędnych przy większej liczbie stron. Domyślna wielkość strony to 1 MB.
Zapis i odczyt plików w formacie Parquet Pliki w formacie Parquet zwykle są przetwarzane za pomocą wysokopoziomowych narzędzi (takich jak Pig, Hive lub Impala), jednak czasem potrzebny jest niskopoziomowy dostęp sekwencyjny, opisany w tym podrozdziale.
358
Rozdział 13. Parquet
W formacie Parquet używany jest konfigurowalny model danych przechowywanych w pamięci. Wspomaga to integrację formatu Parquet z różnymi narzędziami i komponentami. W Javie punktami integracji są interfejsy ReadSupport i WriteSupport. Ich implementacje przeprowadzają konwersję między obiektami używanymi przez narzędzie lub komponent oraz obiektami służącymi do reprezentowania każdego typu formatu Parquet w schemacie. W ramach demonstracji można wykorzystać prosty model przechowywanych w pamięci danych dostępny w projekcie Parquet w pakietach parquet.example.data i parquet.example.data.simple. W następnym podrozdziale zobaczysz, jak uzyskać ten sam efekt za pomocą reprezentacji z systemu Avro. Jak wskazują na to nazwy, przykładowe klasy z projektu Parquet tworzą model obiektowy demonstrujący, jak używać plików w formacie Parquet. W systemie produkcyjnym należy zastosować jedną z obsługiwanych platform (Avro, Protocol Buffers lub Thrift).
Aby zapisać plik w formacie Parquet, należy zdefiniować schemat reprezentowany za pomocą obiektu typu parquet.schema.MessageType: MessageType schema = MessageTypeParser.parseMessageType( "message Pair {\n" + " required binary left (UTF8);\n" + " required binary right (UTF8);\n" + "}");
Następnie należy utworzyć egzemplarz komunikatu Parqueta dla każdego zapisywanego w pliku rekordu. W pakiecie parquet.example.data komunikaty są reprezentowane jako obiekt typu Group tworzony za pomocą obiektu typu GroupFactory. GroupFactory groupFactory = new SimpleGroupFactory(schema); Group group = groupFactory.newGroup() .append("left", "L") .append("right", "R");
Warto wiedzieć, że wartości w komunikacie są typu logicznego UTF8, a klasa Group udostępnia naturalną konwersję obiektów typu String Javy na ten typ. Poniższy fragment kodu pokazuje, jak utworzyć plik w formacie Parquet i zapisać w nim komunikat. Metoda write() zwykle jest wywoływana w pętli, aby zapisać w pliku wiele komunikatów. Jednak ten fragment dodaje tylko jeden komunikat. Configuration conf = new Configuration(); Path path = new Path("data.parquet"); GroupWriteSupport writeSupport = new GroupWriteSupport(); GroupWriteSupport.setSchema(schema, conf); ParquetWriter writer = new ParquetWriter(path, writeSupport, ParquetWriter.DEFAULT_COMPRESSION_CODEC_NAME, ParquetWriter.DEFAULT_BLOCK_SIZE, ParquetWriter.DEFAULT_PAGE_SIZE, ParquetWriter.DEFAULT_PAGE_SIZE, /* Wielkość strony słownika */ ParquetWriter.DEFAULT_IS_DICTIONARY_ENABLED, ParquetWriter.DEFAULT_IS_VALIDATING_ENABLED, ParquetProperties.WriterVersion.PARQUET_1_0, conf); writer.write(group); writer.close();
Zapis i odczyt plików w formacie Parquet
359
Konstruktorowi klasy ParquetWriter trzeba przekazać obiekt typu WriteSupport, definiujący, jak przekształcać typ komunikatu na typy Parqueta. Tu dla komunikatów używany jest typ Group, dlatego należy zastosować obiekt typu GroupWriteSupport. Zauważ, że schemat Parqueta jest ustawiany w obiekcie typu Configuration w wyniku wywołania statycznej metody setSchema() klasy Group WriteSupport. Następnie obiekt typu Configuration ze schematem jest przekazywany do obiektu typu ParquetWriter. Ten przykład ilustruje też właściwości plików w formacie Parquet, odpowiadające właściwościom wymienionym w tabeli 13.3. Odczyt pliku w formacie Parquet jest łatwiejszy niż jego zapis, ponieważ nie trzeba podawać schematu (jest on zapisany w pliku). Można jednak ustawić schemat odczytu, aby za pomocą mechanizmu projekcji zwrócić podzbiór kolumn pliku. Nie trzeba ustawiać właściwości pliku, ponieważ są one określane w czasie zapisu. GroupReadSupport readSupport = new GroupReadSupport(); ParquetReader reader = new ParquetReader(path, readSupport);
Klasa ParquetReader udostępnia metodę read(), służącą do wczytywania następnego komunikatu. Po dojściu do końca pliku metoda zwraca null. Group result = reader.read(); assertNotNull(result); assertThat(result.getString("left", 0), is("L")); assertThat(result.getString("right", 0), is("R")); assertNull(reader.read());
Parametr 0 przekazany do metody getString() określa indeks pobieranego elementu, ponieważ pola mogą mieć powtarzające się wartości.
Avro, Protocol Buffers i Thrift W większości aplikacji wygodniej jest definiować modele za pomocą platformy takiej jak Avro, Protocol Buffers lub Thrift. Parquet to umożliwia. Wtedy zamiast klas ParquetWriter i ParquetReader należy zastosować klasę AvroParquetWriter, ProtoParquetWriter lub ThriftParquetWriter i powiązaną klasę odbiorcy. Te klasy odpowiadają za przekształcanie danych ze schematów platform Avro, Protocol Buffers lub Thrift na schematy Parqueta (a także wykonują potrzebne odwzorowania między typami platform i typami formatu Parquet). Oznacza to, że nie trzeba bezpośrednio przetwarzać schematów Parqueta. Oto pokazany wcześniej przykład, przy czym tu zastosowano generyczny interfejs API systemu Avro (tak jak w punkcie „Serializacja i deserializacja w pamięci” w rozdziale 12.). Oto schemat z systemu Avro: { "type": "record", "name": "StringPair", "doc": "Para łańcuchów znaków.", "fields": [ {"name": "left", "type": "string"}, {"name": "right", "type": "string"} ] }
360
Rozdział 13. Parquet
Poniższy kod tworzy obiekt typu Schema i używa go do uzyskania generycznego rekordu. Schema.Parser parser = new Schema.Parser(); Schema schema = parser.parse(getClass().getResourceAsStream("StringPair.avsc")); GenericRecord datum = new GenericData.Record(schema); datum.put("left", "L"); datum.put("right", "R");
Następnie można zapisać plik w formacie Parquet. Path path = new Path("data.parquet"); AvroParquetWriter writer = new AvroParquetWriter(path, schema); writer.write(datum); writer.close();
Klasa AvroParquetWriter przekształca schemat z systemu Avro na schemat Parqueta, a także przekształca każdy obiekt typu GenericRecord systemu Avro na odpowiednie typy Parqueta w celu zapisu danych w pliku w formacie Parquet. Używany jest tu standardowy plik w formacie Parquet, prawie identyczny z plikiem zapisanym wcześniej przy użyciu klas ParquetWriter i GroupWriterSupport. Różnicą są dodatkowe metadane przechowywane w schemacie z systemu Avro. Aby je przejrzeć, wyświetl metadane pliku za pomocą uruchamianych w wierszu poleceń narzędzi Parqueta4. % parquet-tools meta data.parquet ... extra: avro.schema = {"type":"record","name":"StringPair", ... ...
By wyświetlić schemat Parqueta wygenerowany na podstawie schematu z sytemu Avro, wywołaj następującą instrukcję: % parquet-tools schema data.parquet message StringPair { required binary left (UTF8); required binary right (UTF8); }
W celu wczytania zawartości pliku w formacie Parquet należy zastosować klasę AvroParquetReader. Zwracane są obiekty typu GenericRecord systemu Avro. AvroParquetReader reader = new AvroParquetReader(path); GenericRecord result = reader.read(); assertNotNull(result); assertThat(result.get("left").toString(), is("L")); assertThat(result.get("right").toString(), is("R")); assertNull(reader.read());
Projekcja i schematy odczytu Czasem z pliku trzeba wczytać tylko kilka kolumn. Właśnie dlatego utworzono formaty kolumnowe (takie jak Parquet). Pozwalają one przyspieszyć pracę i zmniejszyć liczbę operacji wejściawyjścia. Za pomocą schematu z projekcją można wybrać wczytywane kolumny. Na przykład poniższy schemat powoduje wczytanie tylko pola right z typu StringPair. 4
Narzędzia Parqueta można pobrać jako plik tarball z binariami z repozytorium Mavena. Wpisz wyrażenie „parquet-tools” na stronie http://search.maven.org.
Zapis i odczyt plików w formacie Parquet
361
{ "type": "record", "name": "StringPair", "doc": "Pole right z pary łańcuchów znaków.", "fields": [ {"name": "right", "type": "string"} ] }
Aby zastosować schemat z projekcją, ustaw go w konfiguracji za pomocą statycznej metody pomocniczej setRequstedProjection() klasy AvroReadSupport. Schema projectionSchema = parser.parse( getClass().getResourceAsStream("ProjectedStringPair.avsc")); Configuration conf = new Configuration(); AvroReadSupport.setRequestedProjection(conf, projectionSchema);
Następnie należy przekazać konfigurację do konstruktora klasy AvroParquetReader. AvroParquetReader reader = new AvroParquetReader(conf, path); GenericRecord result = reader.read(); assertNull(result.get("left")); assertThat(result.get("right").toString(), is("R"));
Implementacje dla platform Protocol Buffers i Thrift obsługują projekcję w podobny sposób. Ponadto implementacja dla systemu Avro pozwala wskazać schemat odbiorcy za pomocą metody setReadSchema() klasy AvroReadSupport. Ten schemat służy do przetwarzania rekordów systemu Avro zgodnie z regułami opisanymi w tabeli 12.4. W systemie Avro używane są zarówno schemat z projekcją, jak i schemat odbiorcy, a ponieważ projekcja musi określać podzbiór schematu użytego do zapisu pliku w formacie Parquet, dlatego nie można jej wykorzystać do modyfikowania schematu przez dodawanie do niego nowych pól. Oba schematy pełnią więc różne funkcje i można je stosować razem. Schemat z projekcją służy do filtrowania kolumn wczytywanych z pliku w formacie Parquet. Choć używany jest schemat z systemu Avro, można go traktować jak listę wczytywanych kolumn z formatu Parquet. Natomiast schemat odbiorcy jest używany tylko do interpretowania rekordów z systemu Avro. Nigdy nie jest przekształcany na schemat Parqueta, ponieważ nie wpływa na to, które kolumny z pliku formatu Parquet są wczytywane. Na przykład dodanie pola description do schematu z systemu Avro (tak jak w punkcie „Określanie schematu” w rozdziale 12.) i użycie nowego schematu jako schematu odbiorcy sprawi, że rekordy będą miały wartość domyślną w tym polu, choć w pliku w formacie Parquet w ogóle ono nie występuje.
Format Parquet i model MapReduce Parquet udostępnia zestaw formatów wejściowych i wyjściowych dla modelu MapReduce, służących do odczytu i zapisu plików w formacie Parquet w zadaniach w modelu MapReduce. Dostępne są też formaty do pracy ze schematami i danymi z technologii Avro, Protocol Buffers i Thrift. Program z listingu 13.1 to zadanie obejmujące tylko etap mapowania, które wczytuje pliki tekstowe i zapisuje pliki w formacie Parquet. Każdy rekord obejmuje pozycję wiersza w pliku (wartość typu int64, przekształcaną z typu long z systemu Avro) i sam wiersz (łańcuch znaków). Do tworzenia modelu przechowywanych w pamięci danych służy generyczny interfejs API z systemu Avro. 362
Rozdział 13. Parquet
Listing 13.1. Program w modelu MapReduce przekształcający pliki tekstowe na pliki w formacie Parquet za pomocą klasy AvroParquetOutputFormat public class TextToParquetWithAvro extends Configured implements Tool { private static final Schema SCHEMA = new Schema.Parser().parse( "{\n" + " \"type\": \"record\",\n" + " \"name\": \"Line\",\n" + " \"fields\": [\n" + " {\"name\": \"offset\", \"type\": \"long\"},\n" + " {\"name\": \"line\", \"type\": \"string\"}\n" + " ]\n" + "}"); public static class TextToParquetMapper extends Mapper { private GenericRecord record = new GenericData.Record(SCHEMA); @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { record.put("offset", key.get()); record.put("line", value.toString()); context.write(null, record); } } @Override public int run(String[] args) throws Exception { if (args.length != 2) { System.err.printf("Użytkowanie: %s [opcje] \n", getClass().getSimpleName()); ToolRunner.printGenericCommandUsage(System.err); return -1; } Job job = new Job(getConf(), "Text to Parquet"); job.setJarByClass(getClass()); FileInputFormat.addInputPath(job, new Path(args[0])); FileOutputFormat.setOutputPath(job, new Path(args[1])); job.setMapperClass(TextToParquetMapper.class); job.setNumReduceTasks(0); job.setOutputFormatClass(AvroParquetOutputFormat.class); AvroParquetOutputFormat.setSchema(job, SCHEMA); job.setOutputKeyClass(Void.class); job.setOutputValueClass(Group.class); return job.waitForCompletion(true) ? 0 : 1; } public static void main(String[] args) throws Exception { int exitCode = ToolRunner.run(new TextToParquetWithAvro(), args); System.exit(exitCode); } }
Format Parquet i model MapReduce
363
Format wyjściowy zadania jest ustawiony na typ AvroParquetOutputFormat, a typy wyjściowych kluczy i wartości to Void i GenericRecord (ponieważ używany jest generyczny interfejs API z systemu Avro). Typ Void powoduje, że klucz jest zawsze ustawiany na null. Klasa AvroParquetOutputFormat (podobnie jak klasa AvroParquetWriter z wcześniejszego podrozdziału) automatycznie przekształca schemat z systemu Avro na schemat Parqueta. Schemat z systemu Avro jest ustawiany w obiekcie typu Job, dzięki czemu operacje w modelu MapReduce mogą znaleźć schemat przy zapisie plików. Kod mappera jest prosty — przyjmuje pozycję w pliku (klucz) oraz wiersz (wartość) i na tej podstawie tworzy obiekt typu GenericRecord systemu Avro. Ten obiekt jest zapisywany w obiekcie kontekstu modelu MapReduce jako wartość (klucz to zawsze null). Za przekształcanie obiektów typu GenericRecord na kodowanie używane w plikach w formacie Parquet odpowiada klasa Avro ParquetOutputFormat. Parquet to format kolumnowy, dlatego wiersze są umieszczane w buforze w pamięci. Choć mapper z przykładowego kodu jedynie przekazuje wartości dalej, potrzebuje wystarczającej ilości pamięci, by obiekt zapisujący dane w formacie Parquet mógł umieścić w buforze każdy blok (każdą grupę wierszy). Bloki domyślnie zajmują 128 MB. Jeśli zauważysz, że zadanie kończy się niepowodzeniem z powodu błędów pamięci, możesz dostosować dla obiektu zapisującego wielkość bloków plików w formacie Parquet, używając właściwości parquet.block.size (zobacz tabelę 13.3). Konieczna może być też zmiana alokacji pamięci dla operacji w modelu MapReduce (na potrzeby odczytu lub zapisu danych). Służą do tego ustawienia opisane w punkcie „Ustawienia pamięci w systemie YARN i modelu MapReduce” w rozdziale 10.
Poniższe polecenie uruchamia przedstawiony program dla czterowierszowego pliku tekstowego quangle.txt. % hadoop jar parquet-examples.jar TextToParquetWithAvro \ input/docs/quangle.txt output
Do wyświetlenia pliku w formacie Parquet w celu jego sprawdzenia możesz zastosować narzędzia uruchamiane w wierszu poleceń. % parquet-tools dump output/part-m-00000.parquet INT64 offset -------------------------------------------------------------------------------*** row group 1 of 1, values 1 to 4 *** value 1: R:0 D:0 V:0 value 2: R:0 D:0 V:33 value 3: R:0 D:0 V:57 value 4: R:0 D:0 V:89 BINARY line -------------------------------------------------------------------------------*** row group 1 of 1, values 1 to 4 *** value 1: R:0 D:0 V:On the top of the Crumpetty Tree value 2: R:0 D:0 V:The Quangle Wangle sat, value 3: R:0 D:0 V:But his face you could not see, value 4: R:0 D:0 V:On account of his Beaver Hat.
Zauważ, że wartości z grup wierszy są wyświetlane razem. V to wartość, R to poziom powtórzeń, a D to poziom definicji. W tym schemacie dwie ostatnie wartości to zero, ponieważ dane nie są zagnieżdżone.
364
Rozdział 13. Parquet
ROZDZIAŁ 14.
Flume
Platformę Hadoop opracowano z myślą o przetwarzaniu bardzo dużych zbiorów danych. Często przyjmuje się, że te dane już znajdują się w systemie HDFS lub że można je masowo skopiować do tego systemu. Jednak w wielu sytuacjach to założenie jest nieprawdziwe. Liczne systemy generują strumienie danych, które programista chce zagregować, zapisać i przeanalizować za pomocą Hadoopa. Dla takich systemów idealnym rozwiązaniem jest platforma Apache Flume (http://flume.apache.org/). Platforma Flume jest zaprojektowana z myślą o przesyłaniu do Hadoopa dużych ilości danych opartych na zdarzeniach. Wzorcowym przykładem wykorzystania tej platformy jest użycie jej do wczytywania plików dzienników z grupy serwerów WWW i przenoszenia zapisanych w tych plikach zdarzeń do zagregowanych plików w systemie HDFS w celu ich przetwarzania. Standardową lokalizacją docelową (ujściem w słownictwie używanym w kontekście platformy Flume) jest system HDFS. Jednak Flume pozwala też na zapis danych do innych systemów (takich jak HBase i Solr). Aby zastosować platformę Flume, należy uruchomić agenta, czyli długi proces Javy obsługujący połączone kanałami źródła i ujścia. W platformie Flume źródło generuje zdarzenia i przekazuje je do kanału, który przechowuje zdarzenia do momentu przesłania ich do ujścia. Kombinację źródło-kanał-ujście możesz traktować jak podstawową cegiełkę w platformie Flume. Zainstalowana platforma Flume obejmuje grupę powiązanych agentów działających w środowisku rozproszonym. Agenty pracujące na granicy systemu (na przykład na maszynach z serwerami WWW) zbierają dane i przekazują je do agentów odpowiedzialnych za agregację i zapisywanie danych w docelowej lokalizacji. Agenty są tak skonfigurowane, by obsługiwały zbiory określonych źródeł i ujść. Dlatego posługiwanie się platformą Flume polega głównie na konfigurowaniu jej w celu połączenia różnych elementów. W tym rozdziale zobaczysz, jak w platformie Flume budować topologie służące do wczytywania danych, możliwe do wykorzystania w potoku operacji w Hadoopie.
Instalowanie platformy Flume Pobierz stabilną wersję binarnej dystrybucji platformy Flume ze strony http://flume.apache.org/ download.html i wypakuj plik tarball w odpowiednim miejscu. % tar xzf apache-flume-x.y.z-bin.tar.gz
365
Pliki binarne platformy Flume warto dodać do zmiennej PATH. % export FLUME_HOME=~/sw/apache-flume-x.y.z-bin % export PATH=$PATH:$FLUME_HOME/bin
Do uruchamiania agentów platformy Flume służy polecenie flume-ng, co opisano dalej.
Przykład Aby zobaczyć, jak działa platforma Flume, zacznij od rozwiązania, które: 1. Wykrywa pojawienie się nowych plików tekstowych w lokalnym katalogu. 2. Przesyła każdy wiersz każdego pliku do konsoli w trakcie dodawania plików. Pliki będą tu dodawane ręcznie, łatwo jednak można wyobrazić sobie, że serwer WWW generuje nowe pliki, które mają być nieustannie dodawane do systemu za pomocą platformy Flume. W praktyce zamiast tylko zapisywać zawartość plików, kod zapewne przesyłałby je do systemu HDFS na potrzeby późniejszego przetwarzania. Dalej w rozdziale zobaczysz, jak to zrobić. W tym przykładzie agent platformy Flume obsługuje jeden zestaw źródło-kanał-ujście, skonfigurowany za pomocą pliku właściwości Javy. Za pomocą konfiguracji można ustawić typy źródła, ujścia i kanału, a także powiązania między tymi elementami. Tu używana jest konfiguracja przedstawiona na listingu 14.1. Listing 14.1. Konfiguracja platformy Flume ze źródłem typu spooldir i ujściem typu logger agent1.sources = source1 agent1.sinks = sink1 agent1.channels = channel1 agent1.sources.source1.channels = channel1 agent1.sinks.sink1.channel = channel1 agent1.sources.source1.type = spooldir agent1.sources.source1.spoolDir = /tmp/spooldir agent1.sinks.sink1.type = logger agent1.channels.channel1.type = file
Nazwy właściwości tworzą hierarchię, której najwyższy poziom to nazwa agenta. W tym przykładzie używany jest jeden agent — agent1. Nazwy różnych komponentów w agencie są definiowane na kolejnym poziomie. Na przykład agent1.sources zawiera listę nazw źródeł uruchamianych w agencie agent1 (tu używane jest jedno źródło — source1). Agent agent1 obsługuje też ujście sink1 i kanał channel1. Właściwości każdego komponentu są zdefiniowane na następnym poziomie hierarchii. Właściwości konfiguracyjne dostępne dla komponentu zależą od jego typu. Tu właściwość agent1.sources. source1.type ma wartość spooldir. Takie źródło śledzi katalog na dane i wykrywa pojawiające się w nim nowe pliki. Dla takich źródeł używana jest właściwość spoolDir, a jej pełna nazwa dla źródła source1 to agent1.sources.source1.spoolDir. Kanały źródła są ustawiane za pomocą właściwości agent1.sources.source1.channels.
366
Rozdział 14. Flume
Ujście jest typu logger i służy do rejestrowania zdarzeń w konsoli. Także ujście trzeba połączyć z kanałem (za pomocą właściwości agent1.sinks.sink1.channel)1. Kanał jest typu file, co oznacza, że zdarzenia z kanału są utrwalane na dysku. Cały system pokazano na rysunku 14.1.
Rysunek 14.1. Agent platformy Flume ze źródłem typu spooldir i ujściem typu logger połączonymi kanałem typu file
Przed uruchomieniem przykładowego rozwiązania należy w lokalnym systemie plików utworzyć katalog na przetwarzane dane. % mkdir /tmp/spooldir
Następnie za pomocą polecenia flume-ng można uruchomić agenta platformy Flume. % flume-ng agent \ --conf-file spool-to-logger.properties \ --name agent1 \ --conf $FLUME_HOME/conf \ -Dflume.root.logger=INFO,console
Plik właściwości platformy Flume pokazany na listingu 14.1 jest wskazany w opcji --conf-file. Nazwę agenta trzeba podać w opcji --name (plik właściwości platformy Flume może zawierać definicje kilku agentów, dlatego należy określić, który ma rozpocząć pracę). Opcja --conf informuje platformę Flume, gdzie znajdzie ogólną konfigurację, na przykład ustawienia środowiska. W nowym terminalu utwórz plik w katalogu przeznaczonym na przetwarzane dane. Źródło powiązane z tym katalogiem oczekuje, że pliki będą niezmienne. Aby zapobiec wczytaniu niepełnej zawartości pliku przez źródło, cała zawartość jest zapisywana do ukrytego pliku. Następnie w atomowej operacji program zmienia nazwę pliku, dzięki czemu źródło może go wczytać2. % echo "Hello Flume" > /tmp/spooldir/.file1.txt % mv /tmp/spooldir/.file1.txt /tmp/spooldir/file1.txt
1
Zauważ, że źródło ma właściwość channels (liczba mnoga), natomiast ujście ma właściwość channel (liczba pojedyncza). Jest tak, ponieważ źródło może przekazywać dane do więcej niż jednego kanału (zobacz punkt „Rozsyłanie danych do wielu kanałów”), ale ujście może przyjmować informacje z tylko jednego kanału. Kanał może przy tym kierować dane do kilku ujść. Opisano to w punkcie „Grupy ujść”.
2
Gdy używany jest plik dziennika, do którego ciągle dodawane są informacje, można okresowo tworzyć nowy plik dziennika, a zawartość poprzedniego przenosić do katalogu przeznaczonego na przetwarzane dane, by Flume mógł ją wczytać.
Przykład
367
Wróć do terminalu agenta. Zobaczysz, że Flume wykrył i przetworzył plik. Preparing to move file /tmp/spooldir/file1.txt to /tmp/spooldir/file1.txt.COMPLETED Event: { headers:{} body: 48 65 6C 6C 6F 20 46 6C 75 6D 65
Hello Flume }
Źródło powiązane z katalogiem przeznaczonym na przetwarzane dane pobiera plik, po czym rozbija go na wiersze i dla każdego z nich tworzy zdarzenie platformy Flume. Zdarzenia mają opcjonalne nagłówki i binarne ciało, którym jest reprezentacja wiersza tekstu w formacie UTF-8. Ciało jest rejestrowane przez ujście typu logger w postaci szesnastkowej i jako łańcuch znaków. Plik umieszczony w katalogu przeznaczonym na przetwarzane dane ma tu tylko jeden wiersz, dlatego rejestrowane jest jedno zdarzenie. Widać też, że źródło zmieniło nazwę pliku na file1.txt.COMPLETED, co oznacza, że Flume zakończył przetwarzanie pliku i nie będzie do niego wracał.
Transakcje i niezawodność W platformie Flume odrębne transakcje gwarantują dostarczenie danych ze źródła do kanału i z kanału do ujścia. W przykładzie z poprzedniego podrozdziału źródło powiązane z katalogiem przeznaczonym na przetwarzane dane tworzy zdarzenie dla każdego wiersza z pliku. Źródło oznacza plik jako przetworzony dopiero po udanym zatwierdzeniu transakcji, w których zdarzenia są dostarczane do kanału. Transakcje są używane także do przekazywania zdarzeń z kanału do ujścia. Jeśli z jakichś mało prawdopodobnych powodów zdarzeń nie uda się zapisać, transakcja jest wycofywana i zdarzenia pozostają w kanale, skąd później można je ponownie przesłać. Tu używany jest kanał oparty na pliku. Jest to kanał trwały. Zapisane w nim zdarzenie pozostaje dostępne nawet po ponownym uruchomieniu agenta. Flume udostępnia też kanał oparty na pamięci, który nie ma tej cechy, ponieważ działa w pamięci. W takich kanałach ponowne uruchomienie agenta prowadzi do utraty zdarzeń. W niektórych aplikacjach jest to akceptowalne. Zaletą kanałów opartych na pamięci jest ich wyższa przepustowość w porównaniu z kanałami opartymi na plikach. Efekt końcowy jest taki, że każde zdarzenie wygenerowane w źródle trafia do ujścia. Główny problem polega na tym, że każde zdarzenie dociera do ujścia przynajmniej raz (ponieważ mogą występować duplikaty). Duplikaty mogą powstawać w źródłach i ujściach. Na przykład po ponownym rozruchu agenta źródło powiązane z katalogiem przeznaczonym na przetwarzane dane ponownie zgłasza zdarzenia dotyczące nieprzetworzonych w pełni plików — nawet jeśli przed ponownym uruchomieniem agenta zdarzenia zostały częściowo lub w całości zatwierdzone w kanale. Ponadto po ponownym rozruchu agenta ujście typu logger ponownie rejestruje zarejestrowane, ale niezatwierdzone zdarzenia. Mogą one wystąpić, jeśli agent został zamknięty między zarejestrowaniem a zatwierdzeniem zdarzenia. Możliwość zapisu zdarzeń więcej niż raz wydaje się problemem, jednak w praktyce okazuje się akceptowalnym kompromisem pozwalającym na poprawę wydajności. Bardziej wymagające rozwiązanie, zapis dokładnie raz, wymaga zastosowania kosztownego dwuetapowego protokołu zatwierdzania. Zastosowane rozwiązanie odróżnia platformę Flume (podejście „przynajmniej raz”), która jest systemem o wysokiej przepustowości służącym do równoległego odbierania zdarzeń, od 368
Rozdział 14. Flume
bardziej tradycyjnych korporacyjnych systemów wymiany komunikatów (podejście „dokładnie raz”). W podejściu „przynajmniej raz” powtarzające się zdarzenia można usunąć na dalszych etapach potoku przetwarzania danych. Zwykle wykorzystuje się do tego specyficzne dla aplikacji zadanie napisane w modelu MapReduce lub Hive.
Porcje zdarzeń Ze względu na wydajność Flume próbuje (gdy jest to możliwe) przetwarzać zdarzenia w transakcjach w porcjach, a nie pojedynczo. Wykorzystanie porcji zdarzeń zwiększa wydajność zwłaszcza w kanałach opartych na plikach, ponieważ każda transakcja wymaga wtedy zapisu na dysku lokalnym i wywołania instrukcji fsync. Wielkość porcji jest określana przez używany komponent. Często można ją skonfigurować. Na przykład źródło powiązane z katalogiem przeznaczonym na przetwarzane dane wczytuje pliki w porcjach po 100 wierszy. Tę wartość można zmienić za pomocą właściwości batchSize. Podobnie ujście w systemie Avro (opisane w punkcie „Dystrybucja — warstwy agentów”) próbuje wczytać z kanału 100 zdarzeń przed ich przesłaniem w wywołaniu RPC, ale nie blokuje dalszych operacji, jeśli dostępnych jest mniej zdarzeń.
Ujścia w systemie HDFS Flume ma dostarczać duże ilości danych do magazynu Hadoopa. Warto więc zapoznać się z konfigurowaniem agenta platformy Flume na potrzeby dostarczania zdarzeń do ujścia w systemie HDFS. Konfiguracja z listingu 14.2 to zmodyfikowana wersja poprzedniego przykładu, przy czym tu używane jest ujście w systemie HDFS. Jedyne dwa wymagane ustawienia to typ ujścia (hdfs) i ścieżka (właściwość hdfs.path) określająca katalog, w którym umieszczone zostaną pliki. Jeśli tak jak na omawianym listingu system plików nie jest określony w ścieżce, zostaje ustalony w standardowy sposób — na podstawie właściwości fs.defaultFS Hadoopa. W kodzie ustawiono też przedrostek i rozszerzenie dla nazw plików oraz nakazano platformie Flume, by zapisywała zdarzenia w plikach w formacie tekstowym. Listing 14.2. Konfiguracja platformy Flume ze źródłem typu spooldir i ujściem typu hdfs agent1.sources = source1 agent1.sinks = sink1 agent1.channels = channel1 agent1.sources.source1.channels = channel1 agent1.sinks.sink1.channel = channel1 agent1.sources.source1.type = spooldir agent1.sources.source1.spoolDir = /tmp/spooldir agent1.sinks.sink1.type = hdfs agent1.sinks.sink1.hdfs.path = /tmp/flume agent1.sinks.sink1.hdfs.filePrefix = events agent1.sinks.sink1.hdfs.fileSuffix = .log agent1.sinks.sink1.hdfs.inUsePrefix = _ agent1.sinks.sink1.hdfs.fileType = DataStream agent1.channels.channel1.type = file
Ujścia w systemie HDFS
369
Ponownie uruchom agenta, aby użyć konfiguracji z pliku spool-to-hdfs.properties, i utwórz nowy plik w katalogu przeznaczonym na przetwarzane dane. % echo -e "Hello\nAgain" > /tmp/spooldir/.file2.txt % mv /tmp/spooldir/.file2.txt /tmp/spooldir/file2.txt
Zdarzenia zostaną teraz dostarczone do ujścia typu hdfs i zapisane do pliku. Pliki na czas zapisu mają dodawane rozszerzenie .tmp, informujące, że nie zostały jeszcze przetworzone. W tym przykładzie ustawiana jest też właściwość hdfs.inUsePrefix. Przypisywane jest do niej podkreślenie (_; domyślnie jest to pusta właściwość), co powoduje, że pliki na czas zapisu mają dodawany ustawiony we właściwości przedrostek. Jest to przydatne, ponieważ model MapReduce ignoruje pliki z przedrostkiem _. Tak więc typowy plik tymczasowy ma nazwę w formacie _events.1399295780136. log.tmp. Użyta liczba to znacznik czasu wygenerowany przez ujście typu hdfs. Ujście typu hdfs utrzymuje plik otwarty przez określony czas (domyślnie jest to 30 sekund; tę wartość można zmienić za pomocą właściwości hdfs.rollInterval), do momentu osiągnięcia przez plik określonej wielkości (domyślnie limit wynosi 1024 bajty; tę wartość określa właściwość hdfs.rollSize) lub do chwili zapisania określonej liczby zdarzeń (domyślnie jest to 10 zdarzeń; określa to właściwość hdfs.rollCount). Spełnienie któregokolwiek z tych warunków powoduje zamknięcie pliku i usunięcie przedrostka oraz rozszerzenia informujących o tym, że plik jest używany. Nowe zdarzenia zostaną zapisane w nowym pliku (z ustawionym przedrostkiem i rozszerzeniem). Po 30 sekundach wiadomo, że plik został zamknięty. Dlatego można podejrzeć jego zawartość. % hadoop fs -cat /tmp/flume/events.1399295780136.log Hello Again
Ujście typu hdfs zapisuje pliki z poziomu konta użytkownika, który uruchomił agenta platformy Flume (chyba że ustawiona jest właściwość hdfs.proxyUser, określająca konto używane do zapisu plików).
Podział na partycje i interceptory Duże zbiory danych często są podzielone na partycje. Dzięki temu przetwarzanie można ograniczyć do określonych partycji, jeśli zapytanie dotyczy tylko podzbioru danych. Dane dotyczące zdarzeń z platformy Flume bardzo często dzieli się na podstawie czasu. Można wtedy okresowo uruchamiać proces przetwarzający gotowe partycje (na przykład w celu usunięcia powtarzających się zdarzeń). Przykładowy kod można łatwo zmodyfikować, by zapisywać dane w partycjach. W tym celu należy ustawić we właściwości hdfs.path podkatalogi za pomocą sekwencji formatujących reprezentujących czas. agent1.sinks.sink1.hdfs.path = /tmp/flume/year=%Y/month=%m/day=%d
Tu generowane są partycje dzienne, jednak możliwe jest też dzielenie danych według innych okresów i zastosowanie innej struktury katalogów (jeśli używasz platformy Hive, z punktu „Partycje i kubełki” w rozdziale 17. dowiesz się, jak Hive tworzy partycje na dysku). Pełną listę sekwencji formatujących znajdziesz w dokumentacji ujść typu hdfs w podręczniku użytkownika platformy Flume (http://flume.apache.org/FlumeUserGuide.html).
370
Rozdział 14. Flume
Partycja, w której zapisywane jest zdarzenie platformy Flume, jest wyznaczana na podstawie nagłówka timestamp zdarzenia. Domyślnie zdarzenia nie mają tego nagłówka, można go jednak dodać za pomocą interceptora. Interceptory to komponenty, które potrafią na bieżąco modyfikować lub usuwać zdarzenia. Interceptory są powiązane ze źródłami i są uruchamiane dla zdarzeń przed ich umieszczeniem w kanale3. Poniższe dodatkowe wiersze konfiguracji dodają do źródła source1 interceptor typu timestamp, dołączający nagłówek timestamp do każdego zdarzenia wygenerowanego przez dane źródło. agent1.sources.source1.interceptors = interceptor1 agent1.sources.source1.interceptors.interceptor1.type = timestamp
Użycie takiego interceptora daje gwarancję, że znaczniki czasu będą ściśle odzwierciedlać czas wygenerowania zdarzeń. W niektórych aplikacjach wystarczające są znaczniki czasu reprezentujące czas zapisu zdarzenia w systemie HDFS. Warto jednak wiedzieć, że jeśli występuje kilka warstw agentów platformy Flume, czas utworzenia i czas zapisu zdarzenia mogą się znacznie różnić między sobą — zwłaszcza wtedy, gdy nastąpi przestój agenta (zobacz punkt „Dystrybucja — warstwy agentów”). W takich sytuacjach dla ujścia typu hdfs można zastosować właściwość hdfs.useLocal TimeStamp, co powoduje użycie znacznika czasu wygenerowanego przez agenta platformy Flume obsługującego dane ujście.
Formaty plików Zwykle dobrym rozwiązaniem jest przechowywanie danych w formacie binarnym, ponieważ pliki są wtedy mniejsze niż przy stosowaniu tekstu. W ujściach typu hdfs format plików ustawia się za pomocą zestawu właściwości (hdfs.fileType i kilku innych). Jeśli właściwość hdfs.fileType nie jest określona, domyślnie używane są pliki typu SequenceFile. Zdarzenia są zapisywane do takich plików z kluczami typu LongWritable (obejmują one znacznik czasu zdarzenia, a jeśli nagłówek timestamp nie jest podany, bieżący czas) i wartościami typu Bytes Writable (zawierają one ciało zdarzenia). W plikach typu SequenceFile można wykorzystać wartości typu Text zamiast typu BytesWritable. W tym celu należy ustawić właściwość hdfs.writeFormat na wartość Text. Dla plików systemu Avro konfiguracja wygląda nieco inaczej. Właściwość hdfs.fileType należy ustawić na wartość DataStream, tak jak dla zwykłego tekstu. Ponadto właściwość serializer (zwróć uwagę na brak przedrostka hdfs.) trzeba ustawić na wartość avro_event. By umożliwić kompresję, ustaw właściwość serializer.compressionCodec. Oto przykładowe ujście typu hdfs skonfigurowane pod kątem zapisu plików systemu Avro skompresowanych za pomocą kodeka Snappy. agent1.sinks.sink1.type = hdfs agent1.sinks.sink1.hdfs.path = /tmp/flume agent1.sinks.sink1.hdfs.filePrefix = events agent1.sinks.sink1.hdfs.fileSuffix = .avro agent1.sinks.sink1.hdfs.fileType = DataStream agent1.sinks.sink1.serializer = avro_event agent1.sinks.sink1.serializer.compressionCodec = snappy
Zdarzenie jest reprezentowane jako rekord systemu Avro o dwóch polach — headers (odwzorowanie systemu Avro z wartościami w postaci łańcuchów znaków) i body (pole bajtowe systemu Avro). 3
Interceptory dostępne w platformie Flume opisano w tabeli 14.1.
Ujścia w systemie HDFS
371
Jeśli chcesz zastosować niestandardowy schemat systemu Avro, masz do wyboru kilka możliwości. Jeżeli zamierzasz przesyłać do platformy Flume przechowywane w pamięci obiekty systemu Avro, odpowiednim rozwiązaniem jest klasa Log4jAppender. W ten sposób można rejestrować obiekty z interfejsu generycznego, specyficznego i refleksyjnego systemu Avro za pomocą klasy Logger z biblioteki log4j i wysyłać je do źródła typu avro działającego w agencie platformy Flume (zobacz punkt „Dystrybucja — warstwy agentów”). Wtedy właściwość serializer ujścia typu hdfs powinna mieć wartość org.apache.flume.sink.hdfs.AvroEventSerializer$Builder, a w nagłówku należy podać schemat z systemu Avro (więcej informacji znajdziesz w dokumentacji klasy Log4jAppender). Jeśli zdarzenia nie pochodzą z obiektów systemu Avro, można napisać niestandardowy mechanizm serializacji, aby przekształcał zdarzenia platformy Flume na obiekt systemu Avro na podstawie niestandardowego schematu. Dobrym punktem wyjścia jest wtedy klasa pomocnicza Abstract AvroEventSerializer z pakietu org.apache.flume.serialization.
Rozsyłanie danych do wielu kanałów Technika fan out polega na rozsyłaniu zdarzeń z jednego źródła do wielu kanałów, dzięki czemu informacje docierają do wielu ujść. Na przykład konfiguracja z listingu 14.3 powoduje dostarczanie zdarzeń zarówno do ujścia typu hdfs (sink1a przy użyciu kanału channel1a), jak i do ujścia typu logger (sink1b przy użyciu kanału channel1b). Listing 14.3. Konfiguracja platformy Flume ze źródłem typu spooldir, powodująca rozsyłanie zdarzeń do ujść typów hdfs i logger agent1.sources = source1 agent1.sinks = sink1a sink1b agent1.channels = channel1a channel1b agent1.sources.source1.channels = channel1a channel1b agent1.sinks.sink1a.channel = channel1a agent1.sinks.sink1b.channel = channel1b agent1.sources.source1.type = spooldir agent1.sources.source1.spoolDir = /tmp/spooldir agent1.sinks.sink1a.type = hdfs agent1.sinks.sink1a.hdfs.path = /tmp/flume agent1.sinks.sink1a.hdfs.filePrefix = events agent1.sinks.sink1a.hdfs.fileSuffix = .log agent1.sinks.sink1a.hdfs.fileType = DataStream agent1.sinks.sink1b.type = logger agent1.channels.channel1a.type = file agent1.channels.channel1b.type = memory
Najważniejsza zmiana polega na skonfigurowaniu źródła w taki sposób, by dostarczało zdarzenia do kilku kanałów. W tym celu właściwość agent1.sources.channels jest ustawiona na rozdzieloną spacjami listę nazw kanałów (channel1a i channel1b). W tej konfiguracji kanał channel1b, przesyłający dane do ujścia typu logger, to kanał działający w pamięci (typ memory), ponieważ zdarzenia są rejestrowane na potrzeby diagnostyki i ich utrata po ponownym uruchomieniu agenta jest akceptowalna. Ponadto każdy kanał jest tak skonfigurowany, by kierował zdarzenia do jednego ujścia (tak jak we wcześniejszym przykładzie). Przepływ zdarzeń jest przedstawiony na rysunku 14.2. 372
Rozdział 14. Flume
Rysunek 14.2. Agent platformy Flume ze źródłem typu spooldir, rozsyłający zdarzenia do ujść typów hdfs i logger
Gwarancje dostarczenia Flume wykorzystuje odrębne transakcje do dostarczania każdej porcji zdarzeń ze źródła typu spooldir do poszczególnych kanałów. W przedstawionym przykładzie jedna transakcja służy do dostarczania zdarzeń do kanału dla ujścia typu hdfs, a druga — do dostarczania tych samych porcji zdarzeń do kanału dla ujścia logger. Jeśli któraś z transakcji się nie powiedzie (na przykład z powodu przepełnienia kanału), zdarzenia nie zostaną usunięte ze źródła i później nastąpi ponowna próba ich przesłania. W omawianym przykładzie niedostarczenie niektórych zdarzeń do ujścia typu logger jest akceptowalne. Dlatego można opisać kanał dla tego ujścia jako opcjonalny. Wtedy niepowodzenie transakcji powiązanej z tym kanałem nie powoduje pozostawienia zdarzeń w źródle i późniejszego ponowienia próby przesyłu. Zauważ, że jeśli agent zawiedzie przed zatwierdzeniem transakcji z obu kanałów, zdarzenia po ponownym rozruchu agenta zostaną wysłane jeszcze raz. Dzieje się tak nawet wtedy, jeśli kanały powiązane z niezatwierdzonymi transakcjami są opisane jako opcjonalne. Aby ustawić kanały jako opcjonalne, należy podać je rozdzielone spacjami we właściwości selector. optional. agent1.sources.source1.selector.optional = channel1b
Indeksowanie w czasie zbliżonym do rzeczywistego Indeksowanie zdarzeń na potrzeby wyszukiwania to sytuacja dobrze obrazująca, jak rozsyłanie zdarzeń stosuje się w praktyce. Jedno źródło zdarzeń jest wtedy wiązane z ujściami dla systemu HDFS (jest to główne repozytorium zdarzeń, dlatego używany jest wymagany kanał) i dla platformy Solr (lub dla serwera Elasticsearch). To drugie służy do budowania indeksu używanego przy wyszukiwaniu i jest powiązane z opcjonalnym kanałem. Ujście typu MorphlineSolrSink pobiera pola ze zdarzeń platformy Flume i przekształca je na dokument platformy Solr (na podstawie pliku konfiguracyjnego Morphline), wczytywany później do aktywnego serwera wyszukiwania tej platformy. Cały proces działa w czasie zbliżonym do rzeczywistego, ponieważ pobrane dane stają się dostępne w wynikach wyszukiwania w czasie mierzonym w sekundach.
Rozsyłanie danych do wielu kanałów
373
Selektory replikacji i rozsyłania W normalnym przepływie rozsyłanych zdarzeń zdarzenia są replikowane we wszystkich kanałach. Czasem potrzebne jest bardziej selektywne rozwiązanie, tak aby niektóre zdarzenia trafiały do jednego kanału, a inne — do odmiennego. W tym celu należy ustawić selektor rozsyłania (ang. multiplexing selector) dla źródła i zdefiniować reguły rozsyłania, wiążąc określone wartości nagłówków zdarzeń z kanałami. Szczegółowe informacje o konfiguracji znajdziesz w podręczniku użytkownika platformy Flume (http://flume.apache.org/FlumeUserGuide.html).
Dystrybucja — warstwy agentów Jak przebiega skalowanie agentów platformy Flume? Jeśli w każdym węźle generującym surowe dane działa jeden agent, to w opisanej konfiguracji każdy plik zapisywany w systemie HDFS będzie zawierał zdarzenia z jednego węzła. Lepszym rozwiązaniem jest połączenie w jednym pliku zdarzeń z grupy węzłów, ponieważ pozwala to uzyskać mniejszą liczbę większych plików (co przekłada się na zmniejszenie obciążenia systemu HDFS i wydajniejsze przetwarzanie zadań w modelu MapReduce; zobacz punkt „Małe pliki i format CombineFileInputFormat” w rozdziale 8.). Ponadto w razie potrzeby pliki można przesyłać częściej, ponieważ trafiają do nich dane z większej liczby węzłów. Skraca to czas między momentem wygenerowania zdarzenia i udostępnieniem go do analiz. Agregacja zdarzeń platformy Flume odbywa się za pomocą warstw agentów. Pierwsza warstwa zbiera zdarzenia z pierwotnych źródeł (na przykład z serwerów WWW) i wysyła je do mniejszego zbioru agentów w drugiej warstwie, które agregują zdarzenia z pierwszej warstwy przed ich zapisaniem w systemie HDFS (rysunek 14.3). Gdy liczba węzłów źródłowych jest bardzo duża, uzasadnione może być użycie dodatkowych warstw.
Rysunek 14.3. Użycie agenta warstwy drugiej do agregacji zdarzeń platformy Flume z warstwy pierwszej
Warstwy tworzone są za pomocą specjalnego ujścia, które przesyła zdarzenia przez sieć, i powiązanego źródła pobierającego zdarzenia. Ujście typu avro przesyła zdarzenia za pomocą wywołań RPC systemu Avro do źródła typu avro działającego w innym agencie platformy Flume. Istnieje 374
Rozdział 14. Flume
też ujście dla platformy Thrift, które wykonuje te same czynności za pomocą wywołań RPC tej platformy i jest powiązane ze źródłem Thrifta4. Niech nazwy Cię nie zmylą — ujścia i źródła systemu Avro nie umożliwiają zapisu lub odczytu plików tego systemu. Są one używane tylko do dystrybucji zdarzeń między warstwami agentów, do czego używane są wywołania RPC systemu Avro (stąd stosowane nazwy). Jeśli chcesz zapisywać zdarzenia w plikach systemu Avro, posłuż się ujściem typu hdfs, co opisano w punkcie „Formaty plików”.
Listing 14.4 przedstawia dwuwarstwową konfigurację platformy Flume. W pliku zdefiniowane są dwa agenty — agent1 i agent2. Agent agent1 działa w pierwszej warstwie, używa źródła typu spooldir i ujścia typu avro powiązanego z kanałem typu file. Agent agent2 działa w drugiej warstwie, używa źródła typu avro i oczekuje na dane w porcie, do którego wysyła zdarzenia ujście typu avro agenta agent1. Ujście dla agenta agent2 jest typu hdfs i ma konfigurację z listingu 14.2. Listing 14.4. Dwuwarstwowa konfiguracja platformy Flume ze źródłem typu spooldir i ujściem typu hdfs # Agent pierwszej warstwy agent1.sources = source1 agent1.sinks = sink1 agent1.channels = channel1 agent1.sources.source1.channels = channel1 agent1.sinks.sink1.channel = channel1 agent1.sources.source1.type = spooldir agent1.sources.source1.spoolDir = /tmp/spooldir agent1.sinks.sink1.type = avro agent1.sinks.sink1.hostname = localhost agent1.sinks.sink1.port = 10000 agent1.channels.channel1.type = file agent1.channels.channel1.checkpointDir=/tmp/agent1/file-channel/checkpoint agent1.channels.channel1.dataDirs=/tmp/agent1/file-channel/data # Agent drugiej warstwy agent2.sources = source2 agent2.sinks = sink2 agent2.channels = channel2 agent2.sources.source2.channels = channel2 agent2.sinks.sink2.channel = channel2 agent2.sources.source2.type = avro agent2.sources.source2.bind = localhost agent2.sources.source2.port = 10000 agent2.sinks.sink2.type = hdfs agent2.sinks.sink2.hdfs.path = /tmp/flume
4
Para ujście-źródło dla systemu Avro jest starsza niż jej odpowiednik dla platformy Thrift. W czasie, gdy powstawała ta książka, mechanizmy dla systemu Avro udostępniały pewne funkcje (na przykład szyfrowanie) nieobecne dla platformy Thrift.
Dystrybucja — warstwy agentów
375
agent2.sinks.sink2.hdfs.filePrefix = events agent2.sinks.sink2.hdfs.fileSuffix = .log agent2.sinks.sink2.hdfs.fileType = DataStream agent2.channels.channel2.type = file agent2.channels.channel2.checkpointDir=/tmp/agent2/file-channel/checkpoint agent2.channels.channel2.dataDirs=/tmp/agent2/file-channel/data
Zauważ, że ponieważ w tej samej maszynie działają dwa kanały typu file, skonfigurowano je tak, by prowadziły do różnych katalogów przeznaczonych na dane i punkty kontrolne (domyślnie używany jest katalog główny użytkownika). Dzięki temu kanały nie próbują zapisywać danych do tego samego pliku. Ten system jest pokazany na rysunku 14.4.
Rysunek 14.4. Dwa agenty platformy Flume połączone parą ujście-źródło typu avro
Każdy agent jest uruchamiany niezależnie za pomocą tego samego parametru --conf-file, ale z różnymi parametrami --name. % flume-ng agent --conf-file spool-to-hdfs-tiered.properties --name agent1 ... % flume-ng agent --conf-file spool-to-hdfs-tiered.properties --name agent2 ...
Gwarancje dostarczenia danych Flume wykorzystuje transakcje, by zagwarantować, że każda porcja zdarzeń zostanie poprawnie dostarczona ze źródła do kanału i z kanału do ujścia. Przy połączeniu ujście-źródło typu avro transakcje gwarantują, że zdarzenia zostaną prawidłowo dostarczone z jednego agenta do następnego. Odczyt porcji zdarzeń z kanału typu file z agenta agent1 przez ujście typu avro odbywa się w ramach transakcji. Transakcja jest zatwierdzana dopiero po tym, jak ujście typu avro otrzyma (przesyłane synchronicznie) potwierdzenie, że zapis do punktu końcowego RPC źródła typu avro zakończył 376
Rozdział 14. Flume
się sukcesem. Potwierdzenie jest przesyłane po udanym zatwierdzeniu transakcji agenta agent2 obejmującej zapis porcji zdarzeń do kanału typu file. W ten sposób para ujście-źródło typu avro gwarantuje, że zdarzenie zostanie dostarczone (przynajmniej raz) z kanału jednego agenta platformy Flume do kanału innego agenta. Jeśli któryś z agentów nie działa, zdarzeń nie można przesłać do systemu HDFS. Na przykład jeśli agent agent1 przestanie działać, pliki będą akumulować się w katalogu przeznaczonym na przetwarzane dane. Będzie można je przetworzyć, gdy agent agent1 wznowi pracę. Ponadto zdarzenia znajdujące się w kanale typu file tego agenta w momencie jego zatrzymania będą dostępne w momencie wznowienia przez niego działania. Wynika to z gwarancji trwałości zapewnianej przez kanały typu file. Jeśli agent agent2 przestanie działać, zdarzenia będą przechowywane w kanale typu file agenta agent1 do momentu wznowienia pracy przez agenta agent2. Pamiętaj jednak, że kanały mają ograniczoną pojemność. Jeżeli kanał agenta agent1 zapełni się w czasie braku aktywności agenta agent2, wszystkie nowe zdarzenia zostaną utracone. Kanał typu file domyślnie przechowuje do miliona zdarzeń (ten poziom można zmienić za pomocą właściwości capacity) i przestaje akceptować zdarzenia, gdy ilość wolnego miejsca dla katalogu na punkt kontrolny spada poniżej 500 MB (do kontrolowania tej wartości służy właściwość minimumRequiredSpace). W obu opisanych scenariuszach założono, że agent ostatecznie wznowi pracę. Jednak nie zawsze tak się dzieje. Możliwe, że zepsuł się używany przez agenta sprzęt. Jeśli agent agent1 nie wznowi pracy, utrata danych będzie ograniczona do zdarzeń z kanału typu file tego agenta, niedostarczonych do agenta agent2 przed momentem wystąpienia awarii. W opisanej architekturze istnieje kilka agentów pierwszej warstwy podobnych do agenta agent1. Dlatego inne węzły z warstwy mogą przejąć funkcje uszkodzonego węzła. Na przykład jeśli w węzłach działają serwery WWW z równoważeniem obciążenia, pozostałe węzły przejmą ruch z uszkodzonego serwera WWW i zaczną generować nowe zdarzenia platformy Flume dostarczane do agenta agent2. W ten sposób nowe zdarzenia nie zostaną utracone. Nieodwracalna awaria agenta agent2 jest bardziej kłopotliwa. Wszystkie zdarzenia z kanałów agentów pierwszej warstwy (egzemplarzy agenta agent1) zostaną utracone. Także nowych zdarzeń wygenerowanych przez te agenty nie da się dostarczyć. Rozwiązaniem problemu jest utworzenie dla agenta agent1 dodatkowych ujść typu avro połączonych w grupę ujść. Wtedy gdy docelowy punkt końcowy typu avro powiązany z agentem agent2 jest niedostępny, można sprawdzić następne ujście z grupy. W następnym podrozdziale zobaczysz, jak przygotować takie rozwiązanie.
Grupy ujść Grupa ujść pozwala traktować zestaw ujść jak jedno na potrzeby przełączania awaryjnego lub równoważenia obciążenia (zobacz rysunek 14.5). Jeśli agent drugiej warstwy jest niedostępny, zdarzenia będą bez zakłóceń dostarczane do innego agenta drugiej warstwy i do systemu HDFS. Aby skonfigurować grupę ujść, właściwość sinkgroups agenta należy ustawić na nazwę grupy. Należy też podać listę ujść w grupie i typ procesora ujść, odpowiedzialnego za wybór używanego ujścia. Listing 14.5 przedstawia konfigurację używaną do równoważenia obciążenia dwóch punktów końcowych typu avro.
Grupy ujść
377
Rysunek 14.5. Używanie wielu ujść na potrzeby równoważenia obciążenia lub przełączania awaryjnego Listing 14.5. Konfiguracja platformy Flume używana do równoważenia obciążenia między dwoma punktami końcowymi typu avro za pomocą grupy ujść agent1.sources = source1 agent1.sinks = sink1a sink1b agent1.sinkgroups = sinkgroup1 agent1.channels = channel1 agent1.sources.source1.channels = channel1 agent1.sinks.sink1a.channel = channel1 agent1.sinks.sink1b.channel = channel1 agent1.sinkgroups.sinkgroup1.sinks = sink1a sink1b agent1.sinkgroups.sinkgroup1.processor.type = load_balance agent1.sinkgroups.sinkgroup1.processor.backoff = true agent1.sources.source1.type = spooldir agent1.sources.source1.spoolDir = /tmp/spooldir agent1.sinks.sink1a.type = avro agent1.sinks.sink1a.hostname = localhost agent1.sinks.sink1a.port = 10000 agent1.sinks.sink1b.type = avro agent1.sinks.sink1b.hostname = localhost agent1.sinks.sink1b.port = 10001 agent1.channels.channel1.type = file
Zdefiniowane są tu dwa ujścia typu avro — sink1a i sink1b. Różnią się one tylko punktem końcowym, z którym są połączone. Ponieważ wszystkie przykłady działają w hoście lokalnym, punkty końcowe różnią się portem. W środowisku rozproszonym hosty byłyby inne, natomiast porty byłyby takie same. W kodzie zdefiniowana jest też grupa sinkgroup1 z ujściami sink1a i sink1b.
378
Rozdział 14. Flume
Typ procesora jest ustawiony na load_balance. Takie procesory próbują rozdzielać zdarzenia między oba ujścia z grupy. W tym celu kierują kolejne zdarzenia na zmianę do każdego ujścia (można to zmienić za pomocą właściwości processor.selector). Jeśli ujście jest niedostępne, procesor sprawdza następne. Jeżeli wszystkie ujścia są zablokowane, zdarzenie nie jest usuwane z kanału (podobnie jak przy używaniu jednego ujścia). Procesor ujść domyślnie nie zapamiętuje, że ujście jest niedostępne. Dlatego sprawdza niedostępne wcześniej ujścia przy każdej porcji przekazywanych zdarzeń. Czasem jest to niewydajne, dlatego ustawiono właściwość processor.backoff, by niedostępne ujścia trafiały na czarną listę na wykładniczo rosnący przedział czasu (aż do 30 sekund; tę wartość określa właściwość processor.selector.maxTimeOut). Inny typ procesora to failover. Taki procesor zamiast równoważyć obciążenie między ujścia, zawsze wybiera preferowane ujście, jeśli jest dostępne. Inne ujścia są używane wtedy, gdy preferowane przestaje działać. Procesor typu failover ma określone priorytety ujść z grupy i na tej podstawie przekazuje zdarzenia. Jeśli ujście o najwyższym priorytecie jest niedostępne, wybierane jest ujście o następnym priorytecie itd. Niedziałające ujścia trafiają na czarną listę na coraz dłuższy czas (do 30 sekund; tę wartość można kontrolować za pomocą właściwości processor.maxpenalty).
Konfigurację jednego z agentów drugiej warstwy (agent2a) przedstawia listing 14.6. Listing 14.6. Konfiguracja platformy Flume dla agenta drugiej warstwy używanego do równoważenia obciążenia agent2a.sources = source2a agent2a.sinks = sink2a agent2a.channels = channel2a agent2a.sources.source2a.channels = channel2a agent2a.sinks.sink2a.channel = channel2a agent2a.sources.source2a.type = avro agent2a.sources.source2a.bind = localhost agent2a.sources.source2a.port = 10000 agent2a.sinks.sink2a.type = hdfs agent2a.sinks.sink2a.hdfs.path = /tmp/flume agent2a.sinks.sink2a.hdfs.filePrefix = events-a agent2a.sinks.sink2a.hdfs.fileSuffix = .log agent2a.sinks.sink2a.hdfs.fileType = DataStream agent2a.channels.channel2a.type = file
Konfiguracja agenta agent2b wygląda prawie tak samo. Inny jest port źródła typu avro (ponieważ przykłady działają w hoście lokalnym) i przedrostek plików generowanych przez ujście typu hdfs. Ten przedrostek pozwala zapewnić, że pliki utworzone w systemie HDFS przez agenty drugiej warstwy w tym samym momencie będą miały różne nazwy. W częściej spotykanym rozwiązaniu, gdy agenty działają w różnych maszynach, unikatowe nazwy plików można uzyskać za pomocą nazwy hosta. Wymaga to skonfigurowania interceptora hosta (zobacz tabelę 14.1) i dołączenia sekwencji %{host} w ścieżce lub przedrostku. agent2.sinks.sink2.hdfs.filePrefix = events-%{host}
Diagram całego systemu przedstawia rysunek 14.6.
Grupy ujść
379
Rysunek 14.6. Równoważenie obciążenia między dwa agenty
Integrowanie platformy Flume z aplikacjami Źródło typu avro to punkt końcowy wywołań RPC akceptujący zdarzenia platformy Flume. Dlatego można napisać klienta RPC służącego do przesyłania zdarzeń do punktu końcowego. Taki klient może być częścią dowolnej aplikacji, która ma przekazywać zdarzenia do platformy Flume. Flume SDK to moduł udostępniający klasę RpcClient Javy, która wysyła obiekty typu Event do punktu końcowego Avro (źródła typu avro działającego w agencie platformy Flume — zwykle w innej warstwie). Klienty można skonfigurować w taki sposób, by umożliwić przełączanie awaryjne lub równoważenie obciążenia między punktami końcowymi. Obsługiwane są też punkty końcowe Thrift (źródła typu thrift). Zagnieżdżony agent platformy Flume zapewnia podobne możliwości. Jest to uproszczony agent platformy Flume działający w aplikacjach Javy. Ma on jedno specjalne źródło, do którego aplikacje mogą przesyłać obiekty typu Event platformy Flume za pomocą metod obiektu typu EmbeddedAgent. Obsługiwane są tu tylko ujścia typu avro, można jednak skonfigurować kilka ujść pod kątem przełączania awaryjnego lub równoważenia obciążenia. Wspomniany moduł SDK i zagnieżdżony agent są szczegółowo opisane w podręczniku programisty platformy Flume (http://flume.apache.org/FlumeDeveloperGuide.html).
380
Rozdział 14. Flume
Katalog komponentów W tym rozdziale wykorzystano niewielki zestaw komponentów. Flume udostępnia ich znacznie więcej. Krótki opis dostępnych komponentów przedstawia tabela 14.1. Więcej informacji na temat ich konfigurowania i stosowania zawiera podręcznik użytkownika platformy Flume (http://flume. apache.org/FlumeUserGuide.html). Tabela 14.1. Komponenty platformy Flume Kategoria
Komponent
Opis
Źródło
Avro
Odbiera w porcie zdarzenia przesyłane za pomocą wywołań RPC systemu Avro przez ujście typu avro lub moduł Flume SDK.
Exec
Uruchamia polecenie uniksowe (na przykład tail -F/ścieżka/do/pliku) i przekształca wiersze wczytane ze standardowego wyjścia na zdarzenia. Zauważ, że to źródło nie gwarantuje dostarczenia zdarzeń do kanału. Aby uzyskać takie gwarancje, zastosuj źródło typu spooldir lub moduł Flume SDK.
HTTP
Odbiera w porcie żądania HTTP i przekształca je na zdarzenia za pomocą podłączonego mechanizmu (na przykład dla danych w formacie JSON lub dużych obiektów binarnych).
JMS
Wczytuje komunikaty z kolejek lub tematów interfejsu JMS i przekształca je na zdarzenia.
Ujście
Kanał
Netcat
Oczekuje w porcie na wiersze tekstu i przekształca je na zdarzenia.
Generator sekwencji
Generuje zdarzenia na podstawie zwiększającego się licznika. Przydatny przy testach.
Katalog na przetwarzane dane
Wczytuje wiersze z plików z katalogu przeznaczonego na przetwarzane dane i przekształca je na zdarzenia.
Syslog
Wczytuje wiersze z dziennika systemowego i przekształca je na zdarzenia.
Thrift
Odbiera w porcie zdarzenia przesyłane za pomocą wywołań RPC platformy Thrift przez ujście typu thrift lub moduł Flume SDK.
Twitter
Łączy się ze strumieniowym interfejsem API Twittera (udostępniającym do 1% generowanych tweetów) i przekształca tweety na zdarzenia.
Avro
Przesyła zdarzenia do źródła typu avro za pomocą wywołań RPC systemu Avro.
Elasticsearch
Zapisuje zdarzenia w formacie Logstash do klastra z serwerem Elasticsearch.
Zamykanie starych plików i otwieranie nowych
Zapisuje zdarzenia do lokalnego systemu plików.
HBase
Zapisuje zdarzenia do bazy HBase za pomocą wybranego mechanizmu serializacji.
HDFS
Zapisuje zdarzenia do systemu HDFS jako tekst, plik typu SequenceFile lub plik systemu Avro albo w niestandardowym formacie.
IRC
Przesyła zdarzenia do kanału IRC.
Logger
Rejestruje zdarzenia na poziomie INFO za pomocą interfejsu SLF4J. Przydatne przy testach.
Morphline (Solr)
Przetwarza zdarzenia za pomocą wewnątrzprocesowego łańcucha poleceń z pliku Morphline. Zwykle służy do wczytywania danych do platformy Solr.
Null
Odrzuca wszystkie zdarzenia.
Thrift
Przesyła zdarzenia do źródła typu thrift za pomocą wywołań RPC platformy Thrift.
Plikowy
Przechowuje zdarzenia w dzienniku transakcji w lokalnym systemie plików.
JDBC
Przechowuje zdarzenia w bazie danych (osadzonej bazie Derby).
W pamięci
Przechowuje zdarzenia w kolejce w pamięci.
Katalog komponentów
381
Tabela 14.1. Komponenty platformy Flume — ciąg dalszy Kategoria
Komponent
Opis
Interceptor
Z hostem
Ustawia dla wszystkich zdarzeń nagłówek host zawierający nazwę hosta i adres IP agenta.
Z plikiem Morphline
Filtruje zdarzenia na podstawie pliku konfiguracyjnego Morphline. Przydatny do warunkowego usuwania zdarzeń lub dodania nagłówków na podstawie dopasowywania do wzorca lub pobierania treści.
Z pobieraniem danych na podstawie wyrażenia regularnego
Na podstawie podanego wyrażenia regularnego ustawia nagłówki pobrane z ciała zdarzenia.
Z filtrowaniem danych na podstawie wyrażenia regularnego
Akceptuje lub odrzuca zdarzenia na podstawie dopasowania tekstu z ciała zdarzenia do podanego wyrażenia regularnego.
Statyczny
Ustawia dla wszystkich zdarzeń określony nagłówek z ustaloną wartością.
Ze znacznikiem czasu
Ustawia nagłówek timestamp zawierający podany w milisekundach moment przetworzenia zdarzenia przez agenta.
Z identyfikatorem UUID
Ustawia dla wszystkich zdarzeń nagłówek id zawierający identyfikator UUID. Przydatny do późniejszego usuwania duplikatów.
Dalsza lektura Ten rozdział zawiera krótki przegląd platformy Flume. Więcej szczegółów znajdziesz w książce Using Flume Hariego Shreedharana (O’Reilly, 2014). W książce Hadoop Application Architectures Marka Grovera, Teda Malaski, Jonathana Seidmana i Gwen Shapira (O’Reilly, 2014) opisano wiele praktycznych informacji na temat projektowania potoków przetwarzania danych (i ogólnej wiedzy o budowaniu aplikacji Hadoopa).
382
Rozdział 14. Flume
ROZDZIAŁ 15.
Sqoop
Aaron Kimball Wielką zaletą Hadoopa jest możliwość pracy z danymi o różnej formie. System HDFS potrafi przechowywać dzienniki i dane z wielu różnych źródeł, a programy w modelu MapReduce pozwalają przetwarzać rozmaite doraźnie tworzone formaty danych, wyodrębniać istotne informacje i łączyć różne zbiory danych w wartościowe zbiory wyników. Jednak do interakcji z danymi z repozytoriów spoza systemu HDFS programy w modelu MapReduce muszą używać zewnętrznych interfejsów API. Wartościowe dane organizacji często są przechowywane w ustrukturyzowanych magazynach danych, na przykład w systemach RDBMS (ang. Relational Database Management System). Apache Sqoop (http://sqoop.apache.org/) to narzędzie o otwartym dostępie do kodu źródłowego, które umożliwia użytkownikom pobieranie danych z ustrukturyzowanych magazynów do Hadoopa na potrzeby dalszego przetwarzania. Przetwarzać dane można w programach w modelu MapReduce lub w innych narzędziach wyższego poziomu (takich jak Hive). Sqoopa można wykorzystać także do przenoszenia danych z jednej bazy do bazy HBase. Gdy pojawiają się ostateczne wyniki z potoku analiz, Sqoop może wyeksportować je z powrotem do magazynu danych, aby mogły z nich korzystać inne klienty. W tym rozdziale zobaczysz, jak Sqoop działa i jak wykorzystać to narzędzie w potoku przetwarzania danych.
Pobieranie Sqoopa Sqoop jest dostępny w kilku miejscach. Głównym „domem” projektu jest witryna organizacji Apache Software Foundation (http://sqoop.apache.org/). W repozytorium w tej witrynie znajdziesz cały kod źródłowy i dokumentację Sqoopa. Dostępne są oficjalne wersje narzędzia, a także kod źródłowy obecnie rozwijanego wydania. W repozytorium znajdują się też instrukcje kompilacji projektu. Inna możliwość to użycie narzędzia Sqoop z dystrybucji od dostawcy Hadoopa. Jeśli pobierasz narzędzie z witryny Apache’a, zostanie ono umieszczone w katalogu /home/nazwa_ użytkownika/sqoop-x.y.z/ lub podobnym. Tu jest on nazywany katalogiem $SQOOP_HOME. Aby uruchomić Sqoopa, wywołaj skrypt wykonywalny $SQOOP_HOME/bin/sqoop.
383
Jeżeli zainstalowałeś wersję od innego dostawcy, skrypty Sqoopa zostaną umieszczone w standardowej lokalizacji, na przykład /usr/bin/sqoop. Sqoopa można uruchomić, wpisując w wierszu poleceń sqoop. Skrypt uruchomieniowy od tego miejsca będzie nazywany sqoop (niezależnie od tego, w jaki sposób zainstalowałeś Sqoopa).
Sqoop 2 Sqoop 2 to nowa wersja Sqoopa, w której rozwiązano architektoniczne ograniczenia Sqoopa 1. Sqoop 1 to narzędzie uruchamiane z poziomu wiersza poleceń, które nie udostępnia interfejsu API Javy, dlatego trudno jest je umieszczać w innych programach. Ponadto w Sqoopie 1 każdy konektor musi znać wszystkie formaty wyjściowe, dlatego pisanie nowych konektorów wymaga dużo pracy. W Sqoopie 2 dostępny jest komponent serwerowy, który uruchamia zadania. Oprócz tego używane są różne klienty: interfejs działający w wierszu poleceń (ang. command-line interface — CLI), sieciowy interfejs użytkownika, interfejs API typu REST i interfejs API Javy. W Sqoopie 2 będzie też można używać innych silników wykonawczych, na przykład Sparka. Zauważ, że interfejs CLI ze Sqoopa 2 nie jest zgodny z interfejsem CLI ze Sqoopa 1. Obecnie stabilne są wersje z rodziny Sqoop 1. Właśnie one są używane w tym rozdziale. Sqoop 2 jest aktywnie rozwijany, jednak na razie nie udostępnia identycznych funkcji jak Sqoop 2. Dlatego przed wykorzystaniem Sqoopa 2 w rozwiązaniach produkcyjnych sprawdź, czy sprawdzi się w danej sytuacji.
Uruchomienie Sqoopa bez argumentów nie daje ciekawych efektów. % sqoop Try sqoop help for usage.
Sqoop jest używany jako zestaw narzędzi lub poleceń. Jeśli nie wybierzesz konkretnego narzędzia, Sqoop nie będzie wiedział, co ma zrobić. Jednym z takich narzędzi jest help. Wyświetla ono listę dostępnych instrukcji. % sqoop help usage: sqoop COMMAND [ARGS] Available commands: codegen create-hive-table eval export help import import-all-tables job list-databases list-tables merge metastore version
Generate code to interact with database records Import a table definition into Hive Evaluate a SQL statement and display the results Export an HDFS directory to a database table List available commands Import a table from a database to HDFS Import tables from a database to HDFS Work with saved jobs List available databases on a server List available tables in a database Merge results of incremental imports Run a standalone Sqoop metastore Display version information
See 'sqoop help COMMAND' for information on a specific command.
Narzędzie help może też wyświetlić informacje dotyczące użytkowania różnych poleceń. Wymaga to podania nazwy określonej instrukcji jako argumentu.
384
Rozdział 15. Sqoop
% sqoop help import usage: sqoop import [GENERIC-ARGS] [TOOL-ARGS] Common arguments: --connect --driver --hadoop-home --help -P --password --username --verbose ...
Specify JDBC connect string Manually specify JDBC driver class to use Override $HADOOP_HOME Print usage instructions Read password from console Set authentication password Set authentication username Print more information while working
Inny sposób na uruchamianie narzędzi Sqoopa polega na użyciu ich skryptów. Takie skrypty mają nazwy w formacie sqoop-nazwa_narzędzia (na przykład sqoop-help, sqoop-import itd.). Uruchomienie takich skryptów z poziomu wiersza poleceń ma identyczny efekt jak wywołanie instrukcji sqoop help lub sqoop import.
Konektory Sqoopa Sqoop obejmuje platformę rozszerzeń, która umożliwia importowanie danych z dowolnego zewnętrznego systemu z obsługą masowego transferu danych. Możliwe jest też eksportowanie danych do takich systemów. Konektor Sqoopa to modułowy komponent, który wykorzystuję tę platformę, aby umożliwiać import i eksport danych. Sqoop jest udostępniany z konektorami, które działają dla wielu popularnych baz danych, w tym dla baz: MySQL, PostgreSQL, Oracle, SQL Server, DB2 i Netezza. Istnieje też uniwersalny konektor JDBC służący do łączenia się z dowolną bazą, która obsługuje protokół JDBC Javy. Sqoop udostępnia zoptymalizowane konektory dla baz MySQL, PostgreSQL, Oracle i Netezza. Te konektory wykorzystują specyficzne dla tych baz interfejsy API do wydajniejszego przeprowadzania masowego transferu danych (dokładnie jest to opisane w punkcie „Importowanie w trybie bezpośrednim”). Oprócz wbudowanych konektorów Sqoopa dostępne są też różne niezależne konektory dla rozmaitych magazynów danych — od korporacyjnych hurtowni danych (takich jak Teradata) po magazyny typu NoSQL (takie jak Couchbase). Takie konektory trzeba pobierać niezależnie. Można je dodać do istniejącej instalacji Sqoopa na podstawie instrukcji dostępnych razem z konektorem.
Przykładowa operacja importu Po zainstalowaniu Sqoopa można wykorzystać go do zaimportowania danych do Hadoopa. W przykładach w tym rozdziale zastosowano system bazodanowy MySQL. Jest łatwy w użyciu i dostępny w wielu systemach operacyjnych. Aby zainstalować i skonfigurować system MySQL, zastosuj się do dokumentacji elektronicznej (http://dev.mysql.com/doc/). Pomocny jest zwłaszcza rozdział 2. („Installing and Upgrading MySQL”). Użytkownicy dystrybucji Linuksa opartych na Debianie (na przykład dystrybucji Ubuntu) mogą wpisać polecenie sudo apt-get install mysql-client mysql-server. Użytkownicy dystrybucji Red Hat mogą posłużyć się poleceniem sudo yum install mysql mysql-server.
Przykładowa operacja importu
385
Po zainstalowaniu systemu MySQL można zalogować się do niego i utworzyć bazę danych (listing 15.1). Listing 15.1. Tworzenie schematu nowej bazy danych w systemie MySQL % mysql -u root -p Enter password: Welcome to the MySQL monitor. Commands end with ; or \g. Your MySQL connection id is 235 Server version: 5.6.21 MySQL Community Server (GPL) Type 'help;' or '\h' for help. Type '\c' to clear the current input statement. mysql> CREATE DATABASE hadoopguide; Query OK, 1 row affected (0.00 sec) mysql> GRANT ALL PRIVILEGES ON hadoopguide.* TO ''@'localhost'; Query OK, 0 rows affected (0.00 sec) mysql> quit; Bye
Gdy pojawi się monit o hasło, należy wprowadzić hasło administratora. Prawdopodobnie jest ono identyczne z hasłem używanym do logowania się do powłoki administratora. Jeśli używasz Ubuntu lub innej dystrybucji Linuksa, w której administrator nie może logować się bezpośrednio, wprowadź hasło ustawione w trakcie instalowania systemu MySQL. Jeżeli nie ustawiłeś hasła, wystarczy wcisnąć przycisk Enter. W przedstawionej sesji utworzono schemat nowej bazy danych hadoopguide. Z tej bazy będziesz korzystał w tym rozdziale. Dalsze instrukcje umożliwiają lokalnym użytkownikom wyświetlanie i modyfikowanie zawartości schematu hadoopguide oraz zamykają sesję1. Teraz zaloguj się ponownie do bazy danych (tym razem z poziomu swojego konta, a nie jako administrator) i utwórz tabelę, którą zaimportujesz w systemie HDFS (listing 15.2). Listing 15.2. Zapełnianie bazy danych % mysql hadoopguide Welcome to the MySQL monitor. Commands end with ; or \g. Your MySQL connection id is 257 Server version: 5.6.21 MySQL Community Server (GPL) Type 'help;' or '\h' for help. Type '\c' to clear the current input statement. mysql> CREATE TABLE widgets(id INT NOT NULL PRIMARY KEY AUTO_INCREMENT, -> widget_name VARCHAR(64) NOT NULL, -> price DECIMAL(10,2), -> design_date DATE, -> version INT, -> design_comment VARCHAR(100)); Query OK, 0 rows affected (0.00 sec)
1
Oczywiście w środowisku produkcyjnym należy zachować znacznie większą ostrożność w zakresie kontroli dostępu, jednak to rozwiązanie służy tylko do ilustracji. Instrukcje GRANT z przykładu są oparte na założeniu, że używasz pseudorozproszonej instalacji Hadoopa. Jeśli pracujesz w rozproszonym klastrze opartym na Hadoopie, musisz umożliwić zdalny dostęp przynajmniej jednemu użytkownikowi. Jego konto posłuży do importowania i eksportowania danych za pomocą Sqoopa.
386
Rozdział 15. Sqoop
mysql> INSERT INTO widgets VALUES (NULL, 'sprocket', 0.25, '2010-02-10', -> 1, 'Connects two gizmos'); Query OK, 1 row affected (0.00 sec) mysql> INSERT INTO widgets VALUES (NULL, 'gizmo', 4.00, '2009-11-30', 4, -> NULL); Query OK, 1 row affected (0.00 sec) mysql> INSERT INTO widgets VALUES (NULL, 'gadget', 99.99, '1983-08-13', -> 13, 'Our flagship product'); Query OK, 1 row affected (0.00 sec) mysql> quit;
Kod z tego listingu tworzy nową tabelę o nazwie widgets. Baza danych z tą tabelą z fikcyjnymi produktami będzie używana w dalszych przykładach z tego rozdziału. Tabela widgets obejmuje kilka pól o różnych typach danych. Przed przejściem do dalszej lektury należy pobrać plik JAR ze sterownikiem JDBC dla systemu MySQL (jest to sterownik Connector/J) i dodać plik do ścieżki do klas Sqoopa. W tym celu wystarczy umieścić ten plik w katalogu lib Sqoopa. Teraz można za pomocą Sqoopa zaimportować tabelę w systemie HDFS. % sqoop import --connect jdbc:mysql://localhost/hadoopguide \ > --table widgets -m 1 ... 14/10/28 21:36:23 INFO tool.CodeGenTool: Beginning code generation ... 14/10/28 21:36:28 INFO mapreduce.Job: Running job: job_1413746845532_0008 14/10/28 21:36:35 INFO mapreduce.Job: Job job_1413746845532_0008 running in uber mode : false 14/10/28 21:36:35 INFO mapreduce.Job: map 0% reduce 0% 14/10/28 21:36:41 INFO mapreduce.Job: map 100% reduce 0% 14/10/28 21:36:41 INFO mapreduce.Job: Job job_1413746845532_0008 completed successfully ... 14/10/28 21:36:41 INFO mapreduce.ImportJobBase: Retrieved 3 records.
Narzędzie import Sqoopa wykonuje zadanie w modelu MapReduce, które nawiązuje połączenie z bazą MySQL i wczytuje tabelę. Domyślnie używane są cztery równoległe operacje mapowania, co przyspiesza proces importu. Każda operacja zapisuje zaimportowane wyniki do innego pliku, przy czym wszystkie pliki trafiają do tego samego katalogu. Ponieważ wiadomo, że importowane są tylko trzy wiersze, określono, że należy zastosować jedną operację mapowania (-m 1), dlatego w systemie HDFS generowany jest jeden plik. Łańcuch znaków połączenia (jdbc:mysql://localhost/hadoopguide) użyty w przykładzie powoduje wczytanie danych z bazy z lokalnej maszyny. W rozproszonym klastrze opartym na Hadoopie w łańcuchu znaków nie należy używać nazwy localhost, ponieważ operacje mapowania, które nie działają w tej samej maszynie, gdzie działa baza danych, nie będą mogły nawiązać połączenia. Nawet gdy Sqoop jest uruchamiany w tym samym hoście, w którym pracuje serwer bazy danych, należy podawać pełną nazwę hosta.
Przykładowa operacja importu
387
Zawartość pliku można sprawdzić w następujący sposób. % hadoop fs -cat widgets/part-m-00000 1,sprocket,0.25,2010-02-10,1,Connects two gizmos 2,gizmo,4.00,2009-11-30,4,null 3,gadget,99.99,1983-08-13,13,Our flagship product
Sqoop domyślnie umieszcza importowane dane w plikach tekstowych z ogranicznikami w postaci przecinków. Można jawnie ustawić ograniczniki, a także znaki służące do wyznaczania pól i znaki ucieczki, umożliwiające umieszczanie ograniczników w zawartości pól. Podawane w wierszu poleceń argumenty określające ograniczniki, formaty plików, kompresję i inne aspekty procesu importu są opisane w podręczniku użytkownika dostępnym razem ze Sqoopem2, a także w pomocy elektronicznej (sqoop help import lub man sqoop-import w dystrybucji CDH).
Formaty plików tekstowych i binarnych Sqoop potrafi importować dane do plików w kilku różnych formatach. Pliki tekstowe (używane domyślnie) zapewniają dane w postaci czytelnej dla człowieka, są niezależne od platformy i mają najprostszą strukturę. Nie mogą jednak przechowywać pól binarnych (na przykład wartości kolumn typu VARBINARY z bazy danych), a odróżnianie wartości null od pól typu String z tekstem "null" sprawia czasem problemy (przy czym użycie opcji importu --null-string pozwala kontrolować reprezentację wartości null). Na potrzeby obsługi różnych możliwości Sqoop współdziała z plikami typu SequenceFile, plikami danych systemu Avro i plikami w formacie Parquet. Te formaty binarne zapewniają najdokładniejszą reprezentację importowanych danych. Ponadto pozwalają na kompresję danych przy zachowaniu możliwości równoległego generowania różnych fragmentów tego samego pliku w modelu MapReduce. Jednak obecne wersje Sqoopa nie pozwalają na wczytywanie plików danych systemu Avro i plików typu SequenceFile do platformy Hive (choć można ręcznie wczytać pliki systemu Avro do platformy Hive i bezpośrednio za pomocą Sqoopa przesłać pliki w formacie Parquet do tej platformy). Inną wadą plików typu SequenceFile jest to, że są specyficzne dla Javy. Pliki systemu Avro i pliki w formacie Parquet można przetwarzać za pomocą różnych języków.
Wygenerowany kod Oprócz zapisywania zawartości tabeli bazy danych do systemu HDFS Sqoop generuje pliki z kodem źródłowym w Javie (widgets.java) umieszczane w bieżącym katalogu lokalnym. Po uruchomieniu przedstawionej wcześniej instrukcji sqoop import taki plik można wyświetlić za pomocą polecenia ls widgets.java. Z punktu „Importowanie — dokładne omówienie” dowiesz się, że Sqoop może wykorzystać wygenerowany kod do obsługi deserializacji specyficznych dla tabeli danych ze źródłowej bazy przed ich zapisem w systemie HDFS.
2
Znajdziesz go w witrynie organizacji Apache Software Foundation (http://sqoop.apache.org/).
388
Rozdział 15. Sqoop
Wygenerowana klasa (widgets) potrafi zapisać rekord pobrany z importowanej tabeli. Taki rekord można przetwarzać w modelu MapReduce lub zapisać jako plik typu SequenceFile w systemie HDFS. Pliki typu SequenceFile zapisywane w Sqoopie w procesie importu zawierają każdy zaimportowany wiersz jako wartość w parze klucz-wartość. Do zapisu danych używana jest wygenerowana klasa. Możliwe, że nie chcesz, aby wygenerowana klasa nosiła nazwę widgets (ponieważ każdy obiekt tej klasy reprezentuje tylko jeden rekord). Możesz wykorzystać inne narzędzie Sqoopa do wygenerowania kodu źródłowego bez wpływu na proces importu. Wygenerowany w ten sposób kod też sprawdza tabelę bazy, aby ustalić typy danych odpowiednie dla poszczególnych pól. % sqoop codegen --connect jdbc:mysql://localhost/hadoopguide \ > --table widgets --class-name Widget
Narzędzie codegen tylko generuje kod — nie przeprowadza pełnego importu. Tu określono, że narzędzie ma wygenerować klasę o nazwie Widget. Jest ona zapisywana w pliku Widget.java. Argument --class-name i inne argumenty związane z generowaniem kodu można też podać w ramach procesu importu. Narzędzie codegen pozwala odtworzyć kod po przypadkowym usunięciu pliku z kodem źródłowym lub wygenerować kod z innymi ustawieniami niż opcje z procesu importu. Jeśli pracujesz z rekordami zaimportowanymi do plików typu SequenceFile, z pewnością będziesz musiał użyć wygenerowanych klas (w celu deserializacji danych z takich plików). Rekordy z plików tekstowych można przetwarzać bez wygenerowanego kodu, jednak (jak zobaczysz w punkcie „Praca z zaimportowanymi danymi”) taki kod umożliwia automatyczną obsługę niektórych żmudnych aspektów przetwarzania danych.
Inne systemy serializacji Nowe wersje Sqoopa obsługują serializację i generowanie schematów z wykorzystaniem systemu Avro (zobacz rozdział 12.), co pozwala wykorzystać Sqoopa w projekcie bez integrowania go z wygenerowanym kodem.
Importowanie — dokładne omówienie Jak wcześniej wspomniano, Sqoop importuje tabelę z bazy danych za pomocą zadania w modelu MapReduce, które pobiera wiersze z tabeli i zapisuje rekordy do systemu HDFS. W jaki sposób model MapReduce wczytuje wiersze? W tym podrozdziale opisano, jak Sqoop działa na zapleczu. Rysunek 15.1 przedstawia na ogólnym poziomie interakcje Sqoopa ze źródłową bazą danych i z Hadoopem. Sqoop, podobnie jak sam Hadoop, jest napisany w Javie. Java udostępnia interfejs API JDBC (ang. Java Database Connectivity), który umożliwia aplikacjom dostęp do danych przechowywanych w systemie RDBMS, a także analizę takich danych. Większość producentów baz danych udostępnia sterownik JDBC, który implementuje interfejs API JDBC i zawiera kod potrzebny do łączenia się z określonymi serwerami baz danych.
Importowanie — dokładne omówienie
389
Rysunek 15.1. Proces importu w Sqoopie
Sqoop na podstawie adresu URL z łańcucha znaków połączenia używanego do uzyskania dostępu do bazy próbuje ustalić, który sterownik należy wczytać. Użytkownik musi pobrać sterownik JDBC i zainstalować go w kliencie Sqoopa. W sytuacjach, gdy Sqoop nie potrafi określić, który sterownik JDBC jest odpowiedni, użytkownicy mogą wskazać właściwy sterownik za pomocą argumentu --driver. Pozwala to na współdziałanie Sqoopa z różnymi platformami bazodanowymi.
Przed rozpoczęciem importu Sqoop używa sterownika JDBC do przeanalizowania przeznaczonej do zaimportowania tabeli. Pobiera listę wszystkich kolumn i określa ich typy danych z SQL-a. Typy SQL-a (VARCHAR, INTEGER itd.) można odwzorować na typy danych Javy (String, Integer itd.) używane do przechowywania wartości pól w aplikacjach w modelu MapReduce. Generator kodu ze Sqoopa wykorzystuje uzyskane informacje do utworzenia specyficznej dla tabeli klasy, przechowującej pobrane z tabeli rekordy. Na przykład przedstawiona wcześniej klasa Widget zawiera poniższe metody, które pobierają każdą kolumnę rekordu. public public public public public public
Integer get_id(); String get_widget_name(); java.math.BigDecimal get_price(); java.sql.Date get_design_date(); Integer get_version(); String get_design_comment();
Ważniejsze dla działania procesu importu są metody serializacji z interfejsu DBWritable. Umożliwiają one klasie Widget interakcję ze sterownikiem JDBC. public void readFields(ResultSet __dbResults) throws SQLException; public void write(PreparedStatement __dbStmt) throws SQLException;
390
Rozdział 15. Sqoop
Interfejs ResultSet z interfejsu JDBC zapewnia kursor, który pobiera rekordy z zapytania. W omawianym przykładzie metoda readFields() zapełnia pola obiektu typu Widget kolumnami z wiersza danych ze zbioru wyników ResultSet. Metoda write() umożliwia Sqoopowi wstawianie do tabeli nowych wierszy typu Widget. Tak działa proces eksportu. Eksportowanie opisano w punkcie „Eksportowanie”. Uruchomione przez Sqoopa zadanie w modelu MapReduce używa obiektu typu InputFormat, który wczytuje dane tabeli z bazy za pomocą sterownika JDBC. Dostępna w Hadoopie klasa DataDriven DBInputFormat rozdziela wyniki zapytania między operacje mapowania. Odczyt tabeli odbywa się zwykle za pomocą prostego zapytania. SELECT kol1,kol2,kol3,... FROM nazwaTabeli
Jednak często wyższą wydajność można uzyskać dzięki podziałowi zapytania między różne węzły. Używana jest do tego kolumna podziału. Na podstawie metadanych tabeli Sqoop ustala kolumnę odpowiednią do podziału tabeli (zwykle jest nią kolumna klucza głównego tabeli, jeśli jest dostępna). Pobierane są wartości minimalna i maksymalna tej kolumny, po czym Sqoop używa ich razem z docelową liczbą operacji do określenia zapytań, które każda operacja powinna wywołać. Załóżmy, że w tabeli widgets znajduje się 100 000 elementów, a kolumna id ma wartości od 0 do 99999. Przy importowaniu tej tabeli Sqoop ustala, że id to kolumna klucza głównego tabeli. W momencie uruchamiania zadania w modelu MapReduce obiekt typu DataDrivenDBInputFormat służący do przeprowadzania importu wywołuje polecenie SELECT MIN(id), MAX(id) FROM widgets. Uzyskane wartości służą do podziału całego zbioru danych. Jeśli programista określił, że równolegle wykonywanych ma być pięć operacji mapowania (opcja -m 5), poszczególne operacje wywołają zapytania w postaci SELECT id, widget_name, ... FROM widgets WHERE id >= 0 AND id < 20000, SELECT id, widget_name, ... FROM widgets WHERE id >= 20000 AND id < 40000 itd. Wybór kolumny podziału jest bardzo ważny, jeśli równoległe operacje mają być wykonywane wydajnie. Jeżeli wartości w kolumnie id nie są równomiernie rozłożone (na przykład nie istnieją elementy o identyfikatorach z przedziału od 50000 do 75000), niektóre operacje mapowania będą miały do wykonania niewiele pracy, natomiast inne będą mocno obciążone. Użytkownicy mogą wskazać kolumnę podziału w momencie uruchamiania importu (umożliwia to argument --split-by), aby dopasować działanie zadania do rozkładu danych. Jeśli w ramach importu używana jest jedna (sekwencyjna) operacja (opcja -m 1), podział nie jest przeprowadzany. Po wygenerowaniu kodu służącego do deserializacji i skonfigurowaniu obiektu typu InputFormat Sqoop przekazuje zadanie do klastra, w którym używany jest model MapReduce. Operacje mapowania przesyłają zapytania i deserializują wiersze z obiektu typu ResultSet, by zapisać je w obiektach wygenerowanej klasy. Dane albo są zapisywane bezpośrednio w obiektach typu SequenceFile, albo przekształcane na tekst z ogranicznikami przed zapisaniem w systemie HDFS.
Kontrolowanie procesu importu Sqoop nie musi importować całej tabeli. Można wskazać podzbiór kolumn tabeli przeznaczonych do zaimportowania. Użytkownicy mogą też dodać do zapytania klauzulę WHERE. Służy do tego argument --where, określający wiersze, które należy zaimportować. Na przykład: jeśli w zeszłym Kontrolowanie procesu importu
391
miesiącu zaimportowano produkty o identyfikatorach z przedziału od 0 do 99999, a w bieżącym miesiącu firma dodała do katalogu 1000 nowych pozycji, proces importu można skonfigurować za pomocą klauzuli WHERE id >= 100000. To sprawi, że odpowiedzialne za import zadanie pobierze wszystkie nowe wiersze dodane do źródłowej bazy od czasu ostatniego importu. Podane przez użytkownika klauzule WHERE są uwzględniane przed podziałem danych i są dodawane do zapytań wykonywanych w każdej operacji. Aby uzyskać większą kontrolę (na przykład w celu przeprowadzenia transformacji kolumn), użytkownicy mogą zastosować argument --query.
Import i spójność W trakcie importowania danych do systemu HDFS ważne jest, by zapewnić dostęp do spójnego snapshota z danymi źródłowymi (operacje mapowania wczytujące równolegle dane z bazy działają w odrębnych procesach, dlatego nie mogą pracować w ramach jednej transakcji bazodanowej). Najlepsze rozwiązanie polega na upewnieniu się, że procesy aktualizujące istniejące wiersze tabeli są wyłączone na czas importu.
Przyrostowy import Często import przeprowadza się okresowo, w celu zsynchronizowania danych z systemu HDFS z danymi z bazy. To podejście wymaga sposobu identyfikowania nowych danych. Sqoop importuje wiersze, w których wartość kolumny (wskazanej za pomocą argumentu --check-column) jest większa od podanej (za pomocą argumentu --last-value). Wartością podaną w argumencie --last-value może być rosnący identyfikator wiersza, na przykład klucz główny z atrybutem AUTO_INCREMENT z bazy MySQL. To rozwiązanie jest odpowiednie w sytuacji, gdy do tabeli bazy danych dodawane są nowe wiersze, natomiast istniejące nie są aktualizowane. Tak działa tryb dodawania, aktywowany za pomocą opcji --incremental append. Inne podejście polega na przyrostowym importowaniu danych na podstawie czasu (opcja --incremental lastmodified). Jest ono odpowiednie, gdy dostępna jest kolumna z czasem ostatniej aktualizacji, a istniejące wiersze mogą być modyfikowane. Po zakończeniu importu przyrostowego Sqoop wyświetla wartość, którą należy podać w argumencie --last-value przy następnym imporcie. Jest to przydatne przy ręcznym uruchamianiu importu przyrostowego, jednak w celu okresowego importu lepiej jest zastosować zapisane zadanie Sqoopa, które automatycznie zachowuje ostatnią wartość i wykorzystuje ją przy następnym uruchomieniu. Instrukcje użytkowania zapisanych zadań można wyświetlić za pomocą polecenia sqoop job --help.
Importowanie w trybie bezpośrednim Architektura Sqoopa umożliwia wybór jednej z wielu strategii importu. W większości baz danych wykorzystywane jest opisane wcześniej podejście oparte na obiektach typu DataDrivenDBInputFormat. Jednak niektóre bazy udostępniają specjalne narzędzia zaprojektowane w celu szybkiego pobierania danych. Na przykład aplikacja mysqldump z bazy MySQL potrafi wczytywać dane z tabeli szybciej
392
Rozdział 15. Sqoop
niż kanał JDBC. W dokumentacji Sqoopa zastosowanie tego typu zewnętrznych narzędzi jest opisywane jako tryb bezpośredni (ang. direct mode). Ten tryb użytkownik musi włączyć za pomocą argumentu -direct, ponieważ nie jest to rozwiązanie równie uniwersalne jak stosowanie interfejsu JDBC. Tryb bezpośredni w bazie MySQL nie zapewnia na przykład obsługi dużych obiektów, takich jak CLOB lub BLOB. Dlatego Sqoop przy wczytywaniu takich kolumn do systemu HDFS musi wykorzystać interfejs API interfejsu JDBC. Gdy baza danych udostępnia odpowiednie narzędzia, Sqoop potrafi bardzo skutecznie z nich korzystać. Import danych z bazy MySQL w trybie bezpośrednim jest zwykle znacznie wydajniejszy (jeśli chodzi o operacje mapowania i potrzebny czas) niż podobny proces oparty na interfejsie JDBC. Sqoop także w trybie bezpośrednim uruchamia wiele równoległych operacji mapowania. Uruchamiają one wiele egzemplarzy programu mysqldump i wczytują wygenerowane w nim dane wyjściowe. Sqoop obsługuje też import w trybie bezpośrednim z baz PostgreSQL, Oracle i Netezza. Nawet gdy do dostępu do zawartości bazy danych używany jest tryb bezpośredni, pobieranie metadanych odbywa się za pomocą interfejsu JDBC.
Praca z zaimportowanymi danymi Po zaimportowaniu danych do systemu HDFS można przystąpić do ich przetwarzania za pomocą programów w modelu MapReduce. Zaimportowany tekst można łatwo wykorzystać w skryptach uruchamianych w technologii Hadoop Streaming lub w zadaniach w modelu MapReduce wykorzystujących domyślny format TextInputFormat. Jednak by wykorzystać poszczególne pola zaimportowanego rekordu, trzeba znaleźć ograniczniki pól (oraz symbole ucieczki i symbole wyznaczające wartości) w celu wyodrębnienia wartości pól i przekształcenia ich na odpowiednie typy danych. Na przykład identyfikator elementu sprocket z wcześniejszego przykładu jest reprezentowany w pliku tekstowym jako łańcuch znaków "1". W Javie ten identyfikator trzeba zinterpretować jako zmienną typu Integer lub int. Klasa wygenerowana przez Sqoopa pozwala zautomatyzować ten proces i skoncentrować się na zadaniu w modelu MapReduce. Każda automatycznie wygenerowana klasa ma kilka wersji przeciążonej metody parse(), przeznaczonych dla danych reprezentowanych jako wartości typu Text, CharSequence, char[] itd. Dostępna w przykładowym kodzie aplikacja MaxWidgetId w modelu MapReduce znajduje element o największym identyfikatorze. Klasę MaxWidgetId można skompilować do pliku JAR razem z plikiem Widget.java, używając pliku POM Mavena (też dostępnego w przykładowym kodzie). Uzyskany plik JAR to sqoop-examples.jar. Można go uruchomić w następujący sposób: % HADOOP_CLASSPATH=$SQOOP_HOME/sqoop-wersja.jar hadoop jar \ > sqoop-examples.jar MaxWidgetId -libjars $SQOOP_HOME/sqoop-wersja.jar
Ta instrukcja gwarantuje, że Sqoop znajduje się w katalogu lokalnym (określonym w zmiennej $HADOOP_CLASSPATH) przy uruchamianiu metody MaxWidgetId.run(), a także przy wykonywaniu operacji mapowania w klastrze (tu katalog jest wskazany w argumencie -libjars). Po wykonaniu programu w katalogu maxwidget systemu HDFS znajdzie się plik o nazwie part-r-00000 o przedstawionej poniżej oczekiwanej zawartości: 3,gadget,99.99,1983-08-13,13,Our flagship product
Praca z zaimportowanymi danymi
393
Warto zauważyć, że w tym przykładowym programie w modelu MapReduce obiekt typu Widget jest przekazywany z mappera do reduktora. Automatycznie wygenerowana klasa Widget implementuje interfejs Writable z Hadoopa, co pozwala przesyłać obiekty tej klasy za pomocą mechanizmu serializacji z Hadoopa, a także zapisywać je w plikach typu SequenceFile i wczytywać je z takich plików. Przykładowa klasa MaxWidgetId jest napisana przy użyciu nowego interfejsu API modelu MapReduce. Aplikacje w modelu MapReduce wykorzystujące kod wygenerowane przez Sqoopa można budować za pomocą nowego i dawnego interfejsu API. Jednak stosowanie niektórych zaawansowanych mechanizmów (na przykład praca z dużymi obiektami) jest łatwiejsze w nowym interfejsie API. Przy imporcie opartym na systemie Avro dane można przetwarzać za pomocą interfejsów API opisanych w punkcie „Avro i model MapReduce” w rozdziale 12. Gdy używane jest odwzorowanie generyczne, program w modelu MapReduce nie musi korzystać z kodu wygenerowanego dla danego schematu (choć jest to możliwe dzięki kompilatorowi z interfejsu specyficznego z systemu Avro; wtedy Sqoop nie generuje kodu). W przykładowym kodzie znajdziesz program MaxWidgetId GenericAvro, który wyszukuje produkt o największym identyfikatorze i zapisuje wynik w pliku danych systemu Avro.
Importowane dane i platforma Hive Z rozdziału 17. dowiesz się, że w wielu analizach używanie systemu takiego jak Hive do obsługi operacji relacyjnych pozwala znacznie uprościć budowanie potoku analiz. Stosowanie systemu Hive jest uzasadnione zwłaszcza dla danych pochodzących ze źródła relacyjnego. Hive i Sqoop razem tworzą przydatny łańcuch narzędzi analitycznych. Załóżmy, że dostępny jest dziennik generowany przez sieciowy system zakupu produktów. System zwraca pliki dziennika zawierające identyfikator produktu, liczbę sztuk, adres dostawy i datę złożenia zamówienia. Oto fragment danych z przykładowego dziennika: 1,15,120 Any St.,Los Angeles,CA,90210,2010-08-01 3,4,120 Any St.,Los Angeles,CA,90210,2010-08-01 2,5,400 Some Pl.,Cupertino,CA,95014,2010-07-30 2,7,88 Mile Rd.,Manhattan,NY,10005,2010-07-18
Użycie Hadoopa do przeanalizowania dziennika pozwala uzyskać wgląd w proces sprzedaży. Połączenie tych danych z danymi ze źródła relacyjnego (z tabeli widgets) daje dodatkowe możliwości. W pokazanej dalej przykładowej sesji zobaczysz, jak ustalić kod pocztowy odpowiedzialny za największą część przychodów. Pozwoli to lepiej skoncentrować wysiłki zespołu sprzedażowego. Potrzebne będą dane zarówno z dziennika sprzedaży, jak i z tabeli widgets. Aby rozwiązanie zadziałało, tabela przedstawiona we wcześniejszym fragmencie kodu powinna znajdować się w pliku lokalnym sales.log. Zacznij od wczytania danych sprzedażowych do platformy Hive: hive> > > >
394
CREATE TABLE sales(widget_id INT, qty INT, street STRING, city STRING, state STRING, zip INT, sale_date STRING) ROW FORMAT DELIMITED FIELDS TERMINATED BY ',';
Rozdział 15. Sqoop
OK Time taken: 5.248 seconds hive> LOAD DATA LOCAL INPATH "ch15-sqoop/sales.log" INTO TABLE sales; ... Loading data to table default.sales Table default.sales stats: [numFiles=1, numRows=0, totalSize=189, rawDataSize=0] OK Time taken: 0.6 seconds
Sqoop potrafi wygenerować tabelę platformy Hive na podstawie tabeli z istniejącego relacyjnego źródła danych. Dane z tabeli widgets zostały już zaimportowane do systemu HDFS, dlatego można wygenerować definicję tabeli platformy Hive, a następnie wczytać dane z systemu HDFS. % sqoop create-hive-table --connect jdbc:mysql://localhost/hadoopguide \ > --table widgets --fields-terminated-by ',' ... 14/10/29 11:54:52 INFO hive.HiveImport: OK 14/10/29 11:54:52 INFO hive.HiveImport: Time taken: 1.098 seconds 14/10/29 11:54:52 INFO hive.HiveImport: Hive import complete. % hive hive> LOAD DATA INPATH "widgets" INTO TABLE widgets; Loading data to table widgets OK Time taken: 3.265 seconds
W trakcie pisania definicji tabeli platformy Hive z myślą o zaimportowanym już określonym zbiorze danych należy podać ograniczniki używane w tym zbiorze. W przeciwnym razie Sqoop pozwoli na użycie ograniczników domyślnych z platformy Hive (innych niż domyślne ograniczniki w Sqoopie). System typów w platformie Hive jest uboższy niż w większości systemów SQL-owych. Wiele typów z SQL-a nie ma bezpośrednich odpowiedników w tej platformie. Gdy Sqoop generuje definicję tabeli platformy Hive na potrzeby importu danych, wykorzystuje najlepszy dostępny typ platformy Hive do przechowywania wartości kolumny. Może to skutkować zmniejszeniem precyzji. Wtedy Sqoop wyświetla komunikat ostrzegawczy podobny do poniższego: 14/10/29 11:54:43 WARN hive.TableDefWriter: Column design_date had to be cast to a less precise type in Hive
Opisany trzyetapowy proces (importowanie danych do systemu HDFS, tworzenie tabeli platformy Hive i wczytywanie danych z systemu HDFS do tej platformy) można skrócić do jednego kroku, jeśli programista wie, że chce zaimportować dane z bazy bezpośrednio do platformy Hive. W procesie importowania Sqoop może wygenerować definicję tabeli platformy Hive, a następnie wczytać dane. Jeżeli nie zaimportowałeś wcześniej danych, możesz wywołać poniższą instrukcję. Tworzy ona w platformie Hive tabelę widgets na podstawie jej wersji z bazy MySQL. % sqoop import --connect jdbc:mysql://localhost/hadoopguide \ > --table widgets -m 1 --hive-import
Wywołanie instrukcji sqoop import z argumentem --hive-import powoduje wczytanie danych bezpośrednio ze źródłowej bazy do platformy Hive. Schemat tabeli platformy Hive jest wtedy generowany automatycznie na podstawie schematu tabeli ze źródłowej bazy danych. W ten sposób pracę z danymi w platformie Hive można rozpocząć po wywołaniu jednego polecenia.
Praca z zaimportowanymi danymi
395
Niezależnie od wybranej drogi importu danych po zakończeniu tego procesu można wykorzystać zbiory danych widgets i sales do określenia kodu pocztowego klientów generujących razem najwyższy przychód. Zrób to i zapisz wynik zapytania w nowej tabeli, by można go było później użyć. hive> CREATE TABLE zip_profits > AS > SELECT SUM(w.price * s.qty) AS sales_vol, s.zip FROM SALES s > JOIN widgets w ON (s.widget_id = w.id) GROUP BY s.zip; ... Moving data to: hdfs://localhost/user/hive/warehouse/zip_profits ... OK hive> SELECT * FROM zip_profits ORDER BY sales_vol DESC; ... OK 403.71 90210 28.0 10005 20.0 95014
Importowanie dużych obiektów Większość baz danych umożliwia przechowywanie dużych ilości danych w jednym polu. W zależności od tego, czy dane są tekstowe, czy binarne, w tabeli zwykle mają postać kolumny typu CLOB lub BLOB. Duże obiekty tego typu są zwykle obsługiwane przez bazę w specjalny sposób. Większość tabel ma na dysku fizyczny układ przedstawiony na rysunku 15.2. W trakcie skanowania wierszy w celu ustalenia, które z nich spełniają kryteria zapytania, z dysku przeważnie wczytywane są wszystkie kolumny każdego wiersza. Gdyby duże obiekty były przechowywane wewnątrzwierszowo w pokazany tu sposób, wydajność skanowania byłaby niska. Dlatego duże obiekty często przechowuje się poza wierszami, co przedstawia rysunek 15.3. Dostęp do dużego obiektu wymaga otwarcia go za pomocą zapisanej w wierszu referencji.
Rysunek 15.2. Tabele bazy danych są zwykle fizycznie reprezentowane jako seria wierszy, w których wszystkie kolumny znajdują się jedna za drugą
Trudność pracy z dużymi obiektami zapisanymi w bazie oznacza, że system taki jak Hadoop, znacznie lepiej dostosowany do przechowywania i przetwarzania dużych, złożonych obiektów z danymi, stanowi doskonałe repozytorium dla danych tego rodzaju. Sqoop potrafi pobierać duże obiekty z tabel i zapisywać je w systemie HDFS w celu ich dalszego przetwarzania. Gdy używana jest baza danych, model MapReduce zwykle materializuje każdy rekord przed przekazaniem go do mappera. Jeśli poszczególne rekordy są naprawdę duże, ten proces może być bardzo niewydajny.
396
Rozdział 15. Sqoop
Rysunek 15.3. Duże obiekty są zazwyczaj przechowywane w odrębnym miejscu pamięci, a w podstawowej lokalizacji wiersza znajdują się referencje do tych obiektów
Jak wcześniej pokazano, rekordy zaimportowane w Sqoopie są rozmieszczane na dysku w podobny sposób jak dane w wewnętrznej strukturze bazy danych. Powstaje tablica rekordów, w których wszystkie pola rekordu znajdują się jedno po drugim. W trakcie wykonywania programu w modelu MapReduce używającego zaimportowanych rekordów każda operacja mapowania musi w pełni zmaterializować wszystkie pola każdego rekordu z przetwarzanej porcji danych wejściowych. Jeśli zawartość pól z dużymi obiektami jest istotna tylko w niewielkiej liczbie rekordów wejściowych programu w modelu MapReduce, pełna materializacja wszystkich rekordów to niewydajne rozwiązanie. Ponadto pełna materializacja bardzo dużych obiektów w pamięci bywa niemożliwa. Aby przezwyciężyć te trudności, Sqoop zapisuje zaimportowane duże obiekty w odrębnym pliku LobFile, jeśli obiekt ma rozmiar większy niż progowy poziom 16 MB (tę wartość można ustawić w bajtach za pomocą właściwości sqoop.inline.lob.length.max). Format LobFile pozwala zapisywać bardzo duże rekordy (używana jest 64-bitowa przestrzeń adresowa). Każdy rekord z pliku LobFile przechowuje jeden duży obiekt. Format LobFile umożliwia klientom przechowywanie referencji do rekordu bez konieczności dostępu do zawartości rekordu. Gdy ten dostęp jest potrzebny, można go uzyskać za pomocą klasy java.io.InputStream (dla obiektów binarnych) lub java.io.Reader (dla obiektów znakowych). Gdy rekord jest importowany, zwykłe pola są materializowane razem w pliku tekstowym. Do tego dodawane są referencje do pliku LobFile, w którym przechowywane są kolumny typu CLOB lub BLOB. Załóżmy, że tabela widgets obejmuje pole typu BLOB o nazwie schematic, przechowujące diagram każdego produktu. Zaimportowany rekord może wyglądać tak: 2,gizmo,4.00,2009-11-30,4,null,externalLob(lf,lobfile0,100,5011714)
Fragment externalLob(…) to referencje do zewnętrznie przechowywanego dużego obiektu, zapisanego w formacie LobFile (lf) w pliku lobfile0. Podane są też pozycja obiektu w bajtach i jego długość.
Importowanie dużych obiektów
397
Metoda Widget.get_schematic() używana do pracy z takim rekordem może zwracać obiekt typu BlobRef z referencją do kolumny schematic, ale bez jej zawartości. To metoda BlobRef.getDataStream() otwiera plik LobFile i zwraca obiekt typu InputStream, umożliwiający dostęp do zawartości pola schematic. Możliwe, że w zadaniu w modelu MapReduce przetwarzającym wiele rekordów typu Widget potrzebny będzie dostęp do pól schematic z tylko nielicznych rekordów. Opisane podejście pozwala ograniczyć koszty operacji wejścia-wyjścia dzięki dostępowi tylko do niezbędnych dużych obiektów. Oznacza to duże oszczędności, ponieważ poszczególne diagramy mogą mieć wielkość kilku megabajtów lub większą. Klasy BlobRef i ClobRef zachowują w pamięci podręcznej referencje do plików LobFile z operacji mapowania. Jeśli potrzebujesz dostępu do pól schematic z kilku kolejnych rekordów, pozwala to wykorzystać obecną lokalizację wskaźnika z pliku do wczytania zawartości następnego rekordu.
Eksportowanie W Sqoopie import polega na przenoszeniu danych z systemu bazodanowego do systemu HDFS. Natomiast eksport polega na użyciu systemu HDFS jako źródła danych i zdalnej bazy danych jako ich docelowej lokalizacji. We wcześniejszych podrozdziałach zobaczyłeś, jak zaimportować dane, a następnie przeprowadzić analizy za pomocą platformy Hive. Wyniki analiz można wyeksportować do bazy danych, by były dostępne dla innych narzędzi. Przed eksportem tabeli z systemu HDFS do bazy danych trzeba przygotować bazę do zapisu danych. W tym celu utwórz docelową tabelę. Choć Sqoop potrafi ustalić, które typy Javy nadają się do przechowywania wartości typów danych z SQL-a, proces ten działa tylko w jedną stronę. Na przykład istnieje kilka typów kolumn SQL-a, w których można zapisać dane typu String z Javy. Można zastosować typ CHAR(64), VARCHAR(200) lub zupełnie inny. Dlatego trzeba wskazać, które typy są najbardziej odpowiednie. Teraz zobacz, jak wyeksportować tabelę zip_profits z platformy Hive. Najpierw trzeba utworzyć w bazie MySQL tabelę z docelowymi kolumnami w odpowiedniej kolejności i z właściwymi typami SQL-a. % mysql hadoopguide mysql> CREATE TABLE sales_by_zip (volume DECIMAL(8,2), zip INTEGER); Query OK, 0 rows affected (0.01 sec)
Następnie należy wywołać polecenie export. % sqoop export --connect jdbc:mysql://localhost/hadoopguide -m 1 \ > --table sales_by_zip --export-dir /user/hive/warehouse/zip_profits \ > --input-fields-terminated-by '\0001' ... 14/10/29 12:05:08 INFO mapreduce.ExportJobBase: Transferred 176 bytes in 13.5373 seconds (13.0011 bytes/sec) 14/10/29 12:05:08 INFO mapreduce.ExportJobBase: Exported 3 records.
W ostatnim kroku należy sprawdzić w bazie MySQL, czy eksport zakończył się powodzeniem.
398
Rozdział 15. Sqoop
% mysql hadoopguide -e 'SELECT * FROM sales_by_zip' +--------+-------+ | volume | zip | +--------+-------+ | 28.00 | 10005 | | 403.71 | 90210 | | 20.00 | 95014 | +--------+-------+
Przy tworzeniu tabeli zip_profits w platformie Hive nie określono ograniczników. Dlatego Hive wykorzystuje ograniczniki domyślne — symbol Ctrl+A (kod Unicode 0x0001) między polami i znak nowego wiersza po każdym rekordzie. Gdy Hive posłużył do uzyskania dostępu do zawartości tabeli (pobieranej w instrukcji SELECT), przekształcił dane w reprezentację z ogranicznikami w postaci tabulacji, aby wyświetlić je w konsoli. Jednak przy odczycie tabel bezpośrednio z plików trzeba wskazać Sqoopowi używane ograniczniki. Sqoop domyślnie przyjmuje, że rekordy są rozdzielane znakiem nowego wiersza. Trzeba jednak określić, że pola są rozdzielane symbolem Ctrl+A. Można to zrobić w argumencie --input-fields-terminated-by instrukcji sqoop export. Sqoop obsługuje kilka ograniczników reprezentowanych jako sekwencje ucieczki rozpoczynające się od lewego ukośnika (\). W przykładowej instrukcji sekwencja ucieczki znajduje się w apostrofach, dzięki czemu powłoka traktuje ją dosłownie. Jeśli pominiesz apostrofy, dodaj jeszcze jeden lewy ukośnik (--input-fieldsterminated-by \\0001). Sekwencje ucieczki obsługiwane w Sqoopie znajdziesz w tabeli 15.1. Tabela 15.1. Sekwencje ucieczki, które można wykorzystać w Sqoopie do wskazywania niewyświetlanych znaków używanych jako ograniczniki pól i rekordów Sekwencja ucieczki
Opis
\b
Klawisz Backspace
\n
Nowy wiersz
\r
Przeniesienie karetki
\t
Tabulacja
\'
Apostrof
\"
Cudzysłów
\\
Lewy ukośnik
\0
Wstawia znak NUL między polami lub wierszami albo wyłącza sekwencję ucieczki, jeśli zostanie użyta dla argumentu --enclosed-by, --optionally-enclosed-by lub --escaped-by.
\0ooo
Ósemkowa reprezentacja punktu kodowego znaku Unicode. Sam znak jest określany na podstawie ósemkowej wartości ooo.
\0xhhh
Szesnastkowa reprezentacja punktu kodowego znaku Unicode. Powinna mieć postać \0xhhh, gdzie hhh to wartość szesnastkowa. Na przykład w instrukcji --fields-terminated-by '\0x10' ustawiony jest znak przeniesienia karetki.
Eksportowanie — dokładne omówienie Sqoop eksportuje dane w sposób bardzo podobny do tego, jak je importuje (zobacz rysunek 15.4). Przed rozpoczęciem eksportu Sqoop ustala strategię na podstawie łańcucha znaków połączenia z bazą danych. W większości systemów Sqoop wykorzystuje sterownik JDBC i generuje klasę Javy
Eksportowanie — dokładne omówienie
399
na podstawie definicji docelowej tabeli. Wygenerowana klasa umożliwia przetwarzanie rekordów z plików tekstowych i wstawianie do tabeli wartości odpowiednich typów (oprócz tego potrafi wczytywać kolumny z obiektów typu ResultSet). Po wygenerowaniu klasy uruchamiane jest zadanie w modelu MapReduce, które wczytuje źródłowe pliki z danymi z systemu HDFS, przetwarza rekordy za pomocą wspomnianej klasy i stosuje wybraną strategię eksportu.
Rysunek 15.4. Eksport odbywa się równolegle z wykorzystaniem modelu MapReduce
Strategia oparta na sterowniku JDBC polega na generowaniu wsadowych instrukcji INSERT, z których każda dodaje kilka rekordów do docelowej tabeli. Wstawianie w każdej instrukcji grup rekordów w większości systemów bazodanowych zapewnia wyższą wydajność niż uruchamianie wielu jednowierszowych instrukcji INSERT. Do wczytywania danych z systemu HDFS i komunikowania się z bazą danych używane są odrębne wątki. Pozwala to zagwarantować, że operacje wejścia-wyjścia w różnych systemach będą odbywały się możliwie równolegle. W bazach MySQL Sqoop może zastosować tryb bezpośredni, wykorzystujący proces mysqlimport. Każda operacja mapowania może utworzyć taki proces i komunikować się z nim za pomocą nazwanego pliku FIFO z lokalnego systemu plików. Dane za pomocą tego kanału FIFO są przesyłane strumieniowo do procesu mysqliport, a następnie trafiają do bazy danych. Choć większość zadań w modelu MapReduce wczytujących dane z systemu HDFS określa poziom równoległości (liczbę operacji mapowania) na podstawie liczby i wielkości przetwarzanych plików, system eksportowania w Sqoopie zapewnia użytkownikom bezpośrednią kontrolę nad liczbą operacji. Wydajność eksportu może zależeć od liczby równoległych procesów zapisu danych w bazie, dlatego Sqoop używa klasy CombineFileInputFormat do grupowania plików wejściowych, aby zmniejszyć liczbę operacji mapowania. 400
Rozdział 15. Sqoop
Eksport i transakcje Ponieważ proces eksportu jest z natury równoległy, nie jest operacją atomową. Sqoop uruchamia wiele operacji w celu równoległego eksportu porcji danych. Operacje kończą pracę w różnym czasie, dlatego choć w ramach operacji używane są transakcje, wyniki z jednej operacji mogą pojawić się przed wynikami innej. Ponadto bazy danych często używają dla transakcji buforów o stałej pojemności. W efekcie jedna transakcja nie zawsze potrafi obsłużyć wszystkie czynności wykonywane w operacji. Sqoop zatwierdza wyniki co kilka tysięcy wierszy, aby zagwarantować, że nie wyczerpie dostępnej pamięci. Zatwierdzone wyniki pośrednie są widoczne jeszcze w trakcie trwania eksportu. Aplikacji, które korzystają z eksportowanych danych, nie należy uruchamiać do momentu zakończenia tego procesu. W przeciwnym razie aplikacja może użyć niepełnych wyników. Aby rozwiązać ten problem, w Sqoopie można wyeksportować tymczasową tabelę pośrednią, a po wykonaniu zadania — jeśli eksport zakończył się powodzeniem — przenieść pośrednie dane do docelowej tabeli w jednej transakcji. Tabelę pośrednią można wskazać za pomocą opcji --staging-table. Tabela pośrednia musi zostać utworzona wcześniej i mieć ten sam schemat co tabela docelowa. Ponadto musi być pusta (chyba że użyto opcji --clear-staging-table). Używanie tabeli pośredniej spowalnia pracę, ponieważ dane trzeba zapisać dwukrotnie — raz w tabeli pośredniej, a następnie w tabeli docelowej. Ponadto proces eksportu wymaga wtedy więcej miejsca, ponieważ w trakcie kopiowania danych pośrednich do docelowej lokalizacji istnieją dwie kopie danych.
Eksport i pliki typu SequenceFile Przykładowy proces eksportu wczytuje dane źródłowe z tabeli platformy Hive, zapisanej w systemie HDFS jako plik tekstowy z ogranicznikami. Sqoop potrafi też eksportować pliki tekstowe z ogranicznikami, które nie są zapisane jako tabele platformy Hive. Możliwy jest na przykład eksport plików tekstowych utworzonych jako dane wyjściowe zadania w modelu MapReduce. Sqoop potrafi też eksportować rekordy zapisane w pliku typu SequenceFile do tabeli wyjściowej, przy czym obowiązują wtedy pewne ograniczenia. Eksportowany plik SequenceFile nie może zawierać rekordów dowolnego typu. Narzędzie Sqoopa odpowiedzialne za eksport wczytuje obiekty z pliku typu SequenceFile i przesyła je bezpośrednio do obiektu typu OutputCollector, który przekazuje eksportowane dane w formacie OutputFormat do bazy. By móc przetwarzać rekordy za pomocą Sqoopa, trzeba je zapisać jako wartości w parach klucz-wartość w pliku typu SequenceFile. Ponadto typ rekordu musi być pochodny od klasy abstrakcyjnej org.apache.sqoop.lib.SqoopRecord (tę cechę mają wszystkie klasy generowane w Sqoopie). Jeśli używasz narzędzia sqoop-codegen do wygenerowania klasy z implementacją typu SqoopRecord dla rekordu na podstawie docelowej tabeli, możesz napisać program w modelu MapReduce, który zapełni obiekty tej klasy i zapisze je do plików typu SequenceFile. Następnie może wyeksportować te pliki do tabeli za pomocą polecenia sqoop-export. Oto jeszcze inny sposób utworzenia danych jako obiektów z rodziny SqoopRecord w plikach typu SequenceFile — jeśli dane zostały zaimportowane z tabeli bazy danych do systemu HDFS i zmodyfikowane, wyniki można zapisać w plikach typu SequenceFile z rekordami odpowiedniego typu danych.
Eksportowanie — dokładne omówienie
401
W omawianym przykładzie Sqoop powinien przy wczytywaniu danych z pliku typu SequenceFile wykorzystać istniejącą definicję klasy, zamiast generować nową (tymczasową) klasę do przechowywania rekordów w celu wyeksportowania danych (co ma miejsce przy przekształcaniu tekstowych rekordów na wiersze bazy danych). Aby zapobiec generowaniu kodu i wykorzystać istniejącą klasę rekordu oraz gotowy plik JAR, podaj w poleceniu Sqoopa argumenty --class-name i --jar-file. Sqoop przy eksportowaniu rekordów zastosuje wtedy wskazaną klasę wczytaną z podanego pliku JAR. Poniższy przykład pokazuje, jak zaimportować tabelę widgets jako plik typu SequenceFile, a następnie wyeksportować ją z powrotem do innej tabeli bazy danych. % sqoop import --connect jdbc:mysql://localhost/hadoopguide \ > --table widgets -m 1 --class-name WidgetHolder --as-sequencefile \ > --target-dir widget_sequence_files --bindir . ... 14/10/29 12:25:03 INFO mapreduce.ImportJobBase: Retrieved 3 records. % mysql hadoopguide mysql> CREATE TABLE widgets2(id INT, widget_name VARCHAR(100), -> price DOUBLE, designed DATE, version INT, notes VARCHAR(200)); Query OK, 0 rows affected (0.03 sec) mysql> exit; % sqoop export --connect jdbc:mysql://localhost/hadoopguide \ > --table widgets2 -m 1 --class-name WidgetHolder \ > --jar-file WidgetHolder.jar --export-dir widget_sequence_files ... 14/10/29 12:28:17 INFO mapreduce.ExportJobBase: Exported 3 records.
W trakcie importowania użyto formatu SequenceFile i określono, że plik JAR ma zostać umieszczony w bieżącym katalogu (--bindir), co pozwala ponownie go wykorzystać. Domyślnie plik JAR jest umieszczany w katalogu tymczasowym. Następnie utworzono docelową tabelę na potrzeby eksportu. Schemat tej tabeli jest nieco inny od pierwotnego, ale zgodny z danymi. W ostatnim kroku uruchomiono proces eksportu, w którym wykorzystano istniejący wygenerowany kod do wczytania rekordów z pliku typu SequenceFile i zapisania ich w bazie danych.
Dalsza lektura Więcej informacji o używaniu Sqoopa znajdziesz w książce Apache Sqoop Cookbook Kathleen Ting i Jareka Jarceca Cecho (O’Reilly, 2013).
402
Rozdział 15. Sqoop
ROZDZIAŁ 16.
Pig
Platforma Apache Pig (http://pig.apache.org/) umożliwia przetwarzanie dużych zbiorów danych na wyższym poziomie abstrakcji. Model MapReduce pozwala programiście wskazać funkcję mapującą, po której ma działać funkcja redukująca. Jednak ustalenie, jak dostosować proces przetwarzania danych do tego wzorca (często niezbędnych jest wiele etapów modelu MapReduce), bywa trudne. W platformie Pig struktury danych są znacznie bogatsze. Obsługują wiele wartości i poziomów zagnieżdżenia oraz umożliwiają skomplikowane przekształcenia danych. Pig udostępnia na przykład złączenia, których stosowanie w modelu MapReduce jest trudne. Platforma Pig składa się z dwóch elementów. Oto one:
Język używany do zapisywania przepływu danych (nazywany Pig Latin, czyli „świńską łaciną”).
Środowisko wykonawcze do uruchamiania programów napisanych w języku Pig Latin. Obecnie dostępne są dwa środowiska — do lokalnego wykonywania kodu w jednej maszynie JVM i do wykonywania kodu w środowisku rozproszonym w klastrach opartych na Hadoopie.
Program w języku Pig Latin składa się z serii operacji (lub transformacji) stosowanych do danych wejściowych w celu uzyskania danych wyjściowych. Cała operacja opisuje przepływ danych, przekształcany przez środowisko wykonawcze platformy Pig do postaci wykonywalnej i uruchamiany. Na zapleczu Pig przekształca transformacje na zadania w modelu MapReduce, jednak dla programisty nie jest to odczuwalne, co pozwala się skoncentrować na danych, a nie na naturze wykonywania programu. Pig to język skryptowy służący do eksplorowania dużych zbiorów danych. Model MapReduce jest krytykowany za to, że proces programowania w nim zajmuje bardzo dużo czasu. Pisanie mapperów i reduktorów, kompilowanie kodu, tworzenie pakietów, przesyłanie zadań i pobieranie wyników jest czasochłonne. Nawet gdy używana jest technologia Streaming, pozwalająca pominąć kompilację i tworzenie pakietów, proces pozostaje skomplikowany. Atrakcyjną cechą platformy Pig jest możliwość przetwarzania terabajtów danych na podstawie kilku wprowadzonych w konsoli wierszy kodu w języku Pig Latin. To narzędzie zostało opracowane w firmie Yahoo!, aby ułatwić naukowcom i inżynierom analizowanie bardzo dużych zbiorów danych. Pig wspomaga pracę programisty piszącego zapytania, ponieważ udostępnia kilka instrukcji do badania struktury danych w programie w trakcie jego pisania. Jeszcze przydatniejsza jest możliwość wykonania przykładowego przebiegu programu na reprezentatywnym podzbiorze danych wejściowych. Pozwala to przed uruchomieniem kodu dla pełnego zbioru danych zobaczyć, czy w trakcie przetwarzania nie pojawiają się błędy.
403
Platformę Pig zaprojektowano z myślą o jej rozszerzaniu. Niemal wszystkie elementy ścieżki przetwarzania można dostosować do potrzeb — wczytywanie, zapisywanie, filtrowanie, grupowanie i złączanie można zmodyfikować za pomocą funkcji zdefiniowanych przez użytkownika (ang. user-defined functions — UDF). Funkcje UDF działają na modelu zagnieżdżanych danych z platformy Pig, dlatego można je ściśle zintegrować z operatorami z tej platformy. Inna korzyść jest taka, że funkcje UDF zwykle ułatwiają ponowne wykorzystanie kodu w większym stopniu niż biblioteki opracowane na potrzeby programów w modelu MapReduce. W niektórych sytuacjach wydajność programów platformy Pig jest niższa niż programów napisanych w modelu MapReduce. Jednak z każdą nową wersją platformy ta różnica maleje, ponieważ zespół odpowiedzialny za to narzędzie opracowuje zaawansowane algorytmy związane z operatorami relacyjnymi platformy Pig. Można bezpiecznie przyjąć, że jeśli nie zamierzasz wkładać dużo wysiłku w optymalizowanie kodu Javy w modelu MapReduce, pisanie zapytań w języku Pig Latin zaoszczędzi Ci dużo czasu.
Instalowanie i uruchamianie platformy Pig Pig działa jako aplikacja po stronie klienta. Nawet jeśli chcesz używać tej platformy w klastrze opartym na Hadoopie, nie trzeba instalować w nim nic dodatkowego. Pig uruchamia zadania i komunikuje się z systemem HDFS (lub innym systemem plików stosowanym w Hadoopie) z poziomu stacji roboczej. Proces instalacji jest prosty. Pobierz stabilną wersję platformy ze strony http://pig.apache.org/ releases.html i wypakuj plik tarball w odpowiednim katalogu stacji roboczej. % tar xzf pig-x.y.z.tar.gz
Wygodnym rozwiązaniem jest dodanie katalogu z binariami Piga do ścieżki wiersza poleceń. Oto przykład: % export PIG_HOME=~/sw/pig-x.y.z % export PATH=$PATH:$PIG_HOME/bin
Ponadto należy ustawić zmienną środowiskową JAVA_HOME w taki sposób, by prowadziła do odpowiedniej wersji Javy. Aby wyświetlić instrukcje użytkowania Piga, wpisz polecenie pig -help.
Tryby wykonywania Pig udostępnia dwa tryby wykonywania kodu — tryb lokalny i tryb modelu MapReduce. W czasie, gdy powstawała ta książka, toczyły się prace nad trybami wykonywania dla narzędzi Apache Tez i Spark (zobacz rozdział 19.). Oba mają zapewniać znaczny wzrost wydajności w porównaniu z trybem modelu MapReduce. Dlatego wypróbuj je, gdy staną się dostępne w używanej przez Ciebie wersji Piga.
Tryb lokalny W trybie lokalnym Pig działa w jednej maszynie JVM i korzysta z lokalnego systemu plików. Ten tryb nadaje się tylko dla małych zbiorów danych i przy wypróbowywaniu platformy Pig. 404
Rozdział 16. Pig
Tryb wykonywania można ustawić za pomocą opcji -x lub -exectype. Aby użyć trybu lokalnego, ustaw opcję na wartość local. % pig -x local grunt
Uruchomiona zostanie wtedy interaktywna powłoka Piga, Grunt, opisana szczegółowo dalej w tym rozdziale.
Tryb modelu MapReduce W trybie modelu MapReduce Pig przekształca zapytania na zadania w modelu MapReduce i uruchamia je w klastrze opartym na Hadoopie. Używany może być tu klaster pseudorozproszony lub rozproszony. Tryb modelu MapReduce z rozproszonym klastrem to zestaw, który należy stosować przy używaniu Piga dla dużych zbiorów danych. Aby zastosować tryb modelu MapReduce, najpierw sprawdź, czy pobrana wersja Piga jest zgodna z używaną wersją Hadoopa. Wersje Piga współdziałają tylko z określonymi wersjami Hadoopa. Jest to opisane w uwagach w poszczególnych wersjach. Pig używa zmiennej środowiskowej HADOOP_HOME do określania, którego klienta Hadoopa należy uruchomić. Jeśli ta zmienna nie jest ustawiona, Pig korzysta z wbudowanych bibliotek Hadoopa. Zauważ, że mogą one pochodzić z innej wersji Hadoopa niż wersja pracująca w klastrze. Dlatego zaleca się ustawienie zmiennej HADOOP_HOME. Następnie należy wskazać Pigowi węzeł nazw i menedżer zasobów klastra. Jeśli instalacja Hadoopa ustawiona w zmiennej HADOOP_HOME jest odpowiednio skonfigurowana, nie trzeba robić nic więcej. W przeciwnym razie ustaw zmienną HADOOP_CONF_DIR na katalog z plikiem (lub plikami) *-site.xml Hadoopa, gdzie zdefiniowane są właściwości fs.defaultFS, yarn.resourcemanager.address i mapreduce.framework.name (tę ostatnią należy ustawić na wartość yarn). Inna możliwość to ustawienie tych właściwości w pliku pig.properties w katalogu conf Piga (lub w katalogu podanym w zmiennej PIG_CONF_DIR). Oto przykładowa konfiguracja dla środowiska pseudorozproszonego: fs.defaultFS=hdfs://localhost/ mapreduce.framework.name=yarn yarn.resourcemanager.address=localhost:8032
Po skonfigurowania Piga pod kątem łączenia się z klastrem opartym na Hadoopie możesz uruchomić tę platformę. Opcję -x ustaw na wartość mapreduce; możesz ją też pominąć, ponieważ tryb modelu MapReduce jest stosowany domyślnie. Poniżej zastosowano opcję -brief, aby nie rejestrować znaczników czasu. % pig -brief Logging error messages to: /Users/tom/pig_1414246949680.log Default bootup file /Users/tom/.pigbootup not found Connecting to hadoop file system at: hdfs://localhost/ grunt>
W danych wyjściowych widać, że platforma Pig informuje o systemie plików, z którym nawiązała połączenie (nie podaje jednak menedżera zasobów systemu YARN).
Instalowanie i uruchamianie platformy Pig
405
W trybie modelu MapReduce opcjonalnie można włączyć tryb automatycznego uruchamiania zadań lokalnie (ustawiając właściwość pig.auto.local.enabled na wartość true). Jest to optymalizacja sprawiająca, że krótkie zadania są uruchamiane lokalnie, jeśli dane wejściowe zajmują mniej niż 100 MB (ten poziom określa właściwość pig.auto.local.input.maxbytes; jej wartość domyślna to 100 000 000) i używany jest nie więcej niż jeden reduktor.
Uruchamianie programów platformy Pig Istnieją trzy sposoby wykonywania programów Piga. Wszystkie te techniki są dostępne zarówno w trybie lokalnym, jak i w trybie modelu MapReduce. Skrypty Pig może uruchamiać pliki skryptów z instrukcjami Piga. Na przykład polecenie pig script.pig powoduje wykonanie instrukcji z lokalnego pliku script.pig. Dla bardzo krótkich skryptów można użyć opcji -e i uruchomić skrypt podany jako łańcuch znaków w wierszu poleceń. Grunt Grunt to interaktywna powłoka służąca do uruchamiania poleceń Piga. Grunt jest uruchamiany, jeśli nie podano pliku do uruchomienia i nie zastosowano opcji -e. Skrypty Piga można uruchamiać w powłoce Grunt za pomocą poleceń run i exec. Zagnieżdżony kod Programy Piga można uruchamiać w Javie za pomocą klasy PigServer. Odbywa się to podobnie jak uruchamianie programów SQL-a za pomocą interfejsu JDBC. Aby uzyskać programowy dostęp do powłoki Grunt, zastosuj klasę PigRunner.
Grunt Powłoka Grunt udostępnia mechanizmy edycji wierszy, podobne do tych z narzędzia GNU Readline (używanego w powłoce bash i wielu innych aplikacjach wiersza poleceń). Na przykład kombinacja Ctrl+E powoduje przeniesienie kursora na koniec wiersza. Grunt zapamiętuje też historię instrukcji1, a zapisane w niej wiersze można przywołać za pomocą kombinacji Ctrl+P (poprzednie polecenie) i Ctrl+N (następne polecenie) lub klawiszy strzałek skierowanych w górę i w dół. Inną wygodną cechą powłoki Grunt jest mechanizm uzupełniania kodu. Próbuje on uzupełniać słowa kluczowe i funkcje z języka Pig Latin, gdy programista wciśnie klawisz Tab. Przyjrzyj się poniższemu niepełnemu wierszowi: grunt> a = foreach b ge
Jeśli na tym etapie wciśniesz klawisz Tab, ge zostanie rozwinięte do generate (jest to słowo kluczowe z języka Pig Latin). grunt> a = foreach b generate
1
Historia poleceń jest zapisywana w pliku .pig_history w katalogu głównym użytkownika.
406
Rozdział 16. Pig
Możesz dostosować listę uzupełnianych słów. W tym celu utwórz plik o nazwie autocomplete i umieść go w katalogu dostępnym w ścieżce do klas Piga (na przykład w podkatalogu conf katalogu install Piga) lub w katalogu, w którym wywołałeś platformę Grunt. W pliku należy umieścić po jednym słowie na wiersz, a słowa nie mogą obejmować spacji. Przy dopasowywaniu uwzględniana jest wielkość liter. Bardzo przydatne jest dodanie do listy często używanych ścieżek (ponieważ Pig nie uzupełnia nazw plików) i nazw własnych funkcji. Listę poleceń można wyświetlić za pomocą instrukcji help. Gdy zakończysz sesję powłoki Grunt, możesz wyjść z niej za pomocą instrukcji quit lub jej krótszego odpowiednika \q.
Edytory kodu w języku Pig Latin Mechanizm kolorowania składni języka Pig Latin jest dostępny w wielu edytorach. Oto wybrane z nich: Eclipse, IntelliJ IDEA, Vim, Emacs i TextMate. Szczegółowe informacje znajdziesz w serwisie wiki poświęconym platformie Pig (https://cwiki.apache.org/confluence/display/PIG/PigTools). Wiele dystrybucji Hadoopa udostępnia interfejs sieciowy Hue (http://gethue.com/). Jest to narzędzie do edycji i uruchamiania skryptów Piga.
Przykład Przyjrzyj się prostemu przykładowemu programowi, który na podstawie zbioru danych meteorologicznych wyznacza maksymalną temperaturę dla poszczególnych lat. Program jest napisany w języku Pig Latin i działa podobnie jak program w modelu MapReduce z rozdziału 2. Kompletny kod zajmuje tylko kilka wierszy. -- max_temp.pig: wyznacza maksymalną temperaturę dla poszczególnych lat records = LOAD 'input/ncdc/micro-tab/sample.txt' AS (year:chararray,temperature:int,quality:int); filtered_records = FILTER records BY temperature != 9999 AND quality IN (0, 1, 4, 5, 9); grouped_records = GROUP filtered_records BY year; max_temp = FOREACH grouped_records GENERATE group, MAX(filtered_records.temperature); DUMP max_temp;
Aby zrozumieć, jak działa ten kod, posłuż się interpreterem Piga z powłoki Grunt. To narzędzie pozwala wprowadzać wiersze i wchodzić z programem w instrukcje, by zobaczyć, co się w nim dzieje. Uruchom powłokę Grunt w trybie lokalnym, a następnie wprowadź pierwszy wiersz przedstawionego skryptu Piga. grunt> records = LOAD 'input/ncdc/micro-tab/sample.txt' >> AS (year:chararray, temperature:int, quality:int);
Dla uproszczenia w programie przyjęto, że dane wejściowe to tekst rozdzielony tabulacjami, a każdy wiersz zawiera tylko pola z rokiem, temperaturą i informacją o jakości danych. Pig obsługuje także inne formaty wejściowe, o czym przekonasz się dalej. Pokazany wiersz kodu opisuje przetwarzane dane wejściowe. Notacja year:chararray określa nazwę i typ pola. Typ chararray przypomina typ String Javy, a typ int działa podobnie jak typ int Javy. Operator LOAD przyjmuje identyfikator URI jako argument. Tu używany jest plik lokalny, jednak można też podać identyfikator URI prowadzący do systemu HDFS. Opcjonalna klauzula AS pozwala nadać polom nazwy, dzięki czemu można wygodnie wskazywać pola w dalszych instrukcjach. Przykład
407
Wynikiem działania operatora LOAD (a także innych operatorów języka Pig Latin) jest relacja, czyli zestaw krotek. Krotka to wiersz danych w tabeli bazy, obejmujący zbiór pól występujących w określonej kolejności. W tym przykładzie funkcja LOAD generuje zbiór krotek (rok, temperatura, jakość) dostępnych w pliku wejściowym. Relacja jest zapisywana po krotce na wiersz, a same krotki są reprezentowane jako rozdzielone przecinkami wartości w nawiasach. (1950,0,1) (1950,22,1) (1950,-11,1) (1949,111,1)
Relacje mają określone nazwy (aliasy) pozwalające na wskazywanie danych. Ta relacja ma nazwę records. Do wyświetlania zawartości relacji służy operator DUMP. grunt> DUMP records; (1950,0,1) (1950,22,1) (1950,-11,1) (1949,111,1) (1949,78,1)
Za pomocą operatora DESCRIBE i nazwy relacji można wyświetlić jej strukturę (schemat). grunt> DESCRIBE records; records: {year: chararray,temperature: int,quality: int}
Wynik informuje, że relacja records ma trzy pola o nazwach year, temperature i quality (są to nazwy ustawione w klauzuli AS). Pola mają typy, które także podano w klauzuli AS. Szczegółowy opis typów platformy Pig znajdziesz dalej w rozdziale. Druga instrukcja usuwa rekordy, w których temperatura nie jest podana (informuje o tym wartość 9999) lub które obejmują dane o niedostatecznej jakości. W używanym tu małym zbiorze danych żadne rekordy nie zostają usunięte. grunt> filtered_records = FILTER records BY temperature != 9999 AND >> quality IN (0, 1, 4, 5, 9); grunt> DUMP filtered_records; (1950,0,1) (1950,22,1) (1950,-11,1) (1949,111,1) (1949,78,1)
W trzeciej instrukcji używana jest funkcja GROUP do pogrupowania relacji records na podstawie pola year. Za pomocą instrukcji DUMP można wyświetlić efekt wykonania tego kroku. grunt> grouped_records = GROUP filtered_records BY year; grunt> DUMP grouped_records; (1949,{(1949,78,1),(1949,111,1)}) (1950,{(1950,-11,1),(1950,22,1),(1950,0,1)})
Teraz dostępne są dwa wiersze (dwie krotki) — po jednym dla każdego roku z danych wejściowych. Pierwsze pole w każdej krotce zawiera wartości (tu są to lata), na podstawie których dane są grupowane. Drugie pole obejmuje zbiór krotek z danego roku. Zbiór to nieuporządkowana kolekcja krotek, reprezentowana w języku Pig Latin za pomocą nawiasów klamrowych. Dzięki pogrupowaniu danych w ten sposób utworzono wiersz dla każdego roku. Teraz pozostaje tylko znaleźć maksymalną temperaturę w krotkach z każdego zbioru. Najpierw jednak warto przyjrzeć się strukturze relacji grouped_records. 408
Rozdział 16. Pig
grunt> DESCRIBE grouped_records; grouped_records: {group: chararray,filtered_records: {year: chararray, temperature: int,quality: int}}
To informuje, że Pig przypisuje polu używanemu do grupowania nazwę group. Drugie pole ma tę samą strukturę co relacja filtered_records, z której dane są grupowane. Na podstawie tych informacji można spróbować przeprowadzić czwartą transformację. grunt> max_temp = FOREACH grouped_records GENERATE group, >> MAX(filtered_records.temperature);
Instrukcja FOREACH przetwarza każdy wiersz, by wygenerować nowy zbiór wierszy. Klauzula GENERATE określa pola dodawane do nowych wierszy. Tu pierwszym polem jest group. Zawiera ono tylko rok. Drugie pole jest bardziej skomplikowane. Referencja filtered_records.temperature oznacza pole temperature ze zbioru filtered_records z relacji grouped_records. MAX to wbudowana funkcja do wyznaczania maksymalnej wartości pól ze zbioru. Tu funkcja znajduje maksymalną temperaturę z pól z każdego zbioru filtered_records. Przyjrzyj się wynikom. grunt> DUMP max_temp; (1949,111) (1950,22)
W ten sposób z powodzeniem wyznaczono maksymalną temperaturę dla każdego roku.
Generowanie przykładowych danych W poprzednim przykładzie wykorzystano niewielki przykładowy zbiór danych z małą liczbę wierszy. Dzięki temu łatwiej jest śledzić przepływ danych i diagnozować kod. Tworzenie małych zbiorów danych to sztuka, ponieważ powinny one być wystarczająco bogate, by uwzględniać wszystkie przypadki potrzebne do sprawdzenia zapytań (muszą być kompletne), a jednocześnie na tyle krótkie, aby były zrozumiałe dla programisty (muszą być zwięzłe). Wykorzystanie próbki losowych danych zwykle się nie sprawdza, ponieważ operacje złączania i filtrowania przeważnie usuwają wszystkie losowe informacje. W efekcie powstaje pusty zbiór wyników, co nie jest reprezentatywne dla przepływu danych. Operator ILLUSTRATE to udostępniane przez Piga narzędzie do generowania kompletnych i jednocześnie zwięzłych przykładowych zbiorów danych. Oto dane wyjściowe uzyskane w wyniku uruchomieniu operatora ILLUSTRATE dla używanego zbioru danych (formatowanie danych zmieniono, by zmieściły się na stronie): grunt> ILLUSTRATE max_temp; ------------------------------------------------------------------------------| records | year:chararray | temperature:int | quality:int | ------------------------------------------------------------------------------| | 1949 | 78 | 1 | | | 1949 | 111 | 1 | | | 1949 | 9999 | 1 | ------------------------------------------------------------------------------------------------------------------------------------------------------------| filtered_records | year:chararray | temperature:int | quality:int | ------------------------------------------------------------------------------| | 1949 | 78 | 1 | | | 1949 | 111 | 1 | -------------------------------------------------------------------------------
Przykład
409
------------------------------------------------------------------------------| grouped_records | group:chararray | filtered_records:bag{:tuple( | year:chararray,temperature:int, | quality:int)} | ------------------------------------------------------------------------------| | 1949 | {(1949, 78, 1), (1949, 111, 1)} | --------------------------------------------------------------------------------------------------------------------------------| max_temp | group:chararray | :int | --------------------------------------------------| | 1949 | 111 | ---------------------------------------------------
Zauważ, że Pig wykorzystał niektóre pierwotne dane (jest to ważne, jeśli generowany zbiór danych ma być realistyczny), a także dodał nowe. Platforma wykryła w zapytaniu specjalną wartość 9999 i utworzyła krotkę z tą liczbą, aby sprawdzić działanie instrukcji FILTER. Dane wyjściowe narzędzia ILLUSTRATE są łatwe do analizy i pomagają zrozumieć działanie zapytania.
Porównanie platformy Pig z bazami danych Po przyjrzeniu się pracy Piga można odnieść wrażenie, że język Pig Latin jest podobny do SQL-a. Dostępność operatorów takich jak GROUP BY i DESCRIBE nasila takie odczucia. Jednak między tymi językami występuje kilka różnic, podobnie jak między platformą Pig a systemami RDBMS. Najważniejsza różnica polega na tym, że Pig Latin to język programowania przepływu danych, natomiast SQL to deklaratywny język programowania. Oznacza to, że program w języku Pig Latin to zestaw wykonywanych krok po kroku operacji na wejściowej relacji. Każdy krok to jedna transformacja. Natomiast instrukcje w SQL-u to zestawy ograniczeń, które razem definiują dane wyjściowe. Programowanie w języku Pig Latin pod wieloma względami przypomina pracę na poziomie generatora planów wykonywania zapytań w systemach RDBMS. Taki generator planów odpowiada za przekształcanie deklaratywnych instrukcji na zestaw kroków. Systemy RDBMS przechowują dane w tabelach o ściśle zdefiniowanych schematach. Pig zapewnia większą swobodę w zakresie przetwarzanych danych. Można zdefiniować schemat w trakcie wykonywania programu, jest to jednak opcjonalne. Pig działa dla dowolnego źródła krotek (przy czym musi ono umożliwiać równoległy odczyt — na przykład przechowywać dane w wielu plikach), a do ich wczytywania z surowej reprezentacji danych służy funkcja UDF2. Najczęściej stosowaną reprezentacją jest plik tekstowy z polami rozdzielonymi tabulacją. Pig udostępnia wbudowaną funkcję do wczytywania danych w tym formacie. W Pigu, inaczej niż w tradycyjnych bazach, nie jest używany proces importu, potrzebny do wczytywania danych do systemu RDBMS. Dane są pobierane z systemu plików (zwykle jest nim HDFS) w pierwszym kroku przetwarzania. Obsługa w Pigu złożonych, zagnieżdżonych struktur danych dodatkowo odróżnia język z tej platformy od SQL-a (który działa dla bardziej płaskich struktur danych). Ponadto Pig pozwala stosować funkcje UDF i operatory strumieniowe, które są ściśle zintegrowane z językiem i zagnieżdżonymi strukturami danych tej platformy. To sprawia, że język Pig Latin daje większe możliwości modyfikowania niż większość odmian SQL-a. 2
W filozofii platformy Pig (http://pig.apache.org/philosophy.html) napisano, że „świnie jedzą wszystko”.
410
Rozdział 16. Pig
Systemy RDBMS udostępniają nieobecne w Pigu mechanizmy szybkiego przetwarzania zapytań (na przykład transakcje i indeksy). Pig nie umożliwia odczytu swobodnego ani przetwarzania zapytań w czasie mierzonym w dziesiątkach milisekund. Nie jest też możliwy swobodny zapis w celu aktualizowania niewielkich porcji danych. Zapis zawsze odbywa się w trybie masowym i strumieniowym (tak jak w modelu MapReduce). Hive (opisany w rozdziale 17.) znajduje się pomiędzy Pigiem a tradycyjnymi systemami RDBMS. Hive (podobnie jak Pig) do przechowywania danych używa systemu HDFS, ale między obiema platformami występują istotne różnice. Język zapytań w systemie Hive (HiveQL) jest oparty na SQL-u. Dlatego użytkownicy SQL-a nie będą mieli trudności w pisaniu zapytań w języku HiveQL. Hive (podobnie jak systemy RDBMS) wymaga przechowywania wszystkich danych w tabelach o określonym schemacie. Potrafi jednak powiązać schemat z istniejącymi danymi z systemu HDFS, dzięki czemu etap wczytywania danych jest opcjonalny. Pig potrafi używać tabel z systemu Hive. Służy do tego narzędzie HCatalog, opisane dalej w punkcie „Używanie tabel platformy Hive za pomocą komponentu HCatalog”.
Język Pig Latin W tym podrozdziale znajdziesz nieformalny opis składni i działania języka Pig Latin3. Nie ma to być kompletny opis języka4, ale powinien wystarczyć do dobrego zrozumienia stosowanych konstrukcji.
Struktura Program w języku Pig Latin składa się z zestawu instrukcji. Instrukcję można uznać za operację lub polecenie5. Na przykład operacja GROUP to rodzaj instrukcji. grouped_records = GROUP records BY year;
Inna instrukcja to polecenie wyświetlające pliki z systemu plików Hadoopa. ls /
Instrukcje są zwykle zakończone średnikiem — tak jak w przykładowej instrukcji GROUP. W tej instrukcji średnik jest wymagany. Pominięcie go to błąd składni. Natomiast polecenie ls nie wymaga średnika. Zgodnie z ogólną regułą instrukcje używane interaktywnie w powłoce Grunt nie wymagają końcowego średnika. Dotyczy to interaktywnych poleceń Hadoopa, a także operatorów diagnostycznych (na przykład operatora DESCRIBE). Dodanie końcowego średnika nigdy nie jest traktowane jako błąd, dlatego jeśli masz wątpliwości, najprościej jest zastosować średnik. Instrukcje, które muszą być kończone średnikiem, można w celu zwiększenia ich czytelności rozbić na kilka wierszy. 3
Nie należy go mylić z zabawą w języku angielskim. Polega ona na tym, że angielskie słowa są tłumaczone na „świńską łacinę” (ang. pig latin) w wyniku przeniesienia członu od pierwszej samogłoski na początek słowa i dodania członu „ay” na końcu. Na przykład ze słowa „pig” powstaje wyraz „ig-pay”, a ze słowa „Hadoop” — „Addoop-hay”.
4
Nie istnieje formalna definicja języka Pig Latin, jednak w witrynie poświęconej platformie Pig (http://pig.apache.org/) znajdziesz odnośnik prowadzący do rozbudowanego podręcznika tego języka.
5
W dokumentacji języka Pig Latin pojęcia „command” (polecenie), „operation” (operacja) i „statement” (instrukcja) czasem stosuje się zamiennie, na przykład: „GROUP command”, „GROUP operation”, „GROUP statement”.
Język Pig Latin
411
records = LOAD 'input/ncdc/micro-tab/sample.txt' AS (year:chararray, temperature:int, quality:int);
W języku Pig Latin występują dwa rodzaje komentarzy. Podwójny myślnik oznacza komentarze jednowierszowe. Wszystko od pierwszego myślnika do końca wiersza jest przez interpreter języka Pig Latin ignorowane. -- Mój program DUMP A; -- Jaka jest wartość A?
Komentarze w stylu języka C zapewniają większą elastyczność, ponieważ początek i koniec bloku z komentarzem są wyznaczane za pomocą symboli /* i */. Takie komentarze mogą zajmować wiele wierszy lub mieścić się w jednej linii. /* * Opis programu rozciągający się * na kilka wierszy. */ A = LOAD 'input/pig/join/A'; B = LOAD 'input/pig/join/B'; C = JOIN A BY $0, /* Ignorowane */ B BY $1; DUMP C;
W języku Pig Latin występuje lista słów kluczowych o specjalnym znaczeniu. Nie można ich używać jako identyfikatorów. Te słowa reprezentują operatory (LOAD, ILLUSTRATE), polecenia (cat, ls), wyrażenia (matches, FLATTEN) i funkcje (DIFF, MAX). Wszystkie wymienione elementy są opisane dalej. W języku Pig Latin wielkość liter czasem jest uwzględniana, a czasem nie. W operatorach i poleceniach wielkość liter nie ma znaczenia (co ułatwia interaktywne posługiwanie się językiem), natomiast w aliasach i nazwach funkcji wielkość znaków jest istotna.
Instrukcje W trakcie wykonywania programu w języku Pig Latin po kolei parsowana jest każda instrukcja. Jeśli występują błędy składni lub inne (semantyczne) problemy, na przykład niezdefiniowane nazwy, interpreter wstrzymuje pracę i wyświetla komunikat o błędzie. Interpreter generuje logiczny plan wykonania wszystkich operacji relacyjnych (stanowią one podstawowy element programu w języku Pig Latin). Logiczny plan instrukcji jest dodawany do ogólnego logicznego programu całego programu, po czym interpreter przechodzi do następnej instrukcji. Należy zauważyć, że w trakcie generowania logicznego planu programu dane nie są jeszcze przetwarzane. Przyjrzyj się ponownie programowi w języku Pig Latin z pierwszego przykładu. -- max_temp.pig: wyznacza maksymalną temperaturę dla poszczególnych lat records = LOAD 'input/ncdc/micro-tab/sample.txt' AS (year:chararray, temperature:int, quality:int); filtered_records = FILTER records BY temperature != 9999 AND quality IN (0, 1, 4, 5, 9); grouped_records = GROUP filtered_records BY year; max_temp = FOREACH grouped_records GENERATE group, MAX(filtered_records.temperature); DUMP max_temp;
412
Rozdział 16. Pig
Gdy interpreter języka Pig Latin natrafi na pierwszy wiersz z instrukcją LOAD, stwierdzi, że kod jest poprawny składniowo i semantycznie oraz uwzględni go w logicznym planie. Nie wczyta jednak danych z pliku (a nawet nie sprawdzi, czy plik istnieje). Gdzie interpreter miałby wczytać te dane? Do pamięci? Nawet jeśli można je zmieścić w pamięci, to co z nimi zrobić? Prawdopodobnie nie wszystkie dane wejściowe są niezbędne (na przykład z powodu dalszych instrukcji filtrujących), dlatego ich wczytywanie na tym etapie jest bezcelowe. Nie ma sensu rozpoczynać przetwarzania do momentu zdefiniowania całego przepływu. Podobnie Pig sprawdza poprawność instrukcji GROUP i FOREACH..GENERATE oraz dodaje je do logicznego planu bez ich wykonywania. Wyzwalaczem rozpoczynającym wykonywanie kodu przez platformę Pig jest instrukcja DUMP. Po dojściu do niej plan logiczny jest kompilowany do postaci planu fizycznego i wykonywany.
Jednoczesne przetwarzanie wielu zapytań Ponieważ DUMP to narzędzie diagnostyczne, zawsze uruchamia wykonywanie kodu. Polecenie STORE działa inaczej. W trybie interaktywnym STORE działa podobnie jak DUMP i zawsze rozpoczyna wykonywanie kodu (używane jest wtedy polecenie run). W trybie wsadowym jest inaczej (tu używane jest polecenie exec). Jest tak ze względu na wydajność. W trybie wsadowym Pig parsuje cały skrypt, aby sprawdzić, czy można zastosować optymalizacje i ograniczyć ilość danych zapisywanych na dysku lub wczytywanych z niego. Przyjrzyj się poniższemu prostemu przykładowi. A = LOAD 'input/pig/multiquery/A'; B = FILTER A BY $1 == 'banana'; C = FILTER A BY $1 != 'banana'; STORE B INTO 'output/b'; STORE C INTO 'output/c';
Relacje B i C są oparte na relacji A. Dlatego aby uniknąć dwukrotnego wczytywania relacji A, Pig może uruchomić skrypt jako jedno zadanie w modelu MapReduce. W tym celu należy raz wczytać relację A i zwrócić dwa pliki wyjściowe (po jednym dla relacji B i C). Na tym polega jednoczesne przetwarzanie wielu zapytań (ang. multiquery execution). We wcześniejszych wersjach Piga, bez mechanizmu jednoczesnego przetwarzania wielu zapytań, każda instrukcja STORE w skrypcie była wykonywana w trybie wsadowym. W efekcie dla każdej takiej instrukcji trzeba było utworzyć zadanie. Można przywrócić ten dawny model. W tym celu wyłącz jednoczesne przetwarzanie wielu zapytań za pomocą opcji -M lub -no_multiquery w instrukcji pig.
Fizyczny plan przygotowywany przez platformę Pig obejmuje serię zadań w modelu MapReduce, które w trybie lokalnym Pig uruchamia w lokalnej maszynie JVM, a w trybie modelu MapReduce — w klastrze opartym na Hadoopie. Logiczne i fizyczne plany generowane w Pigu można wyświetlić za pomocą polecenia EXPLAIN wywołanego dla relacji (na przykład EXPLAIN max_temp;). Polecenie EXPLAIN wyświetla też plan z modelu MapReduce, pokazujący, jak operatory fizyczne są pogrupowane w zadaniach tego modelu. Jest to dobra metoda na określenie, ile zadań w modelu MapReduce Pig uruchomi na podstawie zapytania.
Operatory relacyjne, które mogą pojawiać się w planie logicznym w platformie Pig, są opisane w tabeli 16.1. Szczegółowe omówienie operatorów znajdziesz w punkcie „Operatory używane do przetwarzania danych”. Język Pig Latin
413
Tabela 16.1. Relacyjne operatory języka Pig Latin Kategoria
Operator
Opis
Wczytywanie i przechowywanie
LOAD
Wczytuje dane z systemu plików lub innego magazynu do relacji.
STORE
Zapisuje relację w systemie plików lub innym magazynie.
DUMP (\d)
Wyświetla relację w konsoli.
FILTER
Usuwa niepożądane wiersze z relacji.
DISTINCT
Usuwa powtarzające się wiersze z relacji.
FOREACH…GENERATE
Dodaje lub usuwa pola w relacji.
MAPREDUCE
Uruchamia zadanie w modelu MapReduce, używając relacji jako danych wejściowych.
STREAM
Przekształca relację za pomocą zewnętrznego programu.
SAMPLE
Pobiera losową próbkę z relacji.
ASSERT
Sprawdza, czy warunek jest spełniony dla wszystkich wierszy relacji. Jeśli nie jest, zgłasza niepowodzenie.
JOIN
Złącza dwie relacje (lub większą ich liczbę).
COGROUP
Grupuje dane w dwóch relacjach (lub większej ich liczbie).
GROUP
Grupuje dane w jednej relacji.
CROSS
Tworzy iloczyn wektorowy dwóch relacji (lub większej ich liczby).
CUBE
Tworzy agregacje z uwzględnieniem wszystkich kombinacji określonych kolumn relacji.
ORDER
Sortuje relację na podstawie jednego lub kilku pól.
RANK
Przypisuje pozycję każdej krotce relacji. Opcjonalnie najpierw sortuje dane na podstawie pól.
LIMIT
Ogranicza wielkość relacji do maksymalnej liczby krotek.
UNION
Łączy dwie relacje (lub większą ich liczbę) w jedną.
SPLIT
Rozbija relację na dwie (lub większą liczbę).
Filtrowanie
Grupowanie i złączanie
Sortowanie
Łączenie i dzielenie
Istnieją też instrukcje, które nie są dodawane do planu logicznego. Na przykład operatory diagnostyczne (DESCRIBE, EXPLAIN i ILLUSTRATE) umożliwiają użytkownikom interakcję z planem logicznym w celach diagnostycznych (zobacz tabelę 16.2). Także DUMP można uznać za operator diagnostyczny, ponieważ służy do interaktywnego diagnozowania niewielkich zbiorów wyników lub, w połączeniu z operatorem LIMIT, do pobierania niewielkich zbiorów wierszy z większych relacji. Instrukcję STORE należy stosować, gdy dane wyjściowe zajmują więcej niż kilka wierszy. Ta instrukcja zapisuje dane w pliku, zamiast wyświetlać je w konsoli. Tabela 16.2. Operatory diagnostyczne języka Pig Latin Operator (skrócony zapis)
Opis
DESCRIBE (\de)
Wyświetla schemat relacji.
EXPLAIN (\e)
Wyświetla plany logiczne i fizyczne.
ILLUSTRATE (\i)
Przedstawia przykładowe wykonanie planu logicznego, używając wygenerowanego podzbioru jako danych wejściowych.
414
Rozdział 16. Pig
Język Pig Latin udostępnia też trzy instrukcje (REGISTER, DEFINE i IMPORT), które umożliwiają dodawanie do skryptów platformy Pig makr i funkcji zdefiniowanych przez użytkownika (zobacz tabelę 16.3). Tabela 16.3. Instrukcje języka Pig Latin związane z makrami i funkcjami UDF Instrukcja
Opis
REGISTER
Rejestruje plik JAR w środowisku uruchomieniowym platformy Pig.
DEFINE
Tworzy alias dla makra, funkcji UDF, działającego strumieniowo skryptu lub polecenia.
IMPORT
Importuje do skryptu makra zdefiniowane w odrębnym pliku.
Ponieważ polecenia nie przetwarzają relacji, nie są dodawane do planu logicznego — zostają wykonane natychmiast. Pig udostępnia polecenia do interakcji z systemami plików Hadoopa (co jest bardzo pomocne przy przenoszeniu danych przed ich przetwarzaniem lub po zakończeniu ich przetwarzania w platformie Pig) i modelem MapReduce, a także kilka poleceń narzędziowych. Polecenia opisano w tabeli 16.4. Tabela 16.4. Polecenia języka Pig Latin Kategoria
Polecenie
Opis
System plików Hadoopa
cat
Wyświetla zawartość plików.
cd
Zmienia bieżący katalog.
copyFromLocal
Kopiuje lokalny plik lub katalog do systemu plików Hadoopa.
copyToLocal
Kopiuje lokalny plik lub katalog z systemu plików Hadoopa do lokalnego systemu plików.
cp
Kopiuje plik lub katalog do innego katalogu.
fs
Uzyskuje dostęp do powłoki systemu plików Hadoopa.
ls
Wyświetla pliki.
mkdir
Tworzy nowy katalog.
mv
Przenosi plik lub katalog do innego katalogu.
pwd
Wyświetla ścieżkę do bieżącego katalogu roboczego.
rm
Usuwa plik lub katalog.
rmf
Siłowo usuwa plik lub katalog (nie zgłasza niepowodzenia, jeśli dany plik lub katalog nie istnieje).
Model MapReduce w Hadoopie
kill
Zamyka zadanie w modelu MapReduce.
Narzędziowe
clear
Czyści ekran w powłoce Grunt.
exec
Uruchamia skrypt w nowej powłoce Grunt w trybie wsadowym.
help
Wyświetla dostępne polecenia i opcje.
history
Wyświetla instrukcje wykonane w bieżącej sesji powłoki Grunt.
quit (\q)
Wychodzi z interpretera.
run
Uruchamia skrypt w istniejącej powłoce Grunt.
set
Ustawia opcje Piga i właściwości zadań w modelu MapReduce.
sh
Uruchamia polecenie powłoki systemowej w powłoce Grunt.
Język Pig Latin
415
Polecenia systemu plików można wykonywać na plikach i katalogach z dowolnego systemu plików Hadoopa. Działają one w podobny sposób jak polecenia z rodziny hadoop fs (co nie jest zaskoczeniem, bo obie grupy poleceń to proste nakładki na interfejs FileSystem Hadoopa). Aby uzyskać dostęp do poleceń powłoki systemu plików Hadoopa, użyj polecenia fs platformy Pig. Na przykład polecenie fs -ls wyświetla listę plików, a fs -help pokazuje informacje o wszystkich dostępnych poleceniach. To, który system plików Hadoopa jest używany, jest określone za pomocą właściwości fs.defaultFS z pliku *-site.xml dla komponentu Hadoop Core. Informacje o konfigurowaniu tej właściwości zawiera punkt „Interfejs uruchamiany z wiersza poleceń” w rozdziale 3. Większość wymienionych poleceń jest łatwa do zrozumienia. Wyjątkiem jest polecenie set, używane do ustawiania opcji kontrolujących działanie platformy Pig (a także do określania dowolnych właściwości zadań w modelu MapReduce). Opcja debug pozwala włączyć lub wyłączyć w skrypcie rejestrowanie danych diagnostycznych. W trakcie uruchamiania platformy Pig można też ustawić poziom rejestrowania (za pomocą opcji -d lub -debug). grunt> set debug on
Przydatna jest też opcja job.name, pozwalająca ustawić dla zadania platformy Pig nazwę. Dzięki temu łatwiej jest ustalić własne zadania z technologii Pig i MapReduce, gdy są uruchamiane we współużytkowanym klastrze opartym na Hadoopie. Jeśli platforma Pig wykonuje skrypt (zamiast uruchamiać zapytania interaktywnie wprowadzane w powłoce Grunt), nazwa zadania domyślnie jest generowana na podstawie nazwy skryptu. W tabeli 16.4 wymienione są dwa polecenia służące do uruchamiania skryptów platformy Pig — exec i run. Różnica między nimi polega na tym, że polecenie exec uruchamia skrypt w trybie wsadowym w nowej powłoce Grunt, dlatego nazwy zdefiniowane w skrypcie nie będą po jego ukończeniu dostępne w pierwotnej powłoce. Gdy skrypt jest uruchamiany za pomocą polecenia run, traktuje się go tak, jakby został wprowadzony ręcznie. Dlatego historia poleceń powłoki obejmuje wszystkie instrukcje ze skryptu. Jednoczesne wykonywanie wielu poleceń, polegające na uruchamianiu przez platformę Pig grup instrukcji w jednym kroku (zobacz ramkę „Jednoczesne przetwarzanie wielu zapytań”), jest stosowane tylko w poleceniu exec. Polecenie run nie używa tej techniki.
Przepływ sterowania Język Pig Latin nie ma wbudowanych instrukcji do sterowania przepływem. Zalecaną metodą pisania programów z instrukcjami warunkowymi lub pętlami jest zagnieżdżenie kodu w języku Pig Latin w programach napisanych w innych językach, takich jak Python, JavaScript lub Java, i zarządzanie przepływem sterowania w nadrzędnym kodzie. W tym modelu skrypt nadrzędny używa interfejsu API obejmującego kompilację, wiązanie i uruchamianie kodu, aby wykonywać skrypty platformy Pig i określać ich status. Szczegółowe informacje o tym interfejsie API znajdziesz w dokumentacji platformy Pig. Zagnieżdżone programy platformy Pig zawsze działają w maszynie JVM, dlatego dla kodu w Pythonie i JavaScripcie należy użyć polecenia pig z nazwą skryptu, a wybrany zostanie odpowiedni silnik skryptowy Javy (Jython dla Pythona i Rhino dla JavaScriptu).
416
Rozdział 16. Pig
Wyrażenia Wyrażenie służy do generowania wartości. Wyrażenia można stosować w platformie Pig jako część instrukcji obejmującej operator relacyjny. Pig udostępnia wiele rozmaitych wyrażeń. Liczne z nich są znane także z innych języków programowania. Dostępne wyrażenia są wymienione w tabeli 16.5 razem z krótkimi opisami i przykładami. Przykłady zastosowania licznych z tych wyrażeń znajdziesz dalej w rozdziale. Tabela 16.5. Wyrażenia w języku Pig Latin Kategoria
Wyrażenie
Opis
Przykłady
Stała
Literał
Stała wartość (zobacz kolumnę „Przykładowy literał” w tabeli 16.6)
1.0, 'a'
Pole (według pozycji)
$n
Pole na pozycji n (numeracja od zera)
$0
Pole (według nazwy)
f
Pole o nazwie f
year
Pole (doprecyzowanie)
r::f
Pole o nazwie f z relacji r po pogrupowaniu lub złączeniu A::year
Projekcja
c.%n, c.f
Pole z kontenera c (relacji, zbioru lub krotki) określone za pomocą pozycji lub nazwy
records.$0, records.year
Przeszukiwanie odwzorowania
m#k
Wartość powiązana z kluczem k z odwzorowania m
items#'Coat'
Rzutowanie
(t) f
Rzutuje pole f na typ t
(int) year
Arytmetyka
x + y, x - y
Dodawanie i odejmowanie
$1 + $2, $1 - $2
x * y, x / y
Mnożenie i dzielenie
$1 * $2, $1 / $2
x % y
Modulo (reszta z dzielenia x przez y)
$1 % $2
+x, -x
Jednoargumentowa wartość dodatnia lub negacja
+1, -1
x ? y : z
Operator trójargumentowy. Zwraca y, jeśli x ma wartość true, a w przeciwnym razie zwraca z
quality == 0 ? 0 : 1
CASE
Instrukcja warunkowa z wieloma przypadkami
CASE q WHEN 0 THEN 'good'
x == y, x != y
Równe, nierówne
quality == 0, temperature != 9999
x > y, x < y
Większe, mniejsze
quality > 0, quality < 10
x >= y, x = 1, quality records = LOAD 'input/ncdc/micro-tab/sample.txt' >> AS (year:int, temperature:int, quality:int); grunt> DESCRIBE records; records: {year: int,temperature: int,quality: int}
Tym razem rok jest zadeklarowany jako liczba całkowita, a nie jako wartość typu chararray, choć plik, z którego wczytywane są dane, się nie zmienił. Liczba całkowita jest wygodniejsza, jeśli programista chce wykonywać operacje arytmetyczne na latach (na przykład przekształcać je na znacznik czasu). Typ chararray może okazać się lepszy, jeśli rok jest używany tylko jako identyfikator. Elastyczność platformy Pig w zakresie deklarowania schematów kontrastuje z działaniem schematów w tradycyjnych bazach SQL-owych, gdzie schematy trzeba deklarować przed wczytaniem danych do systemu. Platforma Pig jest zaprojektowana z myślą o analizowaniu prostych plików wejściowych bez informacji o typach. Dlatego naturalne jest, że typy pól można określać później niż w systemach RDBMS. Można też całkowicie pominąć deklaracje typów. grunt> records = LOAD 'input/ncdc/micro-tab/sample.txt' >> AS (year, temperature, quality); grunt> DESCRIBE records; records: {year: bytearray,temperature: bytearray,quality: bytearray}
Tu określone są tylko nazwy pól schematu — year, temperature i quality. Domyślnie używany typ to bytearray. Jest to najbardziej uniwersalny typ, reprezentujący tablicę bajtów.
Język Pig Latin
419
Nie musisz określać typów dla każdego pola. Dla niektórych pól możesz pozostawić typ domyślny bytearray, tak jak dla pola year w poniższej deklaracji. grunt> records = LOAD 'input/ncdc/micro-tab/sample.txt' >> AS (year, temperature:int, quality:int); grunt> DESCRIBE records; records: {year: bytearray,temperature: int,quality: int}
Jeśli dodajesz schemat w ten sposób, musisz podać każde pole. Ponadto nie ma możliwości określenia typu pola bez ustawiania jego nazwy. Schemat jest opcjonalny i można go pominąć. W tym celu wystarczy nie dodawać klauzuli AS. grunt> records = LOAD 'input/ncdc/micro-tab/sample.txt'; grunt> DESCRIBE records; Schema for records unknown.
Pola z relacji, dla której nie określono schematu, można wskazywać tylko za pomocą pozycji. Wartość $0 oznacza pierwsze pole relacji, $1 to drugie pole relacji itd. Domyślnie używany jest typ bytearray. grunt> projected_records = FOREACH records GENERATE $0, $1, $2; grunt> DUMP projected_records; (1950,0,1) (1950,22,1) (1950,-11,1) (1949,111,1) (1949,78,1) grunt> DESCRIBE projected_records; projected_records: {bytearray,bytearray,bytearray}
Choć rezygnacja z określania typów pól może być wygodna (zwłaszcza na początkowych etapach pisania zapytania), dodanie typów zwiększa przejrzystość i wydajność programów w języku Pig Latin. Dlatego zwykle zaleca się ustawianie typów pól.
Używanie tabel platformy Hive za pomocą komponentu HCatalog Zadeklarowanie schematu w zapytaniu daje dużo swobody, ale nie umożliwia ponownego wykorzystania schematu. Grupy zapytań Piga dotyczących tych samych danych wejściowych często wykorzystują ten sam schemat, który trzeba za każdym razem powtarzać. Jeśli zapytanie przetwarza dużo pól, konserwacja powtarzającego się schematu może sprawiać trudności. HCatalog (jest to komponent systemu Hive) rozwiązuje problem, ponieważ zapewnia dostęp do magazynu metadanych systemu Hive. Dzięki temu w zapytaniach platformy Pig można wskazywać schematy za pomocą nazwy, zamiast za każdym razem podawać je w całości. Na przykład po wykonaniu operacji z punktu „Przykład” w rozdziale 17. i wczytaniu danych do tabeli records systemu Hive platforma Pig może uzyskać dostęp do schematu tabeli i zapisanych w niej danych w następujący sposób: % pig -useHCatalog grunt> records = LOAD 'records' USING org.apache.hcatalog.pig.HCatLoader(); grunt> DESCRIBE records; records: {year: chararray,temperature: int,quality: int} grunt> DUMP records; (1950,0,1) (1950,22,1) (1950,-11,1) (1949,111,1) (1949,78,1)
420
Rozdział 16. Pig
Sprawdzanie poprawności i wartości null Bazy SQL-owe wymuszają ograniczenia opisane w schemacie tabeli na etapie wczytywania danych. Na przykład próba wczytania łańcucha znaków do kolumny typu liczbowego zakończy się niepowodzeniem. Jeśli w platformie Pig wartości nie da się zrzutować na typ zadeklarowany w schemacie, zostanie ona zastąpiona wartością null. Zobacz, jak to rozwiązanie działa, gdy w meteorologicznych danych wejściowych zamiast liczby całkowitej występuje litera „e”. 1950 1950 1950 1949 1949
0 22 e 111 78
1 1 1 1 1
Pig obsługuje nieprawidłowy wiersz w taki sposób, że zastępuje błędne dane wartością null. Przy wyświetlaniu danych na ekranie tej wartości odpowiada puste miejsce (tak samo jest przy zapisywaniu danych za pomocą polecenia STORE). grunt> records = LOAD 'input/ncdc/micro-tab/sample_corrupt.txt' >> AS (year:chararray, temperature:int, quality:int); grunt> DUMP records; (1950,0,1) (1950,22,1) (1950,,1) (1949,111,1) (1949,78,1)
Pig po wykryciu błędnego pola wyświetla ostrzeżenie (tu pominięte), ale nie wstrzymuje przetwarzania. W dużych zbiorach danych bardzo często występują uszkodzone, nieprawidłowe lub nieoczekiwane dane. Zwykle nie da się na bieżąco poprawiać każdego błędnego rekordu. Zamiast tego można pobrać wszystkie takie rekordy w jednym kroku i podjąć odpowiednie działania — na przykład naprawić program (ponieważ błędne rekordy oznaczają, że popełniono pomyłkę) lub odfiltrować niepoprawne dane (ponieważ są bezużyteczne). grunt> corrupt_records = FILTER records BY temperature is null; grunt> DUMP corrupt_records; (1950,,1)
Zwróć uwagę na zastosowanie operatora is null. Działa on tak samo jak w SQL-u. W praktyce zwykle dodaje się więcej informacji z pierwotnego rekordu, takich jak identyfikator i wartość uniemożliwiająca parsowanie. Pomagają one w analizie błędnych danych. Aby określić liczbę uszkodzonych rekordów, można wykorzystać poniższy idiom. Zlicza on wiersze w relacji. grunt> grouped = GROUP corrupt_records ALL; grunt> all_grouped = FOREACH grouped GENERATE group, COUNT(corrupt_records); grunt> DUMP all_grouped; (all,1)
W punkcie „Instrukcja GROUP” szczegółowo opisano grupowanie i operację ALL. Inna przydatna technika polega na użyciu operatora SPLIT do podziału danych na „dobre” i „złe” relacje, które następnie można niezależnie analizować. grunt> SPLIT records INTO good_records IF temperature is not null, >> bad_records OTHERWISE; grunt> DUMP good_records;
Język Pig Latin
421
(1950,0,1) (1950,22,1) (1949,111,1) (1949,78,1) grunt> DUMP bad_records; (1950,,1)
Wróćmy teraz do wersji kodu, w której typ pola temperature nie jest określony. Nie da się wtedy łatwo wykryć błędnych danych, ponieważ nie są one widoczne jako wartość null. grunt> records = LOAD 'input/ncdc/micro-tab/sample_corrupt.txt' >> AS (year:chararray, temperature, quality:int); grunt> DUMP records; (1950,0,1) (1950,22,1) (1950,e,1) (1949,111,1) (1949,78,1) grunt> filtered_records = FILTER records BY temperature != 9999 AND >> quality IN (0, 1, 4, 5, 9); grunt> grouped_records = GROUP filtered_records BY year; grunt> max_temp = FOREACH grouped_records GENERATE group, >> MAX(filtered_records.temperature); grunt> DUMP max_temp; (1949,111.0) (1950,22.0)
W tej sytuacji pole temperature jest interpretowane jako wartość typu bytearray. Dlatego błędne pole nie jest wykrywane w momencie wczytywania danych wejściowych. Gdy pole temperature trafia do funkcji MAX, jest rzutowane na typ double, ponieważ funkcja ta działa tylko dla typów liczbowych. Błędnej wartości nie da się przedstawić za pomocą typu double, dlatego zostaje ona przekształcona na wartość null, ignorowaną przez funkcję MAX. Najlepsze podejście polega na deklarowaniu typów dla danych przy ich wczytywaniu oraz wykrywaniu brakujących lub błędnych wartości w relacjach przed przystąpieniem do ich przetwarzania. Uszkodzone dane czasem mają postać mniejszych krotek, ponieważ brakuje w nich niektórych pól. Aby odfiltrować takie krotki, użyj funkcji SIZE w następujący sposób: grunt> A = LOAD 'input/pig/corrupt/missing_fields'; grunt> DUMP A; (2,Tie) (4,Coat) (3) (1,Scarf) grunt> B = FILTER A BY SIZE(TOTUPLE(*)) > 1; grunt> DUMP B; (2,Tie) (4,Coat) (1,Scarf)
Scalanie schematów W platformie Pig nie trzeba deklarować schematu dla każdej nowej relacji z przepływu danych. W większości sytuacji Pig potrafi określić schemat dla danych wyjściowych operacji relacyjnej na podstawie schematu relacji wejściowej. W jaki sposób schematy są przenoszone do nowych relacji? Niektóre operatory relacyjne nie zmieniają schematów, dlatego relacja wygenerowana za pomocą operatora LIMIT (który ogranicza relację 422
Rozdział 16. Pig
do maksymalnej liczby krotek) ma ten sam schemat co początkowa relacja. Dla innych operatorów sytuacja jest bardziej skomplikowana. Na przykład operator UNION łączy dwie relacje lub większą ich liczbę w jedną i próbuje scalić schematy relacji wejściowych. Jeśli te schematy są niezgodne ze sobą (z powodu różnych typów lub liczby pól), schemat wynikowych danych relacji UNION jest nieznany. Schemat dowolnej relacji z przepływu danych można wyświetlić za pomocą operatora DESCRIBE. Jeśli chcesz ponownie zdefiniować schemat relacji, możesz użyć operatora FOREACH…GENERATE z klauzulą AS. W ten sposób można zdefiniować schemat z wybranymi lub wszystkimi polami wejściowej relacji. Więcej informacji o schematach zawiera punkt „Funkcje zdefiniowane przez użytkownika”.
Funkcje W platformie Pig istnieją funkcje czterech typów. Oto one: Funkcje obliczeniowe Są to funkcje, które przyjmują jedno lub więcej wyrażeń i zwracają inne wyrażenie. Przykładową funkcją tego typu jest wbudowana funkcja MAX, zwracająca maksymalną wartość elementów zbioru. Niektóre funkcje tego rodzaju są funkcjami agregującymi, co oznacza, że na podstawie zbioru danych generują wartość skalarną. MAX to przykładowa funkcja agregująca. Ponadto wiele funkcji agregujących to funkcje algebraiczne. To oznacza, że wynik funkcji można obliczać przyrostowo. W modelu MapReduce funkcje algebraiczne wykorzystują mechanizm łączenia i pozwalają zwiększyć wydajność obliczeń (zobacz punkt „Funkcje łączące” w rozdziale 2.). MAX to funkcja algebraiczna, natomiast nie jest nią funkcja wyznaczająca medianę zbioru wartości. Funkcje filtrujące Jest to specjalny rodzaj funkcji obliczeniowej, który zwraca wartość logiczną. Jak wskazuje na to nazwa, funkcje filtrujące są używane w operatorze FILTER do usuwania niepożądanych wierszy. Można z nich korzystać także w innych operatorach relacyjnych, które przyjmują wartości logiczne. Funkcje filtrujące można więc stosować w wyrażeniach wykorzystujących wyrażenia logiczne lub warunkowe. Przykładową wbudowaną funkcją filtrującą jest IsEmpty. Sprawdza ona, czy zbiór lub odwzorowanie zawiera jakieś elementy. Funkcje wczytujące Są to funkcje, które określają, jak należy wczytać dane z zewnętrznego magazynu do relacji. Funkcje zapisujące Są to funkcje, które określają, jak zapisywać zawartość relacji w zewnętrznym magazynie. Funkcje wczytujące i zapisujące często są implementowane w tych samych typach. Na przykład typ PigStorage, używany do wczytywania danych z plików tekstowych z ogranicznikami, potrafi zapisywać dane w tym samym formacie.
Język Pig Latin
423
Pig udostępnia zestaw funkcji wbudowanych. Wybrane z nich przedstawiono w tabeli 16.7. Kompletną listę funkcji wbudowanych, obejmującą wiele standardowych funkcji matematycznych związanych z datą i czasem oraz przeznaczonych dla kolekcji znajdziesz w dokumentacji każdej wersji platformy Pig. Tabela 16.7. Wybrane funkcje wbudowane platformy Pig Kategoria
Funkcja
Opis
Obliczeniowe
AVG
Oblicza średnią z wartości ze zbioru.
CONCAT
Złącza ze sobą tablice bajtów lub tablice znaków.
COUNT
Wyznacza liczbę elementów w zbiorze (różnych od null).
COUNT_STAR
Wyznacza liczbę elementów w zbiorze (z wartościami null włącznie).
DIFF
Wyznacza różnicę zbiorów. Jeśli argumentami nie są zbiory, zwraca zbiór zawierający oba argumenty, jeżeli są one różne; dla identycznych argumentów niebędących zbiorami zwracany jest pusty zbiór.
MAX
Wyznacza maksimum z wartości ze zbioru.
MIN
Wyznacza minimum z wartości ze zbioru.
SIZE
Określa wielkość typu. Dla typów liczbowych zawsze jest to wartość 1. Dla tablic znaków jest to liczba znaków, dla tablic bajtów — liczba bajtów, a dla kontenerów (krotek, zbiorów i odwzorowań) — liczba elementów.
SUM
Oblicza sumę wartości elementów zbioru.
TOBAG
Przekształca jedno lub kilka wyrażeń na odrębne krotki umieszczane następnie w zbiorze. Synonim zapisu ().
TOKENIZE
Rozbija tablicę znaków na zbiór obejmujący słowa.
TOMAP
Przekształca parzystą liczbę wyrażeń na odwzorowanie z parami klucz-wartość. Synonim zapisu [].
TOP
Wyznacza pierwszych n krotek zbioru.
TOTUPLE
Przekształca jedno lub więcej wyrażeń na krotkę. Synonim zapisu {}.
Filtrujące
IsEmpty
Sprawdza, czy zbiory lub odwzorowania są puste.
Wczytujące i zapisujące
PigStorage
Wczytuje lub zapisuje relacje w formacie tekstowym z ogranicznikami. Każdy wiersz jest rozbijany na pola na podstawie konfigurowalnego ogranicznika (domyślnie używana jest tabulacja), a uzyskane elementy są zapisywane w polach krotki. Jest to domyślna klasa z rodziny Storage, używana, gdy programista nie określi żadnej a takiej klasy .
TextLoader
Generuje relacje na podstawie zwykłego tekstu. Każdy wiersz odpowiada krotce, której jedynym polem jest wiersz tekstu.
JsonLoader, JsonStorage
Wczytuje relacje z formatu JSON (zdefiniowanego w Pigu) lub zapisuje je w nim. Każda krotka jest zapisywana w jednym wierszu.
AvroStorage
Wczytuje relacje z plików danych systemu Avro lub zapisuje je w nich.
ParquetLoader, ParquetStorer
Wczytuje relacje z plików Parqueta lub zapisuje je w nich.
OrcStorage
Wczytuje relacje z plików ORCFile z systemu Hive lub zapisuje je w nich.
HBaseStorage
Wczytuje relacje z tabel baz HBase lub zapisuje je w nich.
Aby zmienić domyślną klasę z rodziny Storage, ustaw właściwości pig.default.load.func i pig.default.store.func na pełne nazwy klas z funkcjami wczytującymi i zapisującymi. a
424
Rozdział 16. Pig
Inne biblioteki Jeśli potrzebna funkcja jest niedostępna, można napisać własną funkcję zdefiniowaną przez użytkownika (funkcję UDF), co wyjaśniono w punkcie „Funkcje zdefiniowane przez użytkownika”. Najpierw jednak zajrzyj do biblioteki Piggy Bank (https://cwiki.apache.org/confluence/display/ PIG/PiggyBank?). Jest to biblioteka funkcji Piga współużytkowana przez społeczność użytkowników tej platformy i rozpowszechniana razem z nią. W bibliotece Piggy Bank znajdują się na przykład funkcje wczytująca i zapisująca dla plików w formacie CSV, plików RCFiles z systemu Hive, plików typu SequenceFile i plików XML. Plik JAR biblioteki Piggy Bank jest dostępny razem z platformą Pig i można go używać bez konieczności dodatkowego konfigurowania go. W dokumentacji interfejsu API platformy Pig znajdziesz listę funkcji dostępnych w tej bibliotece. Apache DataFu (http://datafu.incubator.apache.org/) to następna bogata biblioteka funkcji UDF platformy Pig. Oprócz ogólnych funkcji narzędziowych ta biblioteka obejmuje funkcje do obliczania podstawowych statystyk, pobierania próbek, obliczania szacunków, tworzenia skrótów i pracy z danymi sieciowymi (do analizy sesji, analizy odnośników itd.).
Makra Makra umożliwiają utworzenie pakietu z nadającymi się do wielokrotnego użytku fragmentami kodu w języku Pig Latin. Można to zrobić z poziomu tego języka. Możesz na przykład zdefiniować makro obejmujące fragment programu w języku Pig Latin grupujący relację i wyszukujący w każdej grupie wartość maksymalną. DEFINE max_by_group(X, group_key, max_field) RETURNS Y { A = GROUP $X by $group_key; $Y = FOREACH A GENERATE group, MAX($X.$max_field); };
To makro, max_by_group, przyjmuje trzy parametry — relację (X), i dwa pola (group_key i max_ field), a zwraca jedną relację (Y). W kodzie makra nazwy parametrów i zwracanej wartości są poprzedzane przedrostkiem $, na przykład $X. Przedstawione makro można zastosować w następujący sposób: records = LOAD 'input/ncdc/micro-tab/sample.txt' AS (year:chararray, temperature:int, quality:int); filtered_records = FILTER records BY temperature != 9999 AND quality IN (0, 1, 4, 5, 9); max_temp = max_by_group(filtered_records, year, temperature); DUMP max_temp
W czasie wykonywania programu Pig rozwija makro za pomocą jego definicji. Wygląd programu po rozwinięciu przedstawiono poniżej. Rozwinięty fragment jest wyróżniony pogrubieniem. records = LOAD 'input/ncdc/micro-tab/sample.txt' AS (year:chararray, temperature:int, quality:int); filtered_records = FILTER records BY temperature != 9999 AND quality IN (0, 1, 4, 5, 9); macro_max_by_group_A_0 = GROUP filtered_records by (year); max_temp = FOREACH macro_max_by_group_A_0 GENERATE group, MAX(filtered_records.(temperature)); DUMP max_temp
Język Pig Latin
425
Zwykle użytkownik nie ma dostępu do rozwiniętego kodu, ponieważ Pig generuje go wewnętrznie. Jednak czasem warto podejrzeć pełny kod w trakcie pisania i diagnozowania makr. Aby platforma Pig tylko rozwinęła makro (bez wykonywania skryptu), przekaż do instrukcji pig argument -dryrun. Zauważ, że przekazane do makra parametry (filtered_records, year i temperature) zostały podstawione pod nazwy używane w definicji makra. Aliasy z definicji makra, które nie mają przedrostka $ (w przykładzie jest to alias A), są lokalne względem definicji i modyfikowane w trakcie rozwijania makra, co pozwala uniknąć konfliktów z nazwami z innych części programu. Tu alias A występuje w rozwiniętym kodzie jako macro_max_by_group_A_0. Aby ułatwić ponowne wykorzystanie kodu, makra można zdefiniować w plikach odrębnych od skryptów platformy Pig. Wtedy makra trzeba zaimportować do każdego skryptu, który ma z nich korzystać. Oto instrukcja importu: IMPORT './ch16-pig/src/main/pig/max_temp.macro';
Funkcje zdefiniowane przez użytkownika Projektanci platformy Pig stwierdzili, że możliwość dodawania niestandardowego kodu jest niezbędna w prawie wszystkich zadaniach związanych z przetwarzaniem kodu (oprócz tych najprostszych). Dlatego umożliwili łatwe definiowanie i używanie funkcji zdefiniowanych przez użytkownika (funkcji UDF). W tym podrozdziale opisane są tylko funkcje UDF pisane w Javie, warto jednak wiedzieć, że można też budować je w językach Python, JavaScript, Ruby i Groovy. Wszystkie takie funkcje są uruchamiane za pomocą interfejsu Java Scripting API.
Funkcje UDF służące do filtrowania Zobacz, jak napisać funkcję odfiltrowującą rekordy z danymi meteorologicznymi, w których jakość odczytu temperatury jest niezadowalająca. Pomysł polega na zmianie poniższego wiersza: filtered_records = FILTER records BY temperature != 9999 AND quality IN (0, 1, 4, 5, 9);
Po zmianie będzie on wyglądać tak: filtered_records = FILTER records BY temperature != 9999 AND isGood(quality);
Nowe podejście ma dwie zalety — skrypt platformy Pig staje się bardziej zwięzły, a ponadto kod jest umieszczany w jednym miejscu, dzięki czemu można go łatwo ponownie wykorzystać w innych skryptach. Jeśli piszesz jednorazowe zapytanie, prawdopodobnie nie będziesz pisał funkcji UDF. Jeżeli jednak wielokrotnie przetwarzasz dane w ten sam sposób, dostrzeżesz korzyści, jakie dają funkcje UDF wielokrotnego użytku. Funkcje UDF służące do filtrowania są klasami pochodnymi od klasy FilterFunc, która sama dziedziczy po klasie EvalFunc. Klasa EvalFunc jest szczegółowo opisana dalej. Na razie zapamiętaj tylko tyle, że wygląda ona tak: public abstract class EvalFunc { public abstract T exec(Tuple input) throws IOException; }
426
Rozdział 16. Pig
Jedyna metoda abstrakcyjna klasy EvalFunc, exec(), przyjmuje krotkę i zwraca jedną wartość sparametryzowanego typu T. Pola z wejściowej krotki obejmują wyrażenia przekazane do funkcji. Tu przekazywana jest jedna liczba całkowita. W klasie FilterFunc typem parametru T jest Boolean, a metoda powinna zwracać wartość true tylko dla tych krotek, które nie mają zostać odfiltrowane. Na potrzeby filtra związanego z jakością danych napiszmy klasę IsGoodQuality. Jest to klasa pochodna od FilterFunc zawierająca implementację metody exec() (listing 16.1). Klasa Tuple to lista obiektów o określonych typach. Tu ważne jest tylko pierwsze pole (ponieważ funkcja przyjmuje tylko jeden argument), pobierane na podstawie indeksu za pomocą metody get() klasy Tuple. To pole przechowuje liczby całkowite, dlatego jeśli ma wartość różną od null, można zrzutować jego typ i sprawdzić, czy dana liczba oznacza, że odczyt temperatury jest poprawny. Na tej podstawie zwracana jest odpowiednia wartość — true lub false. Listing 16.1. Funkcja UDF FilterFunc służąca do usuwania rekordów z odczytami temperatury o niezadowalającej jakości package com.hadoopbook.pig; import java.io.IOException; import java.util.ArrayList; import java.util.List; import org.apache.pig.FilterFunc; import import import import
org.apache.pig.backend.executionengine.ExecException; org.apache.pig.data.DataType; org.apache.pig.data.Tuple; org.apache.pig.impl.logicalLayer.FrontendException;
public class IsGoodQuality extends FilterFunc { @Override public Boolean exec(Tuple tuple) throws IOException { if (tuple == null || tuple.size() == 0) { return false; } try { Object object = tuple.get(0); if (object == null) { return false; } int i = (Integer) object; return i == 0 || i == 1 || i == 4 || i == 5 || i == 9; } catch (ExecException e) { throw new IOException(e); } } }
By zastosować nową funkcję, najpierw należy ją skompilować i spakować do pliku JAR (w przykładowym kodzie powiązanym z książką znajdziesz instrukcje pokazujące, jak to zrobić). Następnie należy poinformować platformę Pig o dostępności nowego pliku JAR. Służy do tego operator REGISTER, któremu należy podać lokalną ścieżkę do pliku (ścieżki nie należy ujmować w cudzysłów). grunt> REGISTER pig-examples.jar;
Funkcje zdefiniowane przez użytkownika
427
W ostatnim kroku można wywołać funkcję. grunt> filtered_records = FILTER records BY temperature != 9999 AND >> com.hadoopbook.pig.IsGoodQuality(quality);
Pig w trakcie przetwarzania wywołań funkcji traktuje nazwę funkcji jak nazwę klasy Javy i próbuje wczytać klasę o podanej nazwie. To dlatego w nazwach funkcji wielkość znaków ma znaczenie — jest ona istotna w nazwach klas Javy. W trakcie wyszukiwania klas Pig wykorzystuje mechanizm wczytywania, który uwzględnia zarejestrowane klasy. W trybie rozproszonym Pig zapewnia, że pliki JAR użytkownika znajdą się w klastrze. Jeśli chodzi o funkcję UDF z przykładu, Pig szuka klasy o nazwie com.hadoopbook.pig.IsGoodQuality. Znajduje ją w zarejestrowanym wcześniej pliku JAR. Przetwarzanie funkcji wbudowanych odbywa się w prawie ten sam sposób. Jedyna różnica polega na tym, że Pig ma zestaw nazw przeszukiwanych wbudowanych pakietów, dlatego w wywołaniach nie trzeba podawać pełnych nazw funkcji. Na przykład funkcja MAX jest zaimplementowana za pomocą klasy MAX z pakietu org.apache.pig.builtin. Jest to jeden z pakietów przeszukiwanych przez platformę Pig, dlatego w programach można napisać samo MAX zamiast org.apache.pig.builtin.MAX. Aby dodać nazwę pakietu z przykładu do listy przeszukiwanych, wywołaj w powłoce Grunt polecenie -Dudf.import.list=com.hadoopbook.pig. Możesz też skrócić nazwę funkcji, tworząc alias za pomocą operatora DEFINE. grunt> DEFINE isGood com.hadoopbook.pig.IsGoodQuality(); grunt> filtered_records = FILTER records BY temperature != 9999 AND >> isGood(quality);
Zdefiniowanie aliasu to dobry pomysł, jeśli zamierzasz używać funkcji wielokrotnie w jednym skrypcie. Jest to niezbędne także wtedy, gdy chcesz przekazać argumenty do konstruktora klasy z implementacją funkcji UDF. Jeśli wiersze rejestrujące pliki JAR i definiujące aliasy funkcji dodasz do pliku .pigbootup w katalogu głównym, zostaną one uruchomione przy każdym włączeniu platformy Pig.
Wykorzystywanie typów Filtr funkcjonuje prawidłowo, gdy reprezentujące jakość pole jest typu int. Jeśli jednak informacje o typie są niedostępne, funkcja UDF nie zadziała. Dzieje się tak, ponieważ pole przyjmuje typ domyślny, bytearray, reprezentowany za pomocą klasy DataByteArray. Ponieważ DataByteArray to nie Integer, próba rzutowania typu kończy się niepowodzeniem. Oczywistym rozwiązaniem jest przekształcenie pola na liczbę całkowitą w metodzie exec(). Istnieje jednak lepsza technika — poinformowanie platformy Pig o typach pól oczekiwanych przez funkcję. Metoda getArgToFuncMapping() klasy EvalFunc służy właśnie do tego. Można ją przesłonić, aby poinformować platformę Pig o tym, że pierwsze pole powinno być liczbą całkowitą. @Override public List getArgToFuncMapping() throws FrontendException { List funcSpecs = new ArrayList();
428
Rozdział 16. Pig
funcSpecs.add(new FuncSpec(this.getClass().getName(), new Schema(new Schema.FieldSchema(null, DataType.INTEGER)))); return funcSpecs; }
Ta metoda zwraca obiekt typu FuncSpec odpowiadający każdemu polu krotki przekazywanemu do metody exec(). Tu używane jest jedno pole i tworzony jest anonimowy schemat FieldSchema (podawana nazwa to null, ponieważ Pig ignoruje ją przy konwersji typów). Typ jest określany za pomocą stałej INTEGER z klasy DataType platformy Pig. Po zmodyfikowaniu funkcji platforma Pig spróbuje przekształcić przekazany do funkcji argument na liczbę całkowitą. Jeśli jest to niemożliwe, Pig przekazuje wartość null. Gdy pole ma wartość null, metoda exec() zawsze zwraca false. W omawianej aplikacji jest to odpowiednie rozwiązanie, ponieważ rekordy z niezrozumiałym polem określającym jakość danych powinny zostać odfiltrowane.
Obliczeniowa funkcja UDF Pisanie funkcji obliczeniowych jest tylko trochę bardziej skomplikowane od pisania funkcji filtrujących. Przyjrzyj się funkcji UDF z listingu 16.2, która usuwa początkowe i końcowe spacje z wartości typu chararray, używając metody trim() klasy java.lang.String6. Listing 16.2. Funkcja UDF z rodziny EvalFunc, usuwająca początkowe i końcowe odstępy z wartości typu chararray public class Trim extends PrimitiveEvalFunc { @Override public String exec(String input) { return input.trim(); } }
Tu wykorzystano klasę PrimitiveEvalFunc, która jest specjalną wersją klasy EvalFunc przeznaczoną dla danych wejściowych w postaci jednego typu prostego. W funkcji UDF Trim typ danych wejściowych i wyjściowych to String7. Przy pisaniu funkcji obliczeniowych zwykle trzeba uwzględnić schemat wyjściowy. W poniższej instrukcji schemat relacji B jest określany na podstawie funkcji udf. B = FOREACH A GENERATE udf($0);
Jeśli funkcja udf tworzy krotki z polami skalarnymi, Pig potrafi ustalić schemat relacji B za pomocą mechanizmu refleksji. W przypadku typów złożonych (zbiorów, krotek lub odwzorowań) Pig potrzebuje więcej pomocy. Dlatego należy zaimplementować metodę outputSchema(), by zapewnić platformie Pig informacje na temat schematu wyjściowego. Funkcja UDF Trim zwraca łańcuch znaków, który platforma Pig traktuje jak wartość typu chararray. Ilustruje to poniższa sesja. grunt> DUMP A; ( pomegranate) (banana ) 6
Pig udostępnia analogiczną funkcję wbudowaną o nazwie TRIM.
7
Choć tu nie jest to istotne, funkcje obliczeniowe działające na zbiorach mogą dodatkowo implementować interfejsy Algebraic i Accumulator platformy Pig. Pozwala to na wydajniejsze przetwarzanie zbiorów w porcjach.
Funkcje zdefiniowane przez użytkownika
429
(apple) ( lychee ) grunt> DESCRIBE A; A: {fruit: chararray} grunt> B = FOREACH A GENERATE com.hadoopbook.pig.Trim(fruit); grunt> DUMP B; (pomegranate) (banana) (apple) (lychee) grunt> DESCRIBE B; B: {chararray}
Relacja A zawiera pola typu chararray ze spacjami na początku i na końcu. Relacja B jest tworzona na podstawie A w wyniku zastosowania funkcji Trim do pierwszego pola (o nazwie fruit) z relacji A. Dla pól relacji B poprawnie dobierany jest typ chararray.
Dynamiczne obiekty wywołujące Czasem programista chce użyć funkcji z biblioteki Javy, ale bez konieczności pisania funkcji UDF. Umożliwiają to dynamiczne obiekty wywołujące (ang. dynamic invoker), pozwalające wywoływać metody Javy bezpośrednio w skryptach platformy Pig. Wadą tej techniki jest używanie mechanizmu refleksji, co może powodować znaczne koszty, gdy wywołania dotyczą wszystkich rekordów z dużego zbioru danych. Dlatego dla często wykonywanych skryptów zwykle lepiej jest napisać specjalną funkcję UDF. Poniższy fragment kodu pokazuje, jak zdefiniować i stosować funkcję UDF trim opartą na klasie StringUtils z pakietu Apache Commons Lang. grunt> DEFINE trim InvokeForString('org.apache.commons.lang.StringUtils.trim', >> 'String'); grunt> B = FOREACH A GENERATE trim(fruit); grunt> DUMP B; (pomegranate) (banana) (apple) (lychee)
Używany jest tu obiekt wywołujący typu InvokeForString, ponieważ typ wartości zwracanej przez używaną metodę to String. Istnieją też obiekty wywołujące typów InvokeForInt, InvokeForLong, InvokeForDouble i InvokeForFloat. Pierwszy argument konstruktora zastosowanego obiektu wywołującego to pełna nazwa wywoływanej metody. Drugi argument to rozdzielona spacjami lista klas argumentów tej metody.
Funkcje UDF służące do wczytywania danych Poniżej pokazana jest niestandardowa funkcja do wczytywania danych, która potrafi wczytywać pola na podstawie przedziałów kolumn ze zwykłego tekstu. Bardzo podobnie działa polecenie cut w Uniksie8. Omawianą funkcję można wykorzystać w następujący sposób:
8
W bibliotece Piggy Bank dostępna jest bardziej rozbudowana funkcja UDF o tym samym przeznaczeniu. Jej nazwa to FixedWidthLoader.
430
Rozdział 16. Pig
grunt> records = LOAD 'input/ncdc/micro/sample.txt' >> USING com.hadoopbook.pig.CutLoadFunc('16-19,88-92,93-93') >> AS (year:int, temperature:int, quality:int); grunt> DUMP records; (1950,0,1) (1950,22,1) (1950,-11,1) (1949,111,1) (1949,78,1)
Łańcuch znaków przekazany do funkcji CutLoadFunc to specyfikacja kolumn. Rozdzielone przecinkami przedziały definiują pola, do których w klauzuli AS przypisywane są nazwa i typ. Przyjrzyj się teraz implementacji funkcji CutLoadFunc. Jest ona pokazana na listingu 16.3. Listing 16.3. Funkcja UDF CutLoadFunc wczytująca pola krotki na podstawie przedziałów kolumn public class CutLoadFunc extends LoadFunc { private static final Log LOG = LogFactory.getLog(CutLoadFunc.class); private final List ranges; private final TupleFactory tupleFactory = TupleFactory.getInstance(); private RecordReader reader; public CutLoadFunc(String cutPattern) { ranges = Range.parse(cutPattern); } @Override public void setLocation(String location, Job job) throws IOException { FileInputFormat.setInputPaths(job, location); } @Override public InputFormat getInputFormat() { return new TextInputFormat(); } @Override public void prepareToRead(RecordReader reader, PigSplit split) { this.reader = reader; } @Override public Tuple getNext() throws IOException { try { if (!reader.nextKeyValue()) { return null; } Text value = (Text) reader.getCurrentValue(); String line = value.toString(); Tuple tuple = tupleFactory.newTuple(ranges.size()); for (int i = 0; i < ranges.size(); i++) { Range range = ranges.get(i); if (range.getEnd() > line.length()) { LOG.warn(String.format( "Koniec przedziału (%s) wykracza poza wiersz (%s)", range.getEnd(), line.length())); continue; } tuple.set(i, new DataByteArray(range.getSubstring(line)));
Funkcje zdefiniowane przez użytkownika
431
} return tuple; } catch (InterruptedException e) { throw new ExecException(e); } } }
W platformie Pig, podobnie jak w Hadoopie, wczytywanie danych ma miejsce przed uruchomieniem mapperów. Dlatego ważne jest, by dane wejściowe można było rozbić na porcje obsługiwane niezależnie przez każdy mapper (więcej informacji na ten temat zawiera punkt „Wejściowe porcje danych i rekordy” w rozdziale 8.). Typy LoadFunc zwykle wykorzystują do generowania rekordów istniejącą klasę z rodziny InputFormat z Hadoopa. Taka funkcja zawiera kod potrzebny do przekształcania rekordów na krotki platformy Pig. Przy tworzeniu funkcji CutLoadFunc używany jest łańcuch znaków określający przedziały kolumn reprezentujące poszczególne pola. Kod odpowiedzialny za parsowanie tego łańcucha znaków i tworzenie listy wewnętrznych obiektów typu Range, które reprezentują przedziały, znajduje się w klasie Range. Nie jest on pokazany w tym miejscu (znajdziesz go w przykładowym kodzie powiązanym z książką). Platforma Pig wywołuje funkcję setLocation() typu LoadFunc, aby przekazać lokalizację danych wejściowych do funkcji wczytującej dane. Ponieważ klasa CutLoadFunc używa typu TextInputFormat do podziału danych wejściowych na wiersze, wystarczy przekazać lokalizację, aby określić ścieżkę do danych wejściowych za pomocą statycznej metody typu FileInputFormat. Platforma Pig używa nowego interfejsu API modelu MapReduce. Dlatego można używać formatów wejściowych i wyjściowych oraz powiązanych z nimi klas z pakietu org.apache.hadoop.mapreduce.
Następnie platforma Pig wywołuje metodę getInputFormat(), by utworzyć obiekt typu RecordReader dla każdej porcji danych (podobnie jak w modelu MapReduce). Platforma przekazuje każdy taki obiekt do metody prepareToRead() klasy CutLoadFunc. Program zapamiętuje referencje do tych obiektów, dlatego można użyć metody getNext() do poruszania się po rekordach. Środowisko uruchomieniowe platformy Pig wywołuje wielokrotnie metodę getNext(), a funkcja wczytująca pobiera krotki z obiektu reader do momentu dotarcia do ostatniego rekordu z danej porcji. Na tym etapie zwracana jest wartość null, co oznacza, że nie ma już więcej krotek do wczytania. Za przekształcanie wierszy pliku wejściowego w obiekty typu Tuple odpowiada kod metody getNext(). Tu do przekształcania używana jest klasa TupleFactory. Jest to klasa platformy Pig służąca do tworzenia obiektów typu Tuple. Metoda newTuple() tworzy nową krotkę o odpowiedniej liczbie pól (określanej na podstawie liczby obiektów typu Range). Pola są zapełniane podłańcuchami z wierszy, określanymi za pomocą obiektów typu Range. Trzeba zastanowić się nad tym, co kod ma robić, gdy wiersz jest krótszy niż żądany przedział. Jedna możliwość to zgłaszanie wyjątku i wstrzymywanie przetwarzania. Jest to właściwe, jeśli aplikacja nie akceptuje niekompletnych lub błędnych rekordów. Jednak w wielu sytuacjach lepiej jest zwrócić krotkę z polem o wartości null i pozwolić platformie Pig obsłużyć niekompletne dane.
432
Rozdział 16. Pig
To podejście zastosowano także tutaj. Kod wychodzi z pętli for, jeśli koniec przedziału wypada poza końcem wiersza, dlatego bieżące pole krotki (i dalsze jej pola) zachowują wartość domyślną null.
Używanie schematu Zastanów się teraz nad typami wczytywanych pól. Jeśli użytkownik określił schemat, pola trzeba przekształcić na odpowiednie typy. Jednak platforma Pig robi to w trybie leniwym. Dlatego kod wczytujący dane zawsze powinien tworzyć krotki typu bytearray, używając typu DataByteArray. Funkcja wczytująca może jednak przeprowadzić konwersję. W tym celu należy przesłonić metodę getLoadCaster() i zwrócić niestandardową implementację interfejsu LoadCaster, który udostępnia zestaw metod konwersji potrzebnych w omawianej sytuacji. W klasie CutLoadFunc metoda getLoadCaster() nie jest przeciążona, ponieważ domyślna implementacja zwraca obiekt typu Utf8StorageConverter, który zapewnia standardową konwersję między danymi w formacie UTF-8 a typami danych platformy Pig. W niektórych sytuacjach funkcja wczytująca potrafi sama określić schemat. Na przykład przy wczytywaniu samoopisowych danych (w formacie XML lub JSON) można utworzyć schemat dla platformy Pig na podstawie analizy danych. Funkcja wczytująca może określić schemat także w inny sposób, na przykład na podstawie zewnętrznego pliku lub informacji przekazanych do konstruktora. Aby umożliwić takie rozwiązania, w funkcji wczytującej należy zaimplementować interfejs LoadMetadata (obok interfejsu LoadFunc). Pozwoli to przekazać schemat do środowiska uruchomieniowego platformy Pig. Zauważ jednak, że jeśli użytkownik ustawi schemat w klauzuli AS operatora LOAD, zostanie on zastosowany zamiast schematu określonego za pomocą interfejsu LoadMetadata. W funkcji wczytującej można dodatkowo zaimplementować interfejs LoadPushDown. Pozwala on określić, które kolumny są potrzebne w zapytaniu. Jest to przydatna optymalizacja dla danych o strukturze kolumnowej, ponieważ kod wczytujący dane może wczytać tylko kolumny potrzebne w zapytaniu. W klasie CutLoadFunc nie istnieje prosty sposób wczytywania podzbioru kolumn, ponieważ pobierany jest cały wiersz każdej krotki. Dlatego w przykładzie nie zastosowano opisanej optymalizacji.
Operatory używane do przetwarzania danych Wczytywanie i zapisywanie danych W tym rozdziale pokazano, jak wczytywać dane z zewnętrznych źródeł w celu ich przetwarzania w platformie Pig. Także proces zapisywania wyników jest prosty. Oto przykład pokazujący, jak za pomocą klasy PigStorage zapisać krotki jako zwykłe wartości tekstowe rozdzielone dwukropkami. grunt> STORE A INTO 'out' USING PigStorage(':'); grunt> cat out Joe:cherry:2 Ali:apple:3 Joe:banana:2 Eve:apple:7
Inne wbudowane funkcje związane z zapisywaniem danych opisano w tabeli 16.7.
Operatory używane do przetwarzania danych
433
Filtrowanie danych Po wczytaniu danych do relacji następnym krokiem często jest ich przefiltrowanie w celu usunięcia zbędnych informacji. Dzięki filtrowaniu danych w początkowej części potoku przetwarzania danych można zminimalizować ilość danych przepływających przez system, co zwiększa jego wydajność.
Operator FOREACH…GENERATE Wiesz już, jak usunąć wiersze z relacji za pomocą operatora FILTER oraz prostych wyrażeń i funkcji UDF. Operator FOREACH…GENERATE działa na każdym wierszu relacji. Można go zastosować do usunięcia pól lub wygenerowania nowych. W tym przykładzie wykonywane są obie te czynności. grunt> DUMP A; (Joe,cherry,2) (Ali,apple,3) (Joe,banana,2) (Eve,apple,7) grunt> B = FOREACH A GENERATE $0, $2+1, 'Constant'; grunt> DUMP B; (Joe,3,Constant) (Ali,4,Constant) (Joe,3,Constant) (Eve,8,Constant)
Tu tworzona jest nowa relacja, B, o trzech polach. Pierwsze pole powstaje w wyniku projekcji pierwszego pola ($0) z relacji A. Drugie pole relacji B to trzecie pole ($2) relacji A powiększone o 1. Trzecie pole relacji B jest polem stałym (wszystkie wiersze w tej relacji mają tę samą wartość tego pola) o wartości Constant typu chararray. Operator FOREACH…GENERATE ma też wersję zagnieżdżoną, przydatną przy bardziej skomplikowanym przetwarzaniu. W poniższym przykładzie obliczane są różne statystyki dotyczące zbioru danych meteorologicznych. -- year_stats.pig REGISTER pig-examples.jar; DEFINE isGood com.hadoopbook.pig.IsGoodQuality(); records = LOAD 'input/ncdc/all/19{1,2,3,4,5}0*' USING com.hadoopbook.pig.CutLoadFunc('5-10,11-15,16-19,88-92,93-93') AS (usaf:chararray, wban:chararray, year:int, temperature:int, quality:int); grouped_records = GROUP records BY year PARALLEL 30; year_stats = FOREACH grouped_records { uniq_stations = DISTINCT records.usaf; good_records = FILTER records BY isGood(quality); GENERATE FLATTEN(group), COUNT(uniq_stations) AS station_count, COUNT(good_records) AS good_record_count, COUNT(records) AS record_count; } DUMP year_stats;
Za pomocą utworzonej wcześniej funkcji UDF CutLoadFunc różne pola z wejściowego zbioru danych są wczytywane do relacji records. Następnie kod grupuje relację records na podstawie lat. Zwróć uwagę na słowo kluczowe PARALLEL. Pozwala ono ustawić liczbę używanych reduktorów. Jest to bardzo istotne, gdy program działa w klastrze. Następnie każda grupa jest przetwarzana za pomocą
434
Rozdział 16. Pig
zagnieżdżonego operatora FOREACH…GENERATE. Pierwsza zagnieżdżona instrukcja za pomocą operatora DISTINCT tworzy relację dotyczącą niepowtarzalnych identyfikatorów USAF stacji. Druga zagnieżdżona instrukcja przy użyciu operatora FILTER i funkcji UDF tworzy relację dotyczącą rekordów z poprawnymi odczytami. Ostatnia zagnieżdżona instrukcja to GENERATE (w zagnieżdżonym bloku FOREACH…GENERATE ostatnią zagnieżdżoną instrukcją musi być właśnie GENERATE), która generuje pola z podsumowaniem na podstawie pogrupowanych rekordów i relacji utworzonych w zagnieżdżonym bloku. Uruchomienie tego kodu dla zbioru danych obejmującego kilkadziesiąt lat pozwala uzyskać następujące wyniki: (1920,8L,8595L,8595L) (1950,1988L,8635452L,8641353L) (1930,121L,89245L,89262L) (1910,7L,7650L,7650L) (1940,732L,1052333L,1052976L)
Pola reprezentują rok, liczbę unikatowych stacji, łączną liczbę poprawnych odczytów i łączną liczbę wszystkich odczytów. Widać tu, że liczba stacji i odczytów z czasem rosła.
Operator STREAM Operator STREAM umożliwia przekształcanie danych w relacji za pomocą zewnętrznego programu lub skryptu. Nazwa nawiązuje do technologii Hadoop Streaming, która zapewnia podobne możliwości w modelu MapReduce (zobacz punkt „Narzędzie Streaming Hadoop” w rozdziale 2.). Operator STREAM współdziała z wbudowanymi poleceniami i ich argumentami. Poniżej wykorzystano polecenie cut z Uniksa, by pobrać drugie pole z każdej krotki relacji A. Zauważ, że polecenie i argumenty znajdują się między odwróconymi apostrofami. grunt> C = STREAM A THROUGH `cut -f 2`; grunt> DUMP C; (cherry) (apple) (banana) (apple)
Operator STREAM wykorzystuje klasę PigStorage do serializowania i deserializowania relacji przesyłanych oraz pobieranych w standardowych strumieniach wejścia i wyjścia programu. Krotki z relacji A zostają przekształcone na rozdzielone tabulacją wiersze przekazywane do skryptu. Dane wyjściowe skryptu są wczytywane wiersz po wierszu i rozdzielane w miejscu wykrycia tabulacji, by utworzyć nowe krotki dla relacji wyjściowej C. Aby przygotować niestandardowy mechanizm serializacji i deserializacji, należy utworzyć klasę pochodną od klasy PigStreamingBase (z pakietu org.apache.pig) i użyć operatora DEFINE. Strumienie w platformie Pig dają najwięcej możliwości, gdy programista pisze skrypty służące do niestandardowego przetwarzania danych. Poniższy skrypt Pythona odfiltrowuje błędne rekordy z danymi meteorologicznymi. #!/usr/bin/env python import re import sys
Operatory używane do przetwarzania danych
435
for line in sys.stdin: (year, temp, q) = line.strip().split() if (temp != "9999" and re.match("[01459]", q)): print "%s\t%s" % (year, temp)
Aby zastosować ten skrypt, trzeba przesłać go do klastra. Można to zrobić za pomocą klauzuli DEFINE, która tworzy alias na potrzeby instrukcji STREAM. W instrukcji STREAM można potem wykorzystać ten alias, co ilustruje poniższy skrypt platformy Pig. -- max_temp_filter_stream.pig DEFINE is_good_quality `is_good_quality.py` SHIP ('ch16-pig/src/main/python/is_good_quality.py'); records = LOAD 'input/ncdc/micro-tab/sample.txt' AS (year:chararray, temperature:int, quality:int); filtered_records = STREAM records THROUGH is_good_quality AS (year:chararray, temperature:int); grouped_records = GROUP filtered_records BY year; max_temp = FOREACH grouped_records GENERATE group, MAX(filtered_records.temperature); DUMP max_temp;
Grupowanie i złączanie danych Złączanie zbiorów danych w modelu MapReduce wymaga nieco pracy od programisty (zobacz punkt „Złączanie” w rozdziale 9.), natomiast platforma Pig ma bardzo dobrą wbudowaną obsługę takich operacji, dzięki czemu są one dużo bardziej przystępne. Jednak ponieważ duże zbiory danych przeznaczone do analizowania w platformie Pig (i modelu MapReduce) nie są znormalizowane, złączenia stosuje się tu rzadziej niż w SQL-u.
Operator JOIN Przyjrzyj się przykładowi ilustrującemu złączanie wewnętrzne. Używane są relacje A i B. grunt> DUMP A; (2,Tie) (4,Coat) (3,Hat) (1,Scarf) grunt> DUMP B; (Joe,2) (Hank,4) (Ali,0) (Eve,3) (Hank,2)
Te dwie relacje można złączyć na podstawie (identyfikacyjnego) pola liczbowego. grunt> C = JOIN A BY $0, B BY $1; grunt> DUMP C; (2,Tie,Hank,2) (2,Tie,Joe,2) (3,Hat,Eve,3) (4,Coat,Hank,4)
Jest to klasyczne złączenie wewnętrzne, w którym każda pasująca para z dwóch relacji daje w wynikach jeden wiersz. Generowane tu złączenie jest złączeniem równościowym, ponieważ dane są złączane na podstawie równości pól. W wynikach pojawiają się wszystkie pola z każdej relacji wejściowej. 436
Rozdział 16. Pig
Ogólny operator złączania należy stosować, gdy wszystkie złączane relacje są zbyt duże, by zmieścić je w pamięci. Jeśli jedna z relacji jest na tyle mała, że można zmieścić ją w pamięci, możesz zastosować złączenie specjalnego typu — fragmentaryczne złączanie z replikacją. Polega ono na rozsyłaniu niewielkich zbiorów danych wejściowych do wszystkich mapperów i przeprowadzaniu złączania po stronie mapowania za pomocą przechowywanej w pamięci tabeli wyszukiwania i (podzielonej na fragmenty) większej relacji. Istnieje specjalna składnia przeznaczona do informowania platformy Pig, że należy użyć fragmentarycznego złączania z replikacją9. grunt> C = JOIN A BY $0, B BY $1 USING 'replicated';
Najpierw należy podać dużą relację, a potem można dodać jedną lub kilka małych (wszystkie małe relacje muszą mieścić się w pamięci). Platforma Pig obsługuje też złączenia zewnętrzne. Tworzy się je za pomocą składni podobnej do tej z SQL-a (te złączenia są opisane w kontekście systemu Hive w punkcie „Złączenia zewnętrzne” w rozdziale 17.). Oto przykład: grunt> C = JOIN A BY $0 LEFT OUTER, B BY $1; grunt> DUMP C; (1,Scarf,,) (2,Tie,Hank,2) (2,Tie,Joe,2) (3,Hat,Eve,3) (4,Coat,Hank,4)
Instrukcja COGROUP Operator JOIN zawsze zwraca płaską strukturę — zbiór krotek. Instrukcja COGROUP działa podobnie jak JOIN, ale tworzy zagnieżdżony zbiór krotek wyjściowych. Jest to przydatne, gdy chcesz wykorzystać daną strukturę w dalszych instrukcjach. grunt> D = COGROUP A BY $0, B BY $1; grunt> DUMP D; (0,{},{(Ali,0)}) (1,{(1,Scarf)},{}) (2,{(2,Tie)},{(Hank,2),(Joe,2)}) (3,{(3,Hat)},{(Eve,3)}) (4,{(4,Coat)},{(Hank,4)})
Instrukcja COGROUP generuje krotkę dla każdego unikatowego klucza grupującego. Pierwszym polem każdej krotki jest właśnie ten klucz, a pozostałe pola to zbiory zawierających ten klucz krotek z relacji. Pierwszy zbiór zawiera pasujące krotki (o danym samym kluczu) z relacji A, a w drugim zbiorze znajdują się pasujące krotki z relacji B. Jeśli dla danego klucza w relacji nie ma pasujących krotek, zbiór jest pusty. Na przykład ponieważ nikt nie kupił szalika (identyfikator 1), drugi zbiór w krotce dla tego produktu jest pusty. Tak działa zewnętrzne złączenie. Jest to domyślny typ złączenia używany przez instrukcję COGROUP.
9
Istnieją też inne słowa kluczowe, które można wykorzystać w klauzuli USING. Oto niektóre z nich: 'skewed' (dla dużych zbiorów danych z niesymetryczną przestrzenią kluczy), 'merge' (włącza złączanie ze scalaniem dla danych wejściowych, które są już posortowane według klucza złączania) i 'merge-sparse' (dla sytuacji, gdy 1% lub mniej danych zostaje dopasowanych do siebie). Szczegółowe informacje o stosowaniu tych specjalnych złączeń zawiera dokumentacja platformy Pig.
Operatory używane do przetwarzania danych
437
Można jawnie utworzyć złączenie zewnętrzne za pomocą słowa kluczowego OUTER. Poniższa instrukcja COGROUP działa tak samo jak wcześniejsza. D = COGROUP A BY $0 OUTER, B BY $1 OUTER;
Możesz pominąć wiersze z pustymi zbiorami, używając słowa kluczowego INNER. Wtedy instrukcja COGROUP zastosuje złączenie wewnętrzne. Słowo kluczowe INNER dotyczy relacji, dlatego poniższa instrukcja ignoruje wiersze tylko wtedy, gdy w relacji A nie ma pasujących danych (tu skutkuje to pominięciem nieznanego produktu 0). grunt> E = COGROUP A BY $0 INNER, B BY $1; grunt> DUMP E; (1,{(1,Scarf)},{}) (2,{(2,Tie)},{(Hank,2),(Joe,2)}) (3,{(3,Hat)},{(Eve,3)}) (4,{(4,Coat)},{(Hank,4)})
Można spłaszczyć tę strukturę, by ustalić, kto kupił poszczególne produkty z relacji A. grunt> F = FOREACH E GENERATE FLATTEN(A), B.$0; grunt> DUMP F; (1,Scarf,{}) (2,Tie,{(Hank),(Joe)}) (3,Hat,{(Eve)}) (4,Coat,{(Hank)})
Za pomocą kombinacji instrukcji COGROUP, INNER i FLATTEN (ta ostatnia usuwa zagnieżdżenie) można zasymulować złączenie zewnętrzne (tworzone przez instrukcję JOIN). grunt> G = COGROUP A BY $0 INNER, B BY $1 INNER; grunt> H = FOREACH G GENERATE FLATTEN($1), FLATTEN($2); grunt> DUMP H; (2,Tie,Hank,2) (2,Tie,Joe,2) (3,Hat,Eve,3) (4,Coat,Hank,4)
Wynik będzie taki sam jak dla instrukcji JOIN A BY $0, B BY $1. Jeśli klucz złączania składa się z kilku pól, można ustawić je wszystkie w klauzulach BY instrukcji JOIN i COGROUP. Upewnij się, że liczba pól w każdej klauzuli BY jest taka sama. Oto następny przykład złączania w platformie Pig. Jest to skrypt obliczający maksymalną temperaturę dla każdej stacji z okresu kontrolowanego za pomocą danych wejściowych. -- max_temp_station_name.pig REGISTER pig-examples.jar; DEFINE isGood com.hadoopbook.pig.IsGoodQuality(); stations = LOAD 'input/ncdc/metadata/stations-fixed-width.txt' USING com.hadoopbook.pig.CutLoadFunc('1-6,8-12,14-42') AS (usaf:chararray, wban:chararray, name:chararray); trimmed_stations = FOREACH stations GENERATE usaf, wban, TRIM(name); records = LOAD 'input/ncdc/all/191*' USING com.hadoopbook.pig.CutLoadFunc('5-10,11-15,88-92,93-93') AS (usaf:chararray, wban:chararray, temperature:int, quality:int); filtered_records = FILTER records BY temperature != 9999 AND isGood(quality); grouped_records = GROUP filtered_records BY (usaf, wban) PARALLEL 30;
438
Rozdział 16. Pig
max_temp = FOREACH grouped_records GENERATE FLATTEN(group), MAX(filtered_records.temperature); max_temp_named = JOIN max_temp BY (usaf, wban), trimmed_stations BY (usaf, wban) PARALLEL 30; max_temp_result = FOREACH max_temp_named GENERATE $0, $1, $5, $2; STORE max_temp_result INTO 'max_temp_by_station';
Napisana wcześniej funkcja UDF CutLoadFunc jest tu używana do wczytania relacji przechowującej identyfikatory (USAF i WBAN) i nazwy stacji oraz relacji przechowującej wszystkie rekordy z danymi meteorologicznymi (z kluczami w postaci identyfikatorów stacji). Przefiltrowane rekordy z danymi są grupowane na podstawie identyfikatorów stacji i agregowane według maksymalnej temperatury. Dopiero wyniki tych operacji są złączane ze stacjami. W ostatnim kroku następuje projekcja w celu uzyskania pól, które mają znaleźć się w wynikach. Są to pola: identyfikatora USAF, identyfikatora WBAN, nazwy stacji i maksymalnej temperatury. Oto kilka wyników dla lat 1910 – 1919. 228020 029110 040650
99999 99999 99999
SORTAVALA VAASA AIRPORT GRIMSEY
322 300 378
Wydajność tego zapytania można zwiększyć za pomocą fragmentarycznego złączania z replikacją, ponieważ metadane na temat stacji zajmują mało miejsca.
Operator CROSS Język Pig Latin udostępnia operator iloczynu wektorowego (nazywanego też iloczynem kartezjańskim). Jest to operator CROSS, złączający każdą krotkę jednej relacji z każdą krotką drugiej relacji (i wszystkimi krotkami dalszych relacji, jeśli są podane). Wielkość danych wyjściowych to iloczyn wielkości danych wejściowych. Oznacza to, że dane wyjściowe mogą zajmować bardzo dużo miejsca. grunt> I = CROSS A, B; grunt> DUMP I; (2,Tie,Joe,2) (2,Tie,Hank,4) (2,Tie,Ali,0) (2,Tie,Eve,3) (2,Tie,Hank,2) (4,Coat,Joe,2) (4,Coat,Hank,4) (4,Coat,Ali,0) (4,Coat,Eve,3) (4,Coat,Hank,2) (3,Hat,Joe,2) (3,Hat,Hank,4) (3,Hat,Ali,0) (3,Hat,Eve,3) (3,Hat,Hank,2) (1,Scarf,Joe,2) (1,Scarf,Hank,4) (1,Scarf,Ali,0) (1,Scarf,Eve,3) (1,Scarf,Hank,2)
Przy przetwarzaniu dużych zbiorów danych należy starać się unikać operacji, które generują pośrednie reprezentacje o wielkości równej kwadratowi danych wejściowych (lub większej). Tworzenie iloczynu wektorowego dla całego wejściowego zbioru danych prawie nigdy nie jest konieczne. Operatory używane do przetwarzania danych
439
Na przykład na pozór może się wydawać, że określenie podobieństwa między parami dokumentów ze zbioru wymaga wygenerowania najpierw wszystkich możliwych par. Jeśli jednak zacząć od założenia, że w większości par podobieństwo wynosi zero (dokumenty są niepowiązane), można wymyślić lepszy algorytm. Należy skoncentrować się na elementach używanych do wyznaczania podobieństwa (na przykład słowach z dokumentów) i zbudować algorytm z myślą o nich. W praktyce można też usunąć wyrazy, które nie pomagają odróżniać dokumentów (czyli najpopularniejsze słowa występujące w prawie wszystkich dokumentach), co dodatkowo zmniejsza przestrzeń problemową. Użycie tej techniki do analizy około miliona dokumentów (106) daje około miliard (109) pośrednich par10 zamiast tryliona (1012) par generowanych w naiwnym podejściu (przy tworzeniu iloczynu wektorowego) lub bez usuwania najpopularniejszych słów.
Instrukcja GROUP Instrukcja COGROUP grupuje dane z dwóch lub więcej relacji, natomiast instrukcja GROUP dotyczy jednej relacji. GROUP umożliwia grupowanie danych nie tylko na podstawie równości kluczy. Jako klucz grupy można też zastosować wyrażenie lub funkcję UDF. Oto przykładowa relacja A: grunt> DUMP A; (Joe,cherry) (Ali,apple) (Joe,banana) (Eve,apple)
Te dane można pogrupować według liczby znaków w drugim polu. grunt> B = GROUP A BY SIZE($1); grunt> DUMP B; (5,{(Eve,apple),(Ali,apple)}) (6,{(Joe,banana),(Joe,cherry)})
Instrukcja GROUP tworzy relację, w której pierwszym polem jest polecenie grupujące (przypisywany jest mu alias group). Drugie pole to zbiór zawierający pogrupowane pola o takim samym schemacie jak w pierwotnej relacji (tu jest nią A). Dostępne są też dwie specjalne operacje grupujące — ALL i ANY. ALL łączy wszystkie krotki z relacji w jedną grupę (funkcja GROUP działa wtedy bez warunków). grunt> C = GROUP A ALL; grunt> DUMP C; (all,{(Eve,apple),(Joe,banana),(Ali,apple),(Joe,cherry)})
Zauważ, że w tej wersji instrukcji GROUP nie występuje klauzula BY. Grupowanie typu ALL często stosuje się do określenia liczby krotek w relacji, co opisano w punkcie „Sprawdzanie poprawności i wartości null”. Słowo kluczowe ANY służy do losowego grupowania krotek z relacji. Jest to przydatne do generowania próbek danych.
10
Tamer Elsayed, Jimmy Lin i Douglas W. Oard, „Pairwise Document Similarity in Large Collections with MapReduce”, Proceedings of the 46th Annual Meeting of the Association of Computational Linguistics, czerwiec 2008 (http://www.ece.umd.edu/~oard/pdf/acl08elsayed2.pdf).
440
Rozdział 16. Pig
Sortowanie danych Relacje w platformie Pig są nieuporządkowane. Przyjrzyj się relacji A. grunt> DUMP A; (2,3) (1,2) (2,4)
Nie ma gwarancji, w jakiej kolejności wiersze będą przetwarzane. Gdy zawartość relacji A jest pobierana za pomocą instrukcji DUMP lub STORE, wiersze mogą pojawiać się w dowolnej kolejności. Jeśli chcesz wymusić określony porządek w danych wyjściowych, zastosuj operator ORDER w celu posortowania relacji według jednego lub kilku pól. Domyślnie przy sortowaniu dla pól tego samego typu używany jest porządek naturalny, a dla pól różnych typów — arbitralne, ale deterministyczne uporządkowanie (na przykład krotka jest zawsze uznawana za mniejszą od zbioru). Poniższa instrukcja sortuje relację A według pierwszego pola w porządku rosnącym i według drugiego pola w porządku malejącym. grunt> B = ORDER A BY $0, $1 DESC; grunt> DUMP B; (1,2) (2,4) (2,3)
Nie ma gwarancji, że przy późniejszym przetwarzaniu posortowanych danych ich porządek zostanie zachowany. Oto przykładowa instrukcja: grunt> C = FOREACH B GENERATE *;
Choć relacja C ma tę samą zawartość co relacja B, jej krotki mogą być zwracane przez instrukcje DUMP i STORE w dowolnej kolejności. To dlatego operację ORDER zwykle wykonuje się bezpośrednio przed pobraniem danych wyjściowych. Instrukcja LIMIT służy do ograniczania liczby wyników i jest prowizorycznym mechanizmem pobierania próbek danych z relacji (by uzyskać bardziej reprezentatywne próbki, należy zastosować zwracający losowe dane operator SAMPLE lub wygenerować prototypowe dane za pomocą polecenia ILLUSTRATE). Instrukcję tę można wywołać bezpośrednio po instrukcji ORDER, aby pobrać n pierwszych krotek. Instrukcja LIMIT zwykle pobiera z relacji dowolne n krotek, jednak zaraz po wykonaniu instrukcji ORDER kolejność danych zostaje zachowana (jest to wyjątek od reguły mówiącej, że przy przetwarzaniu relacji kolejność krotek nie jest zachowywana). grunt> D = LIMIT B 2; grunt> DUMP D; (1,2) (2,4)
Jeśli podany limit jest większy niż liczba krotek w relacji, zwracane są wszystkie krotki. Instrukcja LIMIT nie wpływa wtedy na dane. Używanie instrukcji LIMIT pozwala poprawić wydajność zapytań, ponieważ platforma Pig próbuje uwzględnić limit jak najwcześniej w potoku przetwarzania, aby zminimalizować ilość przetwarzanych danych. Dlatego jeśli nie są potrzebne wszystkie dane wyjściowe, zawsze należy stosować instrukcję LIMIT.
Operatory używane do przetwarzania danych
441
Łączenie i dzielenie danych Czasem jest kilka relacji, które programista chce połączyć w jedną. Służy do tego instrukcja UNION. Oto przykład: grunt> DUMP A; (2,3) (1,2) (2,4) grunt> DUMP B; (z,x,8) (w,y,1) grunt> C = UNION A, B; grunt> DUMP C; (2,3) (z,x,8) (1,2) (w,y,1) (2,4)
C to suma relacji A i B, a ponieważ relacje nie są uporządkowane, kolejność krotek w relacji C jest
nieokreślona. Sumy można tworzyć (tak jak zrobiono to powyżej) na podstawie dwóch relacji o odmiennych schematach lub o innej liczbie pól. Platforma Pig stara się scalić schematy z relacji podanych w instrukcji UNION. Tu łączone relacje są niezgodne ze sobą, dlatego relacja C nie ma schematu. grunt> DESCRIBE A; A: {f0: int, f1: int} grunt> DESCRIBE B; B: {f0: chararray, f1: chararray, f2: int} grunt> DESCRIBE C; Schema for C unknown.
Jeśli relacja wyjściowa nie ma schematu, skrypt musi radzić sobie z krotkami o różnej liczbie pól lub o odmiennych typach. Operator SPLIT to przeciwieństwo operatora UNION — dzieli relację na dwie lub większą ich liczbę. Przykład zastosowania tego operatora zawiera punkt „Sprawdzanie poprawności i wartości null”.
Platforma Pig w praktyce W tym podrozdziale opisano wybrane praktyczne techniki, które warto znać przy pisaniu i uruchamianiu programów przeznaczonych na platformę Pig.
Współbieżność W trybie modelu MapReduce ważne jest, by poziom współbieżności odpowiadał wielkości zbioru danych. Domyślnie platforma Pig określa liczbę reduktorów na podstawie rozmiaru danych wejściowych. Używany jest jeden reduktor na 1 GB danych wejściowych (do maksymalnej liczby 999 reduktorów). Obie te wartości można zmienić. Służą do tego właściwości pig.exec.reducers.bytes. per.reducer (wartość domyślna to 1 000 000 000 bajtów) i pig.exec.reducers.max (wartość domyślna to 999).
442
Rozdział 16. Pig
W celu bezpośredniego ustawienia liczby reduktorów dla każdego zadania można użyć klauzuli PARALLEL dla operatorów działających na etapie redukcji. Dotyczy to wszystkich operatorów grupowania i złączania (GROUP, COGROUP, JOIN i CROSS), a także operatorów DISTINCT i ORDER. W poniższym wierszu liczba reduktorów dla operatora GROUP jest ustawiona na 30. grouped_records = GROUP records BY year PARALLEL 30;
Można też ustawić opcję default_parallel, określającą liczbę reduktorów dla wszystkich późniejszych zadań. grunt> set default_parallel 30
Więcej informacji znajdziesz w ramce „Określanie liczby reduktorów” w rozdziale 8. Liczba operacji mapowania jest wyznaczana na podstawie wielkości danych wejściowych (używana jest jedna taka operacja na każdy blok z systemu HDFS); klauzula PARALLEL nie jest tu uwzględniana.
Relacje anonimowe Operatory diagnostyczne, na przykład DUMP lub DESCRIBE, zwykle stosuje się do ostatnio zdefiniowanych relacji. Ponieważ jest to tak częste, w platformie Pig dostępny jest skrót (@) wskazujący ostatnią relację. Ponadto męczące bywa wymyślanie nazwy dla każdej relacji używanej w interpreterze. Dlatego platforma Pig pozwala zastosować specjalną składnię => do tworzenia relacji bez nazw. Takie relacje można wskazywać tylko za pomocą skrótu @. Oto przykład: grunt> => LOAD 'input/ncdc/micro-tab/sample.txt'; grunt> DUMP @ (1950,0,1) (1950,22,1) (1950,-11,1) (1949,111,1) (1949,78,1)
Podstawianie wartości pod parametry Jeśli regularnie wywołujesz dany skrypt platformy Pig, często przydatna jest możliwość uruchamiania go z różnymi parametrami. Na przykład w codziennie uruchamianym skrypcie można podawać datę, aby określić przetwarzane pliki wejściowe. Platforma Pig obsługuje mechanizm podstawiania wartości pod parametry. Polega to na zastępowaniu parametrów skryptu wartościami podanymi w czasie wykonywania kodu. Parametry to identyfikatory poprzedzone symbolem $. W poniższym skrypcie użyto parametrów $input i $output do określenia ścieżek do plików wejściowego i wyjściowego. -- max_temp_param.pig records = LOAD '$input' AS (year:chararray, temperature:int, quality:int); filtered_records = FILTER records BY temperature != 9999 AND quality IN (0, 1, 4, 5, 9); grouped_records = GROUP filtered_records BY year; max_temp = FOREACH grouped_records GENERATE group, MAX(filtered_records.temperature); STORE max_temp into '$output';
Parametry można podawać za pomocą opcji -param przy uruchamianiu platformy Pig. Opcję tę należy zastosować raz dla każdego parametru.
Platforma Pig w praktyce
443
% pig -param input=/user/tom/input/ncdc/micro-tab/sample.txt \ > -param output=/tmp/out \ > ch16-pig/src/main/pig/max_temp_param.pig
Parametry można też zapisać w pliku i przekazać do platformy Pig za pomocą opcji -param_file. Aby uzyskać ten sam efekt co w poprzedniej instrukcji, najpierw umieść definicje parametrów w pliku. # Plik wejściowy input=/user/tom/input/ncdc/micro-tab/sample.txt # Plik wyjściowy output=/tmp/out
Następnie wywołaj instrukcję pig w następujący sposób: % pig -param_file ch16-pig/src/main/pig/max_temp_param.param \ > ch16-pig/src/main/pig/max_temp_param.pig
Aby wskazać kilka plików z parametrami, zastosuj kilkakrotnie opcję -param_file. Możesz też jednocześnie podać opcje -param i -param_file. Jeśli któryś parametr jest zdefiniowany zarówno w pliku z parametrami, jak i w wierszu poleceń, użyta zostanie ostatnia wartość.
Parametry dynamiczne Wartości parametrów podawanych za pomocą opcji -param można łatwo określać dynamicznie, wywołując polecenie lub skrypt. Wiele powłok uniksowych obsługuje podstawianie wartości pod polecenia umieszczone w odwróconych apostrofach. Za pomocą tej techniki można generować katalogi wyjściowe na podstawie dat. % pig -param input=/user/tom/input/ncdc/micro-tab/sample.txt \ > -param output=/tmp/`date "+%Y-%m-%d"`/out \ > ch16-pig/src/main/pig/max_temp_param.pig
Platforma Pig obsługuje odwrócone apostrofy także w plikach z parametrami. Wykonuje w powłoce polecenie umieszczone w odwróconych apostrofach i używa danych wyjściowych z powłoki jako podstawianej wartości. Jeśli polecenie lub skrypt zakończy pracę z niezerowym statusem wyjścia, zgłaszany jest komunikat o błędzie i wykonywanie instrukcji zostaje wstrzymane. Obsługa odwróconych apostrofów w plikach z parametrami jest przydatna. Dzięki temu parametry można definiować w taki sam sposób w plikach i w wierszu poleceń.
Proces podstawiania wartości pod parametry Podstawianie wartości pod parametry ma miejsce na etapie wstępnego przetwarzania przed uruchomieniem skryptu. Aby wyświetlić wartości podstawione przez preprocesor, użyj polecenia pig z opcją -dryrun. W tym trybie platforma Pig podstawia wartości pod parametry (i rozwija makra) oraz generuje kopię pierwotnego skryptu z podstawionymi wartościami, jednak nie wykonuje samego skryptu. Możesz przeanalizować wygenerowany skrypt i sprawdzić, czy podstawione wartości są poprawne (na przykład gdy są generowane dynamicznie), a dopiero potem uruchomić kod w normalnym trybie.
Dalsza lektura Ten rozdział zawiera wprowadzenie do korzystania z platformy Pig. Szczegółowym przewodnikiem po tym narzędziu jest książka Programming Pig Alana Gatesa (O’Reilly, 2011). 444
Rozdział 16. Pig
ROZDZIAŁ 17.
Hive
W rozdziale „Information Platforms and the Rise of the Data Scientist”1 Jeff Hammerbacher opisuje platformy przetwarzania informacji jako „najważniejszy aspekt starań organizacji w zakresie pozyskiwania, przetwarzania i generowania informacji”. Stwierdza też, że „służą one do przyspieszania procesu wyciągania wniosków z danych empirycznych”. Jednym z najważniejszych komponentów platformy przetwarzania informacji zbudowanej w firmie Facebook przez zespół Jeffa był Apache Hive (https://hive.apache.org/). Jest to hurtownia danych działająca w warstwie powyżej Hadoopa. Platforma Hive powstała z potrzeby zarządzania dużymi zbiorami danych generowanych każdego dnia w ciągle rozwijającej się sieci społecznościowej Facebook oraz analizowania tych informacji. Po wypróbowaniu kilku systemów zespół wybrał do przechowywania i przetwarzania danych Hadoopa, ponieważ było to ekonomiczne rozwiązanie i spełniało wymagania z zakresu skalowania. Platformę Hive utworzono, by umożliwić analitykom o dobrej znajomości SQL-a (i jednocześnie mniejszej biegłości w Javie) wykonywanie zapytań dotyczących dużych zbiorów danych, które firma Facebook przechowywała w systemie HDFS. Obecnie Hive to udany projekt fundacji Apache, wykorzystywany przez wiele organizacji jako przeznaczona do ogólnego użytku skalowalna platforma przetwarzania danych. SQL oczywiście nie jest idealnym rozwiązaniem w każdym problemie z obszaru wielkich danych. Nie nadaje się na przykład do budowania złożonych algorytmów związanych z uczeniem maszynowym. Jednak w wielu analizach sprawdza się doskonale, a jego wielką zaletą jest to, że jest bardzo dobrze znany przez wielu programistów. Co więcej, SQL to lingua franca w obszarze narzędzi do analizy biznesowej (często używanym interfejsem jest w nich ODBC). Dlatego Hive dobrze nadaje się do integracji z takimi produktami. Ten rozdział stanowi wprowadzenie do używania platformy Hive. Przyjęto, że masz już praktyczną wiedzę z zakresu SQL-a i architektury baz danych. Przy omawianiu funkcji tej platformy często są one porównywane z ich odpowiednikami z tradycyjnych systemów RDBMS.
1
Toby Segaran i Jeff Hammerbacher, Beautiful Data: The Stories Behind Elegant Data Solutions (O’Reilly, 2009).
445
Instalowanie platformy Hive Standardowo Hive działa w stacji roboczej i przekształca zapytania SQL-a na serię zadań wykonywanych w klastrze opartym na Hadoopie. Hive porządkuje dane w tabele, co pozwala nadać strukturę danym przechowywanym w systemie HDFS. Metadane (na przykład schematy tabel) są przechowywane w magazynie metadanych — w bazie o nazwie metastore. Gdy poznajesz platformę Hive, wygodnie jest uruchamiać magazyn metadanych na lokalnej maszynie. W tej konfiguracji (jest to rozwiązanie domyślne) tworzone definicje tabel platformy Hive są lokalne względem maszyny programisty, dlatego nie można współużytkować ich z innymi. W punkcie „Magazyn metadanych” dowiesz się, jak skonfigurować współużytkowany zdalny magazyn metadanych, co jest standardowym podejściem w środowiskach produkcyjnych. Instalowanie platformy Hive jest proste. Wymogiem wstępnym jest zainstalowanie lokalnie tej samej wersji Hadoopa, która jest używana w klastrze2. Oczywiście na etapie poznawania platformy Hive możesz też używać Hadoopa tylko lokalnie (w trybie niezależnym lub pseudorozproszonym). Wszystkie te możliwości są omówione w dodatku A.
Z którymi wersjami Hadoopa współdziała platforma Hive? Każda wersja platformy Hive współdziała z wieloma wersjami Hadoopa. Zwykle Hive współpracuje z najnowszą stabilną wersją Hadoopa, a także z określonymi starszymi edycjami wymienionymi w uwagach do wersji. Nie musisz robić niczego specjalnego, by wskazać platformie Hive używaną wersję Hadoopa. Jednak plik wykonywalny hadoop powinien być dostępny w zmiennej PATH (można też ustawić zmienną środowiskową HADOOP_HOME).
Pobierz wybraną wersję (http://hive.apache.org/downloads.html) i wypakuj archiwum tarball w odpowiednim katalogu stacji roboczej. % tar xzf apache-hive-x.y.z-bin.tar.gz
Wygodnie jest dodać katalog platformy Hive do zmiennej PATH, co pozwala na łatwe uruchamianie platformy. % export HIVE_HOME=~/sw/apache-hive-x.y.z-bin % export PATH=$PATH:$HIVE_HOME/bin
Teraz wpisz hive, by uruchomić powłokę platformy Hive. % hive hive>
Powłoka platformy Hive Powłoka to podstawowe narzędzie służące do interakcji z platformą Hive. Wpisuje się w niej polecenia w języku HiveQL. Jest to język zapytań używany w platformie Hive (HiveQL to odmiana SQL-a). HiveQL jest bardzo zbliżony do języka z bazy MySQL. Dlatego jeśli znasz bazę MySQL, korzystanie z platformy Hive będzie dla Ciebie łatwe. 2
Przyjęto, że z poziomu stacji roboczej możesz nawiązać połączenie z klastrem opartym na Hadoopie. Aby to przetestować przed uruchomieniem platformy Hive, zainstaluj Hadoopa lokalnie i wykonaj dowolne operacje w systemie HDFS za pomocą polecenia hadoop fs.
446
Rozdział 17. Hive
Gdy pierwszy raz uruchamiasz platformę Hive, możesz sprawdzić, czy działa, wyświetlając listę tabel (początkowo lista powinna być pusta). Instrukcję trzeba zakończyć średnikiem, aby nakazać platformie Hive jej wykonanie. hive> SHOW TABLES; OK Time taken: 0.473 seconds
HiveQL (podobnie jak SQL) zwykle nie uwzględnia wielkości liter (wyjątkiem są porównania łańcuchów znaków). Dlatego równie dobrze można zastosować polecenie show tables;. Klawisz Tab służy do automatycznego uzupełniania słów kluczowych i funkcji platformy Hive. W nowej instalacji wykonanie podanego polecenia zajmuje kilka sekund, ponieważ platforma w trybie leniwym tworzy wtedy magazyn metadanych na komputerze. Pliki tej bazy są zapisywane w katalogu metastore_db tworzonym w miejscu wywołania polecenia hive. Powłokę platformy Hive można też uruchomić w trybie nieinteraktywnym. Opcja -f pozwala wywołać polecenia ze wskazanego pliku, którym tu jest plik script.q. % hive -f script.q
Dla krótkich skryptów można użyć opcji -e i podać polecenia wewnątrzwierszowo. Wtedy końcowy średnik nie jest niezbędny. % hive -e 'SELECT * FROM dummy' OK X Time taken: 1.22 seconds, Fetched: 1 row(s)
Przydatna jest mała tabela z danymi służąca do testowania zapytań — na przykład w celu wypróbowania funkcji w wyrażeniach SELECT z wykorzystaniem literałów (zobacz punkt „Operatory i funkcje”). Oto jeden ze sposobów na zapełnienie jednowierszowej tabeli: % echo 'X' > /tmp/dummy.txt % hive -e "CREATE TABLE dummy (value STRING); \ LOAD DATA LOCAL INPATH '/tmp/dummy.txt' \ OVERWRITE INTO TABLE dummy"
W obu trybach (interaktywnym i nieinteraktywnym) Hive w trakcie wykonywania operacji przekazuje do standardowego wyjścia błędów informacje (na przykład o czasie przetwarzania zapytania). Możesz zablokować te komunikaty za pomocą opcji -S podawanej w trakcie uruchamiania instrukcji. Wtedy widoczne będą tylko wyniki przetwarzania zapytań. % hive -S -e 'SELECT * FROM dummy' X
Inne przydatne cechy powłoki platformy Hive to możliwość uruchamiania poleceń systemu operacyjnego (służy do tego przedrostek !, którym należy poprzedzić instrukcję) i dostęp do systemów plików Hadoopa (umożliwia to polecenie dfs).
Instalowanie platformy Hive
447
Przykład Zobacz teraz, jak za pomocą platformy Hive wywołać zapytanie do zbioru danych meteorologicznych używanego w poprzednich rozdziałach. Pierwszy krok polega na wczytaniu danych do zarządzanego magazynu platformy Hive. Tu Hive zapisuje dane w lokalnym systemie plików. Dalej zobaczysz, jak umieszczać tabele w systemie HDFS. Hive, podobnie jak system RDBMS, porządkuje dane w tabelach. Aby utworzyć tabelę przeznaczoną na dane meteorologiczne, wywołaj instrukcję CREATE TABLE. CREATE TABLE records (year STRING, temperature INT, quality INT) ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t';
W pierwszym wierszu deklarowana jest tabela records o trzech kolumnach — year, temperature i quality. Trzeba określić typ każdej z tych kolumn. Tu kolumna year jest typu STRING, a dwie pozostałe są typu INT. Początkowy kod w SQL-u powinien być zrozumiały. Jednak klauzula ROW FORMAT pochodzi z języka HiveQL. Użyta tu deklaracja oznacza, że każdy wiersz z pliku z danymi to tekst z ogranicznikami w postaci tabulacji. Platforma Hive oczekuje w każdym wierszu trzech pól odpowiadających kolumnom tabeli. Pola są rozdzielone tabulacjami, a wiersze — znakami nowego wiersza. Następnie można zapełnić tabelę platformy Hive danymi. Używana jest tu niewielka próbka danych, pomagająca zapoznać się z platformą. LOAD DATA LOCAL INPATH 'input/ncdc/micro-tab/sample.txt' OVERWRITE INTO TABLE records;
Uruchomienie tego polecenia informuje platformę Hive o tym, że ma umieścić wskazany plik lokalny w katalogu hurtowni danych. Jest to prosta operacja w systemie plików. Platforma nie próbuje na przykład parsować pliku i zapisywać danych w wewnętrznym formacie bazy danych, ponieważ Hive nie narzuca formatu. Pliki są zapisywane w ich pierwotnej postaci — platforma ich nie modyfikuje. W tym przykładzie tabele platformy Hive są umieszczane w lokalnym systemie plików (zmienna fs.defaultFS jest ustawiana na wartość domyślną file:///). Tabele są zapisywane jako katalogi w katalogu hurtowni platformy Hive. Katalog hurtowni można ustawić za pomocą właściwości hive.metastore.warehouse.dir (jej wartość domyślna to /user/hive/warehouse). Tak więc pliki tabeli records znajdują się w katalogu /user/hive/warehouse/records w lokalnym systemie plików. % ls /user/hive/warehouse/records/ sample.txt
Tu używany jest tylko jeden plik, sample.txt, jednak może być ich więcej. Przy przetwarzaniu zapytań do danej tabeli Hive wczytuje wszystkie jej pliki. Słowo kluczowe OVERWRITE w instrukcji LOAD DATA nakazuje platformie Hive usunięcie istniejących plików z katalogu tabeli. Jeśli to słowo zostanie pominięte, nowe pliki zostaną dodane do katalogu (chyba że mają nazwy identyczne z istniejącymi plikami — wtedy dawne pliki zostaną zastąpione przez nowe). 448
Rozdział 17. Hive
Gdy dane znajdują się już w platformie Hive, można wywołać dotyczące ich zapytanie. hive> > > > 1949 1950
SELECT year, MAX(temperature) FROM records WHERE temperature != 9999 AND quality IN (0, 1, 4, 5, 9) GROUP BY year; 111 22
To zapytanie w SQL-u jest zupełnie zwyczajne. To instrukcja SELECT z klauzulą GROUP BY, która grupuje wiersze na podstawie lat. Używana jest też funkcja agregująca MAX w celu znalezienia maksymalnej temperatury w każdej grupie. Niezwykłą rzeczą jest to, że Hive przekształca to zapytanie w automatycznie wykonywane zadanie, a następnie wyświetla wyniki w konsoli. Należy pamiętać o kilku niuansach, na przykład o tym, które elementy SQL-a są obsługiwane w platformie Hive i jaki jest format danych występujących w zapytaniu (niektóre z tych kwestii omówiono dalej w rozdziale), ale to właśnie możliwość pisania w SQL-u zapytań dotyczących surowych danych jest główną zaletą omawianej platformy.
Uruchamianie platformy Hive W tym podrozdziale opisano praktyczne aspekty uruchamiania platformy Hive, w tym konfigurowanie jej pod kątem klastra opartego na Hadoopie i współużytkowanego magazynu metadanych. Znajdziesz tu też szczegółowe omówienie architektury platformy Hive.
Konfigurowanie platformy Hive Platforma Hive, podobnie jak Hadoop, jest konfigurowana za pomocą pliku konfiguracyjnego w formacie XML. Jest to plik hive-site.xml umieszczony w katalogu conf platformy. W tym pliku można ustawić właściwości używane przy każdym uruchomieniu platformy Hive. W tym samym katalogu znajduje się też plik hive-default.xml z dokumentacją dostępnych w platformie właściwości i ich wartości domyślnych. Aby zmienić katalog konfiguracyjny, w którym Hive szuka pliku hive-site.xml, zastosuj opcję --config w poleceniu hive. % hive --config /Users/tom/dev/hive-conf
Zauważ, że w opcji podawany jest katalog, a nie sam plik hive-site.xml. Jest to przydatne, gdy istnieje wiele plików z rodziny site (na przykład dla różnych klastrów), między którymi użytkownik regularnie się przełącza. Ten sam efekt można uzyskać dzięki ustawieniu katalogu konfiguracyjnego w zmiennej środowiskowej HIVE_CONF_DIR. Plik hive-site.xml to naturalna lokalizacja szczegółowych informacji o połączeniu z klastrem. Możesz tu określić system plików i menedżer zasobów za pomocą standardowych właściwości Hadoopa — fs.defaultFS i yarn.resourcemanager.address (szczegółowe informacje o konfigurowaniu Hadoopa zawiera dodatek A). Jeśli te właściwości nie są ustawione, domyślnie używane — tak jak w Hadoopie — są lokalny system plików i lokalny (wewnątrzprocesowy) mechanizm uruchamiania zadań. Jest to bardzo wygodne, gdy chcesz wypróbować platformę Hive dla niewielkiego próbnego zbioru danych. Ustawienia konfiguracyjne magazynu metadanych (opisane w punkcie „Magazyn metadanych”) także zwykle umieszczane są w pliku hive-site.xml. Uruchamianie platformy Hive
449
Hive umożliwia też ustawianie właściwości dla konkretnych sesji. W tym celu należy zastosować opcję -hiveconf w poleceniu hive. Poniższe polecenie ustawia klaster (tu jest to klaster pseudorozproszony) używany w czasie trwania sesji. % hive -hiveconf fs.defaultFS=hdfs://localhost \ -hiveconf mapreduce.framework.name=yarn \ -hiveconf yarn.resourcemanager.address=localhost:8032
Jeśli kilku użytkowników platformy Hive ma współużytkować oparty na Hadoopie klaster, wszyscy użytkownicy powinni mieć uprawnienia do zapisu w katalogach używanych przez platformę. Poniższe polecenia tworzą potrzebne katalogi i odpowiednio ustawiają ich uprawnienia. % % % %
hadoop hadoop hadoop hadoop
fs fs fs fs
-mkdir -chmod -mkdir -chmod
/tmp a+w /tmp -p /user/hive/warehouse a+w /user/hive/warehouse
Jeśli wszyscy użytkownicy należą do tej samej grupy, dla katalogu hurtowni wystarczy ustawić uprawnienia g+w.
Ustawienia można zmieniać też w trakcie sesji. Służy do tego polecenie SET. Pozwala to zmienić ustawienia platformy Hive dla konkretnego zapytania. Na przykład poniższe polecenie gwarantuje, że kubełki zostaną zapełnione zgodnie z definicją tabeli (zobacz punkt „Kubełki”). hive> SET hive.enforce.bucketing=true;
Aby sprawdzić bieżącą wartość dowolnej właściwości, w wywołaniu instrukcji SET podaj tylko nazwę danej właściwości. hive> SET hive.enforce.bucketing; hive.enforce.bucketing=true
Sama instrukcja SET wyświetla wszystkie właściwości (i ich wartości) ustawione w platformie Hive. Zauważ, że ta lista nie obejmuje wartości domyślnych właściwości Hadoopa, chyba że zostały one jawnie zmodyfikowane za pomocą jednej z technik opisanych w tym podrozdziale. Polecenie SET -v wyświetla wszystkie właściwości z systemu, z domyślnymi wartościami właściwości Hadoopa włącznie. Ustawianie właściwości odbywa się zgodnie z określoną hierarchią. Na poniższej liście niższe liczby oznaczają wyższy priorytet. 1. Polecenie SET platformy Hive. 2. Opcja -hiveconf w wierszu poleceń. 3. Plik hive-site.xml i pliki Hadoopa z rodziny site (core-site.xml, hdfs-site.xml, mapred-site.xml i yarn-site.xml). 4. Wartości domyślne platformy Hive i pliki Hadoopa z rodziny default (core-default.xml, hdfsdefault.xml, mapred-default.xml i yarn-default.xml). Ustawianie właściwości konfiguracyjnych Hadoopa szczegółowo opisano w ramce „Które właściwości programista może ustawić?” w rozdziale 6.
450
Rozdział 17. Hive
Silniki wykonawcze Platforma Hive pierwotnie używała modelu MapReduce jako silnika wykonawczego. Nadal jest to domyślnie stosowany silnik. Obecnie można też wykorzystać Apache Tez (http://tez.apache.org/), a ponadto trwają prace nad dodaniem obsługi Sparka (zobacz rozdział 19.). Tez i Spark to uniwersalne silniki oparte na skierowanych grafach acyklicznych, które zapewniają większą swobodę i wyższą wydajność niż model MapReduce. Tez i Spark (w odróżnieniu od modelu MapReduce, gdzie pośrednie dane wyjściowe zadania są zapisywane w systemie HDFS) pozwalają uniknąć kosztów replikacji, ponieważ umieszczają pośrednie dane wyjściowe na dysku lokalnym lub nawet zapisują je w pamięci (na żądanie generatora planów platformy Hive). Silnik wykonawczy można ustawić za pomocą właściwości hive.execution.engine. Jej wartość domyślna to mr (od modelu MapReduce). Łatwo można zmienić silnik wykonawczy dla pojedynczego zapytania, co pozwala sprawdzić efekt użycia innego silnika w konkretnym zapytaniu. Aby ustawić dla platformy Hive silnik Tez, wywołaj następującą instrukcję: hive> SET hive.execution.engine=tez;
Zauważ, że Tez wymaga wcześniejszego zainstalowania w klastrze opartym na Hadoopie. Aktualne instrukcje wyjaśniające, jak to zrobić, znajdziesz w dokumentacji platformy Hive.
Rejestrowanie Dziennik błędów platformy Hive znajduje się w lokalnym systemie plików w katalogu ${java.io.tmpdir}/${user.name}/hive.log. Jest on bardzo przydatny przy diagnozowaniu problemów z konfiguracją i błędów innego rodzaju. Także dzienniki zadań modelu MapReduce z Hadoopa są przydatnym źródłem informacji przy rozwiązywaniu problemów. Z punktu „Dzienniki w Hadoopie” w rozdziale 6. dowiesz się, gdzie je znaleźć. W wielu systemach członowi ${java.io.tmpdir} odpowiada katalog /tmp. Jeśli jest inaczej lub gdy chcesz przeznaczyć na dzienniki inny katalog, wywołaj poniższe polecenie: % hive -hiveconf hive.log.dir='/tmp/${user.name}'
Konfiguracja procesu rejestrowania zdarzeń jest zapisana w pliku conf/hive-log4j.properties. Możesz przeprowadzić edycję tego pliku, by zmienić poziomy rejestrowania zdarzeń i inne ustawienia związane z dziennikami. Jednak często wygodniej jest ustawić konfigurację na poziomie sesji. Na przykład poniższe wygodne wywołanie powoduje, że komunikaty diagnostyczne będą przesyłane do konsoli. % hive -hiveconf hive.root.logger=DEBUG,console
Usługi platformy Hive Powłoka platformy Hive to tylko jedna z kilku usług, które można uruchomić za pomocą polecenia hive. Potrzebną usługę można podać za pomocą opcji --service. Wpisz instrukcję hive --service help, by wyświetlić listę nazw dostępnych usług. Najbardziej przydatne usługi opisano poniżej. cli
Jest to usługa domyślna — interfejs platformy Hive działający w wierszu poleceń (czyli powłoka).
Uruchamianie platformy Hive
451
hiveserver2
Uruchamia platformę Hive jako serwer i udostępnia usługę Thrift, co zapewnia dostęp do platformy z poziomu wielu klientów napisanych w różnych językach. HiveServer 2 to usprawniona wersja serwera HiveServer, obsługująca uwierzytelnianie i zapewniająca wielodostępność. Aplikacje używające konektorów dla technologii Thrift, JDBC i ODBC muszą korzystać z serwera, aby móc komunikować się z platformą Hive. We właściwości konfiguracyjnej hive.server2.thrift.port można ustawić port, w którym serwer odbiera dane (domyślnie jest to port 10000). beeline
Jest to działający w wierszu poleceń interfejs platformy Hive pracujący w trybie zagnieżdżonym (podobnie jak zwykły interfejs CLI) lub łączący się z procesem serwera HiveServer 2 za pomocą interfejsu JDBC. hwi
Interfejs sieciowy platformy Hive. Jest to prosty interfejs sieciowy, który można wykorzystać zamiast interfejsu CLI bez konieczności instalowania oprogramowania klienckiego. Hue (http://gethue.com/) to bardziej kompletny interfejs sieciowy Hadoopa, obejmujący aplikacje służące do uruchamiania zapytań platformy Hive i przeglądania jej magazynu metadanych. jar
Jest to używany w platformie Hive odpowiednik polecenia hadoop jar. Ta usługa umożliwia wygodne uruchamianie aplikacji Javy, które w ścieżce do klas obejmują klasy zarówno Hadoopa, jak i platformy Hive. metastore
Usługa metastore domyślnie działa w tym samym procesie co usługa platformy Hive. Można ją jednak uruchomić jako niezależny (zdalny) proces. Ustaw zmienną środowiskową METASTORE_PORT (lub użyj opcji -p w wierszu poleceń), aby ustawić port, w którym serwer ma odbierać dane (domyślnie jest to port 9083).
Klienty platformy Hive Jeśli używasz platformy Hive jako serwera (hive --service hiveserver2), dostępnych jest wiele różnych mechanizmów łączenia się z nim z poziomu aplikacji. Relacje między klientami a usługami platformy Hive przedstawiono na rysunku 17.1. Klient usługi Thrift Serwer platformy Hive jest udostępniany jako usługa Thrift, dlatego można komunikować się z nim za pomocą dowolnego języka programowania współdziałającego z tą usługą. Istnieją niezależne projekty klientów w językach Python i Ruby. Więcej informacji znajdziesz w serwisie wiki poświęconym platformie Hive (https://cwiki.apache.org/confluence/display/Hive/HiveServer2+Clients). Sterownik JDBC Hive udostępnia sterownik JDBC typu 4. (w czystej Javie), zdefiniowany w klasie org.apache. hadoop.hive.jdbc.HiveDriver. Gdy dla aplikacji Javy ustawiony jest identyfikator URI sterownika JDBC w formacie jdbc:hive2://host:port/nazwa_bazy, aplikacja nawiązuje połączenie 452
Rozdział 17. Hive
Rysunek 17.1. Architektura platformy Hive
z serwerem platformy Hive działającym w odrębnym procesie w określonym hoście i porcie. Sterownik zgłasza wywołania do interfejsu zaimplementowanego przez klienta usługi Hive Thrift; używane są przy tym mechanizmy Javy umożliwiające używanie Thrifta. Inna możliwość to łączenie się z platformą Hive za pomocą sterownika JDBC w trybie osadzonym i identyfikatora URI jdbc:hive2://. W tym trybie platforma Hive działa w tej samej maszynie JVM co wywołująca ją aplikacja. Nie trzeba wtedy uruchamiać platformy jako niezależnego serwera, ponieważ nie używa usługi Thrift ani klienta usługi Hive Thrift. Uruchamiany w wierszu poleceń interfejs Beeline do komunikowania się z platformą Hive wykorzystuje sterownik JDBC. Sterownik ODBC Sterownik ODBC umożliwia aplikacjom obsługującym protokół ODBC (na przykład oprogramowaniu do przeprowadzania analiz biznesowych) łączenie się z platformą Hive. Dystrybucja Apache Hive nie ma sterownika ODBC, jednak jest on bezpłatnie dostępny w kilku innych dystrybucjach. Sterowniki ODBC, podobnie jak sterowniki JDBC, używają usługi Thrift do komunikowania się z serwerem platformy Hive.
Magazyn metadanych Magazyn metadanych (ang. metastore) to centralne repozytorium metadanych platformy Hive. Składa się z dwóch komponentów — usługi i magazynu z danymi. Domyślnie usługa magazynu metadanych działa w tej samej maszynie JVM co usługa platformy Hive i obejmuje zagnieżdżony egzemplarz bazy Derby pracujący na dysku lokalnym. Jest to konfiguracja z zagnieżdżonym magazynem metadanych (zobacz rysunek 17.2).
Uruchamianie platformy Hive
453
Rysunek 17.2. Konfiguracje magazynu metadanych
Używanie zagnieżdżonego magazynu metadanych to prosty sposób na rozpoczęcie pracy z platformą Hive. Jednak w danym momencie tylko jedna zagnieżdżona baza Derby ma dostęp do plików bazy danych z dysku. To oznacza, że tylko jedna sesja platformy Hive może w danym momencie korzystać z magazynu metadanych. W drugiej sesji próba otwarcia połączenia z magazynem metadanych spowoduje błąd. Rozwiązanie zapewniające obsługę wielu sesji (a tym samym i wielu użytkowników) polega na użyciu niezależnej bazy danych. Jest to konfiguracja z lokalnym magazynem metadanych, ponieważ usługa magazynu metadanych także tu działa w tym samym procesie co usługa platformy Hive, ale łączy się z bazą pracującą w odrębnym procesie (z tej samej lub zdalnej maszyny). Można tu wykorzystać dowolną bazę zgodną z interfejsem JDBC. Aby wybrać bazę, należy ustawić właściwości konfiguracyjne javax.jdo.option.* wymienione w tabeli 17.13.
3
Właściwości mają przedrostek javax.jdo, ponieważ w implementacji magazynu metadanych do utrwalania obiektów Javy używany jest interfejs API JDO (ang. Java Data Objects). Stosowana tu implementacja interfejsu JDO to DataNucleus.
454
Rozdział 17. Hive
Tabela 17.1. Ważne właściwości konfiguracyjne magazynu metadanych Nazwa właściwości
Typ
Wartość domyślna
Opis
hive.metastore. warehouse.dir
Identyfikator URI
/user/hive/ warehouse
Katalog przechowujący zarządzane tabele, podany względem katalogu z właściwości fs.defaultFS.
hive.metastore.uris
Rozdzielone przecinkami identyfikatory URI
Brak
Jeśli nie jest ustawiona (tak jest domyślnie), używany jest wewnątrzprocesowy magazyn metadanych. W przeciwnym razie należy łączyć się z jednym lub kilkoma zdalnymi serwerami, określonymi na liście identyfikatorów URI. Gdy zdalnych serwerów jest kilka, klienty łączą się z nimi po kolei.
javax.jdo.option. ConnectionUR
Identyfikator URI
jdbc:derby:; databaseName= metastore_db; create=true
Używany przez interfejs JDBC adres URL bazy magazynu metadanych.
javax.jdo.option. ConnectionDriverName
String
org.apache.derby. jdbc.EmbeddedDriver
Nazwa klasy sterownika JDBC.
javax.jdo.option. ConnectionUserName
String
APP
Nazwa użytkownika dla interfejsu JDBC.
javax.jdo.option. ConnectionPassword
String
mine
Hasło dla interfejsu JDBC.
Dla niezależnego magazynu metadanych najczęściej używa się bazy MySQL. Wtedy właściwość javax.jdo.option.ConnectionURL należy ustawić na wartość jdbc:mysql://host/dbname?createDatabaseIf NotExist=true, a właściwość javax.jdo.option.ConnectionDriverName na wartość com.mysql.jdbc. Driver (oczywiście należy określić także nazwę użytkownika i hasło). Plik JAR sterownika JDBC dla bazy MySQL (jest to sterownik Connector/J) musi znajdować się w ścieżce do klas platformy Hive. W tym celu wystarczy umieścić ten plik w katalogu lib platformy. Istnieje też inna konfiguracja magazynu metadanych — zdalny magazyn metadanych. W tej konfiguracji jeden lub kilka serwerów magazynu danych działa w innych procesach niż usługa platformy Hive. Daje to większe możliwości w zakresie zarządzania i zabezpieczeń, ponieważ całą warstwę bazy danych można ukryć za zaporą, a klienty nie potrzebują podawać danych uwierzytelniających bazie danych. Aby usługę platformy Hive skonfigurować pod kątem używania zdalnego magazynu metadanych, ustaw właściwość hive.metastore.uris na identyfikatory URI serwerów magazynu metadanych (jeśli podawanych jest kilka serwerów, należy je rozdzielić przecinkami). Identyfikatory URI serwerów magazynów metadanych mają format thrift://host:port, gdzie port odpowiada temu ustawionemu w zmiennej METASTORE_PORT przy uruchamianiu serwera (zobacz punkt „Usługi platformy Hive”).
Uruchamianie platformy Hive
455
Porównanie z tradycyjnymi bazami danych Choć Hive pod wieloma względami przypomina tradycyjne bazy danych (na przykład obsługuje język SQL), powiązania z systemem HDFS i modelem MapReduce powodowały wiele różnic w architekturze, które bezpośrednio wpłynęły na funkcje dostępne w platformie Hive. Jednak z czasem wiele ograniczeń zostało wyeliminowanych (i nadal jest likwidowanych). Dzięki temu z każdym rokiem platforma Hive coraz bardziej przypomina tradycyjne bazy danych.
Uwzględnianie schematu przy odczycie lub przy zapisie W tradycyjnych bazach schemat tabeli jest wymuszany w czasie wczytywania danych. Jeśli wczytywane dane są niezgodne ze schematem, zostają odrzucone. Ten model można nazwać uwzględnianiem schematu przy zapisie, ponieważ dane są porównywane ze schematem w momencie ich zapisu w bazie. Platforma Hive nie sprawdza danych w momencie ich wczytywania, ale dopiero przy przetwarzaniu zapytania. Tak więc uwzględnia schemat przy odczycie. Oba podejścia mają wady i zalety. Uwzględnianie schematu przy odczycie pozwala bardzo szybko wczytywać dane do bazy, ponieważ nie trzeba ich odczytywać, parsować i serializować w celu zapisania na dysku w wewnętrznym formacie bazy danych. Operacja wczytywania polega tylko na skopiowaniu lub przeniesieniu pliku. Ten model daje też więcej swobody, ponieważ pozwala zastosować dla tych samych danych dwa schematy w zależności od tego, jakie analizy są przeprowadzane. W platformie Hive taki efekt można uzyskać dzięki zewnętrznym tabelom — zobacz punkt „Tabele zarządzane i tabele zewnętrzne”. Przy uwzględnianiu schematu przy zapisie przetwarzanie zapytań trwa krócej, ponieważ baza danych może indeksować kolumny i kompresować dane. Wadą jest dłuższe wczytywanie danych do bazy. Ponadto w wielu scenariuszach schemat w trakcie wczytywania danych nie jest znany, dlatego nie można dodać indeksów (ponieważ zapytania nie są jeszcze określone). W takich sytuacjach platforma Hive pokazuje swoje zalety.
Aktualizacje, transakcje i indeksy Aktualizacje, transakcje i indeksy to podstawowe mechanizmy tradycyjnych baz danych. Jednak do niedawna nie były one dostępne w platformie Hive. Powodem było to, że platformę Hive zbudowano z myślą o używaniu danych z systemu HDFS za pomocą modelu MapReduce. W tych technologiach normą jest skanowanie całych tabel, a aktualizowanie tabel odbywa się w wyniku przekształcenia danych w nową tabelę. W aplikacjach obsługujących hurtownie danych, które działają dla dużych porcji zbiorów danych, takie podejście dobrze się sprawdza. Platforma Hive od dawna umożliwia masowe dodawanie nowych wierszy do istniejących tabel. Wymaga to użycia instrukcji INSERT INTO w celu dodania do tabeli nowych plików z danymi. Od wersji 0.14.0 możliwe jest wprowadzanie bardziej precyzyjnych zmian. Można wywołać polecenie INSERT INTO TABLE…VALUES, aby wstawić małe porcje wartości wygenerowane w SQL-u. Ponadto możliwe jest używanie instrukcji UPDATE i DELETE dla wierszy tabeli.
456
Rozdział 17. Hive
System HDFS nie umożliwia aktualizowania plików w miejscu, dlatego zmiany spowodowane wstawianiem, aktualizowaniem i usuwaniem danych są przechowywane w małych plikach delta. Pliki te są okresowo scalane z podstawowymi plikami tabeli za pomocą zadań w modelu MapReduce uruchamianych w tle przez magazyn metadanych. Te mechanizmy działają tylko w kontekście transakcji (wprowadzonych w wersji 0.13.0 platformy Hive). Dlatego dla tabel, dla których mają być używane te mechanizmy, trzeba włączyć obsługę transakcji. Zapytania wczytujące dane z tabeli zawsze otrzymują spójny snapshot tabeli. Platforma Hive obsługuje też blokady na poziomie tabel i partycji. Blokada zapobiega na przykład usunięciu tabeli przez jeden proces w trakcie jej odczytu przez inny. Blokadami zarządza automatycznie ZooKeeper. Użytkownik nie musi ich ustawiać i zwalniać. Można jednak uzyskać za pomocą instrukcji SHOW LOCKS informacje na temat używanych blokad. Domyślnie blokady nie są obsługiwane. Indeksy platformy Hive w niektórych sytuacjach przyspieszają przetwarzanie zapytań. Na przykład zapytanie w postaci SELECT * from t WHERE x = a pozwala wykorzystać indeks dla kolumny x, dzięki czemu trzeba będzie przeskanować tylko niewielką część plików tabeli. Obecnie istnieją dwa rodzaje indeksów — kompaktowe i bitmapowe. Implementacje indeksu można zmieniać, dlatego w przyszłości zapewne pojawią się różne implementacje przeznaczone do różnych zastosowań. Indeksy kompaktowe przechowują dla każdej wartości numery bloków z systemu HDFS, zamiast przechowywać pozycje z plików. Dlatego nie zajmują dużo miejsca na dysku, a są przydatne w sytuacjach, gdy wartości są pogrupowane w pobliskich wierszach. W indeksach bitmapowych używane są skompresowane zbiory bitów w celu wydajnego zapisania wierszy, w których występuje określona wartość. Takie indeksy są przydatne zwłaszcza dla kolumn o niskiej kardynalności (na przykład z danymi o płci lub kraju).
Inne silniki obsługujące język SQL w Hadoopie Od czasu pojawienia się platformy Hive udostępniono wiele silników obsługujących SQL w Hadoopie, których twórcy starali się wyeliminować niektóre ograniczenia tej platformy. Cloudera Impala (http://impala.io/), interaktywny silnik SQL-a o otwartym dostępie do kodu źródłowego, był jednym z pierwszych narzędzi tego typu. Zapewniał on znaczną poprawę wydajności w porównaniu z platformą Hive współdziałającą z modelem MapReduce. Impala wykorzystuje specjalnego demona, który działa w każdym węźle danych klastra. Gdy klient wywołuje zapytanie, kontaktuje się z dowolnym węzłem, w którym działa demon Impali. Ten węzeł pełni rolę koordynatora zapytania. Koordynator przesyła żądania do pozostałych demonów Impali z klastra i łączy jednostkowe wyniki w pełny zbiór wyników zapytania. Impala wykorzystuje magazyn metadanych platformy Hive oraz obsługuje formaty tej platformy i większość rozwiązań z języka HiveQL (a także język SQL-92). Dlatego w praktyce można łatwo przeprowadzić migracje między dwoma omawianymi systemami lub korzystać z nich jednocześnie w jednym klastrze. Jednak twórcy platformy Hive także nie próżnują. Od czasu pojawienia się Impali udało im się poprawić wydajność platformy Hive — między innymi dzięki wykorzystaniu Teza jako silnika wykonawczego i dodaniu silnika wektorowego przetwarzania zapytań. Te usprawnienia wprowadzono w ramach projektu Stinger prowadzonego przez firmę Hortonworks.
Porównanie z tradycyjnymi bazami danych
457
Inne godne uwagi alternatywy platformy Hive o otwartym dostępie do kodu źródłowego to: Presto firmy Facebook (https://prestodb.io/), Apache Drill (http://drill.apache.org/) i Spark SQL (https:// spark.apache.org/sql/). Presto i Drill mają architekturę podobną jak Impala, przy czym Drill jest oparty na języku SQL:2011, a nie na języku HiveQL. Spark SQL wykorzystuje silnik Spark i umożliwia zagnieżdżanie SQL-owych zapytań w programach używających silnika Spark. Spark SQL to inna technologia niż używanie silnika wykonawczego Spark w platformie Hive (tak działa „Hive on Spark”; zobacz punkt „Silniki wykonawcze”). „Hive on Spark” udostępnia wszystkie funkcje platformy Hive, ponieważ jest częścią projektu Hive. Natomiast Spark SQL to nowy silnik SQL-a, który jest w pewnym zakresie zgodny z platformą Hive.
W projekcie Apache Phoenix (http://phoenix.apache.org/) przyjęto zupełnie inne założenie i umożliwiono używanie SQL-a dla baz HBase. SQL jest używany za pomocą sterownika JDBC, który przekształca zapytania na operacje skanowania w bazie HBase i wykorzystuje koprocesory bazy HBase do agregowania danych po stronie serwera. Także metadane są wtedy przechowywane w bazie HBase.
HiveQL HiveQL to odmiana SQL-a używana w platformie Hive. Język ten jest połączeniem specyfikacji SQL-92 oraz dialektów SQL-a z baz MySQL i Oracle. Poziom obsługi specyfikacji SQL-92 z czasem się poprawił i zapewne będzie wzrastał w przyszłości. Język HiveQL udostępnia też funkcje z nowszych specyfikacji SQL-a, na przykład funkcje analityczne ze specyfikacji SQL:2003. Niektóre niestandardowe rozszerzenia SQL-a używane w platformie Hive zostały zainspirowane modelem MapReduce. Dotyczy to na przykład wstawiania danych do wielu tabel (zobacz punkt „Wstawianie danych do wielu tabel”) oraz klauzul TRANSFORM, MAP i REDUCE (zobacz punkt „Skrypty modelu MapReduce”). Ten rozdział nie jest kompletnym omówieniem języka HiveQL. Znajdziesz je w dokumentacji platformy Hive (https://cwiki.apache.org/confluence/display/Hive/LanguageManual). Tu skoncentrowano się na często używanych funkcjach i poświęcono wiele uwagi rozwiązaniom niezgodnym ze specyfikacją SQL-92 i popularnymi bazami danych (takimi jak MySQL). Tabela 17.2 zawiera ogólne porównanie języków SQL i HiveQL.
Typy danych Hive udostępnia proste i złożone typy danych. Typy proste to typy liczbowe, logiczne, łańcuchy znaków i znaczniki czasu. Złożone typy danych to tablice, odwzorowania i struktury. Typy danych platformy Hive wymieniono w tabeli 17.3. Zauważ, że przedstawione w niej literały pochodzą z języka HiveQL. Nie są to zserializowane formy używane w formacie przechowywania tabeli (zobacz punkt „Formaty przechowywania”).
458
Rozdział 17. Hive
Tabela 17.2. Ogólne porównanie języków SQL i HiveQL Mechanizmy
SQL
HiveQL
Omówienie
Aktualizacje Transakcje
UPDATE, INSERT, DELETE
UPDATE, INSERT, DELETE
Obsługiwane
Ograniczona obsługa
„Wstawianie”, „Aktualizacje, transakcje i indeksy”
Indeksy
Obsługiwane
Obsługiwane
Typy danych
Całkowitoliczbowe, zmiennoprzecinkowe, stałoprzecinkowe, łańcuchy tekstowe i binarne, typy związane z czasem
Logiczne, całkowitoliczbowe, zmiennoprzecinkowe, stałoprzecinkowe, łańcuchy tekstowe i binarne, typy związane z czasem, tablica, odwzorowanie, struktura
„Typy danych”
Funkcje
Setki funkcji wbudowanych
Setki funkcji wbudowanych
„Operatory i funkcje”
Wstawianie danych do wielu tabel
Nieobsługiwane
Obsługiwane
„Wstawianie danych do wielu tabel”
CREATE TABLE…AS SELECT
Nieopisane w SQL-92, ale używane w niektórych bazach
Obsługiwane
„Instrukcja CREATE TABLE…AS SELECT”
SELECT
SQL-92
SQL-92. SORT BY pozwala częściowo posortować dane, a LIMIT — ograniczyć liczbę zwracanych wierszy
„Pobieranie danych”
Złączenia
SQL-92 i odmiany (złączane tabele w klauzuli FROM, warunki złączania w klauzuli WHERE)
Złączenia wewnętrzne, zewnętrzne, częściowe, na etapie mapowania, krzyżowe
„Złączenia”
Podzapytania
W dowolnej klauzuli (skorelowane lub nieskorelowane)
W klauzulach FROM, WHERE i HAVING (podzapytania nieskorelowane nie są obsługiwane)
„Podzapytania”
Widoki
Modyfikowalne (zmaterializowane lub niezmaterializowane)
Tylko do odczytu (widoki zmaterializowane nie są obsługiwane)
„Widoki”
Możliwości rozszerzenia
Funkcje zdefiniowane przez użytkowników, procedury składowane
Funkcje zdefiniowane przez użytkowników, skrypty modelu MapReduce
„Funkcje zdefiniowane przez użytkowników”, „Skrypty modelu MapReduce”
Typy proste Typy proste platformy Hive są podobne do typów prostych Javy, przy czym ich nazwy są oparte na nazwach typów z bazy MySQL (które z kolei częściowo pokrywają się z nazwami typów ze specyfikacji SQL-92). Istnieje typ BOOLEAN do zapisywania wartości true i false. Dostępne są cztery typy całkowitoliczbowe ze znakiem: TINYINT, SMALLINT, INT i BIGINT. Odpowiadają one typom prostym byte, short, int i long Javy (są to liczby całkowite ze znakiem — 1-bajtowe, 2-bajtowe, 4-bajtowe i 8-bajtowe). Typy zmiennoprzecinkowe platformy Hive, FLOAT i DOUBLE, odpowiadają typom float i double Javy. Reprezentują 32-bitowe i 64-bitowe liczby zmiennoprzecinkowe.
HiveQL
459
Tabela 17.3. Typy danych platformy Hive Kategoria
Typ
Opis
Przykładowe literały
Proste
BOOLEAN
Wartość true lub false
TRUE
TINYINT
1-bajtowa (8-bitowa) liczba całkowita z przedziału od –128 do 127
1Y
SMALLINT
2-bajtowa (16-bitowa) liczba całkowita z przedziału od –32 768 do 32 767
1S
INT
4-bajtowa (32-bitowa) liczba całkowita z przedziału od –2 147 483 648 do 2 147 483 647
1
BIGINT
8-bajtowa (64-bitowa) liczba całkowita z przedziału od –9 223 372 036 854 775 808 do 9 223 372 036 854 775 808
1L
FLOAT
4-bajtowa (32-bitowa) liczba zmiennoprzecinkowa o pojedynczej precyzji
1.0
DOUBLE
8-bajtowa (64-bitowa) liczba zmiennoprzecinkowa o podwójnej precyzji
1.0
DECIMAL
Liczba dziesiętna ze znakiem o dowolnej precyzji
1.0
STRING
Łańcuch znaków o zmiennej i nieograniczonej długości
'a', "a"
VARCHAR
Łańcuch znaków o zmiennej długości
'a', "a"
CHAR
Łańcuch znaków o stałej długości
'a', "a"
BINARY
Tablica bajtów
Brak
TIMESTAMP
Znacznik czasu (z dokładnością do nanosekund)
1325502245000, '2012-01-02 03:04:05.123456789'
DATE
Data
'2012-01-02'
ARRAY
Uporządkowana kolekcja pól; wszystkie pola muszą być tego samego typu.
array(1, 2)
MAP
Nieuporządkowana kolekcja par klucz-wartość; klucze muszą być typu prostego, a wartości mogą być dowolnego typu; w każdym odwzorowaniu wszystkie klucze i wszystkie wartości muszą mieć te same typy.
map('a', 1, 'b', 2)
STRUCT
Kolekcja nazwanych pól; mogą być one różnych typów.
struct('a', 1, 1.0) , named_struct('col1', 'a', 'col2', 1, 'col3', 1.0)
UNION
Wartość jednego ze zdefiniowanych typów danych. Wartości są opisywane za pomocą liczby całkowitej (indeksu rozpoczynającego się od zera), reprezentującej typ danych wartości.
create_union(1, 'a', 63)
Złożone
a
b
a
Literały reprezentujące tablice, odwzorowania, struktury i sumy są podane za pomocą funkcji. Instrukcje array, map, struct i create_union to wbudowane funkcje platformy Hive. b
Nazwy kolumn to col1, col2, col3 itd.
Typ danych DECIMAL służy do reprezentowania liczb dziesiętnych o dowolnej precyzji (w Javie podobny typ to BigDecimal) i jest powszechnie używany do zapisywania wartości pieniężnych. Wartości typu DECIMAL są przechowywane jako liczby całkowite bez wbudowanej skali. Precyzja określa liczbę wszystkich cyfr wartości, a skala wyznacza liczbę cyfr po przecinku. Tak więc typ DECIMAL(5,2) przechowuje liczby z przedziału od –999,99 do 999,99. Jeśli skala zostanie pominięta, 460
Rozdział 17. Hive
domyślnie używana jest wartość 0. Dlatego typ DECIMAL(5) przechowuje liczby z przedziału od –99 999 do 99 999 (czyli liczby całkowite). Precyzja domyślnie wynosi 10, więc zapis DECIMAL to odpowiednik notacji DECIMAL(10,0). Maksymalna dopuszczalna precyzja to 38, a skala nie może być większa od precyzji. Istnieją trzy typy danych platformy Hive służące do przechowywania tekstu. Typ STRING przechowuje łańcuchy znaków o zmiennej długości bez zadeklarowanej wielkości maksymalnej. Teoretycznie typ STRING pozwala zapisywać dane o wielkości do 2 GB, jednak w praktyce materializowanie tak dużych wartości nie jest wydajne. Obsługę dużych obiektów zapewnia Sqoop. Zobacz punkt „Importowanie dużych obiektów” w rozdziale 15. Typy VARCHAR działają podobnie, jednak mają zadeklarowaną maksymalną długość z przedziału od 1 do 65 355 (na przykład VARCHAR(100)). Typy CHAR to łańcuchy znaków o stałej długości uzupełniane w razie potrzeby spacjami (na przykład CHAR(100)). Przy porównywaniu wartości typu CHAR końcowe spacje są pomijane. Typ BINARY służy do przechowywania danych binarnych o zmiennej długości. Typ TIMESTAMP przechowuje znaczniki czasu z precyzją do nanosekund. Hive udostępnia funkcje UDF służące do konwersji danych między znacznikami czasu platformy Hive, znacznikami czasu Uniksa (określają one liczbę sekund od epoki Uniksa) i łańcuchami znaków. Te funkcje ułatwiają wykonywanie większości standardowych operacji na datach. Typ TIMESTAMP nie uwzględnia strefy czasowej, jednak funkcje to_utc_timestamp i from_utc_timestamp pozwalają zmieniać strefy czasowe. Typ danych DATE przechowuje datę z komponentami reprezentującymi rok, miesiąc i dzień.
Typy złożone Hive udostępnia cztery typy złożone: ARRAY, MAP, STRUCT i UNION. Typy ARRAY i MAP działają jak ich odpowiedniki z Javy, natomiast typ STRUCT to typ rekordowy obejmujący zbiór nazwanych pól. Typ UNION określa zestaw możliwych typów danych. Używane wartości muszą być jednego z tych typów. Typy złożone umożliwiają zagnieżdżanie danych na dowolnych poziomach. W deklaracji typu złożonego trzeba określić typ pól używanych w kolekcji. Służą do tego nawiasy ostre, co pokazuje poniższa definicja tabeli o czterech kolumnach (po jednej dla każdego typu złożonego): CREATE TABLE complex ( c1 ARRAY, c2 MAP, c3 STRUCT, c4 UNIONTYPE );
Przy wczytywaniu tabeli z jednym wierszem z danymi typów ARRAY, MAP, STRUCT i UNION, takimi jak w kolumnie „Przykładowe literały” w tabeli 17.3 (potrzebny format pliku jest opisany w punkcie „Formaty przechowywania danych”), operatory dostępu do pól powinny wyglądać tak jak w poniższym zapytaniu: hive> SELECT c1[0], c2['b'], c3.c, c4 FROM complex; 1 2 1.0 {1:63}
HiveQL
461
Operatory i funkcje Platforma Hive udostępnia standardowy zestaw operatorów SQL-a. Są to operatory relacyjne (na przykład x = 'a' do testowania równości, x IS NULL do wykrywania wartości null i x LIKE 'a%' do dopasowywania do wzorca), operatory arytmetyczne (na przykład x + 1 dla dodawania) i operatory logiczne (na przykład x OR y do wykonywania logicznej operacji LUB). Te operatory działają tak jak w bazie MySQL i różnią się od tych opisanych w specyfikacji SQL-92, ponieważ operator || wykonuje logiczną operację LUB, zamiast łączyć łańcuchy znaków. W bazie MySQL i platformie Hive do złączania łańcuchów znaków służy funkcja concat. Platforma Hive udostępnia wiele funkcji wbudowanych. Jest ich zbyt dużo, by je wymieniać w tym miejscu. Dzielą się one na kategorie. Są funkcje matematyczne i statystyczne, funkcje dla łańcuchów znaków, funkcje dla dat (manipulują one tekstowymi reprezentacjami dat), funkcje warunkowe, funkcje agregujące i funkcje do obsługi formatów XML (za pomocą funkcji xpath) oraz JSON. Listę funkcji można wyświetlić w powłoce platformy Hive za pomocą instrukcji SHOW FUNCTIONS4. Aby uzyskać krótkie instrukcje na temat użytkowania danej funkcji, wywołaj polecenie DESCRIBE. hive> DESCRIBE FUNCTION length; length(str | binary) - Returns the length of str or number of bytes in binary data
Gdy nie istnieje potrzebna funkcja wbudowana, można napisać własną. Zobacz punkt „Funkcje zdefiniowane przez użytkowników”.
Konwersje Typy proste tworzą hierarchię, która wyznacza niejawne konwersje typów przeprowadzane przez platformę Hive w wyrażeniach obejmujących funkcje i operatory. Na przykład typ TINYINT jest przekształcany na typ INT, jeśli wyrażenie oczekuje tego ostatniego. Jednak automatyczna konwersja w drugą stronę nie następuje, dlatego trzeba dla niej zastosować operator CAST (w przeciwnym razie Hive zgłosi błąd). Oto opis reguł niejawnej konwersji: każdy typ numeryczny może zostać niejawnie przekształcony na bardziej pojemny typ lub na typ tekstowy (STRING, VARCHAR lub CHAR). Wszystkie typy tekstowe mogą być niejawnie przekształcane na inne typy tekstowe. Co zaskakujące, mogą być także przekształcane na typy DOUBLE lub DECIMAL. Typu BOOLEAN nie można przekształcić na żaden inny. Typy TIMESTAMP i DATE można niejawnie przekształcać na typy tekstowe. Do przeprowadzania jawnej konwersji typów służy operator CAST. Na przykład instrukcja CAST('1' AS INT) przekształca łańcuch znaków '1' na całkowitoliczbową wartość 1. Jeśli rzutowanie się nie powiedzie (tak jak w wyrażeniu CAST('X' AS INT)), zwracana jest wartość NULL.
4
Możesz też zajrzeć do przeglądu funkcji platformy Hive na stronie https://cwiki.apache.org/confluence/display/ Hive/LanguageManual+UDF.
462
Rozdział 17. Hive
Tabele Tabela platformy Hive logicznie składa się z przechowywanych danych i z powiązanych metadanych opisujących strukturę danych w tabeli. Dane zwykle są zapisane w systemie HDFS, choć mogą znajdować się też w dowolnym innym systemie plików Hadoopa (w tym w lokalnym systemie plików lub w systemie S3). Platforma Hive przechowuje metadane w bazie relacyjnej, a nie na przykład w systemie HDFS (zobacz punkt „Magazyn metadanych”). W tym podrozdziale szczegółowo opisano, jak tworzyć tabele, jakie różne fizyczne formaty przechowywania danych udostępnia Hive, a także jak importować dane do tabel.
Obsługa różnych baz danych i schematów Wiele relacyjnych baz danych umożliwia obsługę różnych przestrzeni nazw. Pozwala to łączyć użytkowników i aplikacje z różnymi bazami danych oraz schematami. Platforma Hive obsługuje podobne możliwości i udostępnia polecenia CREATE DATABASE nazwa_bazy, USE nazwa_bazy i DROP DATABASE nazwa_bazy. Aby określić pełną nazwę tabeli, użyj zapisu nazwa_bazy.nazwa_tabeli. Jeśli nie podasz nazwy bazy, użyta zostanie tabela z bazy default.
Tabele zarządzane i tabele zewnętrzne Platforma Hive domyślnie zarządza danymi z tworzonych w niej tabel. Oznacza to, że Hive przenosi dane do katalogu hurtowni danych. Inna możliwość to utworzenie tabeli zewnętrznej. Wtedy Hive używa danych znajdujących się poza katalogiem hurtowni danych. Różnica między tabelami obu rodzajów jest widoczna w działaniu instrukcji LOAD i DROP. Przyjrzyj się najpierw tabelom zarządzanym. Dane wczytywane do tabeli zarządzanej są przenoszone do katalogu hurtowni danych platformy Hive. Oto przykładowa instrukcja: CREATE TABLE managed_table (dummy STRING); LOAD DATA INPATH '/user/tom/data.txt' INTO table managed_table;
Przenosi ona plik hdfs://user/tom/data.txt do katalogu hurtowni danych platformy Hive przeznaczonego dla tabeli managed_table (hdfs://user/hive/warehouse/managed_table)5. Później tabelę można usunąć za pomocą następującego polecenia: DROP TABLE managed_table;
To sprawia, że tabela (metadane oraz dane) zostaje usunięta. Warto zauważyć, że ponieważ początkowa instrukcja LOAD to operacja przenoszenia, a DROP to operacja usuwania, dane przestają wtedy istnieć. Tak wygląda zarządzanie danymi w platformie Hive. 5
Przenoszenie kończy się powodzeniem tylko wtedy, gdy systemy plików źródłowy i docelowy są takie same. Ponadto użycie słowa kluczowego LOCAL oznacza specjalny przypadek, w którym Hive kopiuje dane z lokalnego systemu plików do katalogu hurtowni danych platformy (nawet jeśli ten katalog też znajduje się w tym samym lokalnym systemie). We wszystkich innych sytuacjach instrukcja LOAD jest operacją przenoszenia danych i tak należy ją traktować.
Tabele
463
Operacja wczytywania jest bardzo szybka, ponieważ wystarczy przenieść plik lub zmienić jego nazwę w systemie plików. Pamiętaj jednak, że platforma Hive nie sprawdza, czy pliki w przeznaczonym dla tabeli katalogu są zgodne z zadeklarowanym schematem. Dotyczy to także tabel zarządzanych. Gdy dane są niezgodne ze schematem, może to wyjść na jaw w trakcie przetwarzania zapytania. Często prowadzi to do zwrócenia wartości NULL dla brakującego pola. Aby sprawdzić, czy dane są prawidłowo przetwarzane, wywołaj prostą instrukcję SELECT i pobierz kilka wierszy bezpośrednio z tabeli.
Tabele zewnętrzne działają inaczej. To programista steruje tworzeniem i usuwaniem danych. Lokalizacja danych zewnętrznych jest określana na etapie tworzenia tabeli. CREATE EXTERNAL TABLE external_table (dummy STRING) LOCATION '/user/tom/external_table'; LOAD DATA INPATH '/user/tom/data.txt' INTO TABLE external_table;
Słowo kluczowe EXTERNAL informuje platformę Hive, że nie ma zarządzać danymi. Dlatego platforma nie przenosi danych do katalogu hurtowni danych. Na etapie definiowania tabeli platforma nie sprawdza nawet, czy wskazana zewnętrzna lokalizacja istnieje. Jest to przydatne, ponieważ oznacza, że dane można dodać w trybie leniwym po utworzeniu tabeli. W momencie usuwania tabeli zewnętrznej platforma Hive nie kasuje samych danych — usuwa tylko metadane. Jak wybrać rodzaj używanej tabeli? W większości sytuacji tabele obu typów funkcjonują tak samo (oczywiście oprócz różnicy w działaniu instrukcji DROP), dlatego możesz wybrać dowolny z nich. Zgodnie z ogólną regułą jeżeli wszystkie operacje wykonujesz w platformie Hive, powinieneś zastosować tabele zarządzane. Natomiast jeśli dla tego samego zbioru danych chcesz korzystać zarówno z platformy Hive, jak i innych narzędzi, wybierz tabele zewnętrzne. Często tabele zewnętrzne są używane do uzyskania dostępu do początkowego (utworzonego przez inny proces) zbioru danych przechowywanego w systemie HDFS, po czym platforma Hive jest używana do przekształcenia danych w zarządzaną tabelę tej platformy. Ten mechanizm działa też w drugą stronę. Tabelę zewnętrzną (nie musi ona znajdować się w systemie HDFS) można wykorzystać do wyeksportowania danych z platformy Hive, by mogły z nich korzystać inne aplikacje6. Tabele zewnętrzne są stosowane także wtedy, gdy programista chce powiązać różne schematy z jednym zbiorem danych.
Partycje i kubełki Hive organizuje tabele w partycje. Tabele są dzielone na porcje na podstawie wartości kolumny partycjonowania (zawierającej na przykład daty). Wykorzystanie partycji pozwala przyspieszyć zapytania dotyczące wycinków danych. Tabele i partycje można też podzielić na kubełki, aby nadać danym dodatkową strukturę, którą można wykorzystać do zwiększenia wydajności przetwarzania zapytań. Na przykład podział na kubełki według identyfikatorów sprawia, że można szybko obsłużyć zapytanie dotyczące konkretnego użytkownika, a uruchamiane na losowej próbce obejmującej dane wszystkich użytkowników. 6
Wyeksportować dane do systemu plików Hadoopa można też za pomocą polecenia INSERT OVERWRITE DIRECTORY.
464
Rozdział 17. Hive
Partycje Przykładową sytuacją, w której często stosuje się partycje, jest przechowywanie plików dziennika, gdzie każdy rekord zawiera znacznik czasu. Przy podziale danych na partycje według dat rekordy z tego samego dnia trafią do tej samej partycji. Zaletą tego rozwiązania jest to, że można wydajnie przetwarzać zapytania dotyczące określonego dnia lub grupy dni, ponieważ wystarczy przeskanować pliki z partycji potrzebnych w zapytaniu. Zauważ, że podział na partycje nie utrudnia przetwarzania ogólniejszych zapytań. Nadal można tworzyć zapytania dotyczące całego zbioru danych, obejmującego wiele partycji. Tabelę można podzielić na partycje według wielu kryteriów. Na przykład oprócz podziału dzienników według dat można dodatkowo utworzyć dla każdego dnia podpartycje na podstawie kraju. Pozwala to wydajnie przetwarzać zapytania dotyczące określonych lokalizacji. Partycje definiuje się w trakcie tworzenia tabel za pomocą klauzuli PARTITIONED BY7. Przyjmuje ona listę definicji kolumn. W hipotetycznym przykładzie dotyczącym plików dziennika można zdefiniować tabelę, w której rekordy obejmują znacznik czasu i wiersz danych z dziennika. CREATE TABLE logs (ts BIGINT, line STRING) PARTITIONED BY (dt STRING, country STRING);
Przy wczytywaniu danych do tabeli podzielonej na partycje wartości, od których zależy podział, należy podać jawnie. LOAD DATA LOCAL INPATH 'input/hive/partitions/file1' INTO TABLE logs PARTITION (dt='2001-01-01', country='GB');
W systemie plików partycjom odpowiadają zagnieżdżone podkatalogi w katalogu przeznaczonym na tabelę. Po wczytaniu kilku kolejnych plików do tabeli logs struktura katalogów może wyglądać tak: /user/hive/warehouse/logs ── dt=2001-01-01/ ── country=GB/ ── file1 ── file2 ── country=US/ ── file3 ── dt=2001-01-02/ ── country=GB/ ── file4 ── country=US/ ── file5 ── file6
Tabela logs ma dwie partycje oparte na datach (2001-01-01 i 2001-01-02; odpowiadają im podkatalogi dt=2001-01-01 i dt=2001-01-02) i dwie podpartycje uzyskane na podstawie państw (GB i US; odpowiadają im zagnieżdżone podkatalogi country=GB i country=US). Pliki z danymi znajdują się w katalogach najniższego poziomu. Można zażądać od platformy Hive wyświetlenia partycji tabeli. Służy do tego instrukcja SHOW PARTITIONS:
7
Przy czym po utworzeniu tabeli można dodawać i usuwać partycje za pomocą instrukcji ALTER TABLE.
Tabele
465
hive> SHOW PARTITIONS logs; dt=2001-01-01/country=GB dt=2001-01-01/country=US dt=2001-01-02/country=GB dt=2001-01-02/country=US
Warto pamiętać, że definicje kolumn w klauzuli PARTITIONED BY to standardowe kolumny tabeli (kolumny partycjonowania), natomiast pliki danych nie zawierają wartości tych kolumn, ponieważ wartości te są ustalane na podstawie nazw katalogów. Kolumny partycjonowania można stosować w instrukcjach SELECT w zwykły sposób. Hive przeprowadza wtedy ograniczanie danych wejściowych i skanuje tylko potrzebne partycje. Oto przykład: SELECT ts, dt, line FROM logs WHERE country='GB';
Dla tego zapytania skanowane są tylko pliki file1, file2 i file4. Zauważ też, że zapytanie zwraca wartości kolumny partycjonowania dt, które Hive wczytuje z nazw katalogów (ponieważ nie występują w plikach danych).
Kubełki Tabele (i partycje) dzieli się na kubełki z dwóch powodów. Pierwszy dotyczy zwiększenia wydajności zapytań. Podział na kubełki zapewnia tabeli dodatkową strukturę, którą Hive może wykorzystać przy przetwarzaniu określonych zapytań. Przede wszystkim złączanie dwóch tabel podzielonych na kubełki według tych samych kolumn (w tym kolumn uwzględnianych przy złączaniu) można wydajnie zaimplementować jako złączanie na etapie mapowania. Druga przyczyna dotyczy zwiększenia wydajności generowania próbek. Gdy używane są duże zbiory danych, bardzo wygodnie jest w trakcie pisania lub dopracowywania kodu wypróbować zapytania dla fragmentu zbioru. W końcowej części podpunktu zobaczysz, w jaki sposób wydajnie pobierać próbki danych. Najpierw zobacz, jak poinformować platformę Hive, że tabelę należy podzielić na kubełki. Używana jest tu klauzula CLUSTERED BY do określenia kolumn, według których ma nastąpić podział, i ustawienia liczby kubełków. CREATE TABLE bucketed_users (id INT, name STRING) CLUSTERED BY (id) INTO 4 BUCKETS;
Tu do określenia kubełka używany jest identyfikator użytkownika. Hive oblicza skrót i dzieli go modulo przez liczbę kubełków, dlatego w każdym kubełku znajdzie się losowy zbiór użytkowników. Jeśli przy złączaniu danych na etapie mapowania dwie tabele są podzielone na kubełki w ten sam sposób, mapper przetwarzający tabelę podaną po lewej stronie „wie”, że pasujące wiersze z tabeli podanej po prawej stronie będą znajdowały się w analogicznym kubełku. Dlatego wystarczy pobrać właściwy kubełek (czyli niewielki fragment wszystkich danych z tabeli podanej po prawej stronie), aby przeprowadzić złączenie. Ta optymalizacja sprawdza się także wtedy, gdy liczba kubełków z jednej tabeli jest wielokrotnością liczby kubełków z drugiej. Liczba kubełków w obu tabelach nie musi być identyczna. Kod w języku HiveQL używany do złączania dwóch tabel podzielonych na kubełki znajdziesz w punkcie „Złączenia na etapie mapowania”.
466
Rozdział 17. Hive
Dane w kubełku można dodatkowo posortować według jednej lub kilku kolumn. To pozwala na jeszcze wydajniejsze złączanie danych na etapie mapowania, ponieważ przy złączaniu każdego kubełka wystarczy zastosować wydajne sortowanie przez scalanie. Składnia określająca, że kubełki tabeli są posortowane, wygląda tak: CREATE TABLE bucketed_users (id INT, name STRING) CLUSTERED BY (id) SORTED BY (id ASC) INTO 4 BUCKETS;
W jaki sposób upewnić się, że dane w tabeli są podzielone na kubełki? Choć można wczytać dane wygenerowane poza platformą Hive do tabeli z kubełkami, często łatwiej jest przeprowadzić podział na kubełki (zwykle dotyczy on istniejącej tabeli) za pomocą platformy Hive. Hive nie sprawdza, czy kubełki w plikach danych na dysku są spójne z kubełkami z definicji tabeli (nie są badane ani liczba kubełków, ani uwzględniane do podziału kolumny). Jeśli występuje niedopasowanie, w czasie przetwarzania zapytania może pojawić się błąd lub program może zacząć działać w nieoczekiwany sposób. Dlatego zaleca się, aby to platforma Hive wykonywała podział na kubełki.
Oto tabela użytkowników niepodzielona na kubełki: hive> SELECT * FROM users; 0 Nat 2 Joe 3 Kay 4 Ann
Aby zapełnić tabelę z kubełkami, trzeba ustawić właściwość hive.enforce.bucketing na wartość true. Dzięki temu platforma Hive będzie „wiedziała”, że ma utworzyć tyle kubełków, ile zadeklarowano w definicji tabeli. Następnie wystarczy wywołać polecenie INSERT. INSERT OVERWRITE TABLE bucketed_users SELECT * FROM users;
Fizycznie każdy kubełek to plik w katalogu przeznaczonym dla tabeli (lub partycji). Nazwa pliku nie ma znaczenia, ale kubełek n znajduje się w n-tym pliku, gdy są one uporządkowane leksykograficznie. Kubełki odpowiadają partycjom plików wyjściowych modelu MapReduce. Zadanie generuje tyle kubełków (plików wyjściowych), ile jest operacji redukcji. Można się o tym przekonać na podstawie struktury utworzonej wcześniej tabeli bucketed_users. Wywołaj poniższe polecenie: hive> dfs -ls /user/hive/warehouse/bucketed_users;
Pozwala ono zobaczyć, że utworzone zostały cztery pliki o podanych niżej nazwach (nazwy określa platforma Hive): 000000_0 000001_0 000002_0 000003_0
Pierwszy kubełek zawiera dane użytkowników o identyfikatorach 0 i 4, ponieważ dla typu INT skrótem jest sama liczba całkowita, a wartość jest dzielona modulo przez liczbę kubełków (tu ta liczba to cztery)8. 8
Na ekranie pola z nieprzetworzonego pliku pojawiają się jedno obok drugiego, ponieważ separator w danych wyjściowych to niewyświetlany znak sterujący. Omówienie znaków sterujących znajdziesz w dalszym punkcie.
Tabele
467
hive> dfs -cat /user/hive/warehouse/bucketed_users/000000_0; 0Nat 4Ann
O tym samym można się przekonać, pobierając próbkę danych z tabeli za pomocą klauzuli TABLESAMPLE. Gdy stosujesz tę klauzulę, zapytanie dotyczy tylko wybranych kubełków, a nie całej tabeli. hive> SELECT * FROM bucketed_users > TABLESAMPLE(BUCKET 1 OUT OF 4 ON id); 4 Ann 0 Nat
Kubełki są numerowane od 1, dlatego przedstawione zapytanie pobiera dane wszystkich użytkowników z pierwszego z czterech kubełków. Dla dużych zbiorów danych o równomiernym rozkładzie wartości to zapytanie zwróci mniej więcej jedną czwartą wierszy tabeli. W próbce można pobrać dane z kilku kubełków, co wymaga podania innych wartości. Nie trzeba przy tym silić się na dokładne określanie liczby kubełków, ponieważ pobieranie próbek z założenia nie ma być precyzyjną operacją. Na przykład poniższe zapytanie zwraca dane z połowy kubełków: hive> SELECT * FROM bucketed_users > TABLESAMPLE(BUCKET 1 OUT OF 2 ON id); 4 Ann 0 Nat 2 Joe
Pobieranie próbki z tabeli podzielonej na kubełki to bardzo wydajna operacja, ponieważ zapytanie musi tylko wczytać kubełki pasujące do klauzuli TABLESAMPLE. Porównaj to z pobieraniem próbki z niepodzielonej tabeli przy użyciu funkcji rand(), kiedy to skanowany jest cały wejściowy zbiór danych (nawet gdy potrzebna jest bardzo niewielka próbka). hive> SELECT * FROM users > TABLESAMPLE(BUCKET 1 OUT OF 4 ON rand()); 2 Joe
Formaty przechowywania danych Istnieją dwa aspekty wpływające na przechowywanie tabel w platformie Hive — format wierszy i format pliku. Format wierszy określa, w jaki sposób zapisywane są wiersze (i pola z określonego wiersza). W języku użytkowników platformy Hive format wierszy jest nazywany SerDe (jest to skrócona forma określenia Serializator-Deserializator). Gdy format SerDe jest używany do deserializacji (dzieje się tak przy przetwarzaniu zapytań), przekształca wiersz danych z postaci bajtowej z pliku na obiekty używane wewnętrznie przez platformę Hive do pracy z określonym wierszem. Jeśli format SerDe służy do serializacji (jest tak przy wykonywaniu instrukcji INSERT lub CREATE TABLE AS; zobacz punkt „Importowanie danych”), przekształca wewnętrzną reprezentację wiersza danych używaną w platformie Hive na bajty zapisywane w pliku wyjściowym. Od formatu pliku zależy format kontenerowy używany dla pól wiersza. Najprostsze są zwykłe pliki tekstowe. Ponadto można korzystać z wierszowych i kolumnowych formatów binarnych.
468
Rozdział 17. Hive
Domyślny format przechowywania — tekst z ogranicznikami Gdy tworzysz tabelę z klauzulą ROW FORMAT lub STORED AS, domyślny format to tekst z ogranicznikami obejmujący po jednym wierszu danych na linię tekstu9. Domyślnym ogranicznikiem wierszy nie jest tabulacja, ale kombinacja Ctrl+A z zestawu znaków sterujących ASCII (ta kombinacja ma kod ASCII 1). Kombinacja Ctrl+A (w dokumentacji zapisywana czasem jako ^A) jest używana dlatego, że istnieje mniejsze — w porównaniu z tabulacją — prawdopodobieństwo, że pojawi się w tekście pola. W platformie Hive nie są stosowane sekwencje ucieczki, dlatego ważne jest, aby użyty ogranicznik nie występował w polach z danymi. Domyślnym ogranicznikiem elementów kolekcji jest kombinacja Ctrl+B. Służy ona do rozdzielania elementów w kolekcjach typu ARRAY lub STRUCT, a także w parach klucz-wartość w typie MAP. Domyślnym ogranicznikiem kluczy jest kombinacja Ctrl+C (służy ona do oddzielania kluczy od wartości w typie MAP). Wiersze tabeli są rozdzielane znakami nowego wiersza. Ten opis ograniczników jest poprawny w standardowych przypadkach, gdy struktury danych są płaskie i typy złożone zawierają wyłącznie elementy typów prostych. Dla typów zagnieżdżonych sytuacja jest bardziej skomplikowana. Wtedy to poziom zagnieżdżenia wpływa na używany ogranicznik. Na przykład w tablicy tablic ogranicznikami zewnętrznej tablicy są kombinacje Ctrl+B, natomiast w wewnętrznej są to kombinacje Ctrl+C (jest to następny ogranicznik na liście). Jeśli nie masz pewności, których ograniczników platforma Hive używa dla określonej struktury zagnieżdżonej, możesz uruchomić polecenie o następującej postaci: CREATE TABLE nested AS SELECT array(array(1, 2), array(3, 4)) FROM dummy;
Następnie użyj narzędzia hexdump lub podobnej instrukcji do sprawdzenia ograniczników z pliku wyjściowego. Hive udostępnia osiem poziomów ograniczników odpowiadających kodom ASCII od 1 do 8. Zmienić można tylko pierwsze trzy ograniczniki.
Poniższa instrukcja: CREATE TABLE …;
jest identyczna z bardziej rozbudowanym zapisem: CREATE TABLE ... ROW FORMAT DELIMITED FIELDS TERMINATED BY '\001' COLLECTION ITEMS TERMINATED BY '\002' MAP KEYS TERMINATED BY '\003' LINES TERMINATED BY '\n' STORED AS TEXTFILE;
9
Format domyślny można zmienić za pomocą właściwości hive.default.fileformat.
Tabele
469
Zauważ, że można podawać ograniczniki w zapisie ósemkowym (na przykład jako 001 dla kombinacji Ctrl+A). Wewnętrznie platforma Hive używa formatu SerDe LazySimpleSerDe dla tekstu z ogranicznikami, a także opisanych w rozdziale 8. tekstowych wierszowych formatów wejściowych i wyjściowych z modelu MapReduce. Przedrostek „lazy” (czyli „leniwy”) oznacza, że format służy do leniwej deserializacji pól — dopiero na etapie dostępu do nich. Nie jest to jednak zwięzły format, ponieważ pola są zapisywane w długim formacie tekstowym. Dlatego na przykład wartości logiczne są przechowywane jako literały true i false. Prostota formatu zapewnia wiele korzyści. Pozwala na przykład na łatwe przetwarzanie danych za pomocą innych narzędzi, w tym programów w modelu MapReduce i w technologii Streaming. Istnieją jednak bardziej zwięzłe i wydajne formaty binarne, które warto rozważyć. Ich opis znajdziesz dalej.
Binarne formaty przechowywania — pliki SequenceFile, pliki danych systemu Avro, pliki Parqueta, pliki RCFile i ORCFile Użycie formatu binarnego wymaga jedynie zmiany klauzuli STORED AS w instrukcji CREATE TABLE. Wtedy klauzula ROW FORMAT nie jest używana, ponieważ format jest zależny od zastosowanego rodzaju plików binarnych. Formaty binarne można podzielić na dwie kategorie: wierszowe i kolumnowe. Formaty kolumnowe sprawdzają się dobrze, gdy zapytania dotyczą tylko niewielkiej liczby kolumn z tabeli. Formaty wierszowe są lepsze, gdy jednocześnie trzeba przetwarzać dużą liczbę kolumn z jednego wiersza. Platforma Hive zapewnia wbudowaną obsługę dwóch formatów wierszowych — plików danych systemu Avro (zobacz rozdział 12.) i plików typu SequenceFile (zobacz punkt „Klasa SequenceFile” w rozdziale 5.). Są to formaty ogólnego użytku obsługujące podział i kompresję. Pliki danych systemu Avro umożliwiają też modyfikowanie schematu i współdziałają z różnymi językami. Od wersji 0.14.0 platformy Hive tabelę można zapisać w formacie systemu Avro w następujący sposób: SET hive.exec.compress.output=true; SET avro.output.codec=snappy; CREATE TABLE ...STORED AS AVRO;
Aby włączyć dla tabeli kompresję, należy ustawić odpowiednie właściwości. Podobnie do zapisywania plików typu SequenceFile w platformie Hive można wykorzystać deklarację STORED AS SEQUENCEFILE. Właściwości związane z kompresją opisano w punkcie „Wykorzystywanie kompresji w modelu MapReduce” w rozdziale 5. Platforma Hive ma też wbudowaną obsługę następujących binarnych formatów kolumnowych: plików Parqueta (rozdział 13.), plików RCFile i plików ORCFile (zobacz punkt „Inne formaty plików i formaty kolumnowe” w rozdziale 5.). Oto przykład ilustrujący tworzenie kopii tabeli w formacie Parqueta za pomocą instrukcji CREATE TABLE…AS SELECT (zobacz punkt „Instrukcja CREATE TABLE…AS SELECT”). CREATE TABLE users_parquet STORED AS PARQUET AS SELECT * FROM users;
470
Rozdział 17. Hive
Używanie niestandardowego formatu SerDe — RegexSerDe Zobacz teraz, jak zastosować niestandardowy format SerDe do wczytywania danych. Poniżej używany jest format SerDe z rodziny contrib, który wykorzystuje wyrażenie regularne do wczytywania z pliku tekstowego opisujących stacje meteorologiczne metadanych o stałej szerokości pól. CREATE TABLE stations (usaf STRING, wban STRING, name STRING) ROW FORMAT SERDE 'org.apache.hadoop.hive.contrib.serde2.RegexSerDe' WITH SERDEPROPERTIES ( "input.regex" = "(\\d{6}) (\\d{5}) (.{29}) .*" );
W poprzednich przykładach w klauzuli ROW FORMAT podawano słowo kluczowe DELIMITED, by określić, że używany jest tekst z ogranicznikami. Tu zamiast tego wskazywany jest format SerDe. Służy do tego słowo kluczowe SERDE i pełna nazwa klasy Javy dla danego formatu (org.apache.hadoop.hive. contrib.serde2.RegexSerDe). Formaty SerDe można konfigurować za pomocą dodatkowych właściwości. Służy do tego klauzula WITH SERDEPROPERTIES. Tu używana jest specyficzna dla klasy RegexSerDe właściwość input.regex. Wartość właściwości input.regex to zwykłe wyrażenie regularne używane w trakcie deserializacji w celu przekształcenia wiersza tekstu na zestaw kolumn. Do dopasowywania tekstu używana jest składnia wyrażeń regularnych Javy (http://docs.oracle.com/javase/6/docs/api/java/util/regex/Pattern. html), a kolumny są tworzone za pomocą grup przechwytujących, reprezentowanych za pomocą nawiasów10. W opisywanym przykładzie występują trzy grupy przechwytujące dla pól usaf (6-cyfrowy identyfikator), wban (5-cyfrowy identyfikator) i name (kolumna o stałej szerokości 29 znaków). Do zapełniania tabeli, podobnie jak wcześniej, służy instrukcja LOAD DATA. LOAD DATA LOCAL INPATH "input/ncdc/metadata/stations-fixed-width.txt" INTO TABLE stations;
Warto przypomnieć, że instrukcja LOAD DATA kopiuje lub przenosi pliki do katalogu hurtowni danych platformy Hive. Tu dane są kopiowane, ponieważ ich źródłem jest lokalny system plików. Format SerDe tabeli nie jest używany przy wczytywaniu danych. W trakcie pobierania danych z tabeli format SerDe jest wykorzystywany do deserializacji. Ilustruje to poniższe proste zapytanie, które poprawnie pobiera pola z każdego wiersza. hive> SELECT * FROM 010000 99999 010003 99999 010010 99999 010013 99999
stations LIMIT 4; BOGUS NORWAY BOGUS NORWAY JAN MAYEN ROST
Ten przykład pokazuje, że klasa RegexSerDe może być przydatna do pobierania danych do platformy Hive. Jednak z powodu niskiej wydajności klasy tej zwykle nie należy stosować do przechowywania danych ogólnego użytku. Zamiast tego lepiej jest skopiować dane do formatu binarnego. 10
Czasem trzeba zastosować nawiasy dla elementów wyrażenia regularnego, których nie chcesz traktować jako grupy przechwytującej — na przykład dla wzorca (ab)+ pasującego do łańcucha z jednym lub wieloma wystąpieniami sekwencji ab. Rozwiązanie polega na użyciu grupy nieprzechwytującej. W takiej grupie po pierwszym nawiasie znajduje się znak ?. Istnieją różne konstrukcje grup nieprzechwytujących (znajdziesz je w dokumentacji Javy), a w tym przykładzie można wykorzystać sekwencję (?:ab)+, by nie przechwytywać danej grupy jako kolumny platformy Hive.
Tabele
471
Klasy dostępu do danych Klasy dostępu do danych (ang. storage handler) są używane dla systemów, do których nie zapewniają dostępu wbudowane klasy platformy Hive (na przykład dla bazy HBase). Klasy dostępu do danych podaje się w klauzuli STORED BY, a nie w klauzulach ROW FORMAT lub STORED AS. Więcej informacji o integracji z bazą HBase znajdziesz w serwisie wiki poświęconym platformie Hive (https://cwiki.apache.org/confluence/display/Hive/HBaseIntegration).
Importowanie danych Zobaczyłeś już, jak za pomocą operacji LOAD DATA zaimportować dane do tabeli (lub partycji) platformy Hive. Pliki są wtedy kopiowane lub przenoszone do katalogu tabeli. Ponadto można zapełnić tabelę danymi z innej tabeli platformy Hive. Umożliwiają to instrukcje INSERT i CREATE TABLE…AS SELECT (tę drugą skrótowo nazywa się CTAS). Jeśli chcesz zaimportować dane z bazy relacyjnej bezpośrednio do platformy Hive, pomyśl o wykorzystaniu Sqoopa. To zagadnienie opisano w punkcie „Importowane dane i platforma Hive” w rozdziale 15.
Instrukcja INSERT Oto przykładowa instrukcja INSERT: INSERT OVERWRITE TABLE target SELECT col1, col2 FROM source;
Dla tabel podzielonych na partycje można w klauzuli PARTITION wskazać docelową partycję. INSERT OVERWRITE TABLE target PARTITION (dt='2001-01-01') SELECT col1, col2 FROM source;
Słowo kluczowe OVERWRITE powoduje, że zawartość tabeli target (z pierwszej instrukcji) lub partycji 2001-01-01 (z drugiego przykładu) zostanie zastąpiona wynikiem wywołania instrukcji SELECT. Jeśli chcesz dodać rekordy do już zapełnionej partycji lub tabeli pozbawionej partycji, użyj polecenia INSERT INTO TABLE. Można też dynamicznie określać partycje za pomocą instrukcji SELECT. INSERT OVERWRITE TABLE target PARTITION (dt) SELECT col1, col2, dt FROM source;
Ta operacja to wstawianie do dynamicznie określonej partycji. Od wersji 0.14.0 platformy Hive można wykorzystać instrukcję INSERT INTO TABLE… VALUES do wstawiania niewielkich kolekcji rekordów podawanych jako literały.
472
Rozdział 17. Hive
Wstawianie danych do wielu tabel W języku HiveQL można zmienić kolejność elementów w instrukcji INSERT i zacząć od klauzuli FROM. Efekt jest wtedy taki sam. FROM source INSERT OVERWRITE TABLE target SELECT col1, col2;
Powód stosowania takiej składni staje się oczywisty, gdy okazuje się, że w jednym zapytaniu można umieścić kilka klauzul INSERT. Wykonywane w ten sposób wstawianie danych do wielu tabel jest wydajniejsze niż wywoływanie wielu instrukcji INSERT, ponieważ źródłową tabelę wystarczy przeskanować raz, by uzyskać wiele różnych zbiorów danych wyjściowych. Oto przykładowy kod obliczający różne statystyki dotyczące zbioru danych meteorologicznych: FROM records2 INSERT OVERWRITE TABLE stations_by_year SELECT year, COUNT(DISTINCT station) GROUP BY year INSERT OVERWRITE TABLE records_by_year SELECT year, COUNT(1) GROUP BY year INSERT OVERWRITE TABLE good_records_by_year SELECT year, COUNT(1) WHERE temperature != 9999 AND quality IN (0, 1, 4, 5, 9) GROUP BY year;
Występuje tu jedna tabela źródłowa (records2), a wyniki z trzech różnych dotyczących jej zapytań trafiają do trzech tabel.
Instrukcja CREATE TABLE…AS SELECT Często bardzo wygodnym rozwiązaniem jest zapisanie danych wyjściowych z zapytania platformy Hive w nowej tabeli — na przykład dlatego, że jest ich zbyt dużo, by można było wyświetlić je w konsoli, albo w celu przeprowadzenia dalszych operacji na wynikach. Definicje kolumn nowej tabeli są tworzone na podstawie kolumn pobieranych w klauzuli SELECT. W poniższym zapytaniu tabela target ma dwie kolumny, col1 i col2, których typy są takie same jak w tabeli source. CREATE TABLE target AS SELECT col1, col2 FROM source;
Operacja CTAS jest atomowa, dlatego jeśli zapytanie SELECT z jakichś przyczyn nie zostanie wykonane, nowa tabela nie powstanie.
Modyfikowanie tabel Ponieważ Hive używa schematów przy odczycie, zmiana definicji tabeli jest możliwa także po jej utworzeniu. Zastrzeżenie jest przy tym takie, że często to programista musi zadbać o modyfikację danych w taki sposób, aby były zgodne z nową strukturą tabeli. Za pomocą instrukcji ALTER TABLE można zmienić nazwę tabeli. ALTER TABLE source RENAME TO target;
Tabele
473
Oprócz aktualizowania metadanych tabeli instrukcja ALTER TABLE przenosi powiązany z tabelą katalog, tak by odzwierciedlał jej nową nazwę. W przedstawionym przykładzie nazwa katalogu /user/hive/warehouse/source zostanie zmieniona na /user/hive/warehouse/target. W przypadku tabel zewnętrznych następuje tylko aktualizacja metadanych — katalog nie jest przenoszony. Hive umożliwia zmianę definicji kolumn, dodawanie nowych kolumn, a nawet zastępowanie wszystkich istniejących kolumn ich nowym zbiorem. Oto przykład ilustrujący dodawanie nowej kolumny: ALTER TABLE target ADD COLUMNS (col3 STRING);
Nowa kolumna col3 jest dodawana po istniejących kolumnach (ale przed kolumnami używanymi do tworzenia partycji). Pliki danych nie są aktualizowane, dlatego zapytania będą zwracać dla kolumny col3 wartość null (chyba że w plikach znajdowały się już dodatkowe pola). Ponieważ Hive nie umożliwia aktualizowania istniejących rekordów, pliki trzeba zmodyfikować w inny sposób. Dlatego częściej tworzy się nowe tabele z nowymi kolumnami i zapełnia je za pomocą instrukcji SELECT. Zmiana metadanych kolumny (na przykład nazwy lub typu danych) jest łatwiejsza, przy czym możliwe musi być interpretowanie wartości dawnego typu danych jako wartości nowego typu. Aby dowiedzieć się więcej o przekształcaniu struktury tabeli, w tym o dodawaniu i usuwaniu partycji, zmienianiu i zastępowaniu kolumn oraz modyfikowaniu właściwości tabeli i formatu SerDe, zajrzyj do serwisu wiki poświęconego platformie Hive (https://cwiki.apache.org/confluence/ display/Hive/LanguageManual+DDL).
Usuwanie tabel Instrukcja DROP TABLE usuwa dane i metadane tabeli. Dla tabel zewnętrznych usuwane są tylko metadane, a same dane pozostają nienaruszone. Jeśli chcesz usunąć z tabeli wszystkie dane, ale zachować jej definicję, użyj instrukcji TRUNCATE TABLE. Oto przykład: TRUNCATE TABLE my_table;
Dla tabel zewnętrznych ta instrukcja nie zadziała. Zamiast niej należy wywołać polecenie dfs -rmr (w powłoce platformy Hive), by bezpośrednio usunąć katalog zewnętrznej tabeli. Jeśli chcesz utworzyć nową pustą tabelę o schemacie identycznym jak w innej tabeli, użyj słowa kluczowego LIKE. CREATE TABLE new_table LIKE existing_table;
Pobieranie danych W tym podrozdziale opisano używanie różnych postaci instrukcji SELECT do pobierania danych z platformy Hive.
474
Rozdział 17. Hive
Sortowanie i agregacja danych W platformie Hive dane można posortować za pomocą standardowej klauzuli ORDER BY. Ta klauzula przeprowadza równoległe sortowanie wszystkich danych wejściowych (podobny proces opisano w punkcie „Sortowanie wszystkich danych” w rozdziale 9.). Gdy globalne sortowanie wyników nie jest potrzebne (co zdarza się często), można zastosować niestandardowe rozszerzenie z platformy Hive — klauzulę SORT BY. Powoduje ona wygenerowanie posortowanego pliku w każdym reduktorze. Czasem przydatne jest określanie, do których reduktorów powinny trafić poszczególne wiersze (zwykle celem jest późniejsza agregacja danych). Służy do tego klauzula DISTRIBUTE BY platformy Hive. Oto przykład ilustrujący sortowanie zbioru danych meteorologicznych według lat i temperatury w taki sposób, że wszystkie wiersze z danego roku trafiają do partycji przetwarzanej przez ten sam reduktor11: hive> > > > 1949 1949 1950 1950 1950
FROM records2 SELECT year, temperature DISTRIBUTE BY year SORT BY year ASC, temperature DESC; 111 78 22 0 -11
Następne zapytanie (lub zapytanie nadrzędne; zobacz punkt „Podzapytania”) może wykorzystać to, że temperatury z każdego roku zostały pogrupowane i posortowane (malejąco) w tym samym pliku. Jeśli kolumny z klauzul SORT BY i DISTRIBUTE BY są takie same, można podać kolumny w klauzuli CLUSTER BY, co pozwala skrócić zapis.
Skrypty modelu MapReduce Klauzule TRANSFORM, MAP i REDUCE umożliwiają wywoływanie w platformie Hive zewnętrznych skryptów lub programów (podobna możliwość istnieje w technologii Hadoop Streaming). Załóżmy, że chcesz zastosować skrypt w celu odfiltrowania wierszy, które nie spełniają określonego warunku. Może to być skrypt z listingu 17.1, usuwający odczyty o niskiej jakości. Listing 17.1. Skrypt w Pythonie odfiltrowujący rekordy z danymi meteorologicznymi niskiej jakości #!/usr/bin/env python import re import sys for line in sys.stdin: (year, temp, q) = line.strip().split() if (temp != "9999" and re.match("[01459]", q)): print "%s\t%s" % (year, temp)
11
Jest to oparta na platformie Hive wersja rozwiązania z punktu „Sortowanie pomocnicze” w rozdziale 9.
Pobieranie danych
475
Skrypt można wywołać w następujący sposób: hive> ADD FILE /Users/tom/book-workspace/hadoop-book/ch17-hive/ src/main/python/is_good_quality.py; hive> FROM records2 > SELECT TRANSFORM(year, temperature, quality) > USING 'is_good_quality.py' > AS year, temperature; 1950 0 1950 22 1950 -11 1949 111 1949 78
Przed uruchomieniem zapytania trzeba zarejestrować skrypt w platformie Hive. Dzięki temu platforma będzie „wiedziała”, że ma przesłać plik do klastra opartego na Hadoopie (zobacz punkt „Rozproszona pamięć podręczna” w rozdziale 9.). Samo zapytanie przesyła pola year, temperature i quality jako rozdzielony tabulacjami wiersz do skryptu is_good_quality.py i przetwarza otrzymane ze skryptu dane wyjściowe rozdzielone tabulacjami na pola year i temperature, by uzyskać dane wyjściowe zapytania. W tym przykładzie reduktory nie są używane. Użycie zagnieżdżonego zapytania pozwala ustawić funkcje mapującą i redukującą. Poniżej używane są słowa kluczowe MAP i REDUCE, jednak ten sam efekt można uzyskać za pomocą słów instrukcji SELECT TRANSFORM. Kod źródłowy skryptu max_ temperature_reduce.py znajdziesz na listingu 2.10. FROM ( FROM records2 MAP year, temperature, quality USING 'is_good_quality.py' AS year, temperature) map_output REDUCE year, temperature USING 'max_temperature_reduce.py' AS year, temperature;
Złączenia Jedną z zalet używania platformy Hive zamiast bezpośredniego stosowania modelu MapReduce jest to, że platforma bardzo ułatwia wykonywanie standardowych operacji. Przykładem na to są operatory złączania, ponieważ implementacja procesu złączania w modelu MapReduce jest skomplikowana (zobacz punkt „Złączenia” w rozdziale 9.).
Złączenia wewnętrzne Najprostszy typ złączeń to złączenia wewnętrzne, w których każde dopasowanie wartości z tabel wejściowych daje wiersz w danych wyjściowych. Przyjrzyj się dwóm małym przykładowym tabelom — sales (zawiera listę imion klientów i identyfikatory kupionych przez nich produktów) i things (z listą identyfikatorów i nazw produktów). hive> SELECT * FROM sales; Joe 2 Hank 4 Ali 0 Eve 3 Hank 2
476
Rozdział 17. Hive
hive> SELECT * FROM things; 2 Tie 4 Coat 3 Hat 1 Scarf
Złączenie wewnętrzne obu tabel można przeprowadzić w następujący sposób: hive> SELECT sales.*, things.* > FROM sales JOIN things ON (sales.id = things.id); Joe 2 2 Tie Hank 4 4 Coat Eve 3 3 Hat Hank 2 2 Tie
Tabela z klauzuli FROM (sales) jest złączana z tabelą z klauzuli JOIN (things) na podstawie predykatu z klauzuli ON. Hive obsługuje tylko równozłączenia, co oznacza, że w predykacie można używać tylko operatora równości. Tu dopasowanie odbywa się na podstawie kolumny id z obu tabel. W platformie Hive złączenia można przeprowadzać na podstawie wielu kolumn. Wymaga to użycia w predykacie złączenia serii wyrażeń rozdzielonych słowem kluczowym AND. Ponadto można złączyć więcej niż dwie tabele, używając w zapytaniu dodatkowych klauzul JOIN…ON…. Platforma Hive stara się minimalizować liczbę zadań w modelu MapReduce potrzebnych do przeprowadzenia złączenia. Hive (podobnie jak bazy MySQL i Oracle) umożliwia podanie złączanych tabel w klauzuli FROM i określenie warunku złączania w klauzuli WHERE instrukcji SELECT. Na przykład poniższa instrukcja to inny sposób zapisu przedstawionego wcześniej zapytania: SELECT sales.*, things.* FROM sales, things WHERE sales.id = things.id;
Jedno złączenie jest implementowane jako jedno zadanie w modelu MapReduce. Gdy złączeń jest wiele i w warunkach używana jest ta sama kolumna, liczba zadań w modelu MapReduce może być mniejsza od liczby złączeń12. Aby sprawdzić, ile zadań w modelu MapReduce platforma Hive używa dla określonego zapytania, poprzedź całą instrukcję słowem kluczowym EXPLAIN. EXPLAIN SELECT sales.*, things.* FROM sales JOIN things ON (sales.id = things.id);
Dane wyjściowe instrukcji EXPLAIN zawierają szczegółowe informacje na temat planu wykonania zapytania. Dostępne są na przykład: drzewo składni abstrakcyjnej, graf zależności dla etapów wykonywanych przez platformę Hive i informacje o każdym etapie. Etapami mogą być zadania w modelu MapReduce i operacje takie jak przenoszenie plików. Jeśli chcesz uzyskać jeszcze więcej informacji, poprzedź zapytanie słowami kluczowymi EXPLAIN EXTENDED. Hive do określania sposobu przetwarzania zapytań obecnie używa optymalizatora opartego na regułach, jednak od wersji 0.14.0 platforma udostępnia optymalizator oparty na kosztach. 12
Kolejność tabel w klauzulach JOIN ma znaczenie. Zwykle najlepiej jest umieszczać największą tabelę na końcu. W serwisie wiki poświęconym platformie Hive (https://cwiki.apache.org/confluence/display/Hive/ LanguageManual+Joins) znajdziesz więcej informacji na ten temat. Dowiesz się na przykład, jak dawać wskazówki planerowi z platformy Hive.
Pobieranie danych
477
Złączenia zewnętrzne Złączenia zewnętrzne umożliwiają znalezienie także niedopasowanych elementów ze złączanych tabel. W pokazanym wcześniej złączeniu zewnętrznym wiersz klienta Ali nie pojawił się w danych wyjściowych, ponieważ identyfikator kupionego przez tę osobę produktu nie występuje w tabeli things. Po zmianie typu złączenia na LEFT OUTER JOIN zapytanie zwróci każdy wiersz z lewej tabeli (sales) — także wtedy, jeśli w prawej tabeli (things) nie występuje pasujący wiersz. hive> SELECT sales.*, things.* > FROM sales LEFT OUTER JOIN things ON (sales.id = things.id); Joe 2 2 Tie Hank 4 4 Coat Ali 0 NULL NULL Eve 3 3 Hat Hank 2 2 Tie
Zauważ, że teraz wiersz dla klienta Ali jest zwracany, natomiast kolumny z tabeli things mają w nim wartości NULL, ponieważ w tabeli tej nie występuje pasujący wiersz. Hive obsługuje też prawostronne złączenia zewnętrzne. W takich złączeniach role tabel są odwrotne niż w złączeniach lewostronnych. W omawianym przykładzie w złączeniu prawostronnym pojawią się wszystkie wiersze z tabeli things — także te dotyczące produktów, których nikt nie kupił (tu jest to scarf). hive> SELECT sales.*, things.* > FROM sales RIGHT OUTER JOIN things ON (sales.id = things.id); Joe 2 2 Tie Hank 2 2 Tie Hank 4 4 Coat Eve 3 3 Hat NULL NULL 1 Scarf
Istnieje też pełne złączenie zewnętrzne, w którym dane wyjściowe zawierają wszystkie wiersze z obu tabel złączenia. hive> SELECT sales.*, things.* > FROM sales FULL OUTER JOIN things ON (sales.id = things.id); Ali 0 NULL NULL NULL NULL 1 Scarf Hank 2 2 Tie Joe 2 2 Tie Eve 3 3 Hat Hank 4 4 Coat
Złączenia częściowe Przyjrzyj się poniższemu podzapytaniu IN, które znajduje w tabeli things wszystkie elementy występujące w tabeli sales. SELECT * FROM things WHERE things.id IN (SELECT id from sales);
Instrukcję można zapisać też w następujący sposób: hive> SELECT * > FROM things LEFT SEMI JOIN sales ON (sales.id = things.id); 2 Tie 4 Coat 3 Hat
478
Rozdział 17. Hive
W zapytaniach z klauzulą LEFT SEMI JOIN spełniony musi być pewien warunek — prawa tabela (sales) może występować tylko w klauzuli ON. Nie wolno używać tej tabeli na przykład w wyrażeniu SELECT.
Złączenia na etapie mapowania Przyjrzyj się ponownie pierwotnemu złączeniu wewnętrznemu: SELECT sales.*, things.* FROM sales JOIN things ON (sales.id = things.id);
Jeśli jedna tabela jest na tyle mała, że mieści się w pamięci (tak jak tabela things w tym przykładzie), Hive potrafi wczytać ją do pamięci i przeprowadzić złączenie w każdym z mapperów. Tak przebiega złączanie na etapie mapowania. Zadanie przetwarzające to zapytanie nie ma reduktorów. Dlatego takie zapytanie nie zadziała dla złączeń typu RIGHT lub FULL OUTER JOIN, ponieważ brak dopasowania można wykryć tylko w fazie agregacji (redukcji) wszystkich danych wejściowych. W złączeniach można wykorzystać tabele podzielone na kubełki (zobacz punkt „Kubełki”), ponieważ mapper używający kubełka z lewej tabeli musi wczytać tylko odpowiednie kubełki z prawej tabeli, aby przeprowadzić złączenie. Składnia złączenia jest taka sama jak przy wykonywaniu tej operacji w pamięci, jednak należy wcześniej włączyć optymalizację za pomocą następującej instrukcji: SET hive.optimize.bucketmapjoin=true;
Podzapytania Podzapytanie to instrukcja SELECT zagnieżdżona w innej instrukcji SQL-a. Platforma Hive obsługuje podzapytania w ograniczonym zakresie. Dozwolone są podzapytania w klauzuli FROM instrukcji SELECT i — w określonych sytuacjach — w klauzuli WHERE. Hive pozwala stosować podzapytania nieskorelowane, w których podzapytanie jest niezależnym zapytaniem wykorzystywanym w instrukcji IN lub EXISTS w klauzuli WHERE. Podzapytania skorelowane, w których podzapytanie wykorzystuje zapytanie zewnętrzne, nie są obecnie obsługiwane.
Poniższe zapytanie określa dla każdego roku i stacji średnią maksymalną temperaturę: SELECT station, year, AVG(max_temperature) FROM ( SELECT station, year, MAX(temperature) AS max_temperature FROM records2 WHERE temperature != 9999 AND quality IN (0, 1, 4, 5, 9) GROUP BY station, year ) mt GROUP BY station, year;
Podzapytanie FROM określa maksymalną temperaturę dla każdej pary stacja i rok. Następnie zapytanie zewnętrzne używa funkcji agregującej AVG do obliczenia średniej z ustalonych wcześniej maksymalnych odczytów dla każdej kombinacji stacji i roku.
Pobieranie danych
479
Zapytanie zewnętrzne używa wyników podzapytania tak jak tabeli. Dlatego dla podzapytania trzeba ustawić alias (tu jest to mt). Kolumnom podzapytania przypisywane są unikatowe nazwy, dzięki czemu zapytanie zewnętrzne może używać poszczególnych kolumn.
Widoki Widok jest jak „tabela wirtualna” zdefiniowana w instrukcji SELECT. Widoki można wykorzystać do wyświetlania użytkownikom danych w sposób inny, niż są one zapisane na dysku. Dane z istniejących tabel często są upraszczane lub agregowane w określony sposób, ułatwiający ich późniejsze przetwarzanie. Widoki pozwalają też ograniczyć użytkownikom dostęp do wybranych podzbiorów tabel, do których poszczególne osoby mają uprawnienia. W platformie Hive widoki nie są materializowane na dysku. Zamiast tego w momencie uruchomienia polecenia używającego widoku wywoływana jest odpowiednia instrukcja SELECT. Jeśli widok wymaga znacznych transformacji tabel lub jest często używany, można ręcznie go zmaterializować. Wymaga to utworzenia nowej tabeli z zawartością widoku (zobacz punkt „Instrukcja CREATE TABLE…AS SELECT”). Widoki można wykorzystać do zmodyfikowania pokazanego w poprzednim punkcie zapytania, które wyszukiwało średnią temperaturę maksymalną dla każdego roku i każdej stacji. Najpierw należy utworzyć widok obejmujący poprawne rekordy, czyli rekordy o określonej wartości pola quality. CREATE VIEW valid_records AS SELECT * FROM records2 WHERE temperature != 9999 AND quality IN (0, 1, 4, 5, 9);
W momencie tworzenia widoku powiązane z nim zapytanie nie jest wykonywane. Zostaje jedynie zapisane w magazynie metadanych. Widoki pojawiają się w danych wyjściowych polecenia SHOW TABLES. Więcej informacji na temat wybranego widoku (w tym zapytanie użyte w jego definicji) uzyskasz za pomocą polecenia DESCRIBE EXTENDED nazwa_widoku. Teraz utwórz drugi widok, z maksymalnymi temperaturami dla każdej pary stacja-rok. Ten widok jest oparty na widoku valid_records. CREATE VIEW max_temperatures (station, year, max_temperature) AS SELECT station, year, MAX(temperature) FROM valid_records GROUP BY station, year;
W definicji tego widoku nazwy kolumn są podane jawnie. Dzieje się tak, ponieważ kolumna zawierająca maksymalne temperatury powstaje na podstawie wyrażenia agregującego. Jeśli nie podasz jej nazwy, Hive automatycznie utworzy alias kolumny (na przykład _c2). W celu ustawienia nazw kolumn można też użyć klauzuli AS w instrukcji SELECT. Po przygotowaniu przedstawionych widoków można wykorzystać je w zapytaniu. SELECT station, year, AVG(max_temperature) FROM max_temperatures GROUP BY station, year;
480
Rozdział 17. Hive
Wynik tego zapytania jest taki sam jak wtedy, gdy zamiast widoków używane są podzapytania. Hive w obu przypadkach tworzy tę samą liczbę zadań w modelu MapReduce — po dwa (po jednym dla każdej kolumny z klauzuli GROUP BY). To pokazuje, że Hive potrafi połączyć zapytania z widokami w sekwencję zadań analogiczną do stosowanej dla zapytań bez widoków. Oznacza to, że Hive nie materializuje niepotrzebnie widoków — nawet w trakcie przetwarzania zapytań. Widoki w platformie Hive są przeznaczone tylko do odczytu. Nie istnieje sposób na wstawianie za pomocą widoku danych do powiązanej z nim tabeli.
Funkcje zdefiniowane przez użytkowników Czasem trudne (lub niemożliwe) jest napisanie potrzebnego zapytania tylko za pomocą wbudowanych funkcji platformy Hive. Ponieważ jednak platforma umożliwia pisanie funkcji zdefiniowanych przez użytkowników (ang. user-defined function — UDF), można łatwo dodać własny kod służący do przetwarzania danych i wywoływać go w zapytaniach. Funkcje UDF trzeba pisać w Javie. Jest to język, w którym napisana jest sama platforma Hive. Jeśli używasz innych języków, rozważ zastosowanie zapytań SELECT TRANSFORM. Umożliwiają one przetwarzanie danych za pomocą zdefiniowanych przez użytkowników skryptów (zobacz punkt „Skrypty modelu MapReduce”). W platformie Hive występują trzy rodzaje funkcji UDF: zwykłe (UDF), agregujące (ang. userdefined aggregate function — UDAF) i generujące tabele (ang. user-defined table-generating function — UDTF). Różnią się one liczbą wierszy akceptowanych jako dane wejściowe i generowanych jako dane wyjściowe.
Funkcje UDF działają dla jednego wiersza i jako dane wyjściowe generują też jeden wiersz. Do tej kategorii należy większość funkcji (w tym funkcje matematyczne i związane z łańcuchami znaków).
Funkcje UDAF przyjmują wiele wierszy wejściowych i zwracają jeden wiersz wyjściowy. Do tej grupy należą na przykład funkcje COUNT i MAX.
Funkcje UDTF przyjmują jeden wiersz i jako dane wyjściowe generują wiele wierszy (tabelę).
Funkcje generujące tabele są mniej znane od dwóch pozostałych grup, dlatego warto przyjrzeć się przykładowi. Wyobraź sobie tabelę z jedną kolumną, x, zawierającą tablicę łańcuchów znaków. Warto zobaczyć, w jaki sposób taka tabela jest definiowana i zapełniana. CREATE TABLE arrays (x ARRAY) ROW FORMAT DELIMITED FIELDS TERMINATED BY '\001' COLLECTION ITEMS TERMINATED BY '\002';
Zauważ, że klauzula ROW FORMAT określa, iż ogranicznikiem dla elementów tablicy są kombinacje Ctrl+B. Poniżej pokazano zawartość przykładowego pliku. Kombinacja Ctrl+B jest tu reprezentowana za pomocą sekwencji ^B (lepiej dostosowanej do druku). a^Bb c^Bd^Be
Funkcje zdefiniowane przez użytkowników
481
Po wykonaniu polecenia LOAD DATA można za pomocą poniższego zapytania potwierdzić, że dane zostały prawidłowo wczytane. hive> SELECT * FROM arrays; ["a","b"] ["c","d","e"]
Następnie można użyć funkcji UDTF explode do przekształcenia tej tabeli. Wspomniana funkcja generuje wiersz dla każdego elementu tablicy. Tu typem kolumny wyjściowej y jest STRING. Efekt wywołania funkcji to spłaszczenie tabeli do postaci pięciu wierszy. hive> SELECT explode(x) AS y FROM arrays; a b c d e
Z instrukcjami SELECT obejmującymi funkcje UDTF związane są pewne ograniczenia (na przykład nie mogą obejmować wyrażeń pobierających dodatkowe kolumny), dlatego w praktyce nie jest to często stosowane rozwiązanie. Z tego powodu Hive udostępnia zapytania LATERAL VIEW, które oferują więcej możliwości. Te zapytania nie są omówione w tym miejscu. Więcej na ich temat dowiesz się z serwisu wiki poświęconego platformie Hive (https://cwiki.apache.org/confluence/display/Hive/ LanguageManual+LateralView).
Pisanie funkcji UDF W ramach ilustracji procesu pisania i stosowania funkcji UDF w tym punkcie pokazano, jak utworzyć prostą funkcję przycinającą znaki z końcowej części łańcuchów. Hive udostępnia wbudowaną funkcję trim, dlatego nowa funkcja musi mieć inną nazwę. Nazwijmy ją strip. Kod klasy Strip Javy pokazano na listingu 17.2. Listing 17.2. Funkcja UDF służąca do przycinania znaków z końcowej części łańcuchów package com.hadoopbook.hive; import org.apache.commons.lang.StringUtils; import org.apache.hadoop.hive.ql.exec.UDF; import org.apache.hadoop.io.Text; public class Strip extends UDF { private Text result = new Text(); public Text evaluate(Text str) { if (str == null) { return null; } result.set(StringUtils.strip(str.toString())); return result; } public Text evaluate(Text str, String stripChars) { if (str == null) { return null; } result.set(StringUtils.strip(str.toString(), stripChars)); return result; } }
482
Rozdział 17. Hive
Funkcja UDF musi spełniać dwa warunki:
być podklasą klasy org.apache.hadoop.hive.ql.exec.UDF,
zawierać implementację przynajmniej jednej metody evaluate().
Metoda evaluate() nie jest zdefiniowana w interfejsie, ponieważ może przyjmować dowolną liczbę argumentów dowolnych typów i zwracać wartość dowolnego typu. Hive używa mechanizmu introspekcji, aby znaleźć w funkcji UDF metodę evaluate() pasującą do wywołanej funkcji platformy Hive. W klasie Strip znajdują się dwie metody evaluate(). Pierwsza usuwa początkowe i końcowe odstępy z danych wejściowych, druga pozwala usunąć dowolny zestaw podanych znaków z końca łańcucha. Przetwarzanie łańcuchów znaków jest delegowane do klasy StringUtils z projektu Apache Commons. Dlatego jedyny godny uwagi fragment kodu to wykorzystanie typu Text z implementacją interfejsu Writable z Hadoopa. Hive obsługuje w funkcjach UDF typy proste Javy (a także kilka innych typów, na przykład java.util.List i java.util.Map). Dlatego poprawna jest też poniższa sygnatura: public String evaluate(String str)
Jednak zastosowanie typu Text zapewnia zalety związane z wielokrotnym wykorzystaniem obiektów, co pozwala poprawić wydajność kodu. Dlatego zwykle lepiej jest stosować ten typ. Aby wykorzystać funkcję UDF w platformie Hive, najpierw trzeba spakować skompilowaną klasę Javy do pliku JAR. W tym celu można wywołać polecenie mvn package dla przykładowego kodu z tej książki. Następnie zarejestruj funkcję w magazynie metadanych i ustaw jej nazwę za pomocą instrukcji CREATE FUNCTION. CREATE FUNCTION strip AS 'com.hadoopbook.hive.Strip' USING JAR '/path/to/hive-examples.jar';
Gdy platforma Hive jest używana lokalnie, wystarczy podać ścieżkę do lokalnego pliku. W klastrze należy skopiować plik JAR do systemu HDFS i w klauzuli USING JAR podać identyfikator URI z tego systemu. Teraz funkcja UDF jest gotowa do użycia. Można z niej korzystać tak samo jak z funkcji wbudowanych. hive> SELECT strip(' bee ') FROM dummy; bee hive> SELECT strip('banana', 'ab') FROM dummy; nan
Wielkość znaków w nazwach funkcji UDF nie ma znaczenia. hive> SELECT STRIP(' bee ') FROM dummy; bee
Gdy zechcesz usunąć funkcję, możesz zastosować instrukcję DROP FUNCTION. DROP FUNCTION strip;
Można też utworzyć funkcję na czas trwania sesji platformy Hive. Służy do tego słowo kluczowe TEMPORARY. Takie funkcje nie są utrwalane w magazynie metadanych. ADD JAR /path/to/hive-examples.jar; CREATE TEMPORARY FUNCTION strip AS 'com.hadoopbook.hive.Strip';
Funkcje zdefiniowane przez użytkowników
483
Jeśli programista chce stosować funkcje tymczasowe, może utworzyć w katalogu głównym plik .hiverc zawierający polecenia definiujące potrzebne funkcje UDF. Taki plik jest automatycznie uruchamiany na początku każdej sesji platformy Hive. Zamiast po rozruchu platformy wywoływać polecenie ADD JAR, możesz ustawić ścieżkę, w której platforma Hive będzie szukała pomocniczych plików JAR dodawanych do ścieżki do klas (w tym do ścieżki do klas zadania). Ta technika przydaje się do automatycznego dodawania własnej biblioteki funkcji UDF przy każdym uruchomieniu platformy Hive. Istnieją dwa sposoby ustawiania ścieżki. Pierwszy z nich polega na przekazaniu opcji –-auxpath do polecenia hive: % hive --auxpath /path/to/hive-examples.jar
Drugi to ustawienie zmiennej środowiskowej HIVE_AUX_JARS_PATH przed wywołaniem platformy. Oba sposoby pozwalają podać rozdzieloną przecinkami listę ścieżek do plików JAR lub katalog zawierający takie pliki.
Pisanie funkcji UDAF Pisanie funkcji agregujących jest trudniejsze niż tworzenie zwykłych funkcji UDF. Wartości są agregowane w porcjach (które mogą pochodzić z wielu zadań), dlatego funkcja musi radzić sobie z łączeniem częściowo zagregowanych danych w ostateczny wynik. Potrzebny do tego kod najlepiej jest zaprezentować za pomocą przykładu. Przyjrzyj się implementacji prostej funkcji UDAF obliczającej maksimum dla kolekcji liczb całkowitych (listing 17.3). Listing 17.3. Funkcja UDAF wyznaczająca maksimum dla kolekcji liczb całkowitych package com.hadoopbook.hive; import org.apache.hadoop.hive.ql.exec.UDAF; import org.apache.hadoop.hive.ql.exec.UDAFEvaluator; import org.apache.hadoop.io.IntWritable; public class Maximum extends UDAF { public static class MaximumIntUDAFEvaluator implements UDAFEvaluator { private IntWritable result; public void init() { result = null; } public boolean iterate(IntWritable value) { if (value == null) { return true; } if (result == null) { result = new IntWritable(value.get()); } else { result.set(Math.max(result.get(), value.get())); } return true; }
484
Rozdział 17. Hive
public IntWritable terminatePartial() { return result; } public boolean merge(IntWritable other) { return iterate(other); } public IntWritable terminate() { return result; } } }
Struktura klasy jest tu nieco inna niż dla funkcji UDF. Klasa funkcji UDAF musi dziedziczyć po klasie org.apache.hadoop.hive.ql.exec.UDAF (zwróć uwagę na literę „A” w członie UDAF) i zawierać przynajmniej jedną statyczną klasę zagnieżdżoną z implementacją interfejsu org.apache.hadoop.hive. ql.exec.UDAFEvaluator. W tym przykładzie używana jest jedna klasa zagnieżdżona, MaximumIntUDAF Evaluator. Można jednak dodać więcej takich klas (na przykład MaximumLongUDAFEvaluator, Maximum FloatUDAFEvaluator itd.), by udostępnić przeciążone wersje funkcji UDAF służące do znajdowania maksimum w kolekcjach długich liczb całkowitych, liczb zmiennoprzecinkowych i innych. Klasa z rodziny Evaluator musi udostępniać pięć opisanych poniżej metod (przepływ danych między nimi przedstawia rysunek 17.3).
Rysunek 17.3. Przepływ danych w funkcji UDAF używającej częściowych wyników
Funkcje zdefiniowane przez użytkowników
485
init()
Metoda init() inicjuje obiekt danej klasy i resetuje jego wewnętrzny stan. W klasie MaximumInt UDAFEvaluator obiekt typu IntWritable przeznaczony na końcowy wynik jest ustawiany na null. Ta wartość informuje, że funkcja nie zagregowała jeszcze żadnych danych. Korzystnym skutkiem tego rozwiązania jest to, że maksymalna wartość jest wtedy pustym zbiorem NULL. iterate()
Metoda iterate() jest wywoływana za każdym razem, gdy pojawia się nowa wartość do zagregowania. Obiekt powinien aktualizować swój wewnętrzny stan na podstawie wyniku przeprowadzenia agregacji. Argumenty metody iterate() odpowiadają argumentom funkcji platformy Hive, dla której wywołano tę metodę. Tu używany jest tylko jeden argument. Metoda najpierw sprawdza, czy wartość argumentu to null. Wartości null są ignorowane. Dla innych liczb zmienna result egzemplarza jest ustawiana albo na wartość value (jeśli jest to pierwsza przetwarzana liczba), albo na większą z wartości result i value (przy przetwarzaniu kolejnych danych). Zwracana wartość true oznacza, że dane wejściowe były prawidłowe. terminatePartial()
Metoda terminatePartial() jest wywoływana, gdy Hive ma otrzymać wynik dla częściowej agregacji. Ta metoda musi zwracać obiekt ze stanem agregacji. Tu wystarczy zwrócić obiekt typu IntWritable, ponieważ obejmuje on albo dotychczasową wartość maksymalną, albo null (jeżeli nie przetworzono jeszcze żadnych wartości). merge()
Metoda merge() jest wywoływana, gdy platforma Hive ma łączyć jedną częściową agregację z inną. Ta metoda przyjmuje jeden obiekt, którego typ musi pasować do typu wartości zwracanej przez metodę terminatePartial(). Tu metoda merge() deleguje zadanie do metody iterate(), ponieważ częściowa agregacja jest reprezentowana w ten sam sposób co agregowana wartość. Zwykle jest jednak inaczej (dalej pokazany jest bardziej ogólny przykład), dlatego w metodzie merge() należy umieścić kod łączący stan obiektu ze stanem częściowej agregacji. terminate()
Metoda terminate() jest wywoływana, gdy potrzebny jest ostateczny wynik agregacji. Obiekt powinien zwrócić wtedy swój stan. Tu zwracana jest zmienna result egzemplarza. Wypróbujmy teraz nową funkcję. hive> CREATE TEMPORARY FUNCTION maximum AS 'com.hadoopbook.hive.Maximum'; hive> SELECT maximum(temperature) FROM records; 111
Bardziej skomplikowana funkcja UDAF Wcześniejszy przykład jest nietypowy, ponieważ częściowe agregacje są reprezentowane za pomocą tego samego typu (IntWritable) co ostateczny wynik. W bardziej skomplikowanych funkcjach agregujących zwykle jest inaczej. Pomyśl na przykład o funkcji UDAF obliczającej średnią dla kolekcji liczb zmiennoprzecinkowych podwójnej precyzji. Nie da się połączyć częściowych średnich w ostateczną wartość średniej (zobacz punkt „Funkcje łączące” w rozdziale 2.). Zamiast tego można przedstawić częściową agregację jako parę liczb — sumę przetworzonych dotychczas wartości zmiennoprzecinkowych i liczbę tych wartości. 486
Rozdział 17. Hive
To rozwiązanie zastosowano w funkcji UDAF przedstawionej na listingu 17.4. Zauważ, że częściowa agregacja jest zaimplementowana jako statyczna zagnieżdżona klasa PartialResult pełniąca funkcję struktury. Hive potrafi serializować i deserializować obiekty tej klasy, ponieważ używane są w niej pola typów obsługiwanych przez platformę (są to typy proste Javy). Listing 17.4. Funkcja UDAF służąca do obliczania średniej dla kolekcji liczb zmiennoprzecinkowych podwójnej precyzji package com.hadoopbook.hive; import org.apache.hadoop.hive.ql.exec.UDAF; import org.apache.hadoop.hive.ql.exec.UDAFEvaluator; import org.apache.hadoop.hive.serde2.io.DoubleWritable; public class Mean extends UDAF { public static class MeanDoubleUDAFEvaluator implements UDAFEvaluator { public static class PartialResult { double sum; long count; } private PartialResult partial; public void init() { partial = null; } public boolean iterate(DoubleWritable value) { if (value == null) { return true; } if (partial == null) { partial = new PartialResult(); } partial.sum += value.get(); partial.count++; return true; } public PartialResult terminatePartial() { return partial; } public boolean merge(PartialResult other) { if (other == null) { return true; } if (partial == null) { partial = new PartialResult(); } partial.sum += other.sum; partial.count += other.count; return true; }
}
}
public DoubleWritable terminate() { if (partial == null) { return null; } return new DoubleWritable(partial.sum / partial.count); }
Funkcje zdefiniowane przez użytkowników
487
W tym przykładzie metoda merge() różni się od metody iterate(), ponieważ łączy parami sumy i liczby wartości z częściowych danych. Ponadto typ wartości zwracanej przez metodę terminate Partial() to PartialResult (który oczywiście nigdy nie jest widoczny dla użytkowników korzystających z funkcji), a metoda terminate() zwraca udostępniany użytkownikom ostateczny wynik typu DoubleWritable.
Dalsza lektura Więcej informacji na temat platformy Hive znajdziesz w książce Programming Hive Edwarda Capriolo, Deana Wamplera i Jasona Rutherglena (O’Reilly, 2012).
488
Rozdział 17. Hive
ROZDZIAŁ 18.
Crunch
Biblioteka Apache Crunch (https://crunch.apache.org/) to interfejs API wyższego poziomu służący do pisania potoków dla modelu MapReduce. Główną zaletą Cruncha (w porównaniu ze zwykłym modelem MapReduce) jest dostępność wygodnych dla programistów typów Javy (takich jak String i obiektów POJO), bogatszy zestaw operacji do transformacji danych i obsługa potoków wieloetapowych (nie trzeba bezpośrednio zarządzać poszczególnymi zadaniami modelu MapReduce w przepływie pracy). W opisanych obszarach Crunch przypomina opartą na Javie wersję platformy Pig. Jednym z uciążliwych aspektów używania Piga jest to, że język stosowany do pisania funkcji zdefiniowanych przez użytkowników (Java lub Python) różni się od języka służącego do tworzenia skryptów Piga (Pig Latin). Dlatego proces budowania oprogramowania jest niespójny, ponieważ trzeba przełączać się między dwoma różnymi reprezentacjami i językami. W Crunchu udało się tego uniknąć. Programy i funkcje w Crunchu są pisane w jednym języku (Javie lub Scali), a funkcje UDF można umieszczać bezpośrednio w programach. Programowanie bardzo przypomina tu pisanie programów dla środowiska nierozproszonego. Choć Crunch jest pod wieloma względami podobny do Piga, inspiracją do jego powstania była biblioteka Javy FlumeJava, opracowana w firmie Google w celu budowania potoków dla modelu MapReduce. Biblioteki FlumeJava nie należy mylić z opisanym w rozdziale 14. systemem Apache Flume, przeznaczonym do rejestrowania danych o zdarzeniach związanych ze strumieniami. Więcej o bibliotece FlumeJava dowiesz się z artykułu „FlumeJava: Easy, Efficient Data-Parallel Pipelines” Craiga Chambersa i współpracowników (http://pages. cs.wisc.edu/~akella/CS838/F12/838-CloudPapers/FlumeJava.pdf).
Ponieważ potoki Cruncha są narzędziem wysokopoziomowym, można je łatwo łączyć ze sobą, a często używane funkcje można umieszczać w bibliotekach i wykorzystywać w innych programach. W modelu MapReduce ponowne wykorzystanie kodu nastręcza dużo trudności. Większość programów wymaga wtedy niestandardowych implementacji mapperów i reduktorów (wyjątkiem są proste sytuacje, gdy wystarczy funkcja tożsamościowa lub zwykłe sumowanie za pomocą klasy LongSum Reducer). W modelu MapReduce napisanie biblioteki mapperów i reduktorów dla transformacji różnego rodzaju (na przykład dla operacji sortowania i złączania) jest skomplikowane, natomiast w Crunchu nie sprawia to problemów. Istnieje na przykład klasa biblioteczna org.apache.crunch. lib.Sort z metodą sort(), która sortuje dowolną przekazaną jej kolekcję Cruncha.
489
Choć bibliotekę Crunch pierwotnie napisano z myślą o współpracy z silnikiem wykonawczym modelu MapReduce z Hadoopa, nie jest ona powiązana z tym narzędziem. Potoki Cruncha można też uruchamiać za pomocą rozproszonego silnika wykonawczego Apache Spark (zobacz rozdział 19.). Poszczególne silniki mają różne cechy. Na przykład Spark jest wydajniejszy niż MapReduce, gdy między zadaniami przekazywanych jest wiele pośrednich danych, ponieważ potrafi zachować dane w pamięci (nie musi materializować ich na dysku, jak robi to MapReduce). Możliwość wypróbowania potoku z wykorzystaniem różnych silników bez konieczności ponownego pisania programu jest bardzo cenna, ponieważ pozwala oddzielić to, co program robi, od wydajności tego procesu (wraz z usprawnianiem silników z czasem staje się ona coraz wyższa). Ten rozdział to wprowadzenie do pisania programów przetwarzających dane za pomocą biblioteki Crunch. Więcej informacji znajdziesz w podręczniku użytkownika biblioteki Crunch (http://crunch. apache.org/user-guide.html).
Przykład Najpierw zapoznaj się z prostym potokiem Cruncha, ilustrującym zastosowanie podstawowych technik. Listing 18.1 to oparta na Crunchu wersja programu wyznaczającego maksymalną temperaturę w poszczególnych latach na podstawie zbioru danych meteorologicznych (pierwszą wersję rozwiązania zaprezentowano w rozdziale 2.). Listing 18.1. Aplikacja wyznaczająca maksymalną temperaturę za pomocą Cruncha public class MaxTemperatureCrunch { public static void main(String[] args) throws Exception { if (args.length != 2) { System.err.println("Usage: MaxTemperatureCrunch "); System.exit(-1); } Pipeline pipeline = new MRPipeline(getClass()); PCollection records = pipeline.readTextFile(args[0]); PTable yearTemperatures = records .parallelDo(toYearTempPairsFn(), tableOf(strings(), ints())); PTable maxTemps = yearTemperatures .groupByKey() .combineValues(Aggregators.MAX_INTS()); maxTemps.write(To.textFile(args[1])); PipelineResult result = pipeline.done(); System.exit(result.succeeded() ? 0 : 1); } static DoFn toYearTempPairsFn() { return new DoFn() { NcdcRecordParser parser = new NcdcRecordParser(); @Override public void process(String input, Emitter emitter) { parser.parse(input); if (parser.isValidTemperature()) { emitter.emit(Pair.of(parser.getYear(), parser.getAirTemperature())); }
490
Rozdział 18. Crunch
} }; } }
Po standardowym sprawdzeniu argumentów z wiersza poleceń program tworzy obiekt typu Pipeline z biblioteki Crunch. Ten obiekt reprezentuje wykonywane obliczenia (ich potok). Potok może obejmować wiele etapów. Obsługiwane są potoki z różnymi danymi wejściowymi i wyjściowymi, rozgałęzieniami i iteracjami, choć w przykładzie początkowo stosowany jest potok jednoetapowy. Do uruchamiania potoku posłuży model MapReduce, dlatego używany jest typ MRPipeline. Można też zastosować typ MemPipeline, aby uruchamiać potok w pamięci na potrzeby testów, lub typ SparkPipeline w celu przeprowadzenia tych samych obliczeń za pomocą Sparka. Potok otrzymuje dane z jednego lub kilku źródeł danych wejściowych. W tym przykładzie źródłem jest jeden plik tekstowy o nazwie określonej w pierwszym argumencie wiersza poleceń (args[0]). Klasa Pipeline udostępnia metodę pomocniczą readTextFile(), która przekształca plik tekstowy na kolekcję PCollection obiektów typu String, gdzie każdy taki obiekt to wiersz z pliku. PCollection to podstawowy typ danych w Crunchu, reprezentujący niezmienną, nieuporządkowaną i rozproszoną kolekcję elementów typu S. Typ PCollection możesz traktować jak niezmaterializowany odpowiednik typu java.util.Collection — niezmaterializowany, ponieważ elementy nie są wczytywane do pamięci. W omawianym przykładzie dane wejściowe to rozproszona kolekcja wierszy z pliku tekstowego reprezentowana za pomocą typu PCollection. Obliczenia w Crunchu są wykonywane na obiekcie typu PCollection i zwracają nowy obiekt tego typu. Zacząć należy od parsowania każdego wiersza pliku wejściowego i odfiltrowania nieprawidłowych rekordów. W tym celu można wykorzystać metodę parallelDo() typu PCollection, która stosuje funkcję do każdego elementu kolekcji tego typu i zwraca nowy obiekt typu PCollection. Oto sygnatura tej metody: PCollection parallelDo(DoFn doFn, PType type);
Programista powinien napisać implementację funkcji DoFn, która przekształca obiekt typu S na jeden lub więcej obiektów typu T. Crunch stosuje tę funkcję do każdego elementu z kolekcji typu PCollection. Przekształcenia można przeprowadzać równolegle w operacjach mapowania w zadaniu w modelu MapReduce. Drugi argument metody parallelDo() to obiekt typu PType, który zapewnia Crunchowi informacje na temat typu Javy używanego jako T i serializowania tego typu. W omawianym przykładzie używana jest przeciążona wersja metody parallelDo(), która tworzy obiekt typu pochodnego od PCollection — PTable. PTable to zawierające pary klucz i wartość odwzorowanie z możliwością występowania duplikatów. Ten typ pozwala podać rok jako klucz i temperaturę jako wartość, co umożliwia grupowanie i agregację danych na dalszych etapach potoku. Oto sygnatura metody parallelDo(): PTable parallelDo(DoFn doFn, PTableType type);
W tym przykładzie funkcja DoFn parsuje wiersz danych wejściowych i zwraca parę rok-temperatura. static DoFn toYearTempPairsFn() { return new DoFn() { NcdcRecordParser parser = new NcdcRecordParser(); @Override public void process(String input, Emitter emitter) {
Przykład
491
parser.parse(input); if (parser.isValidTemperature()) { emitter.emit(Pair.of(parser.getYear(), parser.getAirTemperature())); } }
} };
Po zastosowaniu funkcji otrzymywana jest tabela par rok-temperatura. PTable yearTemperatures = records .parallelDo(toYearTempPairsFn(), tableOf(strings(), ints()));
Drugi argument metody parallelDo() to obiekt typu PTableType, tworzony za pomocą statycznych metod klasy Writables Cruncha (ponieważ dla pośrednich danych zapisywanych przez Cruncha zdecydowano się zastosować serializację opartą na typach Writable Hadoopa). Metoda tableOf() tworzy obiekt typu PTableType z kluczami i wartościami podanych typów. Metoda strings() określa, że klucze są reprezentowane w pamięci jako obiekty typu String Javy i serializowane jako obiekty typu Text Hadoopa. Wartości to typy int Javy serializowane jako obiekty typu IntWritable Hadoopa. Na tym etapie reprezentacja danych jest lepiej ustrukturyzowana, jednak liczba rekordów jest taka sama, ponieważ każdy wiersz z pliku wejściowego odpowiada jednemu elementowi tabeli yearTemperatures. Aby wyznaczyć maksymalny odczyt temperatury dla każdego roku ze zbioru danych, trzeba pogrupować elementy tabeli na podstawie lat, a następnie dla każdego roku znaleźć najwyższą temperaturę. Na szczęście Crunch udostępnia potrzebne operacje w interfejsie API typu PTable. Metoda groupByKey() odpowiada za przestawianie danych w modelu MapReduce. Grupuje elementy na podstawie kluczy i zwraca obiekt trzeciego typu z rodziny PCollection, PGroupedTable, który udostępnia metodę combineValues(). Ta metoda przeprowadza agregację wszystkich wartości dla danego klucza (podobnie jak reduktor w modelu MapReduce). PTable maxTemps = yearTemperatures .groupByKey() .combineValues(Aggregators.MAX_INTS());
Metoda combineValues() przyjmuje obiekt typu Aggregator Cruncha. Ten typ to prosty interfejs dla dowolnego sposobu agregowania strumienia wartości. Tu można wykorzystać wbudowany agregator MAX_INTS z klasy Aggregators, który wyszukuje maksymalną wartość w zbiorze liczb całkowitych. Ostatni krok w potoku polega na zapisie tabeli maxTemps w pliku. Używana jest do tego metoda write() przyjmująca obiekt reprezentujący docelowy plik tekstowy. Ten obiekt jest tworzony za pomocą statycznej fabryki To. Crunch wykorzystuje w tej operacji format TextOutputFormat, co oznacza, że klucze i wartości w każdym wierszu danych wyjściowych są od siebie rozdzielone tabulacją. maxTemps.write(To.textFile(args[1]));
Do tego miejsca w programie tylko tworzono potok. Aby uruchomić potok, należy wywołać metodę done(). Wtedy program blokuje interfejs do momentu zakończenia przetwarzania potoku. Następnie Crunch zwraca obiekt typu PipelineResult z różnymi statystykami dotyczącymi zadań uruchamianych w ramach potoku, a także z informacją o tym, czy przetwarzanie potoku zostało zakończone powodzeniem. Ta ostatnia informacja jest używana do odpowiedniego ustawienia kodu wyjścia programu. 492
Rozdział 18. Crunch
Uruchomienie programu dla przykładowego zbioru danych daje następujące wyniki: % hadoop jar crunch-examples.jar crunch.MaxTemperatureCrunch \ input/ncdc/sample.txt output % cat output/part-r-00000 1949 111 1950 22
Podstawowe interfejsy API Cruncha W tym podrozdziale opisano podstawowe interfejsy Cruncha. Crunch udostępnia interfejs API wysokiego poziomu, dlatego programiści mogą skoncentrować się na logicznych operacjach w obliczeniach zamiast na szczegółach ich wykonywania.
Proste operacje Podstawową strukturą danych w Crunchu jest typ PCollection. Jest to niezmienna, nieuporządkowana i rozproszona kolekcja elementów typu S. W tym punkcie opisane są proste operacje na obiektach typu PCollection i typach pochodnych (PTable i PGroupedTable).
Metoda union() Metoda union() to najprostsza z podstawowych operacji w Crunchu. Zwraca ona obiekt typu PCollection zawierający wszystkie elementy obiektu, dla którego została wywołana, i obiektu podanego jako argument. Oto przykład: PCollection a = MemPipeline.collectionOf(1, 3); PCollection b = MemPipeline.collectionOf(2); PCollection c = a.union(b); assertEquals("{2,1,3}", dump(c));
Metoda collectionOf() typu MemPipeline służy do tworzenia obiektu typu PCollection na podstawie małej liczby elementów (zwykle w celach testowych lub demonstracyjnych). Metoda narzędziowa dump() została tu użyta w celu wyświetlenia zawartości niewielkiej kolekcji typu PCollection jako łańcucha znaków. Nie jest to metoda biblioteki Crunch, jednak znajdziesz jej implementację w klasie PCollection w przykładowym kodzie dołączonym do książki. Ponieważ elementy w obiektach typu PCollection nie są uporządkowane, kolejność wartości w kolekcji c jest niezdefiniowana. Gdy tworzona jest suma dwóch obiektów typu PCollection, oba obiekty muszą być generowane w tym samym potoku (w przeciwnym razie operacja w czasie wykonywania programu zakończy się niepowodzeniem) i być tego samego typu. Ten ostatni warunek jest wymuszany w czasie kompilacji, ponieważ PCollection to typ sparametryzowany, a argumenty określające typy dla sumowanych obiektów PCollection muszą do siebie pasować.
Metoda parallelDo() Druga prosta operacja to parallelDo(). Wywołuje ona funkcję dla każdego elementu z wejściowej kolekcji typu PCollection i zwraca nową, wyjściową kolekcję typu PCollection z wynikami wywołań. Najprostsza wersja metody parallelDo() przyjmuje dwa argumenty. DoFn definiuje funkcję przekształcającą elementy typu S na typ T, a obiekt typu PType opisuje typ wyjściowy T. Typ PType omówiono szczegółowo w punkcie „Typy”. Podstawowe interfejsy API Cruncha
493
Poniższy fragment kodu pokazuje, jak użyć metody parallelDo() do zastosowania funkcji length do kolekcji typu PCollection zawierającej łańcuchy znaków. PCollection a = MemPipeline.collectionOf("cherry", "apple", "banana"); PCollection b = a.parallelDo(new DoFn() { @Override public void process(String input, Emitter emitter) { emitter.emit(input.length()); } }, ints()); assertEquals("{6,5,6}", dump(b));
Tu wyjściowa kolekcja typu PCollection (zawierająca liczby całkowite) ma tyle samo elementów co wejściowa. Dlatego można też zastosować klasę MapFn (jest to klasa pochodna od DoFn) do przeprowadzenia odwzorowania jeden do jednego. PCollection b = a.parallelDo(new MapFn() { @Override public Integer map(String input) { return input.length(); } }, ints()); assertEquals("{6,5,6}", dump(b));
Metodę parallelDo() często stosuje się do odfiltrowywania danych, które nie są potrzebne na dalszych etapach przetwarzania. Crunch udostępnia w tym celu metodę filter(), która przyjmuje obiekt typu FilterFn (jest to klasa pochodna od DoFn). Programista musi jedynie zaimplementować metodę accept(), by określała, które elementy powinny znaleźć się w danych wyjściowych. Na przykład poniższy kod zachowuje tylko te łańcuchy, w których liczba znaków jest parzysta. PCollection b = a.filter(new FilterFn() { @Override public boolean accept(String input) { return input.length() % 2 == 0; // Parzyste } }); assertEquals("{cherry,banana}", dump(b));
Zauważ, że w sygnaturze metody filter() nie występuje obiekt typu PType. Jest tak, ponieważ wyjściowa kolekcja PCollection jest tego samego typu co wejściowa. Jeśli klasa DoFn istotnie zmienia wielkość przetwarzanej kolekcji typu PCollection, można przesłonić metodę scaleFactor(), aby przekazywała generatorowi planów Cruncha wskazówkę na temat szacowanego względnego rozmiaru danych wyjściowych. Czasem pozwala to zwiększyć wydajność. Metoda scaleFactor() z klasy FilterFn zwraca wartość 0.5. Wynika ona z założenia, że kod odfiltruje około połowy elementów kolekcji typu PCollection. Jeśli używana funkcja filtrująca usuwa znacznie mniej lub więcej elementów, warto przesłonić metodę scaleFactor().
Dostępna jest przeciążona wersja metody parallelDo(), generująca na podstawie kolekcji typu PCollection kolekcję typu PTable. Warto przypomnieć, że PTable to odwzorowanie z parami klucz-wartość, w którym elementy mogą się powtarzać. W przełożeniu na typy Javy PTable to odpowiednik typu PCollection, gdzie Pair to klasa Cruncha reprezentująca pary.
494
Rozdział 18. Crunch
Poniższy kod tworzy obiekt typu PTable za pomocą klasy DoFn, która przekształca wejściowy łańcuch znaków w parę klucz-wartość (klucz określa długość łańcucha, a wartością jest sam łańcuch). PTable b = a.parallelDo( new DoFn() { @Override public void process(String input, Emitter emitter) { emitter.emit(Pair.of(input.length(), input)); } }, tableOf(ints(), strings())); assertEquals("{(6,cherry),(5,apple),(6,banana)}", dump(b));
Pobieranie kluczy z kolekcji typu PCollection w celu utworzenia obiektu typu PTable to na tyle częsta operacja, że Crunch udostępnia metodę by(), która działa w ten sposób. Ta metoda przyjmuje obiekt typu MapFn, by powiązać wartość wejściową S z kluczem K. PTable b = a.by(new MapFn() { @Override public Integer map(String input) { return input.length(); } }, ints()); assertEquals("{(6,cherry),(5,apple),(6,banana)}", dump(b));
Metoda groupByKey() Trzecia prosta operacja to groupByKey(). Łączy ona wszystkie wartości obiektu typu PTable mające ten sam klucz. Tę operację można traktować jak przestawianie w modelu MapReduce. W silniku wykonawczym MapReduce jest ona zaimplementowana właśnie w postaci przestawiania. W kategoriach typów Cruncha metoda groupByKey() zwraca obiekt typu PGroupedTable (jest to odpowiednik obiektu typu PCollection), czyli odwzorowanie z możliwymi powtórzeniami, w którym każdy klucz jest powiązany z umożliwiającą iterację kolekcją odpowiadających mu wartości. Wróćmy do wcześniejszego fragmentu kodu. Pogrupowanie kolekcji typu PTable z odwzorowaniami długość – łańcuch znaków według klucza pozwoli uzyskać pokazany poniżej obiekt (elementy w nawiasach kwadratowych to kolekcja umożliwiająca iterację): PGroupedTable c = b.groupByKey(); assertEquals("{(5,[apple]),(6,[banana,cherry])}", dump(c));
Crunch wykorzystuje informacje o wielkości tabeli do określenia liczby partycji (operacji redukcji w modelu MapReduce) używanych dla operacji groupByKey(). Zwykle ustawienia domyślne są odpowiednie, jednak w razie potrzeby można bezpośrednio ustawić liczbę partycji za pomocą przeciążonej wersji tej metody (groupByKey(int)).
Metoda combineValues() Mimo nazwy klasa PGroupedTable nie dziedziczy po klasie PTable, dlatego nie umożliwia wywoływania metod takich jak groupByKey(). Jest tak, ponieważ nie ma sensu grupowanie według klucza wartości z kolekcji PTable, które już zostały w ten sposób pogrupowane. Obiekt typu PGroupedTable można też traktować jak pośrednią reprezentację danych przed wygenerowaniem nowego obiektu typu PTable. W końcu wartości grupuje się według klucza po to, aby coś z nimi zrobić. Z tym związana jest czwarta prosta operacja — combineValues().
Podstawowe interfejsy API Cruncha
495
W najbardziej ogólnej postaci combineValues() przyjmuje funkcję łączącą CombineFn (jest to bardziej zwięzła forma zapisu DoFn), a zwraca obiekt typu PTable. Aby zobaczyć, jak działa omawiana funkcja, wyobraź sobie funkcję łączącą, która scala wszystkie łańcuchy znaków dla danego klucza, używając średnika jako separatora. PTable d = c.combineValues(new CombineFn() { @Override public void process(Pair input, Emitter emitter) { StringBuilder sb = new StringBuilder(); for (Iterator i = input.second().iterator(); i.hasNext(); ) { sb.append(i.next()); if (i.hasNext()) { sb.append(";"); } } emitter.emit(Pair.of(input.first(), sb.toString())); } }); assertEquals("{(5,apple),(6,banana;cherry)}", dump(d));
Operacja scalania łańcuchów znaków nie jest przemienna, dlatego wynik nie zawsze musi być identyczny. W aplikacji może to być istotne lub nie.
Kod jest zagmatwany z powodu zastosowania obiektów typu Pair w sygnaturze metody process(). Te obiekty trzeba wypakować za pomocą wywołań first() i second(), po czym należy utworzyć nowy obiekt typu Pair na nową parę klucz-wartość. Pokazana funkcja łącząca nie modyfikuje klucza, dlatego można wykorzystać przeciążoną wersję funkcji combineValues() przyjmującą obiekt typu Aggregator. Ta wersja manipuluje tylko wartościami, a klucze przekazuje w niezmienionej postaci. Co lepsze, można wykorzystać wbudowaną implementację typu Aggregator (dostępną w klasie Aggregators), służącą do złączania łańcuchów znaków. Nowa wersja kodu wygląda tak: PTable e = c.combineValues(Aggregators.STRING_CONCAT(";", false)); assertEquals("{(5,apple),(6,banana;cherry)}", dump(e));
Czasem programista chce zagregować wartości w obiekcie typu PGroupedTable i zwrócić wynik innego typu niż typ grupowanych wartości. Ten efekt można uzyskać za pomocą metody mapValues. Należy do niej przekazać obiekt typu MapFn, który przekształca kolekcję z możliwością iteracji na obiekt innego typu. Na przykład poniższy kod określa liczbę wartości dla każdego klucza. PTable f = c.mapValues(new MapFn() { @Override public Integer map(Iterable input) { return Iterables.size(input); } }, ints()); assertEquals("{(5,1),(6,2)}", dump(f));
Zauważ, że wartości to łańcuchy znaków, natomiast wynik zastosowania funkcji map to liczba całkowita określająca wielkość kolekcji i wyznaczona za pomocą klasy Iterables z projektu Guava. Możesz się zastanawiać, po co w ogóle istnieje operacja combineValues(), skoro metoda mapValues() daje więcej możliwości. Powód jest taki, że metodę combineValues() można uruchomić jako funkcję łączącą w modelu MapReduce. Pozwala to zwiększyć wydajność, ponieważ metoda jest urucha-
496
Rozdział 18. Crunch
miana na etapie mapowania. W efekcie w fazie przestawiania ilość przesyłanych danych jest mniejsza (zobacz punkt „Funkcje łączące” w rozdziale 2.). Metoda mapValues() jest przekształcana w operację parallelDo() i może działać tylko na etapie redukcji. Dlatego nie da się zastosować funkcji łączącej w celu poprawy wydajności rozwiązania. Następną operacją klasy PGroupedTable jest metoda ungroup(). Przekształca ona obiekt typu PGroupedTable na obiekt typu PTable, działa więc odwrotnie niż metoda groupByKey(). Metoda ungroup() nie jest operacją prostą, ponieważ jest zaimplementowana za pomocą metody parallelDo(). Wywołanie dla obiektu typu PTable najpierw metody groupByKey(), a następnie metody ungroup() powoduje częściowe posortowanie tabeli według kluczy. Jednak zwykle wygodniej jest zastosować bibliotekę Sort, która wykonuje sortowanie globalne (przeważnie to ono jest celem programisty) i udostępnia opcje do określania sposobu porządkowania danych.
Typy Z każdym typem PCollection powiązana jest klasa PType, obejmująca informacje o typie elementów kolekcji PCollection. Klasa PType określa klasę Javy (S) dla elementów kolekcji, a także format serializacji używany do wczytywania danych z trwałej pamięci do kolekcji oraz do zapisu danych z kolekcji do trwałej pamięci. W Crunchu są dwie rodziny klas PType — dla typów Writable z Hadoopa i dla systemu Avro. Stosowana klasa zależy od formatu plików używanego w potoku. Klasy dla typów Writable są powiązane z plikami typu SequenceFile, a klasy dla systemu Avro — z plikami danych z tego systemu. Dla plików tekstowych można korzystać z klas z obu grup. W potokach można korzystać z zestawu różnych klas PType (ponieważ te klasy są powiązane z kolekcjami typu PCollection, a nie z całym potokiem), jednak zwykle nie jest to konieczne, chyba że wykonujesz operacje wymagające klas z obu rodzin (na przykład zmieniasz format plików). Crunch zwykle ukrywa różnice między odmiennymi formatami serializacji, dzięki czemu typy używane w kodzie wyglądają znajomo dla programistów Javy. Inna korzyść jest taka, że łatwiej jest pisać biblioteki i narzędzia przetwarzające kolekcje Cruncha bez uwzględniania rodziny klas odpowiedzialnych za serializację. Na przykład wiersze wczytywane z pliku tekstowego są przedstawiane jako obiekty typu String Javy, a nie jako klasa Text z rodziny Writable lub obiekty typu Utf8 z systemu Avro. Klasa PType używana przez kolekcję PCollection jest określana w momencie tworzenia kolekcji, przy czym czasem klasa jest ustalana niejawnie. Na przykład przy odczycie pliku tekstowego domyślnie używane są klasy z rodziny Writable, czego dowodzi poniższy test: PCollection lines = pipeline.read(From.textFile(inputPath)); assertEquals(WritableTypeFamily.getInstance(), lines.getPType().getFamily());
Można jednak jawnie ustawić serializację z systemu Avro. W tym celu należy przekazać odpowiedni typ PType do metody textFile(). Tu statyczna metoda fabryczna klasy Avros jest używana do utworzenia reprezentacji typu PType właściwej dla systemu Avro. PCollection lines = pipeline.read(From.textFile(inputPath, Avros.strings()));
Podstawowe interfejsy API Cruncha
497
Podobnie w operacjach tworzących nowe kolekcje PCollection trzeba określić typ PType pasujący do parametrów określających typy używane w kolekcji1. Wcześniej w kodzie ilustrującym stosowanie metody parallelDo() do pobierania całkowitoliczbowych kluczy z kolekcji PCollection i przekształcania ich na obiekty typu PTable ustawiono odpowiedni typ PType w następujący sposób: tableOf(ints(), strings())
Wszystkie trzy metody są statycznie importowane z klasy Writables.
Rekordy i krotki Gdy w Crunchu używane są złożone obiekty o wielu polach, można zastosować rekordy lub krotki. Rekord to klasa, w której dostęp do pól odbywa się na podstawie nazw. Może to być klasa Generic Record z systemu Avro, obiekt POJO Javy (odpowiadający specyficznemu lub refleksyjnemu interfejsowi z systemu Avro) lub niestandardowa implementacja typu Writable. Natomiast w krotkach dostęp do pól odbywa się na podstawie pozycji. Crunch udostępnia interfejs Tuple, a także kilka klas pomocniczych reprezentujących krotki o niewielkiej liczbie elementów: Pair, Tuple3, Tuple4 i TupleN (ta ostatnia przeznaczona jest dla krotek o dowolnej, ale stałej liczbie wartości). Tam, gdzie to możliwe, należy stosować rekordy zamiast krotek, ponieważ dzięki temu programy oparte na Crunchu są bardziej czytelne i zrozumiałe. Gdy rekord z danymi meteorologicznymi jest reprezentowany jako klasa WeatherRecord z polami przechowującymi rok, temperaturę i identyfikator stacji, łatwiej jest używać poniższego typu: Emitter
niż tego: Emitter 0.01) { scores = pageRank(scores, 0.5f); PObject pDelta = computeDelta(scores); delta = pDelta.getValue(); }
Dzięki temu bez wdawania się w szczegóły samego algorytmu PageRank można zrozumieć, jak działa program wyższego poziomu w Crunchu. Dane wejściowe to plik tekstowy zawierający w każdym wierszu dwa adresy URL — strony i wychodzącego z niej odnośnika. Na przykład poniższy plik informuje, że A zawiera odnośniki do B i C, a B obejmuje odnośnik do D. www.A.com www.B.com www.A.com www.C.com www.B.com www.D.com
Wróćmy do kodu. Pierwszy wiersz wczytuje dane wejściowe i generuje początkowy obiekt typu PageRankData dla każdej unikatowej strony. PageRankData to prosta klasa Javy z polami określającymi wynik, wcześniejszy wynik (używany do sprawdzania zbieżności) i listę wychodzących odnośników. public static class PageRankData { public float score; public float lastScore; public List urls; // ... Metody zostały pominięte }
Ten algorytm jest przeznaczony do obliczania dla każdej strony wyniku reprezentującego jej względną istotność. Początkowo wszystkie strony są uznawane za równe, dlatego pierwotny wynik jest ustawiany dla każdej z nich na 1, a poprzedni — na 0. Do tworzenia listy wychodzących odnośników służą operacje Cruncha, które grupują dane wejściowe według pierwszego pola (określającego stronę), a następnie agregują wartości (wychodzące odnośniki) na liście10. Iteracja jest wykonywana za pomocą zwykłej pętli while Javy. Wyniki są aktualizowane w każdej iteracji pętli za pomocą metody pageRank(), która łączy serię operacji Cruncha w algorytm PageRank. Jeśli zmiana między poprzednim zbiorem wyników a ich nowym zbiorem jest wystarczająco mała (do 0,01), wyniki są uznawane za zbieżne i algorytm kończy pracę. Zmiana jest obliczana za pomocą metody computeDelta(). Jest to agregacja z Cruncha wyznaczająca największą bezwzględną różnicę między dawnymi i nowymi wynikami dla wszystkich stron z kolekcji. 9
Szczegółowe informacje znajdziesz w Wikpedii (https://en.wikipedia.org/wiki/PageRank).
10
Pełny kod źródłowy znajdziesz w testach integracyjnych Cruncha w klasie PageRankIT.
Wykonywanie potoku
511
Kiedy potok jest uruchamiany? Przy każdym wywołaniu metody pDelta.getValue(). Przy pierwszym wykonaniu pętli kolekcja PCollection nie jest jeszcze zmaterializowana. Dlatego trzeba uruchomić zadania dla metod readUrls(), pageRank() i computeDelta(), aby obliczyć wartość zmiennej delta. W kolejnych iteracjach wystarczy uruchomić zadania obliczające nowe wyniki (metoda page Rank()) i zmienną delta (metoda computeDelta()). W tym potoku generator planów Cruncha sprawnie optymalizuje plan wykonania, ponieważ wywołanie scores.materialize().iterator() znajduje się bezpośrednio po wywołaniu pageRank(). To gwarantuje, że tabela scores zostanie jawnie zoptymalizowana, dlatego będzie dostępna przy tworzeniu następnego planu wykonania w kolejnym powtórzeniu pętli. Bez wywołania materialize() program wygeneruje ten sam wynik, ale w mniej wydajny sposób. Generator planów może zdecydować się wtedy na materializację różnych wyników pośrednich, a w następnym powtórzeniu pętli niektóre obliczenia trzeba będzie powtórzyć, aby uzyskać zmienną scores przekazywaną do wywołania pageRank().
Tworzenie punktów kontrolnych w potokach W poprzednim punkcie pokazano, że Crunch potrafi ponownie wykorzystać każdą kolekcję PCollection, którą zmaterializowano w jednym z wcześniejszych przebiegów danego potoku. Jeśli jednak utworzysz nowy egzemplarz potoku, nie będzie on automatycznie korzystać ze zmaterializowanych kolekcji PCollection z innych potoków (nawet jeśli źródło danych wejściowych jest takie samo). To sprawia, że budowanie potoków bywa czasochłonne, ponieważ nawet niewielka zmiana w obliczeniach w końcowej części potoku oznacza, że Crunch musi uruchomić nowy potok od początku. Rozwiązaniem jest zapisanie punktu kontrolnego z kolekcją PCollection w trwałej pamięci (zwykle w systemie HDFS). Dzięki temu Crunch może rozpocząć pracę w nowym potoku od punktu kontrolnego. Zastanów się nad programem Cruncha generującym histogram z liczbami wystąpień słów w pliku tekstowym (listing 18.3). Wiesz już, że generator planów Cruncha przekształca ten potok na dwa zadania w modelu MapReduce. Gdy program jest uruchamiany po raz drugi, Crunch ponownie wykonuje oba zadania i zastępuje pierwotne dane wyjściowe, ponieważ tryb zapisu (WriteMode) jest ustawiony na wartość OVERWRITE. Jeśli jednak utworzysz punkt kontrolny z kolekcją inverseCounts, w następnych uruchomieniach program będzie musiał uruchomić tylko jedno zadanie w modelu MapReduce (generujące kolekcję hist, która jest w całości określana na podstawie kolekcji inverseCounts). Tworzenie punktu kontrolnego jest proste. Wystarczy zapisać kolekcję PCollection w docelowej lokalizacji przy trybie WriteMode ustawionym na CHECKPOINT. PCollection lines = pipeline.readTextFile(inputPath); PTable counts = lines.count(); PTable inverseCounts = counts.parallelDo( new InversePairFn(), tableOf(longs(), strings())); inverseCounts.write(To.sequenceFile(checkpointPath), Target.WriteMode.CHECKPOINT);
512
Rozdział 18. Crunch
PTable hist = inverseCounts .groupByKey() .mapValues(new CountValuesFn(), ints()); hist.write(To.textFile(outputPath), Target.WriteMode.OVERWRITE); pipeline.done();
Crunch porównuje znaczniki czasu plików wejściowych ze znacznikami punktów kontrolnych. Jeśli dane wyjściowe pochodzą z późniejszego czasu niż zależne od nich punkty kontrolne, automatycznie generowane są nowe punkty kontrolne. Nie istnieje więc ryzyko, że w potoku znajdą się nieaktualne dane. Ponieważ punkty kontrolne są zachowywane między uruchomieniami potoku, Crunch ich nie usuwa. Dlatego trzeba skasować punkty kontrolne po stwierdzeniu, że kod generuje oczekiwane wyniki.
Biblioteki w Crunchu Crunch udostępnia rozbudowany zbiór funkcji bibliotecznych w pakiecie org.apache.crunch.lib. Przegląd tych funkcji zawiera tabela 18.1. Tabela 18.1. Biblioteki Cruncha Klasa
Nazwa metody
Opis
Aggregate
length()
Zwraca obiekt typu PObject z liczbą elementów kolekcji PCollection.
min()
Zwraca obiekt typu PObject z elementem o najmniejszej wartości z kolekcji PCollection.
max()
Zwraca obiekt typu PObject z elementem o największej wartości z kolekcji PCollection.
count()
Zwraca tabelę unikatowych elementów wejściowej kolekcji PCollection powiązanych z liczbami ich wystąpień.
top()
Zwraca tabelę pierwszych lub końcowych N par klucz-wartość z kolekcji PTable uporządkowanej według wartości.
collectValues()
Grupuje powiązane z każdym unikatowym kluczem wartości w kolekcji Collection Javy. Zwraca kolekcję PTable.
Cartesian
cross()
Oblicza iloczyn kartezjański dwóch kolekcji PCollection lub PTable.
Channels
split()
Dzieli kolekcję par (PCollection) na parę kolekcji (Pair).
Cogroup
cogroup()
Grupuje elementy z dwóch lub więcej kolekcji PTable na podstawie kluczy.
Distinct
distinct()
Tworzy nową kolekcję PCollection lub PTable po usunięciu duplikatów.
Join
join()
Przeprowadza złączenie wewnętrzne dwóch kolekcji PTable na podstawie kluczy. Istnieją też metody dla złączeń prawostronnych, lewostronnych i pełnych.
Mapred
map()
Uruchamia mapper (w dawnym interfejsie API) dla kolekcji PTable, by utworzyć kolekcję PTable.
reduce()
Uruchamia reduktor (w dawnym interfejsie API) dla kolekcji PGroupedTable, by utworzyć kolekcję PTable.
map(), reduce()
Działa jak klasa Mapred, ale używa nowego interfejsu API modelu MapReduce.
Mapreduce
Biblioteki w Crunchu
513
Tabela 18.1. Biblioteki Cruncha — ciąg dalszy Klasa
Nazwa metody
Opis
PTables
asPTable()
Przekształca kolekcję PCollection na kolekcję PTable.
keys()
Zwraca kolekcję PCollection z kluczami z kolekcji PTable.
values()
Zwraca kolekcję PCollection z wartościami z kolekcji PTable.
mapKeys()
Stosuje funkcję mapującą do wszystkich kluczy z kolekcji PTable. Wartości pozostają niezmienione.
mapValues()
Stosuje funkcję mapującą do wszystkich wartości z kolekcji PTable lub PGroupedTable. Klucze pozostają niezmienione.
sample()
Tworzy próbkę określonej wielkości z kolekcji PCollection. Każdy element jest umieszczany w niej niezależnie zgodnie z określonym prawdopodobieństwem.
reservoirSample()
Tworzy próbkę określonej wielkości z kolekcji PCollection. Dla każdego elementu prawdopodobieństwo pojawienia się w próbce jest identyczne.
SecondarySort
sortAndApply()
Sortuje kolekcję PTable na podstawie K, a następnie według V1, po czym stosuje funkcję, aby zwrócić kolekcję PCollection lub PTable.
Set
difference()
Zwraca kolekcję PCollection będącą różnicą dwóch kolekcji PCollection.
intersection()
Zwraca kolekcję PCollection będącą częścią wspólną dwóch kolekcji PCollection.
comm()
Zwraca kolekcję PCollection z trójkami określającymi dla każdego elementu z dwóch kolekcji PCollection, czy występuje on tylko w pierwszej kolekcji, tylko w drugiej kolekcji, czy w obu. Podobnie działa uniksowe polecenie comm.
Shard
shard()
Tworzy kolekcję PCollection zawierającą dokładnie te same elementy co wejściowa kolekcja PCollection, ale podzielone (poziomo) na określoną liczbę plików.
Sort
sort()
Przeprowadza globalne sortowanie kolekcji PCollection w porządku naturalnym rosnąco (domyślnie) lub malejąco. Istnieją też metody do sortowania kolekcji PTable według kluczy i kolekcji obiektów typu Pair lub krotek w określonym porządku na podstawie podzbioru ich kolumn.
Sample
Jedną z cech Cruncha dającą dużo możliwości jest to, że jeśli potrzebna funkcja jest niedostępna, można ją łatwo napisać samodzielnie. Zwykle wystarczy do tego kilka wierszy kodu w Javie. Przykładową funkcję ogólnego użytku (wyszukującą unikatowe wartości w kolekcjach PTable) przedstawiono na listingu 18.2. Metody length(), min() i count() klasy Aggregate mają ułatwiające pracę odpowiedniki w klasie PCollection. Podobnie metody top() (oraz oparta na niej metoda bottom()) i collectValues() z klasy Aggregate, wszystkie metody z klasy PTables, metoda join() z klasy Join i metoda cogroup() z klasy Cogroup mają swoje odpowiedniki w klasie PTable. Kod z listingu 18.4 przedstawia działanie wybranych metod agregujących. Listing 18.4. Używanie metod agregujących dla kolekcji PCollection i PTable PCollection a = MemPipeline.typedCollectionOf(strings(), "cherry", "apple", "banana", "banana"); assertEquals((Long) 4L, a.length().getValue()); assertEquals("apple", a.min().getValue()); assertEquals("cherry", a.max().getValue());
514
Rozdział 18. Crunch
PTable b = a.count(); assertEquals("{(apple,1),(banana,2),(cherry,1)}", dump(b)); PTable c = b.top(1); assertEquals("{(banana,2)}", dump(c)); PTable d = b.bottom(2); assertEquals("{(apple,1),(cherry,1)}", dump(d));
Dalsza lektura Ten rozdział zawiera krótkie wprowadzenie do Cruncha. Więcej dowiesz się z podręcznika użytkownika ze strony http://crunch.apache.org/user-guide.html.
Dalsza lektura
515
516
Rozdział 18. Crunch
ROZDZIAŁ 19.
Spark
Apache Spark (https://spark.apache.org/) to platforma przetwarzania przeznaczona do pracy z dużymi zbiorami danych w klastrze. W odróżnieniu od większości innych platform przetwarzania omówionych w tej książce Spark nie wykorzystuje modelu MapReduce jako silnika wykonawczego. Zamiast tego korzysta z własnego rozproszonego środowiska uruchomieniowego do wykonywania pracy w klastrze. Jednak w wielu aspektach (takich jak interfejs API i środowisko uruchomieniowe) Spark jest podobny do modelu MapReduce, o czym przekonasz się w tym rozdziale. Spark jest ściśle zintegrowany z Hadoopem. Może działać w systemie YARN oraz obsługuje formaty plików z Hadoopa i używane na zapleczu systemy przechowywania danych (na przykład system HDFS). Spark jest najbardziej znany z możliwości przechowywania w pamięci dużych roboczych zbiorów danych między zadaniami. To pozwala uzyskać za pomocą Sparka wyższą wydajność niż w analogicznym przepływie pracy w modelu MapReduce (jest to różnica o rząd wielkości lub większa1), gdzie zbiory danych zawsze są wczytywane z dysku. Dwa typy aplikacji, w których model przetwarzania używany w Sparku przynosi znaczne korzyści, to algorytmy iteracyjne (gdzie funkcja jest wielokrotnie stosowana do zbioru danych do czasu spełnienia warunku wyjścia) i analizy interaktywne (gdzie użytkownik uruchamia serie jednorazowych zapytań eksploracyjnych na zbiorze danych). Nawet jeśli nie potrzebujesz buforów przechowywanych w pamięci, Spark jest bardzo atrakcyjnym narzędziem. Wynika to z kilku powodów — silnika DAG i komfortu pracy użytkowników. Silnik DAG w Sparku (inaczej niż w modelu MapReduce) może przetwarzać dowolne potoki operatorów i przekształcać je w pojedyncze zadanie dla użytkownika. Komfort pracy użytkowników Sparka jest wyjątkowy dzięki bogatemu zestawowi interfejsów API służących do wykonywania wielu standardowych operacji z zakresu przetwarzania danych (na przykład do obsługi złączeń). W czasie, gdy powstawała ta książka, Spark udostępniał interfejsy API w trzech językach: Scali, Javie i Pythonie. W większości przykładów w tym rozdziale używany jest interfejs w Scali, jednak kod można łatwo przekształcić na inne języki. Spark w Scali i Pythonie udostępnia też tryb REPL (ang. read-eval-print loop), umożliwiający szybkie i łatwe eksplorowanie zbiorów danych. 1
Zobacz artykuł Mateia Zaharii i współpracowników „Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster Computing”, NSDI ’12 Proceedings of the 9th USENIX Conference on Networked Systems Design and Implementation, 2012 (http://www.cs.berkeley.edu/~matei/papers/2012/nsdi_spark.pdf).
517
Spark okazuje się być dobrą platformą do budowania narzędzi analitycznych. W tym kontekście projekt Apache Spark udostępnia moduły do uczenia maszynowego (MLlib), przetwarzania grafów (GraphX), przetwarzania strumieni (Spark Streaming) i obsługi SQL-a (Spark SQL). Tych modułów nie opisano w niniejszym rozdziale. Jeśli jesteś nimi zainteresowany, zajrzyj do witryny projektu Apache Spark (http://spark.apache.org/).
Instalowanie Sparka Pobierz stabilną wersję binarnej dystrybucji Sparka ze strony https://spark.apache.org/downloads. html (wybierz edycję zgodną z używaną dystrybucją Hadoopa). Następnie wypakuj plik tarball w odpowiednim miejscu. % tar xzf spark-x.y.z-bin-dystrybucja.tgz
Wygodnie jest zapisać ścieżkę do plików binarnych Sparka w zmiennej PATH. % export SPARK_HOME=~/sw/spark-x.y.z-bin-dystrybucja % export PATH=$PATH:$SPARK_HOME/bin
Teraz jesteś gotowy do uruchomienia przykładu w Sparku.
Przykład W ramach wprowadzenia do Sparka można uruchomić interaktywną sesję za pomocą powłoki spark-shell. Jest to tryb REPL z kilkoma dodatkami przeznaczonymi dla Sparka. Aby uruchomić powłokę, wywołaj następujące instrukcje: % spark-shell Spark context available as sc. scala>
W danych z konsoli widać, że powłoka utworzyła zmienną sc Scali. W tej zmiennej zapisywany jest obiekt typu SparkContext. Jest to punkt kontaktu ze Sparkiem, umożliwiający wczytanie pliku tekstowego w pokazany poniżej sposób. scala> val lines = sc.textFile("input/ncdc/micro-tab/sample.txt") lines: org.apache.spark.rdd.RDD[String] = MappedRDD[1] at textFile at :12
Zmienna lines to referencja do zbioru danych RDD (ang. Resilient Distributed Dataset). Jest to najważniejszy abstrakcyjny obiekt Sparka — przeznaczona tylko do odczytu kolekcja obiektów dzielona między maszyny klastra. W typowym programie Sparka jeden lub kilka zbiorów RDD wczytuje się jako dane wejściowe i za pomocą serii transformacji przekształca w docelowe zbiory RDD, na których wykonywane są różne działania (można na przykład obliczyć wynik lub zapisać dane w trwałej pamięci). Pojęcie „resilient” (czyli „odporne”) w nazwie „Resilient Distributed Dataset” dotyczy tego, że Spark potrafi automatycznie odtworzyć utraconą partycję. W tym celu ponownie generuje ją na podstawie początkowych zbiorów RDD. Wczytywanie lub przeprowadzanie transformacji zbioru RDD nie powoduje przetwarzania danych, a jedynie utworzenie planu operacji. Operacje są wykonywane dopiero przy wykonywaniu działań (na przykład w instrukcji foreach()) na zbiorze RDD.
518
Rozdział 19. Spark
Przejdźmy teraz do następnych wierszy programu. Pierwsza transformacja ma rozbijać wiersze na pola. scala> val records = lines.map(_.split("\t")) records: org.apache.spark.rdd.RDD[Array[String]] = MappedRDD[2] at map at :14
W tym kodzie metoda map() jest wywoływana dla zbioru RDD w celu zastosowania funkcji do każdego elementu tego zbioru. Tu każdy wiersz (element typu String) jest rozbijany na kolekcję Array[String] języka Scala. Następnie stosowany jest filtr w celu usunięcia wszystkich nieprawidłowych rekordów. scala> val filtered = records.filter(rec => (rec(1) != "9999" && rec(2).matches("[01459]"))) filtered: org.apache.spark.rdd.RDD[Array[String]] = FilteredRDD[3] at filter at :16
Metoda filter() wywoływana dla zbioru RDD przyjmuje predykat, czyli funkcję zwracającą wartość logiczną. Tu predykat sprawdza, czy rekordy zawierają temperaturę (kod 9999 oznacza jej brak) i odczyt o odpowiedniej jakości. Aby znaleźć maksymalną temperaturę z każdego roku, należy pogrupować dane według pola z rokiem. Pozwala to zbadać wszystkie temperatury z każdego roku. Spark na potrzeby grupowania udostępnia metodę reduceByKey(), która wymaga zbioru RDD z parami klucz-wartość reprezentowanymi jako obiekty typu Tuple2 Scali. Najpierw trzeba więc przekształcić zbiór RDD na właściwą postać za pomocą innego wywołania map(). scala> val tuples = filtered.map(rec => (rec(0).toInt, rec(1).toInt)) tuples: org.apache.spark.rdd.RDD[(Int, Int)] = MappedRDD[4] at map at :18
Teraz można przeprowadzić agregację. Argument metody reduceByKey() to funkcja, która przyjmuje parę wartości i łączy je w jedną wartość. Tu używana jest funkcja Math.max Javy. scala> val maxTemps = tuples.reduceByKey((a, b) => Math.max(a, b)) maxTemps: org.apache.spark.rdd.RDD[(Int, Int)] = MapPartitionsRDD[7] at reduceByKey at :21
Zawartość zmiennej maxTemps można wyświetlić za pomocą metody foreach(), do której należy przekazać funkcję println(), pokazującą w konsoli każdy element. scala> maxTemps.foreach(println(_)) (1950,22) (1949,111)
Użyta tu metoda foreach() działa tak samo jak jej odpowiednik dla standardowych kolekcji Scali (na przykład dla typu List) i stosuje funkcję do każdego elementu zbioru RDD. To ta operacja powoduje, że Spark uruchamia zadanie w celu obliczenia wartości zbioru RDD, dzięki czemu można je przekazać do metody println(). Inna możliwość to zapisanie zbioru RDD w systemie plików. Odbywa się to w następujący sposób: scala> maxTemps.saveAsTextFile("output")
Powoduje to utworzenie katalogu output z plikami partycji. % cat output/part-* (1950,22) (1949,111)
Przykład
519
Metoda saveAsTextFile() także uruchamia zadanie Sparka. Główna różnica polega na tym, że ta metoda nie zwraca wartości. Zamiast tego generuje zbiór RDD i zapisuje jego partycje w plikach w katalogu output.
Aplikacje, zadania, etapy i operacje w Sparku W przykładzie pokazano, że Spark (podobnie jak model MapReduce) używa zadań (ang. job). Zadanie w Sparku jest jednak bardziej ogólne niż w modelu MapReduce, ponieważ może składać się z dowolnego acyklicznego grafu skierowanego etapów. Każdy z tych etapów to w przybliżeniu odpowiednik etapu mapowania lub redukcji w modelu MapReduce. Etapy są rozbijane na operacje (ang. tasks) przez środowisko uruchomieniowe Sparka i wykonywane równolegle na partycjach zbiorów RDD dostępnych w klastrze (podobnie przetwarzane są operacje w modelu MapReduce). Zadanie zawsze jest wykonywane w kontekście aplikacji (reprezentowanej jako obiekt typu SparkContext), która służy do grupowania zbiorów RDD i zmiennych współużytkowanych. Aplikacja
może wykonywać więcej niż jedno zadanie (sekwencyjnie lub równolegle) i umożliwia zadaniom dostęp do zbioru RDD zapisanego w pamięci podręcznej przez poprzednie zadanie z tej samej aplikacji. Z punktu „Utrwalanie danych” dowiesz się, jak zapisywać zbiory RDD w pamięci podręcznej. Interaktywna sesja Sparka, na przykład sesja w powłoce spark-shell, to egzemplarz aplikacji.
Niezależna aplikacja w języku Scala Po dopracowaniu programu w powłoce Sparka można spakować kod do postaci niezależnej aplikacji, którą można wykonywać wielokrotnie. Przykładowy program w Scali pokazano na listingu 19.1. Listing 19.1. Aplikacja w Scali wyszukująca za pomocą Sparka maksymalną temperaturę import org.apache.spark.SparkContext._ import org.apache.spark.{SparkConf, SparkContext} object MaxTemperature { def main(args: Array[String]) { val conf = new SparkConf().setAppName("Max Temperature") val sc = new SparkContext(conf) sc.textFile(args(0)) .map(_.split("\t")) .filter(rec => (rec(1) != "9999" && rec(2).matches("[01459]"))) .map(rec => (rec(0).toInt, rec(1).toInt)) .reduceByKey((a, b) => Math.max(a, b)) .saveAsTextFile(args(1)) } }
W celu uruchomienia niezależnego programu trzeba utworzyć obiekt typu SparkContext, ponieważ nie jest on zapewniany przez powłokę. Przedstawiony kod tworzy nowy obiekt tego typu na podstawie obiektu typu SparkConf. Pozwala to przekazać do aplikacji różne właściwości Sparka. Tu ustawiono tylko nazwę aplikacji.
520
Rozdział 19. Spark
W kodzie występuje też kilka innych drobnych zmian. Pierwsza polega na użyciu argumentów wiersza poleceń do określenia ścieżek wejściowej i wyjściowej. Zastosowano też łańcuch metod, dzięki czemu nie trzeba tworzyć zmiennych pośrednich dla każdego zbioru RDD. To sprawia, że program jest krótszy, a w razie potrzeby i tak można podejrzeć informacje o typie każdej transformacji w środowisku IDE Scali. Nie wszystkie transformacje ze Sparka są dostępne w samej klasie RDD. W pokazanym kodzie metoda reduceByKey() (działająca tylko dla zbiorów RDD zawierających pary klucz-wartość) jest zdefiniowana w klasie PairRDDFunctions, można jednak sprawić, by Scala niejawnie przekształciła obiekt typu RDD[(Int, Int)] na typ PairRDDFunctions. Umożliwia to następująca instrukcja importu: import org.apache.spark.SparkContext._
To powoduje zaimportowanie różnych funkcji niejawnej konwersji stosowanych w Sparku, dlatego warto dodawać tę instrukcję w programach.
Tym razem do uruchomienia programu posłuży narzędzie spark-submit. Jako argumenty przyjmuje ono plik JAR ze skompilowanym programem w Scali, a także argumenty wiersza poleceń (ścieżki wejściową i wyjściową). % spark-submit --class MaxTemperature --master local \ spark-examples.jar input/ncdc/micro-tab/sample.txt output % cat output/part-* (1950,22) (1949,111)
Podano tu także dwie opcje. Opcja --class informuje Sparka o nazwie klasy aplikacji, a opcja --master określa, gdzie należy uruchomić zadanie. Wartość local oznacza, że Spark ma uruchamiać cały kod w jednej maszynie JVM na komputerze lokalnym. Opcje związane z uruchamianiem kodu w klastrze są opisane w punkcie „Wykonawcy i menedżery klastra”. Teraz zobacz, jak używać Sparka w innych językach. Na początku opisana jest Java.
Przykład napisany w Javie Spark jest napisany w Scali. Scala to świetnie zintegrowany z Javą język oparty na maszynie JVM. Zapisanie pokazanego wcześniej przykładu w Javie jest proste, choć uzyskany kod jest stosunkowo długi (zobacz listing 19.2)2. Listing 19.2. Aplikacja w Javie wyszukująca za pomocą Sparka maksymalną temperaturę public class MaxTemperatureSpark { public static void main(String[] args) throws Exception { if (args.length != 2) { System.err.println("Usage: MaxTemperatureSpark "); System.exit(-1); } SparkConf conf = new SparkConf(); JavaSparkContext sc = new JavaSparkContext("local", "MaxTemperatureSpark", conf); JavaRDD lines = sc.textFile(args[0]); 2
Kod w Javie można znacznie skrócić za pomocą wyrażeń lambda z Javy 8.
Przykład
521
JavaRDD records = lines.map(new Function() { @Override public String[] call(String s) { return s.split("\t"); } }); JavaRDD filtered = records.filter(new Function() { @Override public Boolean call(String[] rec) { return rec[1] != "9999" && rec[2].matches("[01459]"); } }); JavaPairRDD tuples = filtered.mapToPair( new PairFunction() { @Override public Tuple2 call(String[] rec) { return new Tuple2( Integer.parseInt(rec[0]), Integer.parseInt(rec[1])); } } ); JavaPairRDD maxTemps = tuples.reduceByKey( new Function2() { @Override public Integer call(Integer i1, Integer i2) { return Math.max(i1, i2); } } ); maxTemps.saveAsTextFile(args[1]); } }
W interfejsie API Sparka w Javie zbiory RDD są reprezentowane jako obiekty typu JavaRDD (lub JavaPairRDD w sytuacji, gdy zbiór RDD zawiera pary klucz-wartość). Obie wymienione klasy implementują interfejs JavaRDDLike, w którym można znaleźć (na przykład przy przeglądaniu dokumentacji klas) większość metod służących do pracy ze zbiorami RDD. Przedstawiony program uruchamia się tak samo jak wersję napisaną w Scali, przy czym nazwa klasy to MaxTemperatureSpark.
Przykład napisany w Pythonie Spark zapewnia też obsługę Pythona. Służy do tego interfejs API PySpark. Dzięki wykorzystaniu wyrażeń lambda Pythona można zmodyfikować przykładowy program tak, by był bardzo podobny do swego odpowiednika ze Scali. Ilustruje to listing 19.3. Listing 19.3. Aplikacja w Pythonie wyszukująca za pomocą interfejsu PySpark maksymalną temperaturę from pyspark import SparkContext import re, sys sc = SparkContext("local", "Max Temperature") sc.textFile(sys.argv[1])\ .map(lambda s: s.split("\t"))\ .filter(lambda rec: (rec[1] != "9999" and re.match("[01459]", rec[2])))\ .map(lambda rec: (int(rec[0]), int(rec[1])))\ .reduceByKey(max)\ .saveAsTextFile(sys.argv[2])
Zauważ, że w transformacji reduceByKey() można zastosować wbudowaną funkcję max Pythona.
522
Rozdział 19. Spark
Warto zauważyć, że program jest napisany w zwykłym języku CPython. Spark tworzy podprocesy Pythona w celu uruchamiania napisanego w Pythonie kodu użytkowników (zarówno w programie uruchomieniowym, jak i w wykonawcy, który uruchamia operacje użytkowników w klastrze) i używa gniazda do łączenia obu procesów, dzięki czemu proces nadrzędny może przekazać dane z partycji zbioru RDD przeznaczone do przetwarzania w kodzie w Pythonie. W celu uruchomienia kodu należy wskazać plik Pythona zamiast pliku JAR aplikacji. % spark-submit --master local \ ch19-spark/src/main/python/MaxTemperature.py \ input/ncdc/micro-tab/sample.txt output
Jeśli chce się używać Sparka w Pythonie, można też uruchomić tryb interaktywny za pomocą polecenia pyspark.
Zbiory RDD Zbiory RDD są podstawą każdego programu w Sparku. Z tego podrozdziału dowiesz się szczegółowo, jak korzystać z tych zbiorów.
Tworzenie zbiorów RDD Istnieją trzy sposoby tworzenia zbiorów RDD: na podstawie przechowywanej w pamięci kolekcji obiektów (jest to paralelizacja kolekcji), za pomocą zbioru danych z zewnętrznego źródła (na przykład z systemu HDFS) lub w wyniku transformacji istniejącego zbioru RDD. Pierwsza technika jest przydatna przy równoległym wykonywaniu obliczeń wymagających dużo czasu procesora i dotyczących niewielkich ilości danych wejściowych. Na przykład poniższy kod uruchamia niezależne obliczenia dla liczb z przedziału od 1 do 103. val params = sc.parallelize(1 to 10) val result = params.map(performExpensiveComputation)
Funkcja performExpensiveComputation jest uruchamiana równolegle dla wartości wejściowych. Poziom równoległości jest określany na podstawie właściwości spark.default.parallelism. Jej wartość domyślna zależy od tego, gdzie wykonywane jest zadanie Sparka. Gdy działa ono lokalnie, poziom równoległości odpowiada liczbie rdzeni maszyny. W klastrze wartość domyślna to łączna liczba rdzeni wszystkich węzłów wykonawczych z klastra. Poziom równoległości dla określonych obliczeń można zmienić, podając drugi argument metody parallelize(). sc.parallelize(1 to 10, 10)
Drugi sposób tworzenia zbiorów RDD polega na dodaniu referencji do zewnętrznego zbioru danych. Zobaczyłeś już, jak utworzyć zbiór RDD z obiektami typu String na podstawie pliku tekstowego. val text: RDD[String] = sc.textFile(inputPath)
3
Przebiega to podobnie jak analiza wpływu zmian parametrów za pomocą klasy NLineInputFormat w modelu MapReduce, co omówiono w punkcie „Klasa NLineInputFormat” w rozdziale 8.
Zbiory RDD
523
Ścieżka może prowadzić do lokalizacji w dowolnym systemie plików w Hadoopie — na przykład do pliku w lokalnym systemie plików lub w systemie HDFS. Wewnętrznie Spark przy wczytywaniu danych używa formatu TextInputFormat (zobacz punkt „Klasa TextInputFormat” w rozdziale 8.) z dawnego interfejsu API modelu MapReduce. To oznacza, że podział plików przebiega tak samo jak w Hadoopie. Na przykład w systemie HDFS tworzona jest jedna partycja Sparka na jeden blok tego systemu. Aby zmienić to domyślne ustawienie, należy przekazać drugi argument i zażądać określonej liczby porcji danych. sc.textFile(inputPath, 10)
Inna technika umożliwia przetwarzanie plików tekstowych w całości (podobnie jak opisano to w punkcie „Przetwarzanie całego pliku jako rekordu” w rozdziale 8.). Wymaga to zwrócenia zbioru RDD z parami łańcuchów znaków, gdzie pierwszy łańcuch to ścieżka do pliku, a drugi — to zawartość pliku. Ponieważ wszystkie dane są wczytywane do pamięci, to rozwiązanie nadaje się tylko dla małych plików. val files: RDD[(String, String)] = sc.wholeTextFiles(inputPath)
Spark obsługuje także pliki inne niż tekstowe. Na przykład poniższy kod wczytuje plik typu SequenceFile. sc.sequenceFile[IntWritable, Text](inputPath)
Zauważ, w jaki sposób określone są typy z rodziny Writable dla kluczy i wartości z pliku typu SequenceFile. Standardowe typy Writable Spark potrafi przekształcić na ich odpowiedniki w Javie, dlatego można zastosować też analogiczny zapis. sc.sequenceFile[Int, String](inputPath)
Dostępne są dwie metody do tworzenia zbiorów RDD na podstawie dowolnych typów z rodziny InputFormat z Hadoopa. Metoda hadoopFile() jest przeznaczona dla formatów plików przyjmujących ścieżkę. Metoda hadoopRDD() działa dla pozostałych formatów (takich jak TableInputFormat z bazy HBase). Te metody są przeznaczone dla dawnego interfejsu API modelu MapReduce. Dla nowego interfejsu użyj metod newAPIHadoopFile() i newAPIHadoopRDD(). Oto przykładowy kod wczytujący plik danych z systemu Avro za pomocą specyficznego interfejsu API i klasy WeatherRecord. val job = new Job() AvroJob.setInputKeySchema(job, WeatherRecord.getClassSchema) val data = sc.newAPIHadoopFile(inputPath, classOf[AvroKeyInputFormat[WeatherRecord]], classOf[AvroKey[WeatherRecord]], classOf[NullWritable], job.getConfiguration)
Oprócz ścieżki metoda newAPIHadoopFile() przyjmuje typ z rodziny InputFormat, typ klucza, typ wartości i konfigurację Hadoopa. W konfiguracji należy podać schemat z systemu Avro, ustawiany w drugim wierszu za pomocą klasy pomocniczej AvroJob. Trzeci sposób tworzenia zbiorów RDD polega na przekształcaniu istniejących zbiorów. Transformacje są opisane w następnym punkcie.
Transformacje i akcje Spark udostępnia dwa typy działań na zbiorach RDD — transformacje i akcje. Transformacja generuje nowy zbiór RDD na podstawie istniejącego, natomiast akcja uruchamia obliczenia na zbiorze RDD i używa wyników (na przykład zwraca je użytkownikowi lub zapisuje w zewnętrznej lokalizacji). 524
Rozdział 19. Spark
Model MapReduce w Sparku Choć operacje map() i reduce() w Sparku mają znajome nazwy, nie są bezpośrednimi odpowiednikami tak samo nazwanych funkcji z modelu MapReduce z Hadoopa. Opisana w rozdziale 8. ogólna postać funkcji map i reduce w modelu MapReduce wygląda tak: map: (K1, V1) list(K2, V2) reduce: (K2, list(V2)) list(K3, V3)
Zauważ, że obie funkcje mogą zwracać wiele par wyjściowych, na co wskazuje zapis list. W Sparku (i Scali) podobnie działa operacja flatMap(), która przypomina operację map(), ale usuwa poziom zagnieżdżenia. scala> val l = List(1, 2, 3) l: List[Int] = List(1, 2, 3) scala> l.map(a => List(a)) res0: List[List[Int]] = List(List(1), List(2), List(3)) scala> l.flatMap(a => List(a)) res1: List[Int] = List(1, 2, 3)
Naiwna próba zasymulowania w Sparku modelu MapReduce z Hadoopa polega na użyciu dwóch operacji flatMap() rozdzielonych wywołaniami groupByKey() i sortByKey() odpowiedzialnymi za fazy przestawiania i sortowania danych w modelu MapReduce. val val val val
input: RDD[(K1, V1)] = ... mapOutput: RDD[(K2, V2)] = input.flatMap(mapFn) shuffled: RDD[(K2, Iterable[V2])] = mapOutput.groupByKey().sortByKey() output: RDD[(K3, V3)] = shuffled.flatMap(reduceFn)
Aby można było użyć metody sortByKey(), typ klucza, K2, musi dziedziczyć po typie Ordering Scali. Ten przykład jest przydatny, ponieważ pomaga zrozumieć zależności między modelem MapReduce a Sparkiem, jednak nie należy bezrefleksyjnie stosować tego kodu. Przede wszystkim działa on nieco inaczej niż model MapReduce w Hadoopie, ponieważ metoda sortByKey() przeprowadza sortowanie globalne. Tego problemu można uniknąć dzięki zastosowaniu metody repartitionAndSortWithin Partitions(), która przeprowadza sortowanie częściowe. Jednak nawet to rozwiązanie nie jest równie wydajne, ponieważ Spark przestawia dane dwukrotnie (raz na potrzeby metody groupByKey() i raz w ramach sortowania). Dlatego zamiast symulować pracę modelu MapReduce, lepiej jest wykorzystać tylko potrzebne operacje. Na przykład jeśli klucze nie muszą być sortowane, można pominąć wywołanie sortByKey(). W modelu MapReduce w Hadoopie analogiczne rozwiązanie nie jest możliwe. Podobnie metoda groupByKey() w większości sytuacji okazuje się być zbyt ogólna. Zwykle przestawianie danych jest potrzebne tylko w celu zagregowania wartości, dlatego należy wykorzystać opisane w następnym podpunkcie metody reduceByKey(), foldByKey() lub aggregateByKey(). Są one wydajniejsze od metody groupByKey(), ponieważ mogą działać także jako funkcja łącząca w operacji mapowania. Ponadto nie zawsze potrzebna jest metoda flatMap(). Jeśli zawsze zwracana jest jedna wartość, lepiej jest użyć metody map(), a gdy liczba zwracanych wartości to zero lub jeden, zalecana metoda to filter().
Efekty wykonania akcji są natychmiastowe, natomiast z transformacjami jest inaczej. Transformacje działają w trybie leniwym, czyli są przeprowadzane dopiero w momencie wykonywania akcji na przetworzonym zbiorze RDD. Poniższy przykładowy kod zmienia litery w wierszach pliku tekstowego na małe.
Zbiory RDD
525
val text = sc.textFile(inputPath) val lower: RDD[String] = text.map(_.toLowerCase()) lower.foreach(println(_))
Metoda map() wykonuje transformację reprezentowaną lokalnie w Sparku jako funkcja (toLowerCase()) wywoływana w późniejszym momencie dla każdego elementu z wejściowego zbioru RDD (text). Funkcja jest wywoływana dopiero po rozpoczęciu pracy przez metodę foreach() (która jest uznawana za akcję) i uruchomieniu przez Sparka zadania w celu wczytania pliku wejściowego i wywołania dla każdego jego wiersza funkcji toLowerCase(). Następnie wynik jest wyświetlany w konsoli. Aby ustalić, czy dana czynność to transformacja, czy akcja, można sprawdzić typ zwracanej wartości. Jeśli ten typ to RDD, używana jest transformacja. Inne typy oznaczają akcję. Warto to wiedzieć w trakcie lektury dokumentacji klasy RDD (jest to klasa z pakietu org.apache.spark.rdd). W tej klasie znajdziesz większość operacji, jakie można wykonywać na zbiorach RDD. Dodatkowe działania są dostępne w klasie PairRDDFunctions, zawierającej transformacje i akcje przeznaczone dla zbiorów RDD zawierających pary klucz-wartość. Biblioteka Sparka obejmuje bogaty zestaw operatorów. Umożliwiają one odwzorowywanie, grupowanie, agregowanie, ponowne partycjonowanie, próbkowanie i złączenia zbiorów RDD, a także traktowanie ich jak zwykłych zbiorów. Dostępne są też akcje do materializowania zbiorów RDD jako kolekcji, obliczania statystyk dla zbiorów RDD, pobierania próbek o określonej liczbie elementów ze zbiorów RDD i zapisywania takich zbiorów w zewnętrznej lokalizacji. Szczegółowe informacje znajdziesz w dokumentacji klasy RDD.
Transformacje związane z agregacją Trzy podstawowe transformacje służące do agregowania zbiorów RDD z parami elementów według kluczy to: reduceByKey(), foldByKey() i aggregateByKey(). Działają one w odmienny sposób, jednak wszystkie agregują wartości dla danego klucza i zwracają jedną wartość dla każdego klucza. Analogiczne akcje to reduce(), fold() i aggregate(). Działają one w podobny sposób i zwracają jedną wartość dla całego zbioru RDD. Najprostsza z wymienionych metod to reduceByKey(). Wielokrotnie stosuje ona funkcję binarną do wartości w parach, a ostatecznie generuje pojedynczą wartość. Oto przykład: val pairs: RDD[(String, Int)] = sc.parallelize(Array(("a", 3), ("a", 1), ("b", 7), ("a", 5))) val sums: RDD[(String, Int)] = pairs.reduceByKey(_+_) assert(sums.collect().toSet === Set(("a", 9), ("b", 7)))
Wartości dla klucza a są agregowane za pomocą funkcji dodawania (_+_) w następujący sposób: (3 + 1) + 5 = 9. Dla klucza b występuje tylko jedna wartość, dlatego agregacja jest zbędna. Ponieważ działania są zwykle rozproszone i wykonywane w różnych operacjach powiązanych z różnymi partycjami zbioru RDD, funkcja powinna być łączna i przemienna. Oznacza to, że kolejność i sposób pogrupowania działań nie powinny mieć znaczenia. Tu agregacja może przebiegać na dwa sposoby — 5 + (3 + 1) i (5 + 3) + 1. W obu sytuacjach zwracany wynik jest taki sam. Operator składający się z trzech znaków równości (===) używany w instrukcji assert pochodzi z narzędzia ScalaText i zapewnia bogatsze w informacje komunikaty o niepowodzeniu niż zwykły operator ==.
526
Rozdział 19. Spark
Poniżej pokazano, jak tę samą operację wykonać za pomocą metody foldByKey(). val sums: RDD[(String, Int)] = pairs.foldByKey(0)(_+_) assert(sums.collect().toSet === Set(("a", 9), ("b", 7)))
Zauważ, że tym razem trzeba było podać wartość zerową. Przy dodawaniu liczb całkowitych jest to liczba 0. Dla innych typów i działań może to być inna wartość. Tu wartości dla klucza a są agregowane w następujący sposób: ((0 + 3) + 1) + 5) = 9. Kolejność działań może być inna, przy czym na początku zawsze 0 jest dodawane do innej wartości. Dla klucza b obliczenia wyglądają tak: 0 + 7 = 7. Metoda foldByKey() daje podobne możliwości co metoda reduceByKey(). Żadna z nich nie pozwala na zmianę typu wartości zwracanej po agregacji. Umożliwia to natomiast metoda aggregate ByKey(). Można na przykład zagregować wartości całkowitoliczbowe w zbiór. val sets: RDD[(String, HashSet[Int])] = pairs.aggregateByKey(new HashSet[Int])(_+=_, _++=_) assert(sets.collect().toSet === Set(("a", Set(1, 3, 5)), ("b", Set(7))))
Przy dodawaniu zbiorów wartością zerową jest pusty zbiór. Dlatego kod za pomocą polecenia new HashSet[Int] tworzy nowy zmienny zbiór. Do metody aggregateByKey() należy przekazać dwie funkcje. Pierwsza określa sposób łączenia wartości typu Int z kolekcją typu HashSet[Int]. Tu do dodawania liczb całkowitych do zbioru używana jest funkcja _+=_ (funkcja _+_ spowoduje zwrócenie nowego zbioru, a pierwszy zbiór pozostanie niezmieniony). Druga podawana funkcja określa, jak łączone są dwie wartości typu HashSet[Int] (dzieje się to po wykonaniu funkcji łączącej w operacji mapowania, gdy dwie partycje są agregowane w operacji redukcji). Tu używana jest funkcja _++=_ w celu dodania wszystkich elementów z drugiego zbioru do pierwszego. Dla klucza a sekwencja działań może wyglądać tak: (( + 3) + 1) + 5) = (1, 3, 5) A oto inna możliwość: ( + 3) + 1) ++ ( + 5) = (1, 3) ++ (5) = (1, 3, 5) Ta wersja jest używana, gdy Spark korzysta z funkcji łączącej. Zbiór RDD po transformacji można utrwalić w pamięci, by zwiększyć wydajność dalszych operacji na danym zbiorze. Opisano to w następnym punkcie.
Utrwalanie danych Wróć do początkowego przykładu z punktu „Przykład”. Pośredni zbiór danych z parami roktemperatura można zapisać w pamięci podręcznej w następujący sposób: scala> tuples.cache() res1: tuples.type = MappedRDD[4] at map at :18
Wywołanie metody cache() nie powoduje natychmiastowego zapisania zbioru RDD w pamięci. Zamiast tego zbiór jest oznaczany opcją określającą, że należy zapisać go w pamięci podręcznej w momencie uruchomienia zadania Sparka. Wymuśmy więc uruchomienie zadania.
Zbiory RDD
527
scala> tuples.reduceByKey((a, b) => Math.max(a, b)).foreach(println(_)) INFO BlockManagerInfo: Added rdd_4_0 in memory on 192.168.1.90:64640 INFO BlockManagerInfo: Added rdd_4_1 in memory on 192.168.1.90:64640 (1950,22) (1949,111)
Wiersze z dziennika dotyczące klasy BlockManagerInfo informują, że partycje zbioru RDD były przechowywane w pamięci w trakcie wykonywania zadania. Zgodnie z dziennikiem zbiór RDD ma numer 4 (pojawia się on w konsoli po wywołaniu metody cache()) i obejmuje dwie partycje — 0 i 1. Jeśli dla zapisanego w pamięci podręcznej zbioru danych uruchomisz następne zadanie, zobaczysz, że zbiór zostanie wczytany w pamięci. Poniższy kod znajduje temperatury minimalne. scala> tuples.reduceByKey((a, b) => Math.min(a, b)).foreach(println(_)) INFO BlockManager: Found block rdd_4_0 locally INFO BlockManager: Found block rdd_4_1 locally (1949,78) (1950,-11)
Jest to prosty przykład dotyczący małego zbioru danych, jednak dla bardziej rozbudowanych zadań wzrost wydajności może być bardzo duży. Porównaj to z modelem MapReduce, gdzie w celu przeprowadzenia następnych obliczeń wejściowy zbiór danych trzeba ponownie wczytać z dysku. Nawet jeśli jako dane wejściowe można wykorzystać pośredni zbiór danych (na przykład uporządkowany zbiór danych z usuniętymi błędnymi wierszami i zbędnymi polami), nie da się uniknąć wczytywania danych z dysku, co zajmuje dużo czasu. Spark zapisuje zbiory danych w przechowywanym w pamięci buforze dostępnym w całym klastrze. To oznacza, że wszelkie obliczenia na takich zbiorach danych są bardzo wydajne. Ta technika okazuje się bardzo przydatna przy interaktywnym eksplorowaniu danych. Dobrze współdziała także z algorytmami określonego rodzaju — na przykład z algorytmami iteracyjnymi, w których wynik obliczony w jednej iteracji można zapisać w buforze w pamięci i wykorzystać jako dane wejściowe w następnej iteracji. Takie algorytmy można też budować w modelu MapReduce, jednak wtedy każda iteracja działa jako odrębne zadanie, dlatego wynik z jednej iteracji trzeba zapisać na dysku i potem wczytać w następnej iteracji. Zbiory RDD z buforów (z pamięci podręcznej) mogą wczytywać tylko zadania działające w tej samej aplikacji. Aby umożliwić współużytkowanie zbiorów danych przez różne aplikacje, w pierwszej aplikacji trzeba zapisać dane w zewnętrznej lokalizacji za pomocą jednej z metod z rodziny saveAs*() (na przykład saveAsTextFile(), saveAs HadoopFile() itd.). Następnie w drugiej aplikacji trzeba wczytać dane za pomocą odpowiedniej metody klasy SparkContext (textFile(), hadoopFile() itd.). Gdy aplikacja kończy pracę, wszystkie zbiory RDD zapisane w buforze zostają usunięte i nie można uzyskać do nich dostępu (chyba że zostały jawnie zapisane).
Poziomy utrwalania Wywołanie metody cache() powoduje utrwalenie każdej partycji zbioru RDD w pamięci wykonawcy. Jeśli wykonawca nie ma wystarczającej ilości pamięci na zapisanie partycji zbioru RDD, obliczenia wprawdzie nie zakończą się niepowodzeniem, ale partycję trzeba będzie za każdym razem ponownie wygenerować. W złożonych programach z dużą liczbą transformacji generowanie partycji bywa kosztowne. Dlatego Spark udostępnia różne techniki utrwalania. Aby je ustawić,
528
Rozdział 19. Spark
należy wywołać metodę persist() z argumentem określającym poziom utrwalania (stałą z klasy StorageLevel). Domyślny poziom MEMORY_ONLY powoduje użycie zwykłej reprezentacji obiektów w pamięci. Aby reprezentacja była bardziej zwięzła, można przeprowadzić serializację elementów partycji i zapisać je jako tablicę bajtów (poziom MEMORY_ONLY_SER). W porównaniu z poziomem MEMORY_ONLY ten poziom wymaga więcej pracy procesora, ale warto go zastosować, jeśli partycja zbioru RDD w standardowej postaci nie mieści się w pamięci, natomiast po serializacji jej zapisanie jest możliwe. Poziom MEMORY_ONLY_SER upraszcza też pracę mechanizmu przywracania pamięci, ponieważ każda partycja zbioru RDD jest zapisywana jako tablica bajtów, a nie przy użyciu dużej liczby obiektów. By sprawdzić, czy partycja zbioru RDD mieści się w pamięci, poszukaj w pliku dziennika sterownika komunikatów dotyczących klasy BlockManager. Ponadto obiekt typu SparkContext każdego sterownika uruchamia serwer HTTP (w porcie 4040), który udostępnia przydatne informacje na temat środowiska i wykonywanych zadań — w tym informacje o zapisanych w buforze partycjach zbiorów RDD.
Do serializowania partycji zbiorów RDD domyślnie używana jest zwykła serializacja z Javy. Jednak przeważnie lepszym wyborem jest serializacja Kryo (opisana w następnym punkcie), która zarówno jest szybsza, jak i wymaga mniej miejsca. By jeszcze bardziej zmniejszyć ilość zajmowanego miejsca (kosztem pracy procesora), możesz skompresować serializowane partycje. W tym celu ustaw właściwość spark.rdd.compress na wartość true i opcjonalnie skonfiguruj właściwość spark.io.compression.codec. Jeśli ponowne generowanie zbioru danych jest kosztowne, warto zastosować poziom MEMORY_AND_DISK (jeśli zbiór danych nie mieści się w pamięci, zostaje zapisany na dysku) lub MEMORY_AND_DISK_SER (jeśli zserializowany zbiór danych nie mieści się w pamięci, zostaje zapisany na dysku). Dostępne są też zaawansowane i eksperymentalne poziomy utrwalania używane przy replikacji partycji w więcej niż jednym węźle klastra lub z wykorzystaniem pamięci spoza sterty. Szczegółowy opis tych poziomów znajdziesz w dokumentacji Sparka.
Serializacja W Sparku należy uwzględnić dwa aspekty serializacji — serializację danych i serializację funkcji (lub domknięć).
Dane Najpierw opisana jest serializacja danych. Spark domyślnie wykorzystuje serializację z Javy do przesyłania danych przez sieć z jednego wykonawcy do innego lub przy buforowaniu (utrwalaniu) danych w zserializowanej postaci, co opisano w punkcie „Poziomy utrwalania”. Serializacja z Javy jest dobrze znana programistom (wystarczy utworzyć klasę z implementacją interfejsu java.io.Serializable lub java.io.Externalizable), ma jednak stosunkowo niską wydajność i wymaga sporo miejsca. W większości programów Sparka lepszym wyborem jest serializacja Kryo (https://github.com/ EsotericSoftware/kryo). Kryo to wydajniejsza ogólna biblioteka przeznaczona do serializowania Zbiory RDD
529
danych w Javie. Aby zastosować serializację Kryo, ustaw w obiekcie SparkConf sterownika właściwość spark.serializer w następujący sposób: conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
Kryo nie wymaga implementowania w klasie określonego interfejsu (takiego jak java.io.Serializer), aby można ją było serializować. Dzięki temu w zbiorach RDD można bez dodatkowej pracy stosować obiekty POJO — wystarczy włączyć serializację Kryo. Warto jednak zarejestrować klasy w bibliotece Kryo przed ich zastosowaniem, ponieważ poprawia to wydajność procesu serializacji. Wynika to z tego, że Kryo zapisuje referencje do klas serializowanych obiektów (dla każdego obiektu tworzona jest jedna referencja). Jeśli klasa jest zarejestrowana, referencja to całkowitoliczbowy identyfikator, natomiast dla niezarejestrowanych klas używane są pełne nazwy. Ta wskazówka dotyczy tylko własnych klas. Spark automatycznie rejestruje klasy Scali i klasy z wielu innych platform (na przykład klasy generyczne z systemu Avro i klasy z Thrifta). Rejestrowanie klas w Kryo jest proste. Należy utworzyć klasę pochodną od KryoRegistrator i przesłonić metodę registerClasses(). class CustomKryoRegistrator extends KryoRegistrator { override def registerClasses(kryo: Kryo) { kryo.register(classOf[WeatherRecord]) } }
Ponadto w sterowniku należy ustawić właściwość spark.kryo.registrator na pełną nazwę klasy pochodnej od KryoRegistrator. conf.set("spark.kryo.registrator", "CustomKryoRegistrator")
Funkcje Serializowanie funkcji „po prostu działa”. W Scali funkcje są serializowane za pomocą standardowego mechanizmu z Javy, używanego w Sparku do przesyłania funkcji do zdalnych węzłów wykonawczych. Spark serializuje funkcje także w trybie lokalnym. Dlatego jeśli przypadkowo zastosujesz funkcję, która nie umożliwia serializacji (na przykład funkcję przekształconą z metody klasy bez możliwości serializacji), szybko to wykryjesz na etapie programowania.
Zmienne współużytkowane W programach w Sparku często potrzebny jest dostęp do danych, które nie są częścią zbioru RDD. Na przykład poniższy program używa w operacji map() tablicy wyszukiwania. val lookup = Map(1 -> "a", 2 -> "e", 3 -> "i", 4 -> "o", 5 -> "u") val result = sc.parallelize(Array(2, 1, 3)).map(lookup(_)) assert(result.collect().toSet === Set("a", "e", "i"))
Choć ten kod działa prawidłowo (zmienna lookup jest serializowana jako część domknięcia przekazanego do wywołania map()), istnieje wydajniejszy sposób na uzyskanie tego samego efektu. Umożliwiają to zmienne rozsyłane (ang. broadcast variable).
530
Rozdział 19. Spark
Zmienne rozsyłane Zmienne rozsyłane są serializowane i wysyłane do każdego wykonawcy, gdzie zostają zapisane w pamięci podręcznej, dzięki czemu późniejsze operacje będą miały do nich dostęp. Ten mechanizm działa inaczej niż dla zwykłych zmiennych, które są serializowane jako część domknięcia przesyłanego przez sieć dla każdej operacji. Zmienne rozsyłane odgrywają podobną rolę co rozproszona pamięć podręczna w modelu MapReduce (zobacz punkt „Rozproszona pamięć podręczna” w rozdziale 9.), przy czym Spark przechowuje dane w pamięci i zapisuje je na dysku tylko po wyczerpaniu się pamięci. W celu utworzenia zmiennej rozsyłanej należy przekazać ją do metody broadcast() typu Spark Context. Ta metoda zwraca nakładkę typu Broadcast[T] na zmienną typu T. val lookup: Broadcast[Map[Int, String]] = sc.broadcast(Map(1 -> "a", 2 -> "e", 3 -> "i", 4 -> "o", 5 -> "u")) val result = sc.parallelize(Array(2, 1, 3)).map(lookup.value(_)) assert(result.collect().toSet === Set("a", "e", "i"))
Zauważ, że gdy chcesz użyć zmiennej rozsyłanej w operacji map() na zbiorze RDD, musisz wywołać metodę value na tej zmiennej. Zmienne rozsyłane (jak wskazuje na to nazwa) są wysyłane w jedną stronę — ze sterownika do operacji. Nie można zaktualizować zmiennej rozsyłanej i przekazać jej nowej wartości z powrotem do sterownika. Aby uzyskać taki efekt, należy zastosować akumulator.
Akumulatory Akumulator to zmienna współużytkowana, do której operacje mogą tylko dodawać wartości. Podobnie działają liczniki w modelu MapReduce (zobacz punkt „Liczniki” w rozdziale 9.). Po wykonaniu zadania ostateczną wartość akumulatora można pobrać ze sterownika. Oto przykład zliczający za pomocą akumulatora elementy w zbiorze RDD liczb całkowitych i jednocześnie sumujący wartości tych liczb za pomocą akcji reduce(): val count: Accumulator[Int] = sc.accumulator(0) val result = sc.parallelize(Array(1, 2, 3)) .map(i => { count += 1; i }) .reduce((x, y) => x + y) assert(count.value === 3) assert(result === 6)
Zmienna akumulatora, count, jest tworzona w pierwszym wierszu za pomocą metody accumulator() typu SparkContext. Operacja map() to funkcja tożsamościowa, której efektem ubocznym jest zwiększanie wartości zmiennej count. Po obliczeniu przez zadanie Sparka wyniku dostęp do akumulatora można uzyskać, wywołując na nim metodę value. W tym przykładzie wykorzystano dla akumulatora typ Int, jednak można użyć dowolnego typu liczbowego. Spark umożliwia też stosowanie akumulatorów, których wynikowy typ jest inny niż dodawanych wartości (zobacz metodę accumulable() typu SparkContext). Ponadto możliwe jest akumulowanie wartości w zmiennych kolekcjach (za pomocą metody accumulableCollection()).
Zmienne współużytkowane
531
Anatomia przebiegu zadania w Sparku Zobacz teraz, co się dzieje po uruchomieniu zadania w Sparku. Na najbardziej ogólnym poziomie używane są dwie niezależne jednostki — sterownik obejmujący aplikację (obiekt typu SparkContext) i szeregujący operacje z zadania oraz wykonawcy, którzy są przeznaczeni dla konkretnej aplikacji, działają przez czas jej funkcjonowania i wykonują jej operacje. Sterownik zwykle działa jako niezarządzany przez menedżera klastra klient, a wykonawcy są uruchamiani w maszynach klastra, jednak nie zawsze tak jest (przekonasz się o tym podczas lektury punktu „Wykonawcy i menedżery klastra”). Dalej w podrozdziale przyjęto, że wykonawcy aplikacji już pracują.
Przesyłanie zadań Rysunek 19.1 pokazuje, w jaki sposób Spark uruchamia zadania. Zadanie jest przesyłane automatycznie w momencie wykonywania akcji (na przykład metody count()) na zbiorze RDD. Wewnętrznie powoduje to wywołanie metody runJob() typu SparkContext (krok 1. na rysunku 19.1), która przekazuje wywołanie do programu szeregującego działającego w ramach sterownika (krok 2.). Program szeregujący składa się z dwóch komponentów — programu szeregującego DAG, dzielącego zadanie na skierowany graf acykliczny etapów, i programu szeregującego operacje, odpowiedzialnego za przesyłanie operacji z każdego etapu do klastra.
Rysunek 19.1. Przebieg zadania w Sparku
Zobacz teraz, jak program szeregujący DAG tworzy skierowany graf acykliczny.
532
Rozdział 19. Spark
Tworzenie skierowanego grafu acyklicznego Aby zrozumieć, w jaki sposób zadanie jest rozbijane na etapy, należy przyjrzeć się typom operacji, jakie można uruchamiać w poszczególnych fazach. Istnieją dwa takie typy — operacje mapowania i przestawiania oraz operacje dające wynik. Nazwy typów operacji określają, co Spark robi z danymi wyjściowymi. Operacje mapowania i przestawiania Jak wskazuje na to nazwa, operacje mapowania i przestawiania przypominają fazę przestawiania na etapie mapowania w modelu MapReduce. Każda operacja mapowania i przestawiania przeprowadza obliczenia na jednej partycji zbioru RDD i z wykorzystaniem funkcji partycjonującej zapisuje dane wyjściowe do nowego zbioru partycji, pobieranego na późniejszym etapie (może on obejmować zarówno operacje mapowania i przestawiania, jak i operacje dające wynik). Operacje tego typu są uruchamiane na wszystkich etapach oprócz końcowego. Operacje dające wynik Operacje dające wynik są uruchamiane na końcowym etapie, który zwraca do programu użytkownika wynik (na przykład z funkcji count()). Każda operacja tego typu przeprowadza obliczenia na partycji zbioru RDD, a następnie przekazuje wynik z powrotem do sterownika. Sterownik łączy wyniki z poszczególnych partycji w ostateczny wynik (dla akcji takich jak saveAsTextFile() wynik może być typu Unit). Najprostsze zadanie Sparka nie wymaga fazy przestawiania, dlatego obejmuje tylko jeden etap z operacją dającą wynik. Jest to odpowiednik zadania z samym etapem mapowania w modelu MapReduce. Bardziej skomplikowane zadania obejmują grupowanie i wymagają jednej lub kilku faz przestawiania. Przyjrzyj się poniższemu zadaniu, które generuje histogram z liczbami wystąpień słów w pliku tekstowym określonym za pomocą argumentu inputPath (po jednym słowie na wiersz). val hist: Map[Int, Long] = sc.textFile(inputPath) .map(word => (word.toLowerCase(), 1)) .reduceByKey((a, b) => a + b) .map(_.swap) .countByKey()
Dwie pierwsze transformacje, map() i reduceByKey(), zliczają słowa. Trzecia transformacja to wywołanie map(), które przestawia klucz z wartością w każdej parze. Otrzymywane są więc pary (liczba, słowo). W ostatnim kroku wykonywana jest akcja countByKey(), która zwraca liczbę słów o poszczególnych liczbach wystąpień (czyli określa rozkład częstości dla liczb wystąpień słów). Program szeregujący DAG w Sparku rozbija to zadanie na dwa etapy, ponieważ metoda reduceByKey() wymaga fazy przestawiania4. Wynikowy skierowany graf acykliczny jest przedstawiony na rysunku 19.2.
4
Zauważ, że metoda countByKey() wykonuje końcową agregację lokalnie w sterowniku, a nie za pomocą drugiego etapu przestawiania. Jest to różnica w porównaniu z analogicznym programem Cruncha pokazanym na listingu 18.3, gdzie za zliczanie odpowiadało drugie zadanie w modelu MapReduce.
Anatomia przebiegu zadania w Sparku
533
Rysunek 19.2. Etapy i zbiory RDD w zadaniu Sparka generującym histogram z liczbami wystąpień słów
Także zbiory RDD z każdego etapu są zwykle przedstawiane za pomocą skierowanego grafu acyklicznego. Rysunek pokazuje typ zbioru RDD wraz z operacją, którą go utworzyła. Na przykład zbiór RDD[String] został zwrócony przez metodę textFile(). W celu uproszczenia rysunku pominięto niektóre pośrednie zbiory RDD wygenerowane wewnętrznie przez Sparka. Na przykład zbiór RDD zwrócony przez metodę textFile() jest typu MappedRDD[String], a jego poprzednik to zbiór typu HadoopRDD[LongWritable, Text]. Zauważ, że transformacja reduceByKey() obejmuje dwa etapy. Dzieje się tak, ponieważ jest zaimplementowana z wykorzystaniem przestawiania, a funkcja redukująca działa jako funkcja łącząca w fazie mapowania (etap 1.) i jako reduktor w fazie redukcji (etap 2.) — podobnie jak w modelu MapReduce. W Sparku etap przestawiania (też podobnie jak w modelu MapReduce) zapisuje dane wyjściowe do plików partycji na dysku lokalnym (dotyczy to nawet zbiorów RDD przechowywanych w pamięci). Pliki te są pobierane do zbiorów RDD w następnym etapie5. Jeśli zbiór RDD został utrwalony we wcześniejszym zadaniu w tej samej aplikacji (w tym samym obiekcie typu SparkContext), program szeregujący DAG zapisuje dane i nie dodaje etapów, które ponownie generowałyby dostępne zbiory (i zbiory wcześniejsze). 5
Wydajność fazy przestawiania można zmienić za pomocą konfiguracji (http://spark.apache.org/docs/latest/ configuration.html#shuffle-behavior). Warto też wiedzieć, że Spark wykorzystuje własny kod do obsługi przestawiania — nie używa w tym zakresie kodu z modelu MapReduce.
534
Rozdział 19. Spark
Program szeregujący DAG odpowiada za rozbijanie etapów na operacje, które można przekazać do programu szeregującego operacje. Poziom równoległości dla operacji reduceByKey() można ustawić jawnie, przekazując go w drugim parametrze. Jeśli poziom nie jest podany, zostaje ustalony na podstawie wcześniejszego zbioru RDD, który tu jest dzielony na tyle partycji, ile znajduje się ich w danych wejściowych. Program szeregujący DAG ustawia dla każdej operacji preferowaną lokalizację. Dzięki temu program szeregujący operacje może wykorzystać lokalność danych. Na przykład dla operacji przetwarzającej partycję wejściowego zbioru RDD zapisaną w systemie HDFS preferowana lokalizacja to węzeł danych z blokiem z tą partycją (lokalność względem węzła). Dla operacji przetwarzającej partycję zbioru RDD zapisaną w buforze w pamięci preferowany jest wykonawca, który przechowuje daną partycję (lokalność względem procesu). Wróć do rysunku 19.1. Gdy program szeregujący graf DAG utworzy kompletny skierowany graf acykliczny z etapami, przesyła zestaw operacji z każdego etapu do programu szeregującego operacje (krok 3.). Kolejne etapy są przesyłane dopiero po udanym zakończeniu wcześniejszych.
Szeregowanie operacji Gdy program szeregujący operacje otrzymuje zestaw operacji, na podstawie listy działających dla aplikacji wykonawców wiąże operacje z wykonawcami, uwzględniając przy tym preferowane lokalizacje. Następnie program szeregujący przypisuje operacje do wykonawców mających wolne rdzenie. Jeśli uruchomione jest inne zadanie z tej samej aplikacji, czasem nie udaje się przypisać całego zbioru operacji. Program ustawia wtedy następne operacje w momencie, gdy wykonawcy kończą przetwarzać wcześniejsze. Proces ten trwa do momentu przypisania całego zestawu. Domyślnie każdej operacji przydzielany jest jeden rdzeń. Można to zmienić za pomocą właściwości spark.task.cpus. Zauważ, że dla danego wykonawcy program szeregujący najpierw przypisuje operacje lokalne względem procesu, następnie lokalne względem węzła i lokalne względem szafki. Dopiero potem przypisuje dowolne (nielokalne) operacje, a gdy nie ma żadnych innych możliwości operacje wykonywane spekulacyjnie6. Przypisane operacje są uruchamiane przez backend programu szeregującego (krok 4. na rysunku 19.1), który przesyła zdalny komunikat uruchomienia operacji (krok 5.) do backendu wykonawcy. Jest to dla wykonawcy informacja, że ma uruchomić operację (krok 6.). Zamiast używać dla zdalnych komunikatów wywołań RPC Hadoopa, Spark wykorzystuje narzędzie Akka (http://akka.io/). Jest to oparta na aktorach platforma do budowania wysoce skalowalnych rozproszonych aplikacji opartych na zdarzeniach.
6
Operacje uruchamiane spekulacyjnie to duplikaty istniejących operacji. Program szeregujący może je uruchamiać jako rezerwowe, jeśli pierwotna operacja działa nieoczekiwanie długo. Zobacz punkt „Wykonywanie spekulacyjne” w rozdziale 7.
Anatomia przebiegu zadania w Sparku
535
Gdy operacja zostanie zakończona (sukcesem lub porażką), wykonawcy przesyłają do sterownika komunikaty z aktualizacją statusu. W przypadku niepowodzenia program szeregujący operacje ponownie przesyła daną operację do innego wykonawcy. Ponadto uruchamia spekulacyjnie dodatkowe egzemplarze operacji, które działają powoli (jeśli ten mechanizm jest włączony — domyślnie nie jest).
Wykonywanie operacji Wykonawca uruchamia operację w następujący sposób (krok 7.): najpierw sprawdza, czy plik JAR i inne niezbędne pliki operacji są aktualne. Wykonawca przechowuje lokalnie pamięć podręczną z wszystkimi potrzebnymi plikami używanymi przez wcześniejsze operacje, dlatego musi pobierać pliki tylko wtedy, jeśli zostały zmodyfikowane. Następnie wykonawca deserializuje kod operacji (obejmujący funkcje użytkownika) na podstawie zserializowanych bajtów przesłanych w komunikacie uruchamiania operacji. Potem kod operacji jest wykonywany. Zauważ, że operacje są uruchamiane w tej samej maszynie JVM, w której pracuje wykonawca. Dlatego uruchamianie operacji nie wiąże się z kosztami tworzenia procesu7. Operacje mogą zwracać wynik do sterownika. Wynik jest serializowany i przesyłany do backendu wykonawcy, a następnie do sterownika w komunikacie z aktualizacją stanu. Operacje przestawiania i mapowania zwracają informacje, które pozwalają pobrać wyjściowe partycje na następnym etapie pracy. Operacje dające wynik zwracają wynikową wartość dla przetwarzanej partycji. Sterownik łączy te wartości w ostateczny wynik zwracany do programu użytkownika.
Wykonawcy i menedżery klastra Zobaczyłeś już, jak Spark wykorzystuje wykonawców do uruchamiania operacji z zadań. Nie wyjaśniono jednak, jak uruchamiać samych wykonawców. Za zarządzanie ich cyklem życia odpowiada menedżer klastra. Spark udostępnia różne menedżery klastra o rozmaitych cechach. Lokalny W trybie lokalnym używany jest jeden wykonawca działający w maszynie JVM sterownika. Ten tryb jest przydatny w czasie testów i do uruchamiania prostych zadań. Nadrzędny adres URL w tym trybie to local (dla jednego wątku), local[n] (dla n wątków) lub local(*) (jeden wątek na każdy rdzeń maszyny). Niezależny Niezależny menedżer klastra to prosta implementacja rozproszona, w której działają jeden komponent nadrzędny Sparka i dowolna liczba komponentów roboczych. W momencie uruchamiania aplikacji Sparka komponent nadrzędny żąda od komponentów roboczych utworzenia dla aplikacji procesów wykonawców. W tym trybie nadrzędny adres URL to spark:// host:port.
7
Nie jest to prawdą w trybie precyzyjnym menedżera Mesos, kiedy to każda operacja działa w odrębnym procesie. Szczegółowe informacje znajdziesz w następnym podrozdziale.
536
Rozdział 19. Spark
Mesos Apache Mesos to menedżer zasobów klastra przeznaczony do użytku ogólnego. Umożliwia precyzyjne rozdzielanie zasobów między różne aplikacje zgodnie z polityką organizacji. Domyślnie (w trybie precyzyjnym) każda operacja Sparka jest uruchamiana jako operacja Mesos. Pozwala to wydajniej wykorzystać zasoby klastra, choć zwiększa koszty uruchamiania procesów. W trybie ogólnym wykonawcy uruchamiają operacje w procesie, dlatego zasoby klastra są w trakcie pracy aplikacji Sparka zajmowane przez proces wykonawcy. Nadrzędny adres URL to mesos://host:port. YARN YARN to menedżer zasobów używany w Hadoopie (zobacz rozdział 4.). W tym trybie każda aplikacja Sparka odpowiada egzemplarzowi aplikacji systemu YARN, a każdy wykonawca działa we własnym kontenerze tego systemu. Nadrzędny adres URL to yarn-client lub yarn-cluster. Menedżery klastra Mesos i YARN są lepsze od menedżera niezależnego, ponieważ biorą pod uwagę zapotrzebowanie na zasoby ze strony innych pracujących w klastrze jednostek (na przykład zadań w modelu MapReduce) i stosują politykę szeregowania uwzględniającą wszystkie aplikacje. Niezależny menedżer klastra stosuje statyczną alokację zasobów, dlatego nie potrafi dostosować się do zmieniających się w czasie wymagań innych aplikacji. Ponadto YARN to jedyny menedżer zintegrowany z dostępnymi w Hadoopie mechanizmami zabezpieczeń opartymi na protokole Kerberos (zobacz punkt „Inne usprawnienia w zabezpieczeniach” w rozdziale 10.).
Spark i YARN Uruchamianie Sparka w systemie YARN zapewnia najlepszą integrację z innymi komponentami Hadoopa. Jest to najwygodniejszy sposób używania Sparka, gdy gotowy jest już klaster oparty na Hadoopie. Spark udostępnia dwa tryby pracy w systemie YARN. Są to: tryb klienta systemu YARN, gdzie sterownik działa po stronie klienta, i tryb klastra z systemem YARN, gdy sterownik pracuje w klastrze w zarządcy aplikacji systemu YARN. Tryb klienta systemu YARN jest potrzebny w programach z komponentem interaktywnym (na przykład powłoką spark-shell lub interfejsem pyspark). Ten tryb jest przydatny także w trakcie budowania programów Sparka, ponieważ zapewnia natychmiastowy dostęp do diagnostycznych danych wyjściowych. Tryb klastra z systemem YARN jest odpowiedni dla zadań produkcyjnych, ponieważ cała aplikacja działa w klastrze, co ułatwia zachowywanie plików dziennika (także dla sterownika) w celu ich późniejszej inspekcji. System YARN próbuje też ponownie uruchomić aplikację, jeśli jej zarządca przestanie działać (zobacz punkt „Niepowodzenie zarządcy aplikacji” w rozdziale 7.).
Tryb klienta systemu YARN W tym trybie interakcja z systemem YARN rozpoczyna się w momencie utworzenia obiektu typu SparkContext przez sterownik (krok 1. na rysunku 19.3). Obiekt kontekstu przesyła aplikację systemu YARN do menedżera zasobów z tego systemu (krok 2.). Menedżer zasobów tworzy kontener systemu YARN w menedżerze węzła w klastrze i uruchamia w nim zarządcę aplikacji (obiekt
Wykonawcy i menedżery klastra
537
Rysunek 19.3. Uruchamianie wykonawców Sparka w trybie klienta systemu YARN
typu ExecutorLauncher Sparka; krok 3.). Obiekt typu ExecutorLauncher odpowiada za uruchomienie wykonawców w kontenerach systemu YARN. Obiekt w tym celu żąda zasobów od menedżera (krok 4.), a następnie po przydzieleniu potrzebnych kontenerów uruchamia procesy ExecutorBackend (krok 5.). Gdy wykonawca zaczyna pracę, nawiązuje połączenie z obiektem typu SparkContext i rejestruje się. Dzięki temu obiekt typu SparkContext zna liczbę wykonawców dostępnych dla działających operacji i ich lokalizacje. Na tej podstawie podejmowane są decyzje o lokalizacji operacji (co opisano w punkcie „Szeregowanie operacji”). Liczbę uruchamianych wykonawców można ustawić za pomocą narzędzia spark-shell, spark-submit lub poniższy-spark. Jeśli ta liczba nie jest ustawiona, domyślnie wynosi dwa. Podać można też liczbę rdzeni używanych dla każdego wykonawcy (domyślnie jeden) i ilość przydzielanej pamięci (domyślnie 1024 MB). Oto przykład pokazujący, jak uruchomić powłokę spark-shell w systemie YARN z czterema wykonawcami, z których każdy używa jednego rdzenia i 2 GB pamięci. % spark-shell --master yarn-client \ --num-executors 4 \ --executor-cores 1 \ --executor-memory 2g
Adres menedżera zasobów systemu YARN nie jest określany w nadrzędnym adresie URL (inaczej niż w menedżerze niezależnym lub menedżerze Mesos), ale zostaje pobrany z konfiguracji Hadoopa z katalogu podanego w zmiennej środowiskowej HADOOP_CONF_DIR.
538
Rozdział 19. Spark
Tryb klastra z systemem YARN W tym trybie sterownik użytkownika działa w procesie zarządcy aplikacji systemu YARN. Polecenie spark-submit jest wtedy używane z nadrzędnym adresem URL yarn-cluster. % spark-submit --master yarn-cluster ...
Wszystkie pozostałe parametry, na przykład --num-executors i plik JAR aplikacji (lub plik Pythona), są takie same jak w trybie klienta systemu YARN. Aby dowiedzieć się, jak używać polecenia spark-submit, wpisz instrukcję spark-submit --help. Kliencki skrypt spark-submit uruchamia aplikację systemu YARN (krok 1. na rysunku 19.4), ale nie wykonuje żadnego kodu użytkownika. Reszta procesu wygląda prawie tak samo jak w trybie klienta. Różnica polega na tym, że zarządca aplikacji uruchamia sterownik (krok 3b) przed przydziałem zasobów dla wykonawców (krok 4.).
Rysunek 19.4. Uruchamianie wykonawców Sparka w trybie klastra z systemem YARN
W obu trybach wykorzystujących system YARN wykonawcy są uruchamiani przed pojawieniem się informacji o lokalności danych. Dlatego mogą znaleźć się poza węzłami danych zawierającymi pliki potrzebne zadaniom. W sesjach interaktywnych jest to czasem akceptowalne, ponieważ przed rozpoczęciem sesji nie wiadomo, które zbiory danych będą używane. W zadaniach produkcyjnych rozmieszczenie wykonawców ma znaczenie, dlatego w trybie klastra z systemem YARN Spark pozwala dawać wskazówki zwiększające lokalność danych.
Wykonawcy i menedżery klastra
539
Konstruktor klasy SparkContext przyjmuje drugi argument z preferowanymi lokalizacjami. Są one określane przy użyciu klasy pomocniczej InputFormatInfo na podstawie formatu i ścieżki danych wejściowych. Na przykład dla plików tekstowych używany jest format TextInputFormat. val preferredLocations = InputFormatInfo.computePreferredLocations( Seq(new InputFormatInfo(new Configuration(), classOf[TextInputFormat], inputPath))) val sc = new SparkContext(conf, preferredLocations)
Preferowane lokalizacje są wykorzystywane przez zarządcę aplikacji przy przesyłaniu do menedżera zasobów żądań alokacji (krok 4.)8.
Dalsza lektura W tym rozdziale opisano tylko podstawy Sparka. Więcej informacji znajdziesz w książce Learning Spark Holdena Karaua, Andy’ego Konwinskiego, Patricka Wendella i Mateia Zaharii (O’Reilly, 2014). W witrynie projektu Apache Spark (http://spark.apache.org/) znajdziesz aktualną dokumentację dotyczącą najnowszej wersji Sparka.
8
Interfejs API do określania preferowanych lokalizacji nie jest stabilny (w Sparku 1.2.0; w czasie powstawania książki była to najnowsza wersja) i w przyszłych wersjach może zostać zmodyfikowany.
540
Rozdział 19. Spark
ROZDZIAŁ 20.
HBase
Jonathan Gray Michael Stack
Podstawy HBase to rozproszona kolumnowa baza danych oparta na systemie HDFS. HBase to aplikacja Hadoopa używana, gdy potrzebny jest dostęp swobodny w czasie rzeczywistym do bardzo dużych zbiorów danych (przy zapisie i odczycie). Choć istnieje bardzo dużo strategii i implementacji procesu zapisywania danych w bazach i ich pobierania, większość rozwiązań (zwłaszcza tradycyjnych) nie jest zbudowana z myślą o bardzo dużych zbiorach danych i środowisku rozproszonym. Wielu producentów udostępnia mechanizmy replikacji i partycjonowania pozwalające używać bazy w więcej niż jednym węźle, jednak te dodatki są zwykle dołączane do gotowego produktu. Instalowanie i stosowanie takich rozwiązań jest skomplikowane. Ponadto utrudniają one korzystanie ze standardowych funkcji baz RDBMS. W bazach RDBMS w środowisku rozproszonym złączenia, złożone zapytania, wyzwalacze, widoki i ograniczenia klucza obcego są bardzo kosztowne w użyciu lub w ogóle nie działają. W bazie HBase problem skalowania rozwiązano w inny sposób. Baza HBase została od początku zbudowana tak, by skalowała się liniowo i wymagała przy tym tylko dodawania węzłów. HBase nie jest bazą relacyjną i nie obsługuje SQL-a1, jednak w problemach określonego rodzaju potrafi robić to, z czym nie radzą sobie bazy RDBMS — przechowywać bardzo duże, luźno zapełnione tabele w klastrach opartych na powszechnie dostępnym sprzęcie. Wzorcowym zastosowaniem dla bazy HBase są tabele stron internetowych (ang. webtable), czyli tabele stron sprawdzonych przez roboty internetowe i ich atrybutów (takich jak język i typ MIME). Kluczami w takich tabelach są adresy URL stron. Tabele stron internetowych są bardzo duże — mogą zawierać miliardy wierszy. Na takich tabelach nieustannie są uruchamianie zadania w trybie wsadowym w modelu MapReduce związane z analizą i parsowaniem stron. Te zadania generują statystyki i dodają nowe kolumny ze sprawdzonym typem MIME i sparsowanym tekstem. Nowe 1
Warto jednak zapoznać się z projektem Apache Phoenix, wspomnianym też w punkcie „Inne silniki obsługujące język SQL w Hadoopie” w rozdziale 17., i bazą Trafodion (https://wiki.trafodion.org/wiki/index.php/Main_Page) — opartą na HBase transakcyjną bazą z obsługą SQL-a.
541
dane są później indeksowane przez wyszukiwarkę. Równolegle dostęp do tabeli uzyskują działające z różną szybkością roboty internetowe, które aktualizują wiersze, gdy strony są udostępniane w czasie rzeczywistym użytkownikom klikającym elementy zapisanych w pamięci podręcznej stron witryny.
Tło historyczne Projekt HBase został rozpoczęty pod koniec 2006 roku przez Chada Waltersa i Jima Kellermana z firmy Powerset. Baza HBase była wzorowana na właśnie opublikowanym projekcie Bigtable z firmy Google2. W lutym 2007 roku Mike Cafarella dodał kod prawie kompletnego systemu, rozwijany później przez Jima Kellermana. Pierwsza wersja bazy HBase została udostępniona razem z Hadoopem 0.15.0 w październiku 2007 roku. W maju 2010 roku baza HBase przestała być rozwijana jako podprojekt Hadoopa i trafiła do kategorii Apache Top Level Project. Obecnie HBase to dojrzała technologia używana w systemach produkcyjnych w wielu branżach.
Omówienie zagadnień W tym podrozdziale znajdziesz krótki przegląd najważniejszych zagadnień związanych z bazą HBase. Ich ogólna znajomość powinna przynajmniej ułatwić zrozumienie dalszego tekstu.
Krótki przegląd modelu danych Aplikacje przechowują dane w nazwanych tabelach. Tabele składają się z wierszy i kolumn. Komórki tabel (wyznaczane na podstawie współrzędnych wiersza i kolumny) mają określone wersje. Domyślnie baza HBase automatycznie przypisuje wersjom znaczniki czasu w momencie wstawiania danych do komórki. Zawartość komórki to surowa tablica bajtów. Rysunek 20.1 przedstawia przykładową tabelę z bazy HBase służącą do przechowywania zdjęć.
Rysunek 20.1. Model danych w bazie HBase używany dla tabeli przechowującej zdjęcia 2
Fay Chang i współpracownicy, „Bigtable: A Distributed Storage System for Structured Data”, listopad 2006 (http://research.google.com/archive/bigtable.html).
542
Rozdział 20. HBase
Klucze wierszy to także tablice bajtów, dlatego teoretycznie kluczami mogą być dowolne dane — od łańcuchów znaków po binarne reprezentacje długich liczb całkowitych, a nawet zserializowane struktury danych. Wiersze tabeli są sortowane według klucza wierszy, nazywanego też kluczem głównym tabeli. Sortowanie odbywa się na podstawie bajtów. Dostęp do tabeli zawsze odbywa się za pomocą klucza głównego3. Kolumny wierszy są grupowane w rodziny kolumn. Wszystkie elementy rodziny kolumn mają ten sam przedrostek. Tak więc na przykład kolumny info:format i info:geo należą do rodziny info, a kolumna contents:image należy do rodziny contents. Przedrostek rodziny kolumn musi składać się z wyświetlanych znaków. Końcowy człon nazwy, kwalifikator rodziny kolumn, może zawierać dowolne bajty. Rodzina kolumn i kwalifikator zawsze są rozdzielone dwukropkiem (:). Rodziny kolumn tabeli trzeba podać w definicji schematu tabeli, natomiast nowe elementy rodziny można dodawać na żądanie. Klient może na przykład dodać w ramach aktualizacji kolumnę info: camera i zapisać jej wartość, pod warunkiem jednak, że w tabeli istnieje już rodzina kolumn info. Fizycznie wszystkie elementy rodziny kolumn są przechowywane razem w systemie plików. Dlatego choć wcześniej baza HBase została opisana jako „kolumnowa”, bardziej precyzyjne jest określenie „oparta na rodzinach kolumn”. Ponieważ dostrajanie bazy i określanie jej specyfikacji odbywa się na poziomie rodzin kolumn, zaleca się, by wszystkie elementy rodziny miały ten sam ogólny wzorzec dostępu i tę samą wielkość. W tabeli ze zdjęciami dane reprezentujące grafikę są duże (zajmują megabajty pamięci), dlatego znajdują się w innej rodzinie kolumn niż metadane, które są znacznie mniejsze (zajmują kilobajty pamięci). W skrócie można stwierdzić, że tabele baz HBase są podobne do tabel z baz RDBMS, przy czym komórki mają wersje, wiersze są sortowane, a klienty mogą na bieżąco dodawać kolumny, o ile docelowa rodzina kolumn już istnieje.
Regiony Baza HBase automatycznie dzieli poziomo tabele na regiony. Każdy region obejmuje podzbiór wierszy tabeli. Region jest opisywany za pomocą nadrzędnej tabeli, pierwszego wiersza (należy do regionu) i ostatniego wiersza (nie należy do regionu). Początkowo tabela obejmuje jeden region. Gdy ilość danych rośnie, region przekracza limit wielkości (można go zmieniać). Wtedy dane zostają rozdzielone wzdłuż granicy wierszy na dwa nowe regiony o mniej więcej tym samym rozmiarze. Do czasu pierwszego podziału wszystkie dane są wczytywane do jednego serwera przechowującego pierwotny region. Gdy tabela się rozrasta, powiększa się też liczba jej regionów. Regiony to jednostki rozdzielane w klastrze z bazą HBase. Dzięki temu tabela, która jest zbyt duża, by można ją obsłużyć w jednym serwerze, może działać w klastrze serwerów. Każdy węzeł przechowuje wtedy podzbiór wszystkich regionów tabeli. Ta technika pozwala też rozdzielać operacje wczytywania danych do tabeli. Aktywny zestaw posortowanych regionów daje całą zawartość tabeli.
3
Baza HBase nie umożliwia indeksowania innych kolumn tabeli (czyli nie pozwala na tworzenie indeksów pomocniczych). Istnieje jednak kilka technik obsługi zapytań wykorzystujących indeksy pomocnicze. Każda z tych technik ma różne wady i zalety ze względu na ilość potrzebnego miejsca, obciążenie procesora i czas przetwarzania zapytań. Omówienie tego tematu znajdziesz w podręczniku bazy HBase (http://hbase.apache.org/book.html).
Omówienie zagadnień
543
Blokady Proces aktualizowania wierszy odbywa się atomowo niezależnie od tego, ile surowych kolumn jest używanych w transakcji na poziomie wiersza. Dzięki temu model stosowania blokad jest prosty.
Implementacja W systemach HDFS i YARN używane są klienty, komponenty robocze i koordynujący pracę zarządca (w systemach HDFS występują węzeł nazw i węzły danych, a w systemie YARN — menedżer zasobów i menedżery węzłów). W bazie HBase jest podobnie. Węzeł nadrzędny koordynuje pracę klastra z roboczymi serwerami regionów (rysunek 20.2). Węzeł nadrzędny bazy HBase odpowiada za uruchamianie nowej instalacji, przypisywanie regionów do zarejestrowanych serwerów i odzyskiwanie stanu po awariach serwerów. Węzeł nadrzędny pracuje pod niskim obciążeniem. Serwery regionów przechowują zero lub więcej regionów oraz przetwarzają żądania odczytu i zapisu otrzymane od klientów. Zarządzają też rozdzielaniem regionów i informują węzeł nadrzędny bazy HBase o nowych regionach. Węzeł nadrzędny usuwa wtedy pierwotny region i udostępnia nowe rozdzielone regiony.
Rysunek 20.2. Elementy klastra z systemem HBase
Baza HBase wykorzystuje ZooKeepera (zobacz rozdział 21.) i domyślnie zarządza egzemplarzem ZooKeepera odpowiedzialnym za stan klastra. Baza może też zostać tak skonfigurowana, by używała istniejącego klastra z ZooKeeperem. ZooKeeper przechowuje ważne aspekty hostów, na przykład lokalizację tabeli z katalogiem (hbase:meta) i adres bieżącego węzła nadrzędnego w klastrze. ZooKeeper uczestniczy też w przypisywaniu regionów do serwerów, co jest przydatne, gdy serwery przestaną działać w trakcie tego procesu. Przechowywanie stanu transakcji przypisywania 544
Rozdział 20. HBase
na serwerze ZooKeeper sprawia, że w trakcie odzyskiwania stanu można wznowić proces w momencie, w którym uszkodzony serwer przestał odpowiadać. W momencie nawiązywania połączenia klienta z klastrem z bazą HBase należy przekazać klientowi lokalizację danych na serwerze ZooKeeper. Następnie klient może sprawdzić strukturę danych z serwera ZooKeeper, aby ustalić atrybuty klastra (na przykład lokalizację serwerów). Węzły robocze serwerów regionów są zapisane w pliku conf/regionservers bazy HBase (podobnie w Hadoopie węzły danych i menedżery węzłów są wymienione w pliku etc/hadoop/slaves). Skrypty rozpoczynające i kończące pracę działają podobnie jak w Hadoopie i używają tego samego opartego na protokole SSH mechanizmu do wywoływania zdalnych poleceń. Konfiguracja specyficzna dla klastra jest umieszczona w plikach conf/hbase-site.xml i conf/hbase-env.sh. Mają one taki sam format jak ich odpowiedniki z Hadoopa (zobacz rozdział 10.). Gdy występują podobieństwa w usługach lub typach, w bazie HBase zwykle bezpośrednio używana jest implementacja z Hadoopa lub tworzone są klasy pochodne od tych z Hadoopa. Jeśli nie jest to możliwe, w bazie HBase stosowany jest model znany z Hadoopa. W bazie HBase używany jest na przykład system konfiguracji z Hadoopa, dlatego pliki konfiguracyjne w obu technologiach mają ten sam format. Dla użytkownika oznacza to, że w trakcie poznawania bazy HBase może wykorzystać znajomość Hadoopa. Niezgodne z tym modelem są tylko wyspecjalizowane klasy bazy HBase.
Baza HBase utrwala dane za pomocą interfejsu API systemu plików Hadoopa. Większość użytkowników bazy HBase przechowuje dane w systemie HDFS. Jednak domyślnie baza HBase zapisuje dane w lokalnym systemie plików. Dobrze nadaje się on do eksperymentów z początkową instalacją bazy HBase, jednak później pierwsza zmiana ustawień w klastrze z taką bazą polega zwykle na powiązaniu bazy z klastrem z systemem HDFS, który ma być używany.
Działanie bazy HBase Wewnętrznie baza HBase przechowuje specjalną tabelę z katalogiem, hbase:meta, w której przechowywane są lista, stan i lokalizacja wszystkich dostępnych w klastrze regionów użytkowników. Kluczami w katalogu hbase:meta są nazwy regionów. Taka nazwa obejmuje nazwę tabeli regionu, jego początkowy wiersz, czas utworzenia i skrót MD5 wszystkich tych informacji (nazwy tabeli, początkowego wiersza i znacznika czasu utworzenia). Oto przykładowa nazwa regionu z tabeli TestTable z początkowym wierszem xyz: TestTable,xyz,1279729913622.1b6e176fb8d8aa88fd4ab6bc80247ece.
Przecinki rozdzielają nazwę tabeli, początkowy wiersz i znacznik czasu. Skrót MD5 jest umieszczony między kropkami. Jak wcześniej wspomniano, klucze wierszy są sortowane, dlatego znalezienie regionu zawierającego określony wiersz wymaga tylko znalezienia największego klucza mniejszego od żądanego lub mu równego. Gdy regiony są modyfikowane (rozdzielane, wyłączane, włączane, usuwane i przenoszone przez mechanizm równoważenia obciążenia lub w wyniku awarii serwera regionów), następuje aktualizacja tabeli z katalogiem. Dzięki temu dostępne są aktualne informacje o stanie wszystkich regionów z klastra.
Omówienie zagadnień
545
Nowe klienty najpierw nawiązują połączenie z klastrem z systemem ZooKeeper, by ustalić lokalizację tabeli hbase:meta. Następnie klient wyszukuje w tabeli odpowiedni region użytkownika i jego lokalizację. Od tego momentu klient może bezpośrednio komunikować się z określonym serwerem regionów. Aby uniknąć konieczności trzykrotnej wymiany komunikatów w każdej operacji na wierszach, klienty zapisują w pamięci podręcznej wszystkie uzyskane informacje z tabeli hbase:meta. Zachowywane są lokalizacje, a także początkowe i końcowe wiersze regionów użytkowników. Dzięki temu klient może sam ustalić potrzebny region bez konieczności kontaktowania się z tabelą hbase: meta. Klienty korzystają z danych z pamięci podręcznej do momentu wykrycia niepowodzenia. W takiej sytuacji (spowodowanej na przykład przeniesieniem regionu) klient ponownie kontaktuje się z tabelą hbase:meta, aby ustalić nową lokalizację. Jeśli także lokalizacja z tabeli hbase:meta okazuje się nieprawidłowa, dane są pobierane z systemu ZooKeeper. Operacje zapisu kierowane do serwera regionów są najpierw umieszczane w dzienniku zatwierdzania, a następnie dodawane do przechowywanego w pamięci obszaru memstore. Gdy obszar memstore się zapełni, jego zawartość jest zapisywana w systemie plików. Dziennik zatwierdzania jest przechowywany w systemie HDFS, dlatego pozostaje dostępny także po awarii serwera regionów. Gdy węzeł nadrzędny wykryje, że serwer regionów jest niedostępny (zwykle wynika to z wygaśnięcia rejestru znode serwera w systemie ZooKeeper), rozbija dziennik zatwierdzania z nieaktywnego serwera według regionów. Przy przypisywaniu regionów do węzłów i przed ich ponownym udostępnieniem regiony z nieaktywnego serwera pobierają swoje niezatwierdzone operacje z odpowiedniego pliku i odtwarzają je, aby zaktualizować swój stan do sytuacji sprzed awarii. W trakcie odczytu najpierw sprawdzany jest obszar memstore. Jeśli zawiera on odpowiednie wersje danych, zapytanie na tym etapie zostaje zakończone. W przeciwnym razie sprawdzane są pliki z zapisanymi danymi (od najnowszego do najstarszego). Proces ten trwa do czasu znalezienia wersji danych pozwalających przetworzyć zapytanie lub do momentu wyczerpania się plików. Działający w tle proces pakuje pliki z zapisanymi danymi (ang. flush file), gdy ich liczba przekracza określony poziom. W tym celu łączy wiele plików w jeden (im mniej plików trzeba sprawdzić przy odczycie, tym operacja ta jest wydajniejsza). W trakcie pakowania proces usuwa wersje danych, jeśli ich liczba przekracza ustawione w schemacie maksimum, oraz kasuje usunięte i nieaktualne komórki. Odrębny proces działający w serwerze regionów monitoruje wielkość plików z zapisanymi danymi i dzieli region na mniejsze, jeśli rozrośnie się on ponad ustalony rozmiar.
Instalacja Pobierz stabilną wersję z witryny Apache’a z listą kopii lustrzanych (http://www.apache.org/dyn/ closer.cgi/hbase/) i wypakuj ją w lokalnym systemie plików. Oto przykład: % tar xzf hbase-x.y.z.tar.gz
Podobnie jak w Hadoopie najpierw należy poinformować bazę HBase o lokalizacji Javy w systemie. Jeśli zmienna środowiskowa JAVA_HOME prowadzi do odpowiedniej instalacji Javy, zostanie uwzględniona. Nie trzeba wtedy nic konfigurować. W przeciwnym razie wskaż instalację Javy 546
Rozdział 20. HBase
przeznaczoną dla bazy HBase. W tym celu ustaw zmienną JAVA_HOME w pliku conf/hbase-env.sh (przykłady znajdziesz w dodatku A). Dla wygody dodaj katalog z plikami binarnymi bazy HBase do ścieżki wiersza poleceń. Oto przykład: % export HBASE_HOME=~/sw/hbase-x.y.z % export PATH=$PATH:$HBASE_HOME/bin
Aby uzyskać listę opcji bazy HBase, wywołaj następujące polecenie: % hbase Options: --config DIR --hosts HOSTS
Configuration direction to use. Default: ./conf Override the list in 'regionservers' file
Commands: Some commands take arguments. Pass no args or -h for usage. shell Run the HBase shell hbck Run the hbase 'fsck' tool hlog Write-ahead-log analyzer hfile Store file analyzer zkcli Run the ZooKeeper shell upgrade Upgrade hbase master Run an HBase HMaster node regionserver Run an HBase HRegionServer node zookeeper Run a Zookeeper server rest Run an HBase REST server thrift Run the HBase Thrift server thrift2 Run the HBase Thrift2 server clean Run the HBase clean up script classpath Dump hbase CLASSPATH mapredcp Dump CLASSPATH entries required by mapreduce pe Run PerformanceEvaluation ltt Run LoadTestTool version Print the version CLASSNAME Run the class named CLASSNAME
Przebieg testowy Aby uruchomić niezależną bazę HBase wykorzystującą do utrwalania danych katalog tymczasowy w lokalnym systemie plików, użyj następującej instrukcji: % start-hbase.sh
Baza HBase domyślnie zapisuje dane w katalogu /${java.io.tmpdir}/hbase-${user.name}. Członowi ${java.io.tmpdir} odpowiada zwykle katalog /tmp, jednak należy skonfigurować bazę HBase w taki sposób, by używała trwalszej lokalizacji. W tym celu należy ustawić właściwość hbase.tmp.dir w pliku hbase-site.xml. W trybie niezależnym węzeł nadrzędny bazy HBase, serwer regionów i system ZooKeeper działają w tej samej maszynie JVM. Na potrzeby zarządzania bazą HBase uruchom jej powłokę: % hbase shell HBase Shell; enter 'help' for list of supported commands. Type "exit" to leave the HBase Shell Version 0.98.7-hadoop2, r800c23e2207aa3f9bddb7e9514d8340bcfb89277, Wed Oct 8 15:58:11 PDT 2014 hbase(main):001:0>
Instalacja
547
To powoduje uruchomienie interpretera JRuby IRB z dodaną obsługą poleceń specyficznych dla bazy HBase. Wpisz help i wciśnij przycisk Enter, aby zobaczyć listę poleceń powłoki pogrupowanych według kategorii. Instrukcja help "GRUPA_POLECEŃ" wyświetla informacje na temat danej grupy, a polecenie help "POLECENIE" pokazuje pomoc dotyczącą danej instrukcji i przykłady jej stosowania. Polecenia używają formatowania z języka Ruby do określania list i katalogów. Krótki samouczek znajdziesz na końcu głównego ekranu z pomocą. Teraz zobacz, jak utworzyć prostą tabelę, dodać do niej dane, a następnie je usunąć. W celu utworzenia tabeli należy określić jej nazwę i zdefiniować schemat. Schemat tabeli obejmuje jej atrybuty i listę rodzin kolumn. Same rodziny kolumn też mają atrybuty. Określa się je w trakcie definiowania schematu. Te atrybuty dotyczą na przykład tego, czy zawartość rodziny kolumn ma być kompresowana w systemie plików i ile wersji komórek należy przechowywać. Schematy można później edytować. Wymaga to dezaktywacji tabeli (za pomocą polecenia disable powłoki), wprowadzenia niezbędnych zmian za pomocą instrukcji alter i ponownego aktywowania tabeli (przy użyciu polecenia enable). W celu utworzenia tabeli test z jedną rodziną kolumn o nazwie data i domyślnymi atrybutami tabeli oraz rodziny kolumn wpisz poniższą instrukcję: hbase(main):001:0> create 'test', 'data' 0 row(s) in 0.9810 seconds
Jeśli próba wykonania tego polecenia zakończyła się niepowodzeniem, a powłoka wyświetliła komunikat o błędzie i ślad stosu, oznacza to, że instalacja się nie udała. Przyczyn problemów poszukaj w dzienniku węzła głównego w katalogu logs bazy HBase (domyślna lokalizacja katalogu dzienników to ${HBASE_HOME}/logs).
Instrukcja help pozwala zapoznać się z przykładami ilustrującymi dodawanie w trakcie definiowania schematu atrybutów tabeli i rodziny kolumn. Aby się upewnić, że nowa tabela została poprawnie utworzona, wywołaj polecenie list. Spowoduje to wyświetlenie wszystkich tabel z przestrzeni użytkownika. hbase(main):002:0> list TABLE test 1 row(s) in 0.0260 seconds
W celu wstawienia danych do trzech różnych wierszy i kolumn rodziny data, pobrania pierwszego wiersza i wyświetlenia zawartości tabeli wykonaj poniższe instrukcje: hbase(main):003:0> hbase(main):004:0> hbase(main):005:0> hbase(main):006:0> COLUMN data:1 1 row(s) in 0.0240 hbase(main):007:0> ROW row1 row2 row3 3 row(s) in 0.0240
548
put put put get
'test', 'row1', 'data:1', 'value1' 'test', 'row2', 'data:2', 'value2' 'test', 'row3', 'data:3', 'value3' 'test', 'row1' CELL timestamp=1414927084811, value=value1 seconds scan 'test' COLUMN+CELL column=data:1, timestamp=1414927084811, value=value1 column=data:2, timestamp=1414927125174, value=value2 column=data:3, timestamp=1414927131931, value=value3 seconds
Rozdział 20. HBase
Zauważ, że trzy kolumny dodano bez modyfikowania schematu. Aby usunąć tabelę, trzeba ją najpierw wyłączyć. hbase(main):009:0> 0 row(s) in 5.8420 hbase(main):010:0> 0 row(s) in 5.2560 hbase(main):011:0> TABLE 0 row(s) in 0.0200
disable 'test' seconds drop 'test' seconds list seconds
Do zamykania bazy HBase służy następująca instrukcja: % stop-hbase.sh
Informacje o konfigurowaniu rozproszonego klastra z bazą HBase i łączeniu go z działającym systemem HDFS znajdziesz w odpowiednim fragmencie dokumentacji bazy HBase (http://hbase. apache.org/book.html#configuration).
Klienty Do interakcji z klastrem z systemem HBase można stosować klienty różnego rodzaju.
Java Baza HBase, podobnie jak Hadoop, jest napisana w Javie. Listing 20.1 pokazuje, jak wykonać przedstawione w poprzednim podrozdziale operacje powłoki za pomocą Javy. Listing 20.1. Podstawowe operacje zarządzania tabelą i dostępu do niej public class ExampleClient { public static void main(String[] args) throws IOException { Configuration config = HBaseConfiguration.create(); // Tworzenie tabeli HBaseAdmin admin = new HBaseAdmin(config); try { TableName tableName = TableName.valueOf("test"); HTableDescriptor htd = new HTableDescriptor(tableName); HColumnDescriptor hcd = new HColumnDescriptor("data"); htd.addFamily(hcd); admin.createTable(htd); HTableDescriptor[] tables = admin.listTables(); if (tables.length != 1 && Bytes.equals(tableName.getName(), tables[0].getTableName().getName())) { throw new IOException("Tworzenie tabeli zakończone niepowodzeniem"); } // Wykonywanie operacji na tabeli: trzy razy wstawianie danych, raz pobieranie i skanowanie HTable table = new HTable(config, tableName); try { for (int i = 1; i create 'stations', {NAME => 'info'} 0 row(s) in 0.9600 seconds hbase(main):002:0> create 'observations', {NAME => 'data'} 0 row(s) in 0.1770 seconds
Szerokie tabele Dostęp do danych w bazach HBase zawsze odbywa się za pomocą klucza głównego. Dlatego z projektu klucza wynika wygląd zapytań o dane. W trakcie projektowania schematu należy pamiętać o tym, że jedną z podstawowych cech baz kolumnowych (a dokładniej — opartych na rodzinach kolumn; https://en.wikipedia.org/wiki/Column-oriented_DBMS), takich jak HBase, jest możliwość przechowywania szerokich i rzadko zapełnionych tabel bez dodatkowych kosztów4. W bazie HBase nie istnieje wbudowany mechanizm złączania, jednak dzięki szerokim tabelom nie ma potrzeby, by w ramach złączania pobierać dane z dodatkowych tabel. W szerokich wierszach można czasem zapisać wszystkie dane dotyczące określonego klucza głównego.
Wczytywanie danych Liczba stacji jest stosunkowo niewielka, dlatego statyczne dane można łatwo wstawić za pomocą dowolnego dostępnego interfejsu. Przykładowy kod obejmuje aplikację w Javie, która wykonuje to zadanie. Aplikację można uruchomić w następujący sposób: % hbase HBaseStationImporter input/ncdc/metadata/stations-fixed-width.txt
Załóżmy jednak, że trzeba wczytać miliardy odczytów. Importowanie takich danych to zwykle bardzo skomplikowana i długa operacja na bazie danych. Jednak rozproszony model używany w MapReduce i bazie HBase pozwala wykorzystać wszystkie możliwości klastra. Pokazane rozwiązanie kopiuje surowe dane wejściowe do systemu HDFS, a następnie uruchamia zadanie w modelu MapReduce, które wczytuje te dane i zapisuje je w bazie HBase. Listing 20.3 przedstawia przykładowe zadanie w modelu MapReduce, które importuje do bazy HBase odczyty z plików wejściowych używanych w przykładach z wcześniejszych rozdziałów. Listing 20.3. Aplikacja w modelu MapReduce importująca odczyty temperatury z systemu HDFS do tabeli bazy HBase public class HBaseTemperatureImporter extends Configured implements Tool { static class HBaseTemperatureMapper extends Mapper { private NcdcRecordParser parser = new NcdcRecordParser(); @Override public void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { parser.parse(value.toString()); if (parser.isValidTemperature()) { byte[] rowKey = RowKeyConverter.makeObservationRowKey(parser.getStationId(),
4
Zobacz artykuł Daniela J. Abadiego „Column-Stores for Wide and Sparse Data” ze stycznia 2007 roku (http://db.csail.mit.edu/projects/cstore/abadicidr07.pdf).
Budowanie interaktywnej aplikacji do przesyłania zapytań
555
parser.getObservationDate().getTime()); Put p = new Put(rowKey); p.add(HBaseTemperatureQuery.DATA_COLUMNFAMILY, HBaseTemperatureQuery.AIRTEMP_QUALIFIER, Bytes.toBytes(parser.getAirTemperature())); context.write(null, p); } } } @Override public int run(String[] args) throws Exception { if (args.length != 1) { System.err.println("Użytkowanie: HBaseTemperatureImporter "); return -1; } Job job = new Job(getConf(), getClass().getSimpleName()); job.setJarByClass(getClass()); FileInputFormat.addInputPath(job, new Path(args[0])); job.getConfiguration().set(TableOutputFormat.OUTPUT_TABLE, "observations"); job.setMapperClass(HBaseTemperatureMapper.class); job.setNumReduceTasks(0); job.setOutputFormatClass(TableOutputFormat.class); return job.waitForCompletion(true) ? 0 : 1; } public static void main(String[] args) throws Exception { int exitCode = ToolRunner.run(HBaseConfiguration.create(), new HBaseTemperatureImporter(), args); System.exit(exitCode); } }
Klasa HBaseTemperatureImporter zawiera zagnieżdżoną klasę HBaseTemperatureMapper, przypominającą klasę MaxTemperatureMapper z rozdziału 6. Klasa zewnętrzna implementuje interfejs Tool i konfiguruje zadanie z samym etapem mapowania. Klasa HBaseTemperatureMapper przyjmuje te same dane wyjściowe co klasa MaxTemperatureMapper i tak samo parsuje dane (za pomocą przedstawionej w rozdziale 6. klasy NcdcRecordParser), aby sprawdzić, czy odczyt temperatury jest poprawny. Ale zamiast zapisywać poprawne temperatury w wyjściowym kontekście, jak robi to klasa MaxTemperatureMapper, klasa HBaseTemperatureMapper tworzy obiekt typu Put, aby dodać temperatury do kolumny data:airtemp w tabeli observations bazy HBase. Nazwy data i airtemp są pobierane ze stałych statycznych importowanych z przedstawionej dalej klasy HBaseTemperatureQuery. Klucz wiersza dla każdego odczytu jest tworzony w metodzie makeObservationRowKey() klasy RowKey Converter na podstawie identyfikatora stacji i czasu dokonania pomiaru. public class RowKeyConverter { private static final int STATION_ID_LENGTH = 12; /** * @return Klucz wiersza w formacie: */ public static byte[] makeObservationRowKey(String stationId, long observationTime) { byte[] row = new byte[STATION_ID_LENGTH + Bytes.SIZEOF_LONG]; Bytes.putBytes(row, 0, Bytes.toBytes(stationId), 0, STATION_ID_LENGTH); long reverseOrderTimestamp = Long.MAX_VALUE - observationTime; Bytes.putLong(row, STATION_ID_LENGTH, reverseOrderTimestamp);
556
Rozdział 20. HBase
return row; } }
Przy konwersji wykorzystano to, że identyfikator stacji to łańcuch znaków ASCII o stałej długości. Podobnie jak wcześniej do przekształcenia danych z tablic bajtów na standardowe typy Javy i z powrotem używana jest klasa Bytes bazy HBase. Stała Bytes.SIZEOF_LONG służy do określenia wielkości członu ze znacznikiem czasu w tablicy bajtów z kluczem wiersza. Metody putBytes() i putLong() ustawiają człony z identyfikatorem stacji i znacznikiem czasu na odpowiednich pozycjach w tablicy bajtów. Zadanie jest konfigurowane w metodzie run() klasy TableOutputFormat bazy HBase. Docelową tabelę należy podać we właściwości TableOutputFormat.OUTPUT_TABLE w konfiguracji zadania. Klasa TableOutputFormat jest wygodna w użyciu, ponieważ zarządza generowaniem obiektów typu HTable, które bez tej klasy trzeba tworzyć w metodzie setup() mappera (trzeba wtedy pamiętać o wywołaniu metody close() w metodzie cleanup()). Klasa TableOutputFormat wyłącza też mechanizm automatycznego zapisu danych stosowany w klasie HTable, dlatego wywołania put() są buforowane, co zwiększa wydajność kodu. Przykładowy kod obejmuje klasę HBaseTemperatureDirectImporter, pokazującą, jak używać klasy HTable bezpośrednio z poziomu programu w modelu MapReduce. Program można uruchomić w następujący sposób: % hbase HBaseTemperatureImporter input/ncdc/all
Rozkładanie obciążenia Wyobraź sobie sytuację, w której operacja importu krok po kroku przetwarza tabelę, a jednocześnie wszystkie klienty najpierw używają jednego regionu tabeli (a tym samym jednego węzła), następnie wszystkie przechodzą do kolejnego itd., zamiast równomiernie obciążać wszystkie regiony. Zwykle wynika to z sortowania danych wejściowych i działania rozdzielacza. Pomocne może być losowe wymieszanie kluczy wierszy przed ich wstawieniem do bazy. W omawianym przykładzie z uwagi na rozkład wartości identyfikatorów stationid i generowanie porcji danych przez klasę TextInputFormat przesyłane dane powinny być wystarczająco wymieszane. Jeśli tabela jest nowa, obejmuje tylko jeden region i wszystkie aktualizacje dotyczą właśnie jego (do momentu podziału). Dzieje się tak nawet przy losowym rozkładzie kluczy. To zjawisko powoduje, że początkowo przesyłanie danych trwa długo. Jest tak do czasu utworzenia wystarczającej liczby regionów, tak by wszystkie węzły klastra mogły uczestniczyć we wczytywaniu danych. Nie pomyl tego zjawiska z problemem opisanym w poprzednim akapicie. Obu trudności można uniknąć dzięki masowemu wczytywaniu danych, opisanemu w następnym podrozdziale.
Masowe wczytywanie danych Dostępny jest wydajny mechanizm masowego wczytywania baz HBase. Polega on na zapisywaniu danych w wewnętrznym formacie bezpośrednio do systemu plików za pomocą modelu MapReduce. W ten sposób można wczytać bazę HBase o rząd wielkości szybciej niż za pomocą klienckiego interfejsu API tej bazy. Budowanie interaktywnej aplikacji do przesyłania zapytań
557
Masowe wczytywanie danych to proces dwuetapowy. Pierwszy krok polega na użyciu klasy HFile OutputFormat2 do zapisu pliku typu HFile w katalogu systemu HDFS za pomocą zadania w modelu MapReduce. Ponieważ wiersze trzeba zapisywać w odpowiedniej kolejności, zadanie musi przeprowadzić sortowanie globalne (zobacz punkt „Sortowanie wszystkich danych” w rozdziale 9.) kluczy wierszy. Metoda configureIncrementalLoad() klasy HFileOutputFormat2 automatycznie ustawia potrzebną konfigurację. Drugi krok masowego wczytywania polega na przeniesieniu plików typu HFile z systemu HDFS do istniejącej tabeli bazy HBase. Tabela w trakcie tej operacji może pozostawać aktywna. W przykładowym kodzie znajdziesz klasę HBaseTemperatureBulkImporter służącą do masowego wczytywania odczytów.
Zapytania interaktywne Do zbudowania aplikacji do obsługi interaktywnych zapytań bezpośrednio użyty zostanie tu interfejs API Javy dla bazy HBase. Zobacz, jak ważny jest wybór schematu i formatu przechowywania danych.
Zapytania o stacje Najprostsze zapytanie pobiera statyczne informacje o stacjach. Wymaga to zastosowania jednowierszowej operacji wyszukiwania za pomocą metody get(). Takie zapytania są proste także w tradycyjnych bazach danych, a baza HBase zapewnia dodatkową kontrolę i swobodę. Oto kod z klasy HBaseStationQuery; rodzina info jest tu używana jako słownik z parami klucz-wartość (klucze to nazwy kolumn, a wartości to wartości kolumn). static static static static
final final final final
byte[] byte[] byte[] byte[]
INFO_COLUMNFAMILY = Bytes.toBytes("info"); NAME_QUALIFIER = Bytes.toBytes("name"); LOCATION_QUALIFIER = Bytes.toBytes("location"); DESCRIPTION_QUALIFIER = Bytes.toBytes("description");
public Map getStationInfo(HTable table, String stationId) throws IOException { Get get = new Get(Bytes.toBytes(stationId)); get.addFamily(INFO_COLUMNFAMILY); Result res = table.get(get); if (res == null) { return null; } Map resultMap = new LinkedHashMap(); resultMap.put("name", getValue(res, INFO_COLUMNFAMILY, NAME_QUALIFIER)); resultMap.put("location", getValue(res, INFO_COLUMNFAMILY, LOCATION_QUALIFIER)); resultMap.put("description", getValue(res, INFO_COLUMNFAMILY, DESCRIPTION_QUALIFIER)); return resultMap; } private static String getValue(Result res, byte[] cf, byte[] qualifier) { byte[] value = res.getValue(cf, qualifier); return value == null? "": Bytes.toString(value); }
558
Rozdział 20. HBase
W tym przykładzie metoda getStationInfo() przyjmuje obiekt typu HTable i identyfikator stacji. Aby uzyskać informacje na temat stacji, używa się metody get() z argumentem w postaci obiektu typu Get. Obiekt jest tak skonfigurowany, by kod pobierał wartości wszystkich kolumn wiersza określanego za pomocą identyfikatora stacji ze zdefiniowanej rodziny kolumn (INFO_COLUMNFAMILY). Wyniki metody get() są zwracane w obiekcie typu Result. Zawiera on wiersz, z którego można pobrać wartości komórek. W tym celu należy określić potrzebne komórki. Metoda getStationInfo() przekształca obiekt typu Result na łatwiejszą w użyciu kolekcję typu Map z kluczami i wartościami typu String. Widać tu, że przy korzystaniu z bazy HBase potrzebne są funkcje narzędziowe. Dla bazy HBase rozwijane są coraz to nowe abstrakcje służące do obsługi niskopoziomowych interakcji, należy jednak zrozumieć ich działanie i wpływ dokonywanych wyborów z zakresu przechowywania danych. Jedną z zalet bazy HBase w porównaniu z bazami relacyjnymi jest to, że nie trzeba z góry określać wszystkich kolumn. Dlatego jeśli każda stacja ma przynajmniej trzy atrybuty, a jednocześnie istnieją setki opcjonalnych cech, w przyszłości będzie można dodać nowe atrybuty bez konieczności zmiany schematu. Oczywiście będzie wymagać to zmian w kodzie wczytującym i zapisującym dane. W przykładowym kodzie modyfikacja może polegać na wczytywaniu danych z obiektu typu Result w pętli zamiast jawnego pobierania każdej wartości. Oto przykładowe zapytanie o informacje o stacji: % hbase HBaseStationQuery 011990-99999 name SIHCCAJAVRI location (unknown) description (unknown)
Zapytania o odczyty Zapytania kierowane do tabeli observations obejmują identyfikator stacji, początkowy czas i maksymalną liczbę zwracanych wierszy. Ponieważ wiersze są przechowywane w odwrotnej kolejności chronologicznej i według stacji, zapytania zwracają odczyty poprzedzające podany początkowy czas. Metoda getStationObservations() z listingu 20.4 używa skanera bazy HBase do przejścia po wierszach tabeli. Metoda zwraca obiekt typu NavigableMap, w którym klucz to znacznik czasu, a wartość to temperatura. Ponieważ operacja mapowania sortuje dane według klucza w porządku rosnącym, odczyty są uporządkowane chronologicznie. Listing 20.4. Aplikacja pobierająca z tabeli bazy HBase przedział wierszy z odczytami ze stacji meteorologicznych public class HBaseTemperatureQuery extends Configured implements Tool { static final byte[] DATA_COLUMNFAMILY = Bytes.toBytes("data"); static final byte[] AIRTEMP_QUALIFIER = Bytes.toBytes("airtemp"); public NavigableMap getStationObservations(HTable table, String stationId, long maxStamp, int maxCount) throws IOException { byte[] startRow = RowKeyConverter.makeObservationRowKey(stationId, maxStamp); NavigableMap resultMap = new TreeMap(); Scan scan = new Scan(startRow); scan.addColumn(DATA_COLUMNFAMILY, AIRTEMP_QUALIFIER); ResultScanner scanner = table.getScanner(scan); try { Result res; int count = 0; while ((res = scanner.next()) != null && count++ < maxCount) {
Budowanie interaktywnej aplikacji do przesyłania zapytań
559
byte[] row = res.getRow(); byte[] value = res.getValue(DATA_COLUMNFAMILY, AIRTEMP_QUALIFIER); Long stamp = Long.MAX_VALUE – Bytes.toLong(row, row.length - Bytes.SIZEOF_LONG, Bytes.SIZEOF_LONG); Integer temp = Bytes.toInt(value); resultMap.put(stamp, temp);
}
} } finally { scanner.close(); } return resultMap;
public int run(String[] args) throws IOException { if (args.length != 1) { System.err.println("Użytkowanie: HBaseTemperatureQuery "); return -1; } HTable table = new HTable(HBaseConfiguration.create(getConf()), "observations"); try { NavigableMap observations = getStationObservations(table, args[0], Long.MAX_VALUE, 10).descendingMap(); for (Map.Entry observation : observations.entrySet()) { // Wyświetlanie daty, czasu i temperatury System.out.printf("%1$tF %1$tR\t%2$s\n", observation.getKey(), observation.getValue()); } return 0; } finally { table.close(); } } public static void main(String[] args) throws Exception { int exitCode = ToolRunner.run(HBaseConfiguration.create(), new HBaseTemperatureQuery(), args); System.exit(exitCode); } }
Metoda run() wywołuje metodę getStationObservations() i żąda 10 najnowszych odczytów porządkowanych w kolejności malejącej za pomocą wywołania descendingMap(). Odczyty są formatowane i wyświetlane w konsoli (pamiętaj, że temperatury podawane są w dziesiątych częściach stopnia). Oto przykład: % hbase HBaseTemperatureQuery 011990-99999 1902-12-31 20:00 -106 1902-12-31 13:00 -83 1902-12-30 20:00 -78 1902-12-30 13:00 -100 1902-12-29 20:00 -128 1902-12-29 13:00 -111 1902-12-29 06:00 -111 1902-12-28 20:00 -117 1902-12-28 13:00 -61 1902-12-27 20:00 -22
Zaletą zapisywania znaczników czasu w kolejności odwrotnej do chronologicznej jest to, że można otrzymać najnowsze odczyty, co jest często pożądane w aplikacjach interaktywnych. Gdyby odczyty były zapisane za pomocą zwykłych znaczników czasu, wydajnie można byłoby pobierać 560
Rozdział 20. HBase
tylko najstarsze dane z danego przedziału. Uzyskanie najnowszych wymagałoby wczytania wszystkich wierszy i pobrania ostatniego z nich. Znacznie wydajniej jest pobrać pierwszych n wierszy, a następnie wyjść ze skanera (jest to scenariusz szybkiego kończenia). W wersji HBase 0.98 dodano możliwość skanowania odwrotnego. To oznacza, że można zachować odczyty w porządku chronologicznym, a następnie skanować je wstecz od podanego wiersza początkowego. Skanowanie odwrotne jest o kilka procent wolniejsze od skanowania w przód. Aby odwrócić skanowanie, przed jego rozpoczęciem wywołaj metodę setReversed(true) używanego obiektu typu Scan.
Baza HBase a bazy RDBMS Bazy HBase i inne bazy kolumnowe często porównywane są do tradycyjnych i popularnych baz relacyjnych (do baz RDBMS). Choć oba typy baz są zupełnie inaczej implementowane i projektowane z myślą o innych cechach, to jednak mają stanowić rozwiązanie tych samych problemów, dlatego ich porównywanie jest uzasadnione. Wcześniej opisano już, że HBase to rozproszony i kolumnowy system przechowywania danych. Stanowi rozwinięcie możliwości Hadoopa, ponieważ zapewnia dostęp swobodny przy odczycie i zapisie danych z systemu HDFS. Baza HBase została od podstaw zaprojektowana pod kątem skalowania we wszystkich wymiarach — na szerokość (może obejmować miliardy wierszy) i na wysokość (obsługuje miliony kolumn). Ponadto można ją automatycznie dzielić poziomo i replikować w tysiącach węzłów w postaci powszechnie dostępnych maszyn. Schematy tabel odzwierciedlają fizyczny format danych i umożliwiają tworzenie wydajnych procesów serializacji, przechowywania i pobierania struktur danych. Programista aplikacji odpowiada za odpowiednie wykorzystanie tych procesów. W ścisłym ujęciu RDBMS to baza zgodna z 12 regułami Codda (https://en.wikipedia.org/wiki/ Codd%27s_12_rules). Typowe bazy RDBMS mają stały schemat i są bazami wierszowymi zgodnymi z modelem ACID. Posiadają też zaawansowany silnik przetwarzania zapytań w SQL-u. Nacisk położony jest na spójność, integralność referencyjną, niezależność od warstwy fizycznej i obsługę złożonych zapytań w języku SQL. W takich bazach można łatwo tworzyć indeksy pomocnicze, przeprowadzać złożone złączenia wewnętrzne i zewnętrzne, a także zliczać, sumować, sortować, grupować i dzielić na strony dane z wielu tabel, wierszy i kolumn. W większości aplikacji z małymi i średnimi zbiorami danych nic nie zastąpi łatwości użytkowania, dojrzałości i bogactwa zestawu funkcji z dostępnych systemów RDBMS o otwartym dostępie do kodu źródłowego (takich jak MySQL i PostgreSQL). Jeśli jednak trzeba przejść na wyższy poziom w obszarze wielkości zbioru danych, współbieżności odczytu i zapisu lub w obu tych aspektach, szybko okaże się, że systemy RDBMS, choć wygodne, okazują się wysoce niewydajne. Bardzo trudne jest też korzystanie z nich w środowisku rozproszonym. Skalowanie baz RDBMS zwykle wymaga złamania reguł Codda i ograniczeń związanych z modelem ACID oraz rezygnacji z tradycyjnych rozwiązań stosowanych przez administratorów, co prowadzi do utraty większości cech sprawiających, że relacyjne bazy danych są tak wygodne w użyciu.
Baza HBase a bazy RDBMS
561
Historia cieszącej się powodzeniem usługi Oto krótki opis typowego procesu skalowania systemu RDBMS. Poniższa lista dotyczy cieszącej się powodzeniem i zyskującej na popularności usługi. Pierwsze udostępnienie publicznym użytkownikom Przeniesienie usługi z lokalnej stacji roboczej do współużytkowanego zdalnego egzemplarza bazy MySQL o dobrze zdefiniowanym schemacie. Usługa zyskuje popularność i do bazy kierowanych jest zbyt dużo zapytań Dodanie systemu memcached w celu zapisywania często zgłaszanych zapytań w pamięci podręcznej. Odczyt danych nie jest już w pełni zgodny z modelem ACID. Trzeba też pamiętać o wygasaniu danych w pamięci podręcznej. Usługa ciągle zyskuje popularność i w bazie wykonywanych jest zbyt wiele operacji zapisu Pionowe skalowanie bazy MySQL przez zakup zaawansowanego serwera o 16 rdzeniach, 128 GB pamięci RAM i bankach dysków twardych pracujących z szybkością 15k RPM. To kosztowne rozwiązanie. Nowe funkcje zwiększają złożoność zapytań — teraz liczba złączeń jest za duża Denormalizacja danych w celu zmniejszenia liczby złączeń. Nie tego uczyli mnie na zajęciach dla administratorów baz danych! Rosnąca popularność prowadzi do przeciążenia serwerów — operacje są wykonywane za wolno Zaprzestanie wykonywania jakichkolwiek obliczeń po stronie serwera. Niektóre zapytania wciąż są przetwarzane zbyt wolno Wstępne materializowanie najbardziej skomplikowanych zapytań i próba rezygnacji ze złączeń w większości sytuacji. Odczyty są przetwarzane sprawnie, ale zapis staje się coraz wolniejszy Usunięcie indeksów pomocniczych i wyzwalaczy (baza bez indeksów?). Na tym etapie trudno jest wymyślić dalsze rozwiązania problemów ze skalowaniem. Niezbędne jest rozpoczęcie skalowania poziomego. Możesz spróbować podzielić największe tabele na partycje lub poszukać komercyjnych rozwiązań z obsługą wielu węzłów nadrzędnych. W niezliczonych aplikacjach, firmach i witrynach udało się z powodzeniem zbudować skalowalne, odporne na błędy i rozproszone systemy danych oparte na bazach RDBMS. Prawdopodobnie wykorzystano przy tym liczne z opisanych wcześniej strategii. Jednak uzyskany w ten sposób system nie jest już prawdziwą bazą RDBMS i wymaga poświęcenia funkcji oraz wygody. Zamiast nich pojawiają się kompromisy i komplikacje. Wszelkie odmiany replikacji węzłów podrzędnych i zewnętrznej pamięci podręcznej oznaczają rezygnację ze spójności zdenormalizowanych obecnie danych. Niska wydajność związana ze złączeniami i indeksami pomocniczymi oznacza, że prawie wszystkie zapytania bazują na wyszukiwaniu kluczy głównych. Konfiguracja z zapisem jednoczesnym zwykle oznacza rezygnację z prawdziwych złączeń, a obsługa transakcji w środowisku rozproszonym to wtedy prawdziwy koszmar. W efekcie powstaje niezwykle skomplikowana topologia 562
Rozdział 20. HBase
sieci, którą trzeba zarządzać, i potrzebny jest odrębny klaster do obsługi pamięci podręcznej. Nawet w takim systemie i po pogodzeniu się z różnymi kompromisami nadal pozostają obawy o awarię węzła nadrzędnego oraz przygnębiającą możliwość 10-krotnego wzrostu ilości danych i poziomu obciążenia w najbliższych kilku miesiącach.
Baza HBase Przejdźmy teraz do świata bazy HBase. Ma ona następujące cechy: Brak prawdziwych indeksów Wiersze są zapisywane sekwencyjnie, podobnie jak kolumny w każdym wierszu. Dlatego nie występują problemy z rozrastającymi się indeksami, a wydajność operacji wstawiania danych jest niezależna od wielkości tabeli. Automatyczny podział na partycje Gdy tabela się rozrasta, jest automatycznie rozbijana na regiony i rozdzielana między wszystkie dostępne węzły. Liniowe i automatyczne skalowanie wraz z pojawianiem się nowych węzłów Dodaj węzeł, powiąż go z istniejącym klastrem i uruchom serwer regionów. Regiony zostaną automatycznie ponownie rozmieszczone, co skutkuje równomiernym rozkładem obciążenia. Powszechnie dostępny sprzęt Klastry są budowane za pomocą węzłów kosztujących 3000 – 15 000 złotych, a nie węzłów o cenie 150 000 złotych. Systemy RDBMS wykonują wiele operacji wejścia-wyjścia, co wymaga droższego sprzętu. Odporność na błędy Duża liczba węzłów oznacza, że każdy z nich ma stosunkowo niewielkie znaczenie. Nie trzeba więc martwić się o przestoje pojedynczych węzłów. Przetwarzanie wsadowe Integracja z modelem MapReduce umożliwia wykonywanie w pełni równoległych i rozproszonych zadań z uwzględnieniem lokalności danych. Jeśli pracujesz wieczorami, martwiąc się o bazę danych (czas pracy, skalowanie lub szybkość), powinieneś poważnie rozważyć przejście ze świata systemów RDBMS do świata baz HBase. Wykorzystaj rozwiązanie, które zostało zaprojektowane z myślą o skalowaniu, zamiast ograniczać możliwości niedostosowanego do tego narzędzia i wydawać pieniądze na coś, co sprawdzało się w odmiennych warunkach. Gdy stosujesz bazę HBase, oprogramowanie jest bezpłatne, sprzęt jest niedrogi, a obsługa środowiska rozproszonego jest wbudowana.
Bazy HBase w praktyce W tym podrozdziale opisano typowe problemy, na które użytkownicy natrafiają, gdy uruchamiają klaster z bazą HBase pod obciążeniem. Bazy HBase w praktyce
563
System HDFS Baza HBase używa systemu HDFS w sposób odmienny niż model MapReduce. W modelu MapReduce pliki systemu HDFS są zwykle otwierane, po czym kod przesyła ich zawartość strumieniem do operacji mapowania. Na końcu plik zostaje zamknięty. W bazie HBase pliki danych są otwierane w momencie rozruchu klastra i pozostają otwarte. Pozwala to uniknąć kosztów otwierania plików przy każdym dostępie do nich. Dlatego w bazach HBase występują problemy, które zwykle nie pojawiają się w klientach w modelu MapReduce. Wyczerpanie się deskryptorów plików Ponieważ pliki pozostają otwarte, w obciążonym klastrze szybko mogą zostać przekroczone limity systemu i Hadoopa. Załóżmy, że klaster ma trzy węzły, a w każdym z nich działa węzeł danych i serwer regionów. System wczytuje dane do tabeli mającej obecnie 100 regionów i 10 rodzin kolumn. Przyjmijmy, że każda rodzina kolumn powiązana jest średnio z dwoma plikami z zapisanymi danymi. Daje to 100×10×2, czyli 2000 jednocześnie otwartych plików. Dodaj do tego inne różne deskryptory potrzebne dla dodatkowych skanerów i bibliotek Javy. Każdy otwarty plik wymaga przynajmniej jednego deskryptora w zdalnym węźle danych. Domyślny limit liczby deskryptorów plików na proces (ulimit) wynosi 1024. Przekroczenie tego limitu w systemie plików prowadzi do pojawienia się komunikatu „Too many open files” w dziennikach, jednak często wcześniej można zauważyć nieoczekiwaną pracę bazy HBase. Rozwiązanie polega na zwiększeniu limitu ulimit. Często używana wartość to 10 240. Informacje o tym, jak zwiększyć limit ulimit w klastrze, znajdziesz w podręczniku bazy HBase (http://hbase. apache.org/book.html). Wyczerpanie się wątków węzła danych Węzeł danych w Hadoopie ma też ograniczenie dotyczące liczby wątków, które może jednocześnie uruchomić. W wersji Hadoop 1 ustawiona była niska wartość domyślna tego limitu — 256 (właściwość dfs.datanode.max.xcievers), co prowadziło do niestabilnej pracy bazy HBase. W wersji Hadoop 2 domyślny limit podniesiono do wysokości 4096, dlatego w nowszych wersjach bazy HBase (działających tylko w Hadoopie 2 i nowszych edycjach) problemy pojawiają się rzadziej. To ustawienie można zmienić za pomocą właściwości o nowej nazwie, dfs.datanode. max.transfer.threads, w pliku hdfs-site.xml.
Interfejs użytkownika Baza HBase uruchamia serwer WWW w węźle nadrzędnym, aby udostępniać informacje o stanie działającego klastra. Serwer domyślnie oczekuje na komunikaty w porcie 60010. Interfejs użytkownika w węźle nadrzędnym wyświetla listę podstawowych informacji: wersję oprogramowania, obciążenie klastra, szybkość przychodzenia żądań, listy tabel klastra i pracujące serwery regionów. Gdy klikniesz serwer regionów w interfejsie użytkownika w węźle nadrzędnym, przejdziesz do serwera WWW działającego w wybranym serwerze regionów. Pozwala to przejrzeć regiony przechowywane na serwerze oraz podstawowe wskaźniki (na przykład ilość zajmowanych zasobów i szybkość przychodzenia żądań).
564
Rozdział 20. HBase
Wskaźniki Hadoop udostępnia system wskaźników, które można wykorzystać do zapisania podstawowych informacji z danego okresu w kontekście (opisano to w punkcie „Wskaźniki i technologia JMX” w rozdziale 11.). Włączenie rejestrowania wskaźników Hadoopa, a zwłaszcza powiązanie ich z systemem Ganglia lub generowanie za pomocą technologii JMX, pozwala zrozumieć pracę klastra (zarówno w danym momencie, jak i w nieodległej przeszłości). System HBase dodaje też własne wskaźniki — szybkość przychodzenia żądań, liczbę istotnych zdarzeń, ilość zajmowanych zasobów. Więcej informacji znajdziesz w pliku hadoop-metrics2-hbase.properties w katalogu conf bazy HBase.
Liczniki W serwisie StumbleUpon (https://www.stumbleupon.com/) pierwszą produkcyjną funkcją opartą na bazie HBase było przechowywanie liczników dotyczących frontonu strony głównej. Te liczniki wcześniej były przechowywane w bazie MySQL, jednak szybkość zmian była tak duża, że często pojawiały się błędy. Ponadto obciążenie związane z zapisem liczników było tak wysokie, że projektanci strony sami narzucali limity zliczanych zdarzeń. Metoda incrementColumnValue() klasy HTable pozwala na zwiększanie wartości liczników wiele tysięcy razy na sekundę.
Dalsza lektura W tym rozdziale opisano tylko niewielki wycinek możliwości bazy HBase. Więcej informacji znajdziesz w podręczniku projektu HBase (http://hbase.apache.org/book.html) oraz książkach HBase: The Definitive Guide Larsa George’a (O’Reilly, 2011; trwają prace nad nowym wydaniem) i HBase in Action Nicka Dimiduka i Amandeepa Khurana (Manning, 2012).
Dalsza lektura
565
566
Rozdział 20. HBase
ROZDZIAŁ 21.
ZooKeeper
Jonathan Gray Do tego miejsca w książce opisywane było przetwarzanie dużych zbiorów danych. Ten rozdział jest inny — zawiera omówienie budowania aplikacji rozproszonych za pomocą dostępnej w Hadoopie usługi koordynującej pracę w środowisku rozproszonym. Nazwa tej usługi to ZooKeeper. Pisanie aplikacji rozproszonych jest trudne. Wynika to głównie z możliwości występowania częściowych awarii (ang. partial failure). Gdy komunikat jest przesyłany w sieci do dwóch węzłów i sieć przestanie działać, nadawca nie wie, czy odbiorca otrzymał wiadomość. Mógł ją dostać przed uszkodzeniem sieci, ale nie jest to pewne. Możliwe też, że proces odbiorcy zaprzestał pracy. Jedyny sposób na to, by odbiorca się tego dowiedział, polega na ponownym połączeniu się z odbiorcą i uzyskaniu od niego potrzebnych informacji. Na tym polega częściowa awaria. Nie wiadomo wtedy nawet tego, czy operacja zakończyła się porażką. ZooKeeper nie potrafi wyeliminować częściowych awarii, ponieważ są one nieodłącznym aspektem pracy systemów rozproszonych. Z pewnością nie ukrywa też takich awarii1. Zapewnia jednak zestaw narzędzi do budowania aplikacji rozproszonych, które potrafią bezpiecznie obsługiwać częściowe awarie. Oto cechy systemu ZooKeeper: Prostota ZooKeeper to w swej istocie uproszczony system plików, udostępniający kilka prostych operacji i dodatkowe abstrakcje (związane na przykład z określaniem kolejności i powiadomieniami). Duża swoboda w tworzeniu rozwiązań Proste elementy systemu ZooKeeper to bogaty zestaw cegiełek, które można wykorzystać do budowania różnych struktur danych i protokołów związanych z koordynacją pracy. Dostępne są na przykład rozproszone kolejki, rozproszone blokady i wybór lidera w grupach węzłów równorzędnych. 1
Jest to przesłanie Jima Waldo i współpracowników płynące z artykułu „A Note on Distributed Computing” (Sun Microsystems, październik 1994; http://www.eecs.harvard.edu/~waldo/Readings/waldo-94.pdf). Programowanie rozproszone z natury różni się od programowania w środowisku lokalnym i rozbieżności między tymi modelami nie da się wyeliminować.
567
Wysoka dostępność ZooKeeper działa w grupie maszyn i jest zaprojektowany jako rozwiązanie wysoce dostępne. Dlatego aplikacje mogą na nim polegać. ZooKeeper pomaga uniknąć powstawania jednego punktu krytycznego w systemie, co pozwala budować aplikacje odporne na błędy. Ułatwianie budowania luźno powiązanych interakcji W interakcjach opartych na systemie ZooKeeper mogą uczestniczyć jednostki, które nic o sobie nie wiedzą. ZooKeeper może na przykład pełnić funkcję mechanizmu zarządzania komunikacją, dzięki któremu procesy niewiedzące nic o swoim istnieniu (i nieznające szczegółów dotyczących sieci) mogą się wykrywać i komunikować. Koordynowane jednostki nie muszą nawet istnieć w tym samym czasie — jeden proces może zostawić w systemie ZooKeeper komunikat odczytany przez inny proces już po zamknięciu pierwszego. ZooKeeper ma postać biblioteki ZooKeeper to współużytkowane repozytorium o otwartym dostępie do kodu źródłowego, zawierające implementacje i receptury oparte na często stosowanych wzorcach z obszaru koordynowania pracy. Programiści muszą samodzielnie napisać protokoły, co często nie jest proste. Z czasem społeczność może dodawać i usprawniać kod bibliotek. Jest to korzystne dla wszystkich użytkowników. ZooKeeper ma też wysoką wydajność. W firmie Yahoo!, gdzie system został zbudowany, przepustowość opartego na nim klastra ustalono w ramach testów na ponad 10 000 operacji na sekundę dla obciążenia generowanego przez setki klientów i obejmującego głównie operacje zapisu. Dla obciążenia obejmującego głównie operacje odczytu (co jest częściej spotykane) przepustowość jest kilkakrotnie wyższa2.
Instalowanie i uruchamianie systemu ZooKeeper Gdy chcesz po raz pierwszy uruchomić system ZooKeeper, najłatwiej jest użyć go w trybie niezależnym z jednym serwerem. Można to zrobić na przykład na maszynie używanej do programowania. ZooKeeper wymaga do działania Javy, dlatego upewnij się, że jest zainstalowana. Pobierz stabilną wersję systemu ZooKeeper ze strony z edycjami projektu Apache ZooKeeper (http://zookeeper.apache.org/releases.html) i wypakuj plik tarball w odpowiedniej lokalizacji. % tar xzf zookeeper-x.y.z.tar.gz
ZooKeeper udostępnia kilka plików binarnych służących do uruchamiania usługi i interakcji z nią. Wygodnie jest dodać katalog zawierający pliki binarne do ścieżki do klas dostępnych dla wiersza poleceń. % export ZOOKEEPER_HOME=~/sw/zookeeper-x.y.z % export PATH=$PATH:$ZOOKEEPER_HOME/bin
Przed uruchomieniem usługi ZooKeeper należy przygotować plik konfiguracyjny. Jego nazwa to zwykle zoo.cfg, a umieszczany jest przeważnie w podkatalogu conf (choć można go zapisać także 2
Szczegółowe wyniki testów są dostępne w doskonałej pracy „ZooKeeper: Wait-free coordination for Internet-scale systems” Patricka Hunta i współpracowników (USENIX Annual Technology Conference, 2010; https://www. usenix.org/legacy/event/usenix10/tech/full_papers/Hunt.pdf).
568
Rozdział 21. ZooKeeper
w katalogu /etc/zookeeper lub w katalogu ustawionym w zmiennej środowiskowej ZOOCFGDIR). Oto przykładowa konfiguracja: tickTime=2000 dataDir=/Users/tom/zookeeper clientPort=2181
Jest to standardowy plik właściwości Javy. Trzy zdefiniowane tu właściwości to minimum potrzebne przy uruchamianiu systemu ZooKeeper w trybie niezależnym. Właściwość tickTime określa podstawową jednostkę czasu w ZooKeeperze (podawaną w milisekundach), dataDir to lokalizacja lokalnego systemu plików, gdzie ZooKeeper przechowuje trwałe dane, a clientPort to port, w którym ZooKeeper oczekuje na połączenia z klientami (2181 to często używany port). Wartość właściwości dataDir powinieneś zmienić na lokalizację odpowiednią w Twoim systemie. Po zdefiniowaniu odpowiedniej konfiguracji można uruchomić lokalny serwer ZooKeepera. % zkServer.sh start
Aby sprawdzić, czy ZooKeeper działa, prześlij polecenie ruok (od ang. Are you OK?, czyli „czy wszystko w porządku?”) do portu używanego do komunikacji z klientami. W tym celu zastosuj instrukcję nc (można wykorzystać też instrukcję telnet). % echo ruok | nc localhost 2181 imok
W ten sposób ZooKeeper odpowiada „I’m OK”. W tabeli 21.1 wymieniono polecenia w postaci czteroliterowych słów używanych do zarządzania ZooKeeperem. Tabela 21.1. Polecenia ZooKeepera — czteroliterowe słowa Kategoria
Polecenie
Opis
Status serwera
ruok
Wyświetla imok, jeśli serwer działa i nie znajduje się w stanie błędu.
conf
Wyświetla konfigurację serwera (zapisaną w pliku zoo.cfg).
envi
Wyświetla informacje o środowisku serwera (wersję ZooKeepera, wersję Javy i inne właściwości systemowe).
srvr
Wyświetla statystyki serwera, w tym liczbę węzłów znode i tryb serwera (niezależny, lider lub obserwator).
stat
Wyświetla statystyki serwera i listę połączonych klientów.
srst
Zeruje statystyki serwera.
isro
Informuje, czy serwer działa w trybie tylko do odczytu (ro; dzieje się tak po utracie połączenia między serwerami), czy w trybie odczytu i zapisu (rw).
dump
Wyświetla wszystkie sesje i tymczasowe węzły znode z zestawu. Aby wywołać to polecenie, trzeba nawiązać połączenie z liderem (zobacz instrukcję srvr).
cons
Wyświetla statystyki połączeń dla wszystkich klientów serwera.
crst
Zeruje statystyki połączenia.
wchs
Wyświetla informacje podsumowujące o czujkach serwera.
wchc
Wyświetla wszystkie czujki serwera dla połączenia. Uwaga — przy dużej liczbie czujek może negatywnie wpływać na wydajność serwera.
wchp
Wyświetla wszystkie czujki serwera dla ścieżki do węzła znode. Uwaga — przy dużej liczbie czujek instrukcja może negatywnie wpływać na wydajność serwera.
mntr
Wyświetla statystyki serwera w formacie właściwości Javy. Statystyki można wykorzystać jako dane źródłowe w systemach monitorowania (takich jak Ganglia i Nagios).
Połączenia z klientami
Czujki
Monitorowanie
Instalowanie i uruchamianie systemu ZooKeeper
569
ZooKeeper udostępnia statystyki nie tylko za pomocą polecenia mntr, ale też przy użyciu technologii JMX. Więcej informacji znajdziesz w dokumentacji ZooKeepera (http://zookeeper.apache.org/). W katalogu src/contrib dystrybucji znajdziesz narzędzia do monitorowania i receptury. Od wersji 3.5.0 ZooKeepera dostępny jest wbudowany serwer WWW zapewniający te same informacje co czteroliterowe instrukcje. Listę poleceń znajdziesz pod adresem http://localhost:8080/commands.
Przykład Wyobraź sobie grupę serwerów, które świadczą klientom określoną usługę. Klienty mają mieć możliwość zlokalizowania jednego z serwerów, aby móc korzystać z usługi. Jednym z wyzwań jest przechowywanie listy serwerów z grupy. Listy członków klastra oczywiście nie można przechowywać w tylko jednym węźle sieci, ponieważ awaria tego węzła oznaczałaby uszkodzenie całego systemu. Lista powinna więc być wysoce dostępna. Załóżmy na chwilę, że istnieje odporny na błędy mechanizm przechowywania listy. Do rozwiązania pozostaje problem usuwania serwera z listy po awarii. Jakiś proces musi odpowiadać za usuwanie niedziałających serwerów, przy czym oczywiście nie może on pracować w uszkodzonym serwerze. Opisywana tu rozproszona struktura danych jest aktywna, a nie pasywna. Można w niej zmieniać stan jednostek po wystąpieniu określonych zewnętrznych zdarzeń. ZooKeeper udostępnia taką usługę. Zobacz teraz, jak przy użyciu ZooKeepera zbudować aplikację do zarządzania przynależnością do grup.
Przynależność do grupy w systemie ZooKeeper ZooKeepera można traktować jak narzędzie do zapewniania wysokiej dostępności w systemie plików. ZooKeeper nie obejmuje plików ani katalogów. Zamiast tego używane jest ujednolicone pojęcie węzła znode, który działa jak kontener zarówno na dane (tak jak pliki), jak i na inne węzły znode (tak jak katalogi). Węzły znode tworzą hierarchiczną przestrzeń nazw. Naturalny mechanizm tworzenia listy przynależności polega na utworzeniu nadrzędnego węzła znode z nazwą grupy i węzłów podrzędnych z nazwami jej członków (serwerów). Ten model jest pokazany na rysunku 21.1.
Rysunek 21.1. Węzły znode w systemie ZooKeeper 570
Rozdział 21. ZooKeeper
W omawianym przykładzie żaden węzeł znode nie zawiera danych. Jednak w rzeczywistych aplikacjach w węzłach znode można zapisywać informacje o członkach grupy (na przykład nazwy hostów).
Tworzenie grupy W ramach wprowadzenia do interfejsu API Javy dla ZooKeepera zobacz, jak napisać program tworzący węzeł znode reprezentujący grupę /zoo (listing 21.1). Listing 21.1. Program tworzący węzeł znode reprezentujący grupę w systemie ZooKeeper public class CreateGroup implements Watcher { private static final int SESSION_TIMEOUT = 5000; private ZooKeeper zk; private CountDownLatch connectedSignal = new CountDownLatch(1); public void connect(String hosts) throws IOException, InterruptedException { zk = new ZooKeeper(hosts, SESSION_TIMEOUT, this); connectedSignal.await(); } @Override public void process(WatchedEvent event) { // Z interfejsu Watcher if (event.getState() == KeeperState.SyncConnected) { connectedSignal.countDown(); } } public void create(String groupName) throws KeeperException, InterruptedException { String path = "/" + groupName; String createdPath = zk.create(path, null/*data*/, Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); System.out.println("Created " + createdPath); } public void close() throws InterruptedException { zk.close(); } public static void main(String[] args) throws Exception { CreateGroup createGroup = new CreateGroup(); createGroup.connect(args[0]); createGroup.create(args[1]); createGroup.close(); } }
Metoda main() po uruchomieniu tworzy obiekt typu CreateGroup i wywołuje jego metodę connect(). Ta metoda tworzy nowy obiekt typu ZooKeeper, który jest główną klasą klienckiego interfejsu API. Odpowiada między innymi za utrzymywanie połączenia między klientem i usługą ZooKeeper. Konstruktor tej klasy przyjmuje trzy argumenty. Pierwszy to adres hosta (z opcjonalnym portem; domyślnie używany jest port 2181) usługi ZooKeeper3. Drugi to limit czasu dla sesji, ustawiany tu 3
Dla usługi ZooKeeper z funkcją replikacji w tym parametrze należy podać rozdzieloną przecinkami listę serwerów (hostów i opcjonalnie portów) z grupy.
Przykład
571
na pięć sekund (szczegółowy opis limitu znajdziesz dalej). Trzeci to obiekt typu Watcher. Ten obiekt przyjmuje wywołania zwrotne z ZooKeepera informujące o różnych zdarzeniach. W tym scenariuszu to obiekt typu CreateGroup pełni funkcję obiektu typu Watcher, dlatego jest przekazywany do konstruktora klasy ZooKeeper. Obiekt typu ZooKeeper po utworzeniu uruchamia wątek, aby nawiązać połączenie z usługą ZooKeeper. Konstruktor natychmiast zwraca sterowanie, dlatego ważne jest, by przed użyciem obiektu typu ZooKeeper poczekać na nawiązanie połączenia. Do zablokowania programu do czasu utworzenia obiektu typu ZooKeeper służy klasa CountDownLatch Javy (z pakietu java.util.concurrent). Następnie należy uwzględnić interfejs Watcher. Obejmuje on jedną metodę. public void process(WatchedEvent event);
Po nawiązaniu przez klienta połączenia z ZooKeeperem wywoływana jest metoda process() obiektu typu Watcher. Argumentem jest zdarzenie informujące o nawiązaniu połączenia. Po otrzymaniu takiego zdarzenia (reprezentowanego za pomocą wartości SyncConnected z wyliczenia Watcher. Event.KeeperState) należy za pomocą metody countDown()zmniejszyć wartość licznika w obiekcie typu CountDownLatch. Zatrzask (ang. latch) jest tworzony z wartością jeden. Reprezentuje ona liczbę zdarzeń, które muszą nastąpić, by zatrzask został zwolniony, co umożliwia wznowienie pracy wszystkich wątków. Po jednokrotnym wywołaniu metody countDown() wartość licznika spada do zera i metoda await() zwraca sterowanie. Po zwróceniu sterowania przez metodę connect() wywoływana jest metoda create() typu CreateGroup. Tworzy ona nowy węzeł znode ZooKeepera za pomocą metody create() obiektu typu ZooKeeper. Ta ostatnia metoda przyjmuje jako argumenty ścieżkę (w postaci łańcucha znaków), zawartość węzła znode (tu jest nią tablica bajtów o wartości null), listę kontroli dostępu (ang. Access Control List — ACL; tu lista pozwala wszystkim klientom na odczyt i zapis danych węzła znode) i typ tworzonego węzła znode. Węzły znode mogą być tymczasowe lub trwałe. Węzeł tymczasowy (ang. ephemeral) jest usuwany przez usługę ZooKeeper po zerwaniu połączenia z klientem, który go utworzył. Połączenie może zostać zamknięte jawnie lub z powodu zakończenia pracy przez klienta (z dowolnego powodu). Węzeł trwały (ang. persistent) w momencie rozłączenia się klienta nie jest usuwany. Tu węzeł znode reprezentujący grupę ma być dostępny także po zamknięciu programu, który go utworzył, dlatego należy przygotować węzeł trwały. Wartość zwracana przez metodę create() to ścieżka wygenerowana przez ZooKeepera. Jest ona używana do wyświetlenia komunikatu o udanym utworzeniu ścieżki. Gdy przyjrzysz się sekwencyjnym węzłom znode, zobaczysz, w jaki sposób ścieżka zwrócona przez metodę create() może różnić się od ścieżki przekazanej do tej metody. Aby zobaczyć, jak działa utworzony program, należy uruchomić ZooKeepera na lokalnej maszynie, a następnie wywołać poniższą instrukcję: % export CLASSPATH=ch21-zk/target/classes/:$ZOOKEEPER_HOME/*:\ $ZOOKEEPER_HOME/lib/*:$ZOOKEEPER_HOME/conf % java CreateGroup localhost zoo Created /zoo
572
Rozdział 21. ZooKeeper
Dołączanie członków do grupy Następną częścią aplikacji jest program rejestrujący członków grupy. Każdy z nich jest uruchamiany jako program i dołączany do grupy. Gdy program kończy działanie, należy usunąć go z grupy. Aby uzyskać taki efekt, można utworzyć tymczasowy węzeł znode reprezentujący program w przestrzeni nazw ZooKeepera. W programie JoinGroup (listing 21.2) zastosowano tę technikę. Kod odpowiedzialny za tworzenie obiektu typu ZooKeeper i łączenie się z nim został umieszczony w klasie bazowej ConnectionWatcher, przedstawionej na listingu 21.3. Listing 21.2. Program dołączający do grupy public class JoinGroup extends ConnectionWatcher { public void join(String groupName, String memberName) throws KeeperException, InterruptedException { String path = "/" + groupName + "/" + memberName; String createdPath = zk.create(path, null/* Dane */, Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL); System.out.println("Created " + createdPath); } public static void main(String[] args) throws Exception { JoinGroup joinGroup = new JoinGroup(); joinGroup.connect(args[0]); joinGroup.join(args[1], args[2]); // Program pozostaje aktywny do momentu zamknięcia procesu lub przerwania wątku Thread.sleep(Long.MAX_VALUE); } }
Listing 21.3. Klasa pomocnicza oczekująca na nawiązanie połączenia z ZooKeeperem public class ConnectionWatcher implements Watcher { private static final int SESSION_TIMEOUT = 5000; protected ZooKeeper zk; private CountDownLatch connectedSignal = new CountDownLatch(1); public void connect(String hosts) throws IOException, InterruptedException { zk = new ZooKeeper(hosts, SESSION_TIMEOUT, this); connectedSignal.await(); } @Override public void process(WatchedEvent event) { if (event.getState() == KeeperState.SyncConnected) { connectedSignal.countDown(); } } public void close() throws InterruptedException { zk.close(); } }
Przykład
573
Kod klasy JoinGroup jest bardzo podobny jak klasy CreateGroup. W metodzie join() tworzony jest tymczasowy węzeł znode podrzędny względem węzła znode grupy. Następnie klasa symuluje wykonywanie zadań, usypiając metodę do czasu wymuszonego zamknięcia procesu. Później zobaczysz, że zamknięcie procesu prowadzi do usunięcia tymczasowego węzła znode przez ZooKeepera.
Wyświetlanie członków grupy Teraz potrzebny jest program wyszukujący członków grupy (zobacz listing 21.4). Listing 21.4. Program wyświetlający członków grupy public class ListGroup extends ConnectionWatcher { public void list(String groupName) throws KeeperException, InterruptedException { String path = "/" + groupName; try { List children = zk.getChildren(path, false); if (children.isEmpty()) { System.out.printf("Brak członków w grupie %s\n", groupName); System.exit(1); } for (String child : children) { System.out.println(child); } } catch (KeeperException.NoNodeException e) { System.out.printf("Grupa %s nie istnieje\n", groupName); System.exit(1); } } public static void main(String[] args) throws Exception { ListGroup listGroup = new ListGroup(); listGroup.connect(args[0]); listGroup.list(args[1]); listGroup.close(); } }
W metodzie list() wywoływana jest metoda getChildren() ze ścieżką do węzła znode i opcją czujki powodującą pobranie listy wyświetlanych potem ścieżek węzłów podrzędnych. Umieszczenie czujki w węźle znode powoduje uruchomienie zarejestrowanego obiektu typu Watcher w momencie zmiany stanu węzła znode. Choć tu czujka nie jest używana, obserwowanie węzłów podrzędnych umożliwia programowi otrzymywanie powiadomień o dołączeniu lub odłączeniu członków grupy oraz o usunięciu grupy. Program przechwytuje wyjątek KeeperException.NoNodeException, zgłaszany w sytuacji, gdy węzeł znode grupy nie istnieje. Zobacz teraz, jak działa klasa ListGroup. Zgodnie z oczekiwaniami grupa zoo jest pusta, ponieważ nie dodano do niej jeszcze żadnych członków. % java ListGroup localhost zoo No members in group zoo
574
Rozdział 21. ZooKeeper
Teraz można użyć klasy JoinGroup do dodania członków grupy. Członkowie są uruchamiani jako procesy działające w tle, ponieważ nie kończą samodzielnie pracy (z powodu instrukcji sleep). % % % %
java JoinGroup localhost zoo duck & java JoinGroup localhost zoo cow & java JoinGroup localhost zoo goat & goat_pid=$!
Ostatni wiersz zapisuje identyfikator procesu Javy z programem, który dodaje do grupy węzeł goat. Należy zachować ten identyfikator, by móc później zamknąć proces (po sprawdzeniu członków grupy). % java ListGroup localhost zoo goat duck cow
W celu usunięcia członka grupy należy zamknąć jego proces. % kill $goat_pid
Po kilku sekundach dany członek zniknie z grupy, ponieważ sesja procesu w ZooKeeperze zostanie zamknięta (limit czasu ustawiono na pięć sekund), co spowoduje usunięcie powiązanego węzła tymczasowego. % java ListGroup localhost zoo duck cow
Pora przyjrzeć się temu, co udało się zbudować. Dostępny jest mechanizm budowania listy węzłów grupy używanych w systemie rozproszonym. Te węzły nie muszą nic o sobie wiedzieć. Klient, który chce wykorzystać węzły z listy do wykonania jakichś operacji, może je wykryć. Węzły nie muszą przy tym wiedzieć o istnieniu klienta. Zauważ, że dodawanie węzłów do grupy nie zwalnia z obowiązku obsługi błędów sieci związanych z komunikacją z węzłem. Nawet jeśli węzeł jest członkiem grupy, komunikacja z nim może się nie powieść. Takie usterki trzeba obsługiwać w standardowy sposób — ponawiając próbę, używając innego członka grupy itd.
Narzędzia ZooKeepera uruchamiane w wierszu poleceń ZooKeeper udostępnia uruchamiane w wierszu poleceń narzędzie do interakcji z przestrzenią nazw. Można je wykorzystać do wyświetlenia listy węzłów znode podrzędnych względem węzła /zoo. % zkCli.sh -server localhost ls /zoo [cow, duck]
Aby wyświetlić instrukcje użytkowania, uruchom to polecenie bez argumentów.
Usuwanie grupy W celu uzupełnienia przykładu warto pokazać, jak usunąć grupę. Klasa ZooKeeper udostępnia metodę delete(), która przyjmuje ścieżkę i numer wersji. ZooKeeper kasuje węzeł znode tylko wtedy, jeśli podany numer wersji jest taki sam jak numer wersji usuwanego węzła. Jest to mechanizm blokowania optymistycznego, pozwalający klientom wykrywać konflikty wynikające z modyfikacji węzła znode. Można jednak pominąć sprawdzanie wersji. W tym celu należy podać wartość -1, by usuwać węzeł znode niezależnie od numeru wersji. Przykład
575
W ZooKeeperze nie istnieje operacja rekurencyjnego usuwania węzłów. Dlatego przed usunięciem węzła nadrzędnego trzeba skasować węzły podrzędne. Tak właśnie działa klasa DeleteGroup, usuwająca grupę i wszystkich jej członków (listing 21.5). Listing 21.5. Program usuwający grupę i jej członków public class DeleteGroup extends ConnectionWatcher { public void delete(String groupName) throws KeeperException, InterruptedException { String path = "/" + groupName; try { List children = zk.getChildren(path, false); for (String child : children) { zk.delete(path + "/" + child, -1); } zk.delete(path, -1); } catch (KeeperException.NoNodeException e) { System.out.printf("Grupa %s nie istnieje\n", groupName); System.exit(1); } } public static void main(String[] args) throws Exception { DeleteGroup deleteGroup = new DeleteGroup(); deleteGroup.connect(args[0]); deleteGroup.delete(args[1]); deleteGroup.close(); } }
Na zakończenie można usunąć utworzoną wcześniej grupę zoo. % java DeleteGroup localhost zoo % java ListGroup localhost zoo Group zoo does not exist
Usługa ZooKeeper ZooKeeper to wysoce dostępna i wydajna usługa koordynująca pracę węzłów. W tym podrozdziale znajdziesz charakterystykę tej usługi — jej model danych, działanie i implementację.
Model danych ZooKeeper przechowuje hierarchiczne drzewo węzłów znode. Węzeł znode zawiera dane i jest powiązany z listą ACL. ZooKeeper jest zaprojektowany pod kątem koordynowania pracy (co zwykle związane jest ze stosowaniem małych plików danych), a nie przechowywania dużych zbiorów danych. Dlatego limit ilości danych przechowywanych w węźle znode wynosi 1 MB. Dostęp do danych odbywa się w trybie atomowym. Klient wczytujący dane z węzła znode nigdy nie otrzymuje tylko fragmentu informacji. Dane są albo dostarczane w całości, albo operacja wczytywania kończy się niepowodzeniem. Podobnie operacja zapisu zastępuje wszystkie dane powiązane z węzłem znode. ZooKeeper gwarantuje, że zapis kończy się powodzeniem lub porażką. Nie jest możliwy częściowy zapis, polegający na zapisaniu przez klienta tylko fragmentu danych. ZooKeeper nie obsługuje operacji dodawania danych. Są to inne cechy niż systemu HDFS, zaprojektowanego 576
Rozdział 21. ZooKeeper
z myślą o przechowywaniu dużych zbiorów danych, o strumieniowym dostępie do nich i z możliwością dodawania danych. Węzły znode są wskazywane za pomocą ścieżek, reprezentowanych w ZooKeeperze za pomocą łańcuchów znaków Unicode z ukośnikami (podobnie jak ścieżki z systemu plików w Uniksie). Ścieżki są podawane bezwzględnie, dlatego muszą zaczynać się od ukośnika. Ponadto używane ścieżki są kanoniczne, dlatego dla każdej z nich stosowana jest jedna reprezentacja (ścieżki nie są interpretowane). Na przykład w Uniksie ścieżkę /a/b do pliku można zapisać także jako /a/./b, ponieważ kropka (.) oznacza bieżący katalog w miejscu jej wystąpienia w ścieżce. W ZooKeeperze kropka nie ma specjalnej funkcji. Co więcej, jej stosowanie jako komponentu ścieżki (na przykład w zapisie „..” reprezentującym katalog nadrzędny względem bieżącego) jest niedozwolone. Komponenty ścieżki mogą składać się ze znaków Unicode z kilkoma ograniczeniami (wymienionymi w dokumentacji ZooKeepera). Łańcuch znaków „zookeeper” jest zarezerwowany i nie może występować jako komponent ścieżki. Poddrzewo /zookeeper jest używane do przechowywania informacji administracyjnych, dotyczących na przykład różnych limitów. Warto zauważyć, że ścieżki nie są identyfikatorami URI. W interfejsie API Javy są reprezentowane za pomocą klasy java.lang.String, a nie przy użyciu klasy Path Hadoopa (lub klasy java.net.URI). Węzły znode mają właściwości bardzo przydatne przy budowaniu aplikacji rozproszonych. Omówienie cech węzłów znajdziesz w dalszych podpunktach.
Tymczasowe węzły znode Wcześniej wyjaśniono, że istnieją tymczasowe i trwałe węzły znode. Typ węzła jest określany w momencie jego tworzenia i nie może być później zmieniany. Tymczasowe węzły znode są usuwane przez ZooKeepera po zakończeniu sesji klienta, który je utworzył. Trwałe węzły nie są powiązane z sesją klienta. Klient musi jawnie je usunąć (nie musi to być ten sam klient, który je utworzył). Tymczasowy węzeł znode nie może mieć węzłów podrzędnych (nawet tymczasowych). Choć węzły tymczasowe są powiązane z sesją klienta, pozostają widoczne dla wszystkich klientów (oczywiście zgodnie z listą ACL). Tymczasowe węzły znode doskonale nadają się do budowania aplikacji, w których potrzebna jest wiedza o dostępności określonych zasobów w środowisku rozproszonym. W przykładzie przedstawionym wcześniej w rozdziale użyto tymczasowych węzłów znode do zaimplementowania usługi zarządzającej przynależnością do grupy. Dzięki tej usłudze każdy proces może w dowolnym momencie ustalić członków grupy.
Numery Sekwencyjne węzły mają w nazwach numery przypisane im przez ZooKeepera. Jeśli węzeł znode jest tworzony z opcją numerów, do jego nazwy dodawana jest wartość monotonicznie rosnącego licznika (przechowywanego przez nadrzędny węzeł znode). Gdy klient zażąda utworzenia sekwencyjnego węzła znode o nazwie /a/b-, nowy węzeł będzie miał nazwę na przykład /a/b-34. Jeśli później dodany zostanie inny sekwencyjny węzeł o nazwie /a/b-, 4
Choć nie jest to wymagane, w ścieżkach do węzłów sekwencyjnych zwyczajowo na końcu umieszcza się dywiz. Dzięki temu aplikacjom łatwiej jest odczytywać i parsować numery.
Usługa ZooKeeper
577
otrzyma unikatową nazwę z większą wartością licznika (na przykład /a/b-5). W interfejsie API Javy ścieżka sekwencyjnych węzłów znode jest przekazywana klientom jako wartość zwracana przez wywołanie create(). Numery można wykorzystać do określania globalnej kolejności zdarzeń w systemie rozproszonym. Klienty mogą na podstawie numerów ustalić tę kolejność. Z punktu „Usługa do zarządzania blokadami” dowiesz się, jak wykorzystać sekwencyjne węzły znode do zbudowania współużytkowanej blokady.
Czujki Czujki umożliwiają klientom otrzymywanie powiadomień po wykryciu zmian w węźle znode. Czujki ustawia się za pomocą operacji usługi ZooKeeper, a za wyzwalanie czujek odpowiadają inne operacje tej usługi. Klient może na przykład wywołać operację exists na węźle znode, co powoduje jednocześnie dodanie czujki. Jeśli wskazany węzeł znode nie istnieje, operacja exists zwróci wartość false. Jeżeli później dany węzeł zostanie utworzony przez drugiego klienta, czujka zostanie wyzwolona, co spowoduje powiadomienie pierwszego klienta o dodaniu węzła. Z następnego punktu dowiesz się, które operacje wyzwalają inne. Czujki są uruchamiane tylko raz5. Aby otrzymywać wiele powiadomień, klient musi ponownie zarejestrować czujkę. Dlatego jeśli klient z poprzedniego przykładu ma otrzymywać dalsze powiadomienia o istnieniu węzła znode (na przykład by dowiedzieć się o jego usunięciu), musi ponownie wywołać operację exists, co spowoduje ustawienie nowej czujki. W punkcie „Usługa do zarządzania konfiguracją” pokazano, jak używać czujek do aktualizowania konfiguracji w klastrze.
Operacje W ZooKeeperze występuje dziewięć podstawowych operacji. Są one opisane w tabeli 21.2. Tabela 21.2. Operacje w usłudze ZooKeeper Operacja
Opis
create
Tworzy węzeł znode (węzeł nadrzędny musi już istnieć). Usuwa węzeł znode (usuwany węzeł znode nie może mieć węzłów podrzędnych). Sprawdza, czy węzeł znode istnieje, i pobiera jego metadane. Pobiera lub ustawia listę ACL węzła znode. Pobiera listę węzłów podrzędnych węzła znode. Pobiera lub ustawia dane powiązane z węzłem znode. Synchronizuje informacje o węźle znode w kliencie z danymi z ZooKeepera.
delete exists getACL, setACL getChildren getData, setData sync
Operacje aktualizacji w ZooKeeperze są wykonywane warunkowo. W operacji delete lub setData trzeba określić numer wersji aktualizowanego węzła znode (ustalony we wcześniejszym wywołaniu exists). Jeśli numery wersji nie pasują do siebie, aktualizacja kończy się niepowodzeniem. Aktualizacje to operacje nieblokujące, dlatego klient, który utraci aktualizację (ponieważ inny 5
Wyjątkiem są wywołania zwrotne dla zdarzeń dotyczących połączeń, których nie trzeba ponownie rejestrować.
578
Rozdział 21. ZooKeeper
proces zaktualizował węzeł), może zdecydować, czy ponowić próbę wykonania operacji, czy przejść do innych czynności. Może to zrobić bez blokowania pracy innych procesów. Choć ZooKeepera można traktować jak system plików, ze względu na prostotę zrezygnowano w nim z niektórych podstawowych operacji. Ponieważ pliki są małe i są zapisywane oraz wczytywane w całości, nie są potrzebne operacje otwierania, zamykania i przeszukiwania. Operacja sync działa inaczej niż fsync() w POSIX-owych systemach plików. Wcześniej wspomniano, że zapis w ZooKeeperze odbywa się atomowo. W większości serwerów ZooKeepera udana operacja zapisu oznacza, że dane zostały zachowane w trwałej pamięci. Jednak wczytywane dane mogą być przestarzałe względem najnowszego stanu usługi ZooKeeper. Dlatego istnieje operacja sync, która pozwala klientowi zaktualizować dane. To zagadnienie opisano szczegółowo w punkcie „Spójność”.
Wykonywanie grup operacji W ZooKeeperze dostępna jest operacja multi. Łączy ona zestaw prostych operacji w jednostkę, której wykonywanie w całości kończy się powodzeniem lub porażką. Nie może się zdarzyć, że niektóre z prostych operacji zostaną zakończone powodzeniem, a inne nie. Wykonywanie grup operacji (ang. multiupdate) jest bardzo przydatne do budowania w ZooKeeperze struktur zachowujących globalne niezmienniki. Jednym z przykładów jest graf nieskierowany. Naturalnym odpowiednikiem każdego wierzchołka grafu jest w ZooKeeperze węzeł znode. Aby dodać lub usunąć krawędź grafu, trzeba zaktualizować dwa węzły znode odpowiadające wierzchołkom krawędzi (ponieważ każdy z nich przechowuje referencję do drugiego). Jeśli używane są tylko proste operacje ZooKeepera, może się zdarzyć, że inny klient użyje grafu w niespójnym stanie, kiedy pierwszy wierzchołek jest powiązany z drugim, ale już nie na odwrót. Połączenie aktualizacji obu węzłów znode w operacji multi gwarantuje, że cały proces przebiega atomowo. Dzięki temu nigdy nie powstaje niepełne powiązanie między parą wierzchołków.
Interfejsy API Dla klientów ZooKeepera dostępne są interfejsy dla dwóch podstawowych języków — Javy i C. Dostępne są też interfejsy dodatkowe dla Perla, Pythona i modelu REST (znajdują się one w katalogu contrib). W każdym interfejsie operacje można wykonywać synchronicznie lub asynchronicznie. Poznałeś już synchroniczny interfejs API dla Javy. Poniżej pokazana jest sygnatura operacji exists, która zwraca albo obiekt typu Stat z metadanymi węzła znode, albo null, jeśli dany węzeł nie istnieje. public Stat exists(String path, Watcher watcher) throws KeeperException, InterruptedException
Asynchroniczny odpowiednik tej metody, także dostępny w klasie ZooKeeper, wygląda tak: public void exists(String path, Watcher watcher, StatCallback cb, Object ctx)
W interfejsie API Javy wszystkie metody asynchroniczne zwracają wartość void, ponieważ wynik operacji jest przekazywany za pomocą wywołania zwrotnego. Nadawca przekazuje implementację wywołania zwrotnego, którego metoda jest wywoływana po otrzymaniu odpowiedzi z ZooKeepera. Tu wywołaniem zwrotnym jest interfejs StatCallback zawierający następującą metodę: public void processResult(int rc, String path, Object ctx, Stat stat);
Usługa ZooKeeper
579
Argument rc to kod powrotny, odpowiadający kodom zdefiniowanym w klasie KeeperException. Kod różny od zera oznacza wyjątek. Wtedy parametr stat ma wartość null. Argumenty path oraz ctx odpowiadają analogicznym argumentom przekazanym przez klienta do metody exists() i mogą być używane do identyfikowania żądań, dla których dane wywołanie zwrotne jest odpowiedzią. Parametr ctx może być dowolnym obiektem używanym przez klienta, gdy ścieżka nie zapewnia informacji wystarczających do zidentyfikowania żądania. Gdy taki obiekt nie jest potrzebny, parametr można ustawić na wartość null. Dostępne są dwie współużytkowane biblioteki w języku C. Biblioteka jednowątkowa, zookeeper_st, udostępnia tylko asynchroniczny interfejs API i jest przeznaczona dla platform, w których biblioteka pthread jest niedostępna lub niestabilna. Większość programistów korzysta z biblioteki wielowątkowej, zookeeper_mt, ponieważ udostępnia ona synchroniczny i asynchroniczny interfejs API. Szczegółowe informacje o budowaniu i stosowaniu interfejsu API dla języka C znajdziesz w pliku README w katalogu src/c dystrybucji ZooKeepera.
Lepiej korzystać z synchronicznego czy asynchronicznego interfejsu API? Oba interfejsy API udostępniają te same funkcje, dlatego wybór jednego z nich zależy od preferencji programisty. Asynchroniczny interfejs API jest odpowiedni na przykład wtedy, gdy stosowany jest model programowania sterowany zdarzeniami. Asynchroniczny interfejs API umożliwia połączenie żądań w potok, co czasem zapewnia lepszą wydajność. Wyobraź sobie, że chcesz wczytać dużą grupę węzłów znode i niezależnie przetwarzać poszczególne węzły. Gdy używany jest synchroniczny interfejs API, każda operacja wczytywania blokuje program do czasu zwrócenia sterowania. W asynchronicznym interfejsie API można bardzo szybko uruchomić wszystkie asynchroniczne operacje odczytu i przetwarzać przychodzące odpowiedzi w odrębnym wątku.
Wyzwalacze czujek W związanych z odczytem operacjach exists, getChildren i getData można ustawić czujki. Wyzwalaczami czujek są operacje powodujące zapis — create, delete i setData. Operacje związane z listami ACL nie wpływają na czujki. Gdy zadziała wyzwalacz czujki, generowane jest zdarzenie czujki. Typ zdarzenia zależy zarówno od czujki, jak i operacji.
Czujka ustawiona w operacji exists jest wyzwalana, gdy obserwowany węzeł znode jest tworzony lub usuwany, a także przy aktualizowaniu jego danych.
Czujka ustawiona w operacji getData jest wyzwalana, gdy obserwowany węzeł znode jest usuwany, a także przy aktualizowaniu jego danych. Przy tworzeniu wyzwalacz nie jest uruchamiany, ponieważ aby operacja getData zakończyła się powodzeniem, dany węzeł znode musi już istnieć.
Czujka ustawiona w operacji getChildren jest wyzwalana, gdy węzeł podrzędny względem obserwowanego węzła znode jest tworzony lub usuwany, a także przy usuwaniu obserwowanego węzła. Aby stwierdzić, czy usunięty został obserwowany węzeł, czy jego węzeł podrzędny, należy sprawdzić typ zdarzenia czujki. Zdarzenie NodeDeleted oznacza usunięcie obserwowanego węzła, a zdarzenie NodeChildrenChanged oznacza usunięcie węzła podrzędnego.
580
Rozdział 21. ZooKeeper
Przegląd kombinacji opisanych aspektów znajdziesz w tabeli 21.3. Tabela 21.3. Operacje tworzące czujki i powiązane z nimi wyzwalacze Wyzwalacz czujki Tworzenie czujki
Tworzenie węzła znode
exists
NodeCreated
Tworzenie węzła podrzędnego
getData getChildren
NodeChildrenChanged
Usuwanie węzła znode
Usuwanie węzła podrzędnego
Instrukcj a setData
NodeDeleted
NodeData Changed
NodeDeleted
NodeData Changed
NodeDeleted
NodeChildrenChanged
Zdarzenie czujki obejmuje ścieżkę do węzła znode powiązanego z danym zdarzeniem. Dzięki temu na podstawie zdarzeń NodeCreated i NodeDeleted można ustalić utworzony lub usunięty węzeł (wystarczy sprawdzić ścieżkę). Jeśli na podstawie zdarzenia NodeChildrenChanged chcesz ustalić, które węzły podrzędne zostały zmodyfikowane, musisz ponownie wywołać metodę getChildren i pobrać nową listę węzłów podrzędnych. Podobnie w celu określenia nowych danych na podstawie zdarzenia NodeDataChanged trzeba wywołać metodę getData. W obu sytuacjach stan węzłów znode może się zmienić w czasie między zarejestrowaniem zdarzenia czujki a wykonaniem operacji odczytu. Należy o tym pamiętać w trakcie pisania aplikacji.
Listy ACL Razem z węzłem znode tworzona jest lista ACL. Określa ona, kto może wykonywać poszczególne operacje na nim. Listy ACL wymagają stosowania uwierzytelniania, czyli procesu, w którym klient identyfikuje się przed ZooKeeperem. ZooKeeper udostępnia kilka mechanizmów uwierzytelniania. digest
Klient jest uwierzytelniany na podstawie nazwy użytkownika i hasła. sasl
Klient jest uwierzytelniany za pomocą protokołu Kerberos. ip
Klient jest uwierzytelniany na podstawie adresu IP. Klienty mogą się uwierzytelniać po nawiązaniu sesji w ZooKeeperze. Uwierzytelnianie jest opcjonalne, choć lista ACL węzła znode może wymagać uwierzytelnienia. Wtedy klient musi się uwierzytelnić, by uzyskać dostęp do węzła. Poniżej pokazano, jak w modelu digest uwierzytelnić klienta za pomocą nazwy użytkownika i hasła: zk.addAuthInfo("digest", "tom:secret".getBytes());
Lista ACL określa model uwierzytelniania, odpowiedni dla modelu identyfikator i zestaw uprawnień. Na przykład jeśli chcesz zapewnić klientowi o adresie IP 10.0.0.1 dostęp w trybie do odczytu do węzła znode, należy ustawić dla tego węzła listę ACL z modelem ip, identyfikatorem 10.0.0.1 i uprawnieniami READ. W Javie potrzebny obiekt typu ACL tworzy się tak: Usługa ZooKeeper
581
new ACL(Perms.READ, new Id("ip", "10.0.0.1"));
Pełny zestaw uprawnień przedstawiono w tabeli 21.4. Zauważ, że operacja exists nie jest zależna od uprawnień z listy ACL. Dlatego dowolny klient może wywołać operację exists, by pobrać obiekt typu Stat dla danego węzła znode lub stwierdzić, że węzeł nie istnieje. Tabela 21.4. Uprawnienia z list ACL Uprawnienie z list ACL
Dozwolone operacje
CREATE
create (dla podrzędnego węzła znode)
READ
getChildren getData
WRITE
setData
DELETE
delete (dla podrzędnego węzła znode)
ADMIN
setACL
W klasie ZooDefs.Ids istnieje grupa wbudowanych list ACL. Jedną z nich jest OPEN_ACL_UNSAFE, zapewniająca każdemu prawie wszystkie uprawnienia (z wyjątkiem uprawnień ADMIN). ZooKeeper obsługuje też dołączanie mechanizmu uwierzytelniania, dzięki czemu w razie potrzeby można zintegrować z rozwiązaniem niezależne systemy uwierzytelniania.
Implementacja Usługa ZooKeeper pracuje w dwóch trybach. W trybie niezależnym działa jeden serwer ZooKeeper. Ten serwer ze względu na prostotę przydaje się w trakcie testów (można go nawet osadzić w testach jednostkowych), ale nie zapewnia wysokiej dostępności ani odporności na błędy. W środowisku produkcyjnym ZooKeeper działa w trybie replikacji w klastrze maszyn nazywanym zestawem (ang. ensemble). ZooKeeper zapewnia wysoką dostępność dzięki replikacji i może świadczyć usługi dopóty, dopóki większość maszyn z zestawu jest sprawna. Na przykład w zestawie pięciu węzłów dwie maszyny mogą przestać działać, a usługa nadal będzie dostępna, ponieważ pracują trzy pozostałe węzły, które stanowią większość. Zauważ, że w zestawie sześciu węzłów akceptowalna jest awaria tylko dwóch z nich, ponieważ po uszkodzeniu trzech pozostałe trzy nie stanowią większości. Dlatego zestawy składają się zazwyczaj z nieparzystej liczby maszyn. Na poziomie opisu ZooKeeper to bardzo prosty system. Zapewnia tylko to, że każda modyfikacja w drzewie węzłów znode zostanie zreplikowana w większości maszyn zestawu. Jeśli mniejszość maszyn przestanie działać, wiadomo, że przynajmniej jedna ze sprawnych maszyn będzie zawierać najnowszy stan danych. Pozostałe repliki ostatecznie zaktualizują na tej podstawie swój stan. Jednak implementacja tego prostego systemu nie jest łatwa. ZooKeeper wykorzystuje dwuetapowy protokół Zab, którego fazy mogą być powtarzane w nieskończoność. Oto one: Etap 1. Wybór lidera Maszyny z zestawu wybierają wyróżnionego członka nazywanego liderem. Pozostałe maszyny to obserwatory (ang. follower). Ten etap kończy się w momencie, gdy większość (kworum) obserwatorów zsynchronizuje swój stan z liderem. 582
Rozdział 21. ZooKeeper
Etap 2. Atomowe rozsyłanie zmian Wszystkie żądania zapisu są przekazywane do lidera, który rozsyła aktualizację do obserwatorów. Po utrwaleniu zmiany w większości obserwatorów lider zatwierdza aktualizację, a klient otrzymuje odpowiedź z informacją o powodzeniu operacji. Protokół służący do osiągania konsensusu działa atomowo, dlatego zmiana kończy się albo powodzeniem, albo porażką. Podobnie przebiega dwuetapowe zatwierdzanie zmian.
Czy w ZooKeeperze używany jest algorytm Paxos? Nie — protokół Zab z ZooKeepera różni się od dobrze znanego algorytmu Paxos6. Zab działa podobnie, ale różni się w kilku aspektach działania — na przykład wykorzystuje protokół TCP do gwarantowania kolejności komunikatów7. Algorytm Paxos jest wykorzystywany w usłudze Chubby Lock Service firmy Google8. Usługa ta ma zapewniać te same efekty co ZooKeeper.
Gdy lider przestaje działać, pozostałe maszyny dokonują nowego wyboru i kontynuują pracę z następnym liderem. Jeśli dawny lider wznowi pracę, robi to w roli obserwatora. Wybór lidera odbywa się bardzo szybko. Według opublikowanych badań (http://zookeeper.apache.org/doc/current/ zookeeperOver.html) trwa to około 200 milisekund, dlatego proces wyboru lidera nie prowadzi do zauważalnego spadku wydajności systemu. Wszystkie maszyny z zestawu zapisują aktualizacje na dysku, a dopiero potem aktualizują kopię drzewa węzłów znode przechowywaną w pamięci. Żądania odczytu mogą być przetwarzane przez dowolną maszynę, a ponieważ trzeba tylko znaleźć dane w pamięci, proces jest bardzo szybki.
Spójność Zapoznanie się z podstawami implementacji ZooKeepera pomaga w zrozumieniu gwarancji spójności zapewnianych przez tę usługę. Pojęcia „lider” i „obserwator” dotyczące maszyn zestawu są trafne, ponieważ podkreślają, że obserwator może być opóźniony względem lidera o kilka aktualizacji. Wynika to z tego, że zmiany przed ich zatwierdzeniem są utrwalane tylko w większości członków zestawu, a nie we wszystkich maszynach. Pracę systemu opartego na ZooKeeperze można wyobrazić sobie jako zbiór klientów połączonych z serwerami ZooKeepera podążającymi za liderem. Klient może być połączony z liderem, ale nie ma nad tym kontroli i nie wie nawet, czy tak jest9. Zobacz rysunek 21.2. 6
Leslie Lamport, Paxos Made Simple, „ACM SIGACT News”, grudzień 2001 (http://research.microsoft.com/ en-us/um/people/lamport/pubs/paxos-simple.pdf).
7
Opis protokołu Zab znajdziesz w: Benjamin Reed i Flavio Junqueira, „A simple totally ordered broadcast protocol”, LADIS ’08 Proceedings of the 2nd Workshop on Large-Scale Distributed Systems and Middleware, 2008 (http://diyhpl.us/~bryan/papers2/distributed/distributed-systems/zab.totally-ordered-broadcast-protocol.2008.pdf).
8
Mike Burrows, „The Chubby Lock Service for Loosely-Coupled Distributed Systems”, listopad 2006 (http://research.google.com/archive/chubby.html).
9
Można tak skonfigurować ZooKeepera, by lider nie akceptował połączeń od klientów. Wtedy jedynym zadaniem ZooKeepera jest koordynowanie aktualizacji. Aby uzyskać taki efekt, należy ustawić właściwość leaderServes na no. Jest to zalecane w zestawach składających się z więcej niż trzech serwerów.
Usługa ZooKeeper
583
Rysunek 21.2. Żądania odczytu są przetwarzane przez obserwatory, natomiast zapis jest zatwierdzany przez lidera
Każda aktualizacja w drzewie węzłów znode otrzymuje globalnie unikatowy identyfikator zxid (od ang. ZooKeeper transaction ID). Aktualizacje są uporządkowane. Dlatego jeśli identyfikator zxid z1 jest mniejszy niż z2, aktualizacja z1 miała miejsce przed z2 (przynajmniej według ZooKeepera, który jest jedynym nadzorcą kolejności w systemie rozproszonym). Z projektu ZooKeepera wynikają wymienione poniżej gwarancje spójności danych. Spójność kolejności Aktualizacje z danego klienta są wprowadzane zgodnie z kolejnością ich przesyłania. To oznacza, że jeśli klient zaktualizował węzeł znode z do wartości a, a w późniejszej operacji ustawił w tym węźle wartość b, żaden klient nie pobierze z węzła z wartości a po pobraniu z niego wartości b (jeżeli węzeł z nie zostanie ponownie zaktualizowany). Atomowość Aktualizacje kończą się albo powodzeniem, albo porażką. To oznacza, że jeśli aktualizacja się nie powiedzie, żaden klient nie zobaczy jej efektów. Jeden obraz systemu Klient uzyskuje ten sam obraz systemu niezależnie od serwera, z którym nawiąże połączenie. To oznacza, że gdy klient w tej samej sesji łączy się z nowym serwerem, widzi stan systemu nie starszy niż na poprzednim serwerze. Jeśli klient po awarii serwera próbuje połączyć się z inną maszyną z zestawu, serwery z danymi starszymi niż na uszkodzonym serwerze nie akceptują połączeń od klienta do czasu zaktualizowania swojego stanu. Trwałość Udana aktualizacja zostaje utrwalona i nie jest wycofywana. To oznacza, że aktualizacje potrafią przetrwać awarie serwera. 584
Rozdział 21. ZooKeeper
Aktualność Opóźnienie obrazu systemu udostępnianego klientom ma ograniczoną wielkość. Dlatego dane nie są przestarzałe o więcej niż kilkadziesiąt sekund. To oznacza, że zamiast udostępniać klientowi mocno przestarzałe dane, serwer kończy pracę, co wymusza na kliencie przełączenie się do bardziej aktualnego serwera. Ze względu na wydajność odczyty są obsługiwane za pomocą danych z pamięci serwera ZooKeepera i nie dotyczy ich globalna kolejność operacji zapisu. To sprawia, że klienty komunikujące się za pomocą mechanizmów zewnętrznych względem ZooKeepera mogą zobaczyć niespójny stan. Wyobraź sobie, że klient A aktualizuje węzeł znode z z a do a’, nakazuje klientowi B wczytanie z, a B wczytuje wartość a zamiast a’. Jest to zupełnie możliwe w kontekście gwarancji ZooKeepera (nie zapewniają one spójnego widoku danych dla różnych klientów). Aby zapobiec takiej sytuacji, w kliencie B należy wywołać operację sync dla węzła z przed wczytaniem jego wartości. Operacja sync wymusza na serwerze ZooKeepera, z którym B jest połączony, „dogonienie” lidera. Dzięki temu gdy klient B wczyta wartość węzła z, będzie to wartość ustawiona przez klienta A (lub nowsza). Trochę mylące jest to, że operacja sync jest dostępna tylko jako wywołanie asynchroniczne. Jest tak, ponieważ nie trzeba oczekiwać na zwrócenie sterowania przez tę operację. ZooKeeper gwarantuje, że każda późniejsza operacja zostanie wykonana po zakończeniu operacji sync (nawet jeśli została wywołana przed ukończeniem wykonywania sync).
Sesje W konfiguracji klienta ZooKeepera określana jest lista serwerów z zestawu. Przy rozruchu klient próbuje nawiązać połączenie z jednym z serwerów z listy. Jeśli się to nie uda, klient łączy się z następnym serwerem z listy itd. Ten proces kończy się nawiązaniem połączenia z jednym z serwerów, a gdy wszystkie serwery ZooKeepera są niedostępne niepowodzeniem. Po nawiązaniu połączenia serwer ZooKeepera tworzy nową sesję dla klienta. Sesja ma limit czasu określany przez aplikację, dla której utworzono sesję. Jeśli serwer nie otrzyma żądania w określonym czasie, może ją uznać za wygasłą. Wygasłej sesji nie można ponownie otworzyć. Wszystkie powiązane z nią węzły tymczasowe zostają wtedy utracone. Choć wygaśnięcie sesji to stosunkowo rzadkie zdarzenie (ponieważ sesje trwają długo), ważne jest, aby zapewnić obsługę takich sytuacji w aplikacji. Z punktu „Odporne na błędy aplikacje ZooKeepera” dowiesz się, jak to zrobić. Sesje są podtrzymywane dzięki przesyłaniu przez klienta żądań ping (sygnałów kontrolnych), gdy sesja pozostaje bezczynna dłużej niż przez określony czas. Biblioteka kliencka ZooKeeper automatycznie przesyła żądania ping, dlatego programista nie musi martwić się o podtrzymywanie sesji we własnym kodzie. Należy ustawić na tyle krótki czas bezczynności, by móc wykryć awarię serwera (objawiającą się przekroczeniem limitu czasu odczytu) i połączyć się z innym serwerem w limicie czasu, po którym sesja zostaje uznana za wygasłą. Klient ZooKeepera automatycznie przełącza się awaryjnie do innego serwera ZooKeepera. W takiej sytuacji sesje (i powiązane z nimi tymczasowe węzły znode) pozostają poprawne po przejęciu obowiązków uszkodzonego serwera przez inną maszynę.
Usługa ZooKeeper
585
W trakcie przełączania awaryjnego aplikacja otrzymuje powiadomienia o rozłączaniu się i łączeniu z usługą. W czasie, gdy klient jest odłączony, powiadomienia dotyczące czujek nie są dostarczane. Trafiają one do klienta wtedy, gdy ten ponownie nawiąże połączenie. Ponadto jeśli aplikacja spróbuje wykonać operację w czasie, gdy klient dopiero łączy się z nowym serwerem, operacja zakończy się niepowodzeniem. To podkreśla znaczenie obsługi wyjątków związanych z utratą połączenia w stosowanych w praktyce aplikacjach ZooKeepera (co opisano w punkcie „Odporne na błędy aplikacje ZooKeepera”).
Czas W ZooKeeperze występuje kilka parametrów związanych z czasem. Czas taktu (ang. tick time) to podstawowa jednostka czasu w ZooKeeperze, używana przez serwery z zestawu do definiowania harmonogramu interakcji. Inne ustawienia są określane według czasu taktu (lub ograniczane na jego podstawie). Na przykład limit czasu sesji musi wynosić od 2 do 20 taktów. Jeśli spróbujesz ustawić limit czasu sesji na wartość spoza tego przedziału, podany czas zostanie odpowiednio zmieniony. Czas taktu często wynosi 2 sekundy (2000 milisekund). Limit czasu sesji można wtedy ustawić na wartość od 4 do 40 sekund. Przy ustawianiu limitu czasu sesji należy uwzględnić kilka kwestii. Niska wartość limitu pozwala szybciej wykryć awarię maszyny. W przykładowym programie sprawdzającym przynależność do grupy limit czasu sesji określa, kiedy uszkodzona maszyna jest usuwana z grupy. Uważaj jednak, by nie ustawić limitu na zbyt niską wartość, ponieważ w obciążonej sieci pakiety mogą być przesyłane z opóźnieniem. Może to doprowadzić do przypadkowego wygaśnięcia sesji. W takiej sytuacji maszyna zaczyna pojawiać się i znikać — w krótkim czasie wielokrotnie opuszcza grupę i dołącza do niej. W aplikacjach, w których używany jest złożony tymczasowy stan, warto stosować długi limit czasu sesji, ponieważ koszt odtwarzania stanu jest wysoki. W niektórych sytuacjach można tak zaprojektować aplikację, by wznawiała pracę w limicie czasu sesji, co pozwala uniknąć wygasania sesji. Jest to pożądane na przykład przy konserwacji lub aktualizowaniu systemu. Serwer ustawia dla każdej sesji unikatowy identyfikator i hasło. Jeśli te dane zostaną przekazane do ZooKeepera w trakcie nawiązywania połączenia, możliwe jest odzyskanie sesji (o ile jeszcze nie wygasła). W ten sposób aplikacja może bezpiecznie kończyć pracę. Wymaga to zapisania identyfikatora sesji oraz hasła w trwałej pamięci przed ponownym uruchomieniem procesu, a następnie pobrania zapisanych danych i odzyskania sesji. To rozwiązanie należy traktować jak optymalizację, która pozwala uniknąć wygasania sesji. Opisany mechanizm nie zastępuje konieczności obsługi wygasania sesji, co może nastąpić, jeśli maszyna nieoczekiwanie zawiedzie (a nawet po bezpiecznym zamknięciu aplikacji, jeśli z dowolnych przyczyn nie uda się jej ponownie uruchomić przed wygaśnięciem sesji). Zgodnie z ogólną regułą im większy jest zestaw serwerów w ZooKeeperze, tym dłuższy powinien być limit czasu sesji. Limit czasu nawiązywania połączenia, limit czasu odczytu i odstępy między żądaniami ping są wewnętrznie definiowane na podstawie liczby serwerów z zestawu. Gdy wielkość zestawu rośnie, wymienione limity są skracane. Jeśli zauważasz częstą utratę połączenia, rozważ zwiększenie limitu czasu. Wskaźniki związane z ZooKeeperem (dotyczące na przykład czasu przetwarzaniu żądań) możesz monitorować za pomocą technologii JMX. 586
Rozdział 21. ZooKeeper
Stany Obiekt ZooKeeper w cyklu życia przechodzi przez różne stany (zobacz rysunek 21.3). Stan obiektu możesz sprawdzić w dowolnym momencie za pomocą metody getState(). public States getState()
Rysunek 21.3. Przejścia obiektu typu ZooKeeper między stanami States to wyliczenie reprezentujące różne stany obiektu typu ZooKeeper. Choć nazwa wyliczenia podana jest w liczbie mnogiej, obiekt typu ZooKeeper w danym momencie może pozostawać w tylko jednym stanie. Nowy obiekt tego typu znajduje się w stanie CONNECTING, gdy próbuje nawiązać połączenie z usługą ZooKeeper. Po nawiązaniu połączenia przechodzi w stan CONNECTED.
Klient używający obiektu typu ZooKeeper może otrzymywać powiadomienia o zmianie stanu. W tym celu należy zarejestrować obiekt typu Watcher. Po przejściu w stan CONNECTED taki obiekt otrzymuje zdarzenie WatchedEvent, w którym właściwość KeeperState ma wartość SyncConnected. Obiekt typu Watcher w ZooKeeperze ma dwie funkcje — może służyć do otrzymywania powiadomień o zmianach w stanie ZooKeepera (co opisano w tym punkcie), a także do otrzymywania powiadomień o modyfikacjach w węzłach znode (co omówiono w punkcie „Wyzwalacze czujek”). Domyślny obiekt typu Watcher przekazywany do konstruktora obiektu typu ZooKeeper obsługuje powiadomienia o zmianie stanu. Dla zmian w węźle znode można wykorzystać albo odrębny obiekt typu Watcher (należy go przekazać do odpowiedniej operacji odczytu danych), albo obiekt domyślny. Aby wykorzystać obiekt domyślny, należy użyć operacji odczytu przyjmującej opcję logiczną określającą, czy zastosować obiekt typu Watcher.
Usługa ZooKeeper
587
Obiekt typu ZooKeeper może zerwać połączenie i ponownie połączyć się z usługą ZooKeeper. Następuje wtedy przejście między stanami CONNECTED i CONNECTING. Po zerwaniu połączenia obiekt typu Watcher otrzymuje zdarzenie Disconnected. Zauważ, że wymienione tu zmiany stanu są inicjowane przez sam obiekt typu ZooKeeper. Po utracie połączenia ten obiekt automatycznie próbuje ponownie je nawiązać. Obiekt typu ZooKeeper może przejść w trzeci stan, CLOSED, gdy wywołana zostanie metoda close() lub nastąpi przekroczenie limitu czasu sesji (informuje o tym wartość Expired właściwości KeeperState). W stanie CLOSED obiekt typu ZooKeeper nie jest już uważany za aktywny (aby to sprawdzić, wywołaj metodę isAlive()wyliczenia States) i nie można go ponownie wykorzystać. W celu ponownego nawiązania połączenia z usługą ZooKeeper klient musi wtedy utworzyć nowy obiekt typu ZooKeeper.
Budowanie aplikacji z wykorzystaniem ZooKeepera Po omówieniu ZooKeepera pora wrócić do pisania przydatnych aplikacji z wykorzystaniem tego narzędzia.
Usługa do zarządzania konfiguracją Jedną z najbardziej podstawowych usług potrzebnych rozproszonej aplikacji jest usługa do zarządzania konfiguracją. Pozwala ona na współużytkowanie przez maszyny z klastra wspólnych danych konfiguracyjnych. Na najprostszym poziomie ZooKeeper może pełnić funkcję wysoce dostępnego magazynu na konfigurację i pozwalać elementom aplikacji na pobieranie oraz aktualizowanie plików konfiguracyjnych. Za pomocą czujek ZooKeepera można też utworzyć aktywną usługę, która powiadamia zainteresowane klienty o zmianach w konfiguracji. Napiszmy taką usługę. Przyjęto tu kilka założeń, które pozwalają uprościć implementację (by z nich zrezygnować, trzeba włożyć w rozwiązanie trochę więcej pracy). Po pierwsze, wszystkie przechowywane wartości konfiguracyjne to łańcuchy znaków, a kluczami są ścieżki do węzłów znode. Dlatego do przechowywania każdej pary klucz-wartość służą węzły znode. Po drugie, w danym momencie aktualizować dane może tylko jeden klient. Ten model jest między innymi zgodny z systemem, w którym jednostka nadrzędna (na przykład węzeł nazw w systemie HDFS) ma aktualizować informacje wykorzystywane w jednostkach roboczych. Kod znajduje się w klasie ActiveKeyValueStore. public class ActiveKeyValueStore extends ConnectionWatcher { private static final Charset CHARSET = Charset.forName("UTF-8"); public void write(String path, String value) throws InterruptedException, KeeperException { Stat stat = zk.exists(path, false); if (stat == null) { zk.create(path, value.getBytes(CHARSET), Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); } else { zk.setData(path, value.getBytes(CHARSET), -1); } } }
588
Rozdział 21. ZooKeeper
Metoda write() ma zapisywać klucz o danej wartości w ZooKeeperze. Pozwala to ukryć różnice między tworzeniem nowego węzła znode a aktualizowaniem istniejącego węzła nową wartością. Kod najpierw za pomocą operacji exists sprawdza, czy dany węzeł znode istnieje, a następnie wykonuje odpowiednie czynności. Warto też zwrócić uwagę na konieczność przekształcenia łańcucha znaków na tablicę bajtów. W tym celu używana jest metoda getBytes() i kodowanie UTF-8. Aby zobaczyć, jak używana jest klasa ActiveKeyValueStore, przyjrzyj się klasie ConfigUpdater, która aktualizuje właściwość konfiguracyjną określoną wartością. Kod tej klasy znajdziesz na listingu 21.6. Listing 21.6. Aplikacja aktualizująca w losowych odstępach czasu właściwości w ZooKeeperze public class ConfigUpdater { public static final String PATH = "/config"; private ActiveKeyValueStore store; private Random random = new Random(); public ConfigUpdater(String hosts) throws IOException, InterruptedException { store = new ActiveKeyValueStore(); store.connect(hosts); } public void run() throws InterruptedException, KeeperException { while (true) { String value = random.nextInt(100) + ""; store.write(PATH, value); System.out.printf("Ustawiono %s na %s\n", PATH, value); TimeUnit.SECONDS.sleep(random.nextInt(10)); } } public static void main(String[] args) throws Exception { ConfigUpdater configUpdater = new ConfigUpdater(args[0]); configUpdater.run(); } }
Ten program jest prosty. Klasa ConfigUpdater używa obiektu typu ActiveKeyValueStore, który nawiązuje połączenie z ZooKeeperem w konstruktorze klasy ConfigUpdater. Metoda run() wykonuje pętlę nieskończoną i aktualizuje węzeł znode /config losowymi wartościami w losowych odstępach czasu. Zobacz teraz, jak wczytać właściwość konfiguracyjną węzła /config. Najpierw należy dodać do klasy ActiveKeyValueStore metodę read. public String read(String path, Watcher watcher) throws InterruptedException, KeeperException { byte[] data = zk.getData(path, watcher, null/*Obiekt typu Stat*/); return new String(data, CHARSET); }
Metoda getData() typu ZooKeeper przyjmuje ścieżkę, obiekt typu Watcher i obiekt typu Stat. Obiekt typu Stat jest zapełniany wartościami przez metodę getData() i używany do przekazania informacji z powrotem do jednostki wywołującej. W ten sposób jednostka wywołująca może uzyskać i dane, i metadane z węzła znode. Jednak tu przekazywany jest obiekt null, ponieważ metadane nie są potrzebne.
Budowanie aplikacji z wykorzystaniem ZooKeepera
589
Użytkownik usługi, klasa ConfigWatcher (listing 21.7), tworzy obiekt typu ActiveKeyValueStore i po nawiązaniu połączenia wywołuje jego metodę read() (w metodzie displayConfig()), aby przekazać referencję do siebie jako obiekt typu Watcher. Następnie wyświetlana jest początkowa wartość z wczytanej konfiguracji. Listing 21.7. Aplikacja, która śledzi aktualizacje właściwości w ZooKeeperze i wyświetla wartości w konsoli public class ConfigWatcher implements Watcher { private ActiveKeyValueStore store; public ConfigWatcher(String hosts) throws IOException, InterruptedException { store = new ActiveKeyValueStore(); store.connect(hosts); } public void displayConfig() throws InterruptedException, KeeperException { String value = store.read(ConfigUpdater.PATH, this); System.out.printf("Wczytano %s o wartości %s\n", ConfigUpdater.PATH, value); } @Override public void process(WatchedEvent event) { if (event.getType() == EventType.NodeDataChanged) { try { displayConfig(); } catch (InterruptedException e) { System.err.println("Nastąpiło przerwanie. Koniec pracy."); Thread.currentThread().interrupt(); } catch (KeeperException e) { System.err.printf("KeeperException: %s. Koniec pracy.\n", e); } } } public static void main(String[] args) throws Exception { ConfigWatcher configWatcher = new ConfigWatcher(args[0]); configWatcher.displayConfig(); // Podtrzymywanie pracy do momentu zamknięcia procesu lub wystąpienia przerwania w wątku Thread.sleep(Long.MAX_VALUE); } }
Gdy obiekt typu ConfigUpdater aktualizuje węzeł znode, ZooKeeper powoduje zgłoszenie przez czujkę zdarzenia typu EventType.NodeDataChanged. Obiekt typu ConfigWatcher reaguje na to zdarzenie w metodzie process() — wczytuje i wyświetla w niej najnowszą wersję konfiguracji. Ponieważ czujki są jednorazowe, należy poinformować ZooKeepera o nowej czujce przy każdym wywołaniu metody read() typu ActiveKeyValueStore. To gwarantuje, że użytkownik zobaczy późniejsze aktualizacje. Nie ma jednak gwarancji, że pokazane zostaną wszystkie aktualizacje. Dzieje się tak, ponieważ węzeł znode może zostać zaktualizowany (nawet wielokrotnie) w czasie między odebraniem zdarzenia czujki a następnym odczytem. Klient w tym okresie nie ma zarejestrowanej czujki, dlatego nie otrzymuje powiadomień. W omawianej usłudze służącej do zarządzania konfiguracją nie ma to znaczenia, ponieważ dla klienta istotna jest tylko najnowsza wartość właściwości, zastępująca wcześniejsze wartości. Jednak należy pamiętać o omówionym ograniczeniu.
590
Rozdział 21. ZooKeeper
Zobacz, jak działa ten kod. Uruchom program ConfigUpdater w oknie terminala. % java ConfigUpdater Ustawiono /config na Ustawiono /config na Ustawiono /config na
localhost 79 14 78
Bezpośrednio potem uruchom program ConfigWatcher w innym oknie. % java ConfigWatcher localhost Wczytano /config o wartości 79 Wczytano /config o wartości 14 Wczytano /config o wartości 78
Odporna na błędy aplikacja ZooKeepera Pierwszy mit przetwarzania rozproszonego (https://en.wikipedia.org/wiki/Fallacies_of_distributed_ computing) brzmi: „sieć jest niezawodna”. Przedstawione do tej pory programy są pisane przy założeniu, że sieć jest niezawodna. Dlatego jeśli zaczną działać w rzeczywistej sieci, mogą zawieść na wiele sposobów. Przyjrzyj się możliwym przyczynom błędów i zobacz, co można zrobić, aby wyeliminować problemy i zapewnić odporność programów na awarie. Każda operacja ZooKeepera w interfejsie API Javy ma zadeklarowane w klauzuli throws dwa typy wyjątków: InterruptedException i KeeperException.
InterruptedException Przerwanie operacji prowadzi do zgłoszenia wyjątku InterruptedException. Istnieje standardowy mechanizm Javy do anulowania pracy metod blokujących. Polega on na wywołaniu metody interrupt() dla wątku, w którym uruchomiono metodę blokującą. Udane anulowanie skutkuje wyjątkiem InterruptedException. ZooKeeper działa zgodnie z tym standardem. Dlatego można anulować w ten sposób operację ZooKeepera. Klasy i biblioteki używające ZooKeepera powinny przekazywać wyjątek InterruptedException dalej, by klienty mogły anulować swoje operacje10. Wyjątek InterruptedException nie oznacza jednak awarii, a jedynie to, że operacja została anulowana. Dlatego w przykładowej aplikacji do zarządzania konfiguracją wyjątek należy przekazać dalej. Spowoduje to zamknięcie aplikacji.
KeeperException Wyjątek KeeperException jest zgłaszany, gdy serwer ZooKeepera sygnalizuje błąd lub gdy występują problemy z komunikacją z serwerem. Dla różnych rodzajów błędów istnieją różne klasy pochodne od KeeperException. Na przykład klasa pochodna KeeperException.NoNodeException jest używana przy próbie wykonania operacji na nieistniejącym węźle znode. Każda klasa pochodna od KeeperException ma kod z informacjami o typie błędu. Na przykład dla klasy KeeperException.NoNodeException kod to KeeperException.Code.NONODE (jest to wartość wyliczenia).
10
Szczegółowe omówienie tego zagadnienia znajdziesz w znakomitym artykule „Java theory and practice: Dealing with InterruptedException” Briana Goetza (IBM, maj 2006; http://www.ibm.com/developerworks/java/library/ j-jtp05236/index.html).
Budowanie aplikacji z wykorzystaniem ZooKeepera
591
Istnieją dwa sposoby obsługi wyjątków KeeperException. Można albo przechwytywać takie wyjątki i sprawdzać ich kod, by ustalić działania, które należy podjąć, albo przechwytywać wyjątek wybranej klasy pochodnej od KeeperException i w każdym bloku catch wykonywać odpowiednie czynności. Wyjątki KeeperException należą do trzech ogólnych kategorii. Wyjątki związane ze stanem Wyjątki związane ze stanem zachodzą, gdy operacja kończy się niepowodzeniem, ponieważ nie można jej zastosować do drzewa węzłów znode. Zwykle przyczyną jest to, że inny proces w tym samym czasie modyfikuje węzeł znode. Na przykład operacja setData z ustawionym numerem wersji zgłasza wyjątek KeeperException.BadVersionException, jeśli węzeł znode zostanie najpierw zaktualizowany przez inny proces; numer wersji będzie wtedy niewłaściwy. Programista zwykle wie, że może wystąpić taki konflikt, i pisze kod do jego obsługi. Niektóre wyjątki związane ze stanem wskazują na błąd w programie. Dotyczy to na przykład wyjątku KeeperException.NoChildrenForEphemeralsException, zgłaszanego przy próbie utworzenia podrzędnego węzła znode dla węzła tymczasowego. Wyjątki umożliwiające wznowienie pracy Po wystąpieniu wyjątków z tej kategorii aplikacja może wznowić pracę w tej samej sesji ZooKeepera. Do tej grupy należy wyjątek KeeperException.ConnectionLossException, oznaczający, że połączenie z ZooKeeperem zostało utracone. ZooKeeper próbuje wtedy ponownie nawiązać połączenie. W większości sytuacji kończy się to powodzeniem, a sesja nie zostaje przerwana. ZooKeeper nie potrafi jednak stwierdzić, czy operacja, która zakończyła pracę niepowodzeniem i zgłoszeniem wyjątku KeeperException.ConnectionLossException, została wykonana. Jest to sytuacja częściowej awarii (to zagadnienie opisano na początku rozdziału). To programista odpowiada za obsługę takich sytuacji. Działania, jakie należy wtedy podjąć, zależą od aplikacji. Warto w tym momencie wprowadzić rozróżnienie na operacje idempotentne i nieidempotentne. Operację idempotentną można zastosować wielokrotnie, a wynik pozostanie taki sam. Tak działają na przykład żądania odczytu lub bezwarunkowa instrukcja setData. Operacje z tej grupy wystarczy spróbować wykonać ponownie. Operacji nieidempotentnej nie można bezrefleksyjnie ponowić, ponieważ skutek jej wielokrotnego wykonania jest inny niż jednokrotnego uruchomienia. Program musi sprawdzić, czy aktualizacja została zastosowana. Wymaga to zakodowania informacji w ścieżce do węzła znode lub w danych. Obsługę zakończonych niepowodzeniem operacji nieidempotentnych opisano w punkcie „Wyjątki umożliwiające wznowienie pracy” w kontekście implementowania usługi odpowiedzialnej za blokady. Wyjątki bez możliwości wznowienia pracy Sesja ZooKeepera czasem staje się nieprawidłowa — na przykład w wyniku przekroczenia limitu czasu lub zamknięcia sesji (w obu sytuacjach zgłaszany jest wyjątek KeeperException.SessionExpired Exception) albo z powodu nieudanego uwierzytelniania (wyjątek KeeperException.AuthFailed Exception). Wszystkie węzły tymczasowe powiązane z sesją zostają wtedy utracone, dlatego aplikacja musi odtworzyć stan przed ponownym nawiązaniem połączenia z ZooKeeperem. 592
Rozdział 21. ZooKeeper
Odporna na błędy usługa do zarządzania konfiguracją Wróćmy do metody write() klasy ActiveKeyValueStore. Obejmuje ona operację exists, po której wywoływana jest metoda create lub setData. public void write(String path, String value) throws InterruptedException, KeeperException { Stat stat = zk.exists(path, false); if (stat == null) { zk.create(path, value.getBytes(CHARSET), Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); } else { zk.setData(path, value.getBytes(CHARSET), -1); } }
Cała metoda write() jest idempotentna, dlatego można bezwarunkowo ponawiać próby jej wykonania. Oto zmodyfikowana wersja metody write(), ponawiająca próby w pętli. Maksymalna liczba prób to MAX_RETRIES, a między kolejnymi powtórzeniami ma miejsce przerwa o długości RETRY_ PERIOD_SECONDS. public void write(String path, String value) throws InterruptedException, KeeperException { int retries = 0; while (true) { try { Stat stat = zk.exists(path, false); if (stat == null) { zk.create(path, value.getBytes(CHARSET), Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); } else { zk.setData(path, value.getBytes(CHARSET), stat.getVersion()); } return; } catch (KeeperException.SessionExpiredException e) { throw e; } catch (KeeperException e) { if (retries++ == MAX_RETRIES) { throw e; } // Uśpienie metody, a następnie ponowienie próby TimeUnit.SECONDS.sleep(RETRY_PERIOD_SECONDS); } } }
Kod nie próbuje ponawiać prób po wystąpieniu wyjątku KeeperException.SessionExpiredException, ponieważ po wygaśnięciu sesji obiekt typu ZooKeeper przechodzi w stan CLOSED i nie nawiązuje więcej połączenia (zobacz rysunek 21.3). Dlatego należy ponownie zgłosić wyjątek11 i pozwolić jednostce wywołującej utworzyć nowy obiekt typu ZooKeeper. Można wtedy spróbować ponownie wykonać całą metodę write(). Prosty sposób dodawania nowego obiektu typu ZooKeeper polega na utworzeniu nowego obiektu typu ConfigUpdater (a dokładnie — typu ResilientConfigUpdater) w celu wznowienia pracy po wygaśnięciu sesji. 11
Inne rozwiązanie to utworzenie jednego bloku catch (tylko dla wyjątków KeeperException) i sprawdzanie, czy kod wyjątku ma wartość KeeperException.Code.SESSIONEXPIRED. Oba podejścia działają tak samo. Wybór jednego z nich to kwestia preferowanego stylu.
Budowanie aplikacji z wykorzystaniem ZooKeepera
593
public static void main(String[] args) throws Exception { while (true) { try { ResilientConfigUpdater configUpdater = new ResilientConfigUpdater(args[0]); configUpdater.run(); } catch (KeeperException.SessionExpiredException e) { // Uruchamianie nowej sesji } catch (KeeperException e) { // Próbę już ponowiono, dlatego można wyjść z bloku e.printStackTrace(); break; } } }
Inna technika obsługi wygaśnięcia sesji polega na wykrywaniu wartości Expired we właściwości KeeperState w czujce (w tym przykładzie należy użyć czujki typu ConnectionWatcher) i tworzeniu nowego połączenia po napotkaniu tej wartości. W tym podejściu można cały czas ponawiać próby wykonania metody write() — nawet po wystąpieniu wyjątku KeeperException.SessionExpiredException — ponieważ połączenie ostatecznie zostanie ponownie nawiązane. Niezależnie od mechanizmu wznawiania pracy po wygaśnięciu sesji ważne jest to, że taka sytuacja to inny rodzaj błędu niż zerwanie połączenia, dlatego wymaga odmiennej obsługi. Istnieje też inna kategoria błędów, którą tu pominięto. Obiekt typu ZooKeeper po utworzeniu próbuje nawiązać połączenie z serwerem ZooKeepera. Jeśli próba zakończy się niepowodzeniem lub przekroczeniem limitu czasu, wybierany jest inny serwer z zestawu. Jeżeli nie można nawiązać połączenia z żadnym serwerem, obiekt zgłasza wyjątek IOException. Prawdopodobieństwo braku dostępu do wszystkich serwerów ZooKeepera jest niskie, jednak w niektórych aplikacjach można ponawiać próby wykonania operacji w pętli do momentu, w którym ZooKeeper stanie się dostępny.
To tylko jedna strategia obsługi ponawiania prób. Istnieje też wiele innych rozwiązań, na przykład wykładnicze wydłużanie przerw między próbami (odstęp jest mnożony za każdym razem o stałą wartość).
Usługa do zarządzania blokadami Blokada rozproszona to mechanizm umożliwiający wzajemne blokowanie się procesów z kolekcji. W danym momencie blokada może należeć do tylko jednego procesu. Blokady rozproszone służą na przykład do wyboru lidera w dużych systemach rozproszonych. Liderem jest proces, do którego w danym momencie należy blokada. Nie myl mechanizmu wyboru lidera w ZooKeeperze z ogólną usługą wyboru lidera, którą można zbudować za pomocą prostych operacji ZooKeepera (w tym systemie dostępna jest nawet implementacja takiego narzędzia). Mechanizm wyboru lidera w ZooKeeperze nie jest publicznie dostępny. Natomiast opisana w tym punkcie ogólna usługa wyboru lidera jest dostępna i zaprojektowano ją z myślą o stosowaniu w systemach rozproszonych, gdy trzeba uzgodnić, który proces ma być głównym.
594
Rozdział 21. ZooKeeper
Aby zaimplementować blokadę rozproszoną za pomocą ZooKeepera, można wykorzystać sekwencyjne węzły znode, co pozwala określić kolejność procesów rywalizujących o blokadę. Pomysł jest prosty. Najpierw należy ustawić węzeł znode z blokadą. Zwykle w tym celu opisuje się jednostkę, na którą nakładana jest blokada (na przykład /leader). Następnie klienty, które chcą otrzymać blokadę, tworzą sekwencyjne tymczasowe węzły znode jako elementy podrzędne względem węzła znode z blokadą. W każdym momencie klient powiązany z węzłem o najniższym numerze zajmuje blokadę. Na przykład jeśli dwa klienty w podobnym czasie utworzyły węzły znode /leader/lock-1 i /leader/lock-2, blokadę otrzymuje klient powiązany z węzłem /leader/lock-1, ponieważ ma on najniższy numer. Usługa ZooKeeper zarządza kolejnością węzłów, gdyż odpowiada za przypisywanie numerów. Aby zwolnić blokadę, wystarczy usunąć węzeł znode /leader/lock-1. Jeśli proces klienta zakończy pracę, zostanie automatycznie usunięty, ponieważ używane są tymczasowe węzły znode. Następnie blokada jest przyznawana klientowi, który utworzył węzeł /leader/lock-2, ponieważ to ten węzeł ma teraz najniższy numer. Utworzenie czujki uruchamianej w momencie usuwania węzłów znode gwarantuje, że klient otrzyma powiadomienie o przyznaniu mu blokady. Oto pseudokod opisujący proces zajmowania blokad: 1. Tworzenie tymczasowego sekwencyjnego węzła znode o nazwie lock-, podrzędnego względem węzła znode z blokadą. Zapamiętanie ścieżki do utworzonego węzła (wartości zwróconej przez operację create). 2. Pobranie węzłów podrzędnych względem węzła znode z blokadą i ustawienie czujki. 3. Jeśli ścieżka do węzła znode utworzonego w kroku 1. ma najmniejszy numer spośród węzłów podrzędnych zwróconych w kroku 2., blokada jest przyznawana i można wyjść z bloku. 4. Oczekiwanie na powiadomienie z czujki ustawionej w kroku 2. i przejście do kroku 2.
Efekt stada Choć ten algorytm jest prawidłowy, prowadzi do pewnych problemów. Pierwszy dotyczy podatności algorytmu na efekt stada. Wyobraź sobie, że setki lub tysiące klientów próbują uzyskać blokadę. Każdy klient dodaje czujkę reagującą na zmiany w węzłach podrzędnych względem węzła znode z blokadą. Przy każdym zwolnieniu blokady lub rozpoczęciu procesu zajmowania jej przez inny proces czujka jest uruchamiana i każdy klient otrzymuje powiadomienie. Efekt stada polega na przesyłaniu powiadomień o tym samym zdarzeniu do dużej liczby klientów, choć tylko niewielka grupa klientów może zareagować na powiadomienie. Tu tylko jeden klient otrzymuje blokadę, a proces obsługi czujki i rozsyłania zdarzeń do wszystkich klientów powoduje wzrost natężenia ruchu w sieci i obciąża serwery ZooKeepera. Aby uniknąć efektu stada, należy dopracować warunki przesyłania powiadomień. Najważniejsze spostrzeżenie dotyczy tego, że klient powinien otrzymywać powiadomienie tylko wtedy, gdy podrzędny węzeł znode o poprzednim numerze zostanie usunięty, a nie po usunięciu (lub utworzeniu) dowolnego podrzędnego węzła znode. Załóżmy, że klienty utworzyły węzły znode /leader/lock-1, /leader/lock-2 i /leader/lock-3. Klient powiązany z węzłem /leader/lock-3 powinien otrzymywać powiadomienie tylko po usunięciu węzła /leader/lock-2. Nie potrzebuje powiadomień o usunięciu węzła /leader/lock-1 lub dodaniu nowego węzła /leader/lock-4.
Budowanie aplikacji z wykorzystaniem ZooKeepera
595
Wyjątki umożliwiające wznowienie pracy Inny problem z obecną wersją przedstawionego algorytmu zarządzania blokadami polega na tym, że nie jest obsługiwana sytuacja, w której operacja tworzenia węzła kończy się niepowodzeniem z powodu utraty połączenia. Pamiętaj, że nie wiadomo wtedy, czy operacja zakończyła się powodzeniem, czy porażką. Tworzenie sekwencyjnego węzła znode to operacja nieidempotentna, dlatego nie można jej po prostu ponowić. Jeśli pierwsza operacja create zakończy się sukcesem, jej powtórzenie spowoduje powstanie osieroconego węzła znode, który nigdy nie zostanie usunięty (nie stanie się to przynajmniej do czasu zakończenia sesji klienta). Nieprzyjemnym skutkiem takiej sytuacji jest zakleszczenie. Problem polega na tym, że po ponownym nawiązaniu połączenia klient nie potrafi stwierdzić, czy utworzył podrzędny węzeł znode. Dlatego warto dodawać identyfikator klienta do nazwy węzła znode. Jeśli nastąpi utrata połączenia, klient będzie mógł sprawdzić, czy któryś z węzłów podrzędnych względem węzła z blokadą ma w nazwie odpowiedni identyfikator. Wykrycie węzła podrzędnego z identyfikatorem oznacza, że operacja create zakończyła się powodzeniem, dlatego nie należy tworzyć następnego węzła. Jeżeli żaden węzeł podrzędny nie ma identyfikatora, klient może bezpiecznie utworzyć nowy sekwencyjny podrzędny węzeł znode. Identyfikator sesji klienta to długa liczba całkowita unikatowa dla usługi ZooKeeper, dlatego doskonale nadaje się do identyfikowania klientów po utracie połączenia. Identyfikator sesji można uzyskać dzięki wywołaniu metody getSessionId() klasy ZooKeeper Javy. Tymczasowe sekwencyjne węzły znode należy tworzyć z nazwą w formacie lock-. Po dodaniu przez ZooKeepera numeru nazwa przyjmuje wtedy postać lock-. Numery są unikatowe w ramach węzła nadrzędnego, a nie dla nazw węzłów podrzędnych. Dlatego ta technika pozwala węzłom podrzędnym zidentyfikować klienty, które je utworzyły, a także ustalić kolejność powstawania węzłów.
Wyjątki bez możliwoś