Wysoka wydajność jest jednym z kluczowych wymagań przy pracy z big data. W zarządzaniu przesyłaniem danych w Sberze zajmujemy się przetwarzaniem praktycznie wszystkich transakcji w naszym Cloud Data opartym na Hadoop, więc mamy do czynienia z rzeczywiście dużymi strumieniami informacji. Oczywiście, cały czas szukamy sposobów na zwiększenie wydajności i teraz chcemy opowiedzieć, jak udało się zmodyfikować RegionServer HBase i klienta HDFS, co znacząco zwiększyło szybkość operacji odczytu.

Jednak zanim przejdziemy do istoty poprawek, warto omówić ograniczenia, których zasadniczo nie da się obejść, jeśli korzysta się z HDD.
Dlaczego HDD i szybkie odczyty losowe są niespójne
Jak wiadomo, HBase, jak i wiele innych baz danych, przechowuje dane w blokach o wielkości kilku dziesiątek kilobajtów. Domyślnie jest to około 64 KB. Wyobraźmy sobie teraz, że musimy pobrać zaledwie 100 bajtów i prosimy HBase o wydanie tych danych na podstawie pewnego klucza. Ponieważ rozmiar bloku w HFiles wynosi 64 KB, zażądana ilość będzie o 640 razy większa (na chwilę!) niż potrzebna.
Ponadto, ponieważ zapytanie będzie przechodzić przez HDFS i jego mechanizm buforowania metadanych ShortCircuitCache (który umożliwia bezpośredni dostęp do plików), prowadzi to do odczytu już 1 MB z dysku. Można to jednak regulować parametrem dfs.client.read.shortcircuit.buffer.size a w wielu przypadkach ma sens, aby zmniejszyć tę wartość, na przykład do 126 KB.
Załóżmy, że to zrobimy, ale dodatkowo, kiedy zaczniemy odczytywać dane przez java api, za pomocą funkcji takich jak FileChannel.read i prosimy system operacyjny o odczytanie podanej ilości danych, odczytuje on 'na wszelki wypadek' dwa razy więcej, tzn. 256 KB w naszym przypadku. Dzieje się tak, ponieważ w java nie ma prostego sposobu na ustawienie flagi FADV_RANDOM, która zapobiegałaby takiemu zachowaniu.
W efekcie, aby uzyskać nasze 100 bajtów, w zapleczu odczytywane jest 2600 razy więcej. Wydawałoby się, że wyjście jest oczywiste, zmniejszmy rozmiar bloku do kilobajta, ustawmy wspomnianą flagę i osiągniemy wielkie oświecenie w przyspieszeniu. Ale problem polega na tym, że zmniejszając rozmiar bloku o połowę, zmniejszamy także liczbę odczytanych bajtów w jednostce czasu również o połowę.
Można uzyskać pewne korzyści z ustawienia flagi FADV_RANDOM, ale tylko przy dużej wielowątkowości i rozmiarze bloku od 128 KB, co daje maksymalnie kilkanaście procent;

Testy przeprowadzono na 100 plikach, każdy o rozmiarze 1 GB, umieszczonych na 10 dyskach HDD.
Obliczmy, na co możemy liczyć przy takiej prędkości:
Załóżmy, że odczytujemy z 10 dysków z prędkością 280 MB/s, czyli 3 miliony razy po 100 bajtów. Ale jak pamiętamy, potrzebne nam dane występują 2600 razy rzadziej niż to, co zostało odczytane. Dzielimy więc 3 miliony przez 2600 i otrzymujemy 1100 rekordów na sekundę.
Rozczarowujące, prawda? Taka jest natura dostępu do danych na HDD — niezależnie od rozmiaru bloku. To fizyczny limit dostępu losowego i żaden system baz danych nie wyciśnie więcej w takich warunkach. Jak więc bazom danych udaje się osiągać znacznie wyższe prędkości? Aby odpowiedzieć na to pytanie, przyjrzyjmy się, co dzieje się na następnej ilustracji:
Tutaj widzimy, że przez pierwsze kilka minut prędkość rzeczywiście wynosi około tysiąca rekordów na sekundę. Jednak później, z powodu odczytu znacznie większej ilości danych niż żądano, dane odkładają się w buff/cache systemu operacyjnego (Linux) i prędkość wzrasta do bardziej akceptowalnych 60 tys. na sekundę.

Zatem dalej będziemy analizować przyspieszenie dostępu tylko do tych danych, które znajdują się w pamięci podręcznej OS lub są przechowywane w porównywalnie szybkim magazynie typu SSD/NVMe.
W naszym przypadku przeprowadzimy testy na sprzęcie składającym się z 4 serwerów, z których każdy został skonfigurowany w następujący sposób:
CPU: Xeon E5-2680 v4 @ 2.40GHz 64 wątki.
RAM: 730 GB.
wersja java: 1.8.0_111
I tu jest kluczowy moment — objętość danych w tabelach, które trzeba odczytać. Chodzi o to, że jeśli dane są odczytywane z tabeli, która całkowicie mieści się w pamięci podręcznej HBase, to do odczytu z buff/cache systemu operacyjnego nigdy nie dojdzie. Ponieważ HBase domyślnie rezerwuje 40% pamięci na strukturę zwaną BlockCache. W zasadzie jest to ConcurrentHashMap, gdzie kluczem jest nazwa pliku + offset bloku, a wartością są dane z tego przesunięcia.
W ten sposób, gdy odczyt odbywa się tylko z tej struktury,
widzi wspaniałą prędkość. , wydaje się, że mamy milion zapytań na sekundę. Ale wyobraźmy sobie, że nie możemy poświęcać setek gigabajtów pamięci tylko na potrzeby bazy danych, ponieważ na tych serwerach działa wiele innych przydatnych rzeczy.
Na przykład w naszym przypadku objętość BlockCache na jednym RS wynosi około 12 GB. Uruchomiliśmy dwa RS na jednym węźle, tzn. na wszystkie węzły przydzielono 96 GB na BlockCache. A danych jest przy tym znacznie więcej, na przykład niech to będą 4 tabele, po 130 regionów, w których pliki mają rozmiar 800 MB, skompresowane za pomocą FAST_DIFF, tzn. w sumie 410 GB (to czyste dane, bez uwzględnienia czynnika replikacji).
W ten sposób BlockCache stanowi zaledwie około 23% całkowitej objętości danych, co jest znacznie bliższe rzeczywistym warunkom tego, co nazywamy BigData. I tutaj zaczynają się najciekawsze rzeczy — jest oczywiste, że im mniej trafień w pamięć podręczną, tym gorsza wydajność. W przypadku nietrafienia będzie trzeba wykonać mnóstwo pracy — tzn. zejść do wywołania funkcji systemowych. Jednak tego nie da się uniknąć, dlatego przyjrzyjmy się zupełnie innemu aspektowi — co dzieje się z danymi wewnątrz pamięci podręcznej?
Uprośćmy sytuację i załóżmy, że mamy pamięć podręczną, w której mieści się tylko jeden obiekt. Oto przykład tego, co się stanie, gdy spróbujemy pracować z objętością danych trzy razy większą niż pamięć podręczna, będziemy musieli:
1. Włożyć blok 1 do pamięci podręcznej
2. Usunąć blok 1 z pamięci podręcznej
3. Włożyć blok 2 do pamięci podręcznej
4. Usunąć blok 2 z pamięci podręcznej
5. Włożyć blok 3 do pamięci podręcznej
Wykonano 5 działań! Jednak tej sytuacji nie można nazwać normalną, w rzeczywistości zmuszamy HBase do wykonywania mnóstwa zupełnie niepotrzebnej pracy. On ciągle odczytuje dane z pamięci podręcznej systemu operacyjnego, umieszcza je w BlockCache, aby niemal natychmiast je usunąć, ponieważ nadeszła nowa porcja danych. Animacja na początku posta pokazuje istotę problemu — Garbage Collector szaleje, atmosfera się ociepla, mała Greta w dalekiej i upalnej Szwecji jest zmartwiona. A my, informatycy, bardzo nie lubimy, gdy dzieci są smutne, dlatego zaczynamy się zastanawiać, co z tym zrobić.
A co jeśli nie umieszczać w pamięci podręcznej wszystkich bloków, ale tylko określony procent z nich, tak aby pamięć podręczna się nie przepełniała? Na początek po prostu dodajmy kilka linii kodu na początku funkcji umieszczania danych w BlockCache:
public void cacheBlock(BlockCacheKey cacheKey, Cacheable buf, boolean inMemory) {
if (cacheDataBlockPercent != 100 && buf.getBlockType().isData()) {
if (cacheKey.getOffset() % 100 >= cacheDataBlockPercent) {
return;
}
}
...
Oto o co chodzi, offset to położenie bloku w pliku, a ostatnie cyfry są losowo i równomiernie rozłożone od 00 do 99. Dlatego będziemy pomijać tylko te, które mieszczą się w naszym wymaganym zakresie.
Na przykład ustawimy cacheDataBlockPercent = 20 i zobaczymy, co się stanie:

Wynik jest oczywisty. Na poniższych wykresach widać, jak doszło do tego przyspieszenia — oszczędzamy mnóstwo zasobów GC, nie zajmując się syzyfową pracą umieszczania danych w pamięci podręcznej, tylko po to, aby natychmiast je wyrzucić.

Wykorzystanie CPU rośnie, ale znacznie mniej niż wydajność:

Należy również zauważyć, że bloki przechowywane w BlockCache są różne. Większość, około 95%, to właściwie dane. A reszta to metadane, takie jak filtry Bloom lub LEAF_INDEX i . Tych danych jest mało, ale są bardzo przydatne, ponieważ zanim HBase zwróci się bezpośrednio do danych, odnosi się do metadanych, aby zrozumieć, czy powinien szukać dalej i, jeśli tak, to gdzie dokładnie znajduje się interesujący go blok.
Dlatego w kodzie widzimy warunek sprawdzający buf.getBlockType().isData() i dzięki tym metadanym w każdej sytuacji pozostawimy je w pamięci podręcznej.
Teraz zwiększmy obciążenie i jednocześnie trochę dostroimy funkcję. W pierwszym teście zrobiliśmy procent odrzutu = 20 i BlockCache był lekko niedociążony. Teraz ustawimy 23% i będziemy dodawać po 100 wątków co 5 minut, aby zobaczyć, w którym momencie dochodzi do nasycenia:

Tutaj widzimy, że pierwotna wersja niemal od razu osiąga sufit na poziomie około 100 tys. zapytań na sekundę. Natomiast łatka daje przyspieszenie do 300 tys. Przy tym wiadomo, że dalsze przyspieszenie już nie jest takie „bezpłatne”, wykorzystanie CPU również rośnie.
Jednak to nie jest bardzo eleganckie rozwiązanie, ponieważ z góry nie wiemy, jaki procent bloków należy buforować, to zależy od profilu obciążenia. Dlatego zrealizowano mechanizm automatycznego dostosowywania tego parametru w zależności od aktywności operacji odczytu.
Do zarządzania tym dodano trzy parametry:
hbase.lru.cache.heavy.eviction.count.limit — ustala, ile razy proces oczyszczania pamięci podręcznej powinien być uruchomiony, zanim zaczniemy stosować optymalizację (tj. pomijać bloki). Domyślnie wynosi MAX_INT = 2147483647 i w rzeczywistości oznacza, że funkcja nigdy nie zacznie działać przy takiej wartości. Ponieważ proces czyszczenia uruchamiany jest co 5-10 sekund (zależy od obciążenia) i 2147483647 * 10 / 60 / 60 / 24 / 365 = 680 lat. Możemy jednak ustawić ten parametr na 0, co spowoduje, że funkcja zacznie działać natychmiast po uruchomieniu.
Jednak ten parametr ma również swoje zalety. Jeśli nasze obciążenie polega na częstym mieszaniu krótkoterminowych odczytów (np. w ciągu dnia) i długoterminowych (nocą), możemy sprawić, że funkcja będzie włączana tylko podczas długotrwałych operacji odczytu.
Na przykład wiemy, że krótkoterminowe odczyty zazwyczaj trwają około 1 minuty. Nie powinniśmy zaczynać usuwania bloków, ponieważ pamięć podręczna nie zdąży się zestarzeć, więc możemy ustawić ten parametr na 10. Spowoduje to, że optymalizacja zacznie działać tylko wtedy, gdy rozpoczną się długotrwałe aktywne odczyty, tj. po 100 sekundach. W ten sposób, jeśli mamy krótkoterminowy odczyt, wszystkie bloki trafią do pamięci podręcznej i będą dostępne (z wyjątkiem tych, które będą usuwane standardowym algorytmem). A kiedy dokonujemy długoterminowych odczytów, funkcja zaczyna działać i uzyskujemy znacznie wyższą wydajność.
hbase.lru.cache.heavy.eviction.mb.size.limit — ustala, ile megabajtów chcielibyśmy umieścić w pamięci podręcznej (a naturalnie usunąć) w ciągu 10 sekund. Funkcja będzie starała się osiągnąć tę wartość i utrzymać ją. Chodzi o to, że jeśli wpychamy w pamięć podręczną gigabajty, to i usuwanie też będzie musiało obejmować gigabajty, co, jak widzieliśmy powyżej, jest dość kosztowne. Nie należy jednak ustawiać go zbyt niskiego, ponieważ prowadzi to do przedwczesnego wyjścia z trybu pomijania bloków. Dla potężnych serwerów (około 20-40 fizycznych rdzeni) optymalnie ustawić około 300-400 MB. Dla serwerów średniej klasy (~10 rdzeni) 200-300 MB. Dla słabszych systemów (2-5 rdzeni) może być w porządku 50-100 MB (nie testowano na takich).
Przyjrzyjmy się, jak to działa: załóżmy, że ustawiliśmy hbase.lru.cache.heavy.eviction.mb.size.limit = 500, występuje pewne obciążenie (odczyty), a następnie co około 10 sekund obliczamy, ile bajtów zostało usuniętych według formuły:
Overhead = Suma wyzwolonych bajtów (MB) * 100 / Limit (MB) — 100;
Jeśli faktycznie usunięto 2000 MB, Overhead wynosi:
2000 * 100 / 500 — 100 = 300%
Algorytmy starają się jednak utrzymywać nie więcej niż kilka dziesiątków procent, więc funkcja będzie zmniejszać procent buforowanych bloków, realizując tym samym mechanizm autostrojenia.
Jednak jeśli obciążenie spadło, załóżmy, że usunięto tylko 200 MB, a Overhead stał się ujemny (tak zwane overshooting):
200 * 100 / 500 — 100 = -60%
Wówczas funkcja przeciwnie, będzie zwiększać procent buforowanych bloków, aż Overhead stanie się dodatni.
Poniżej znajduje się przykład, jak to wygląda na rzeczywistych danych. Nie próbuj osiągnąć 0%, to niemożliwe. Dobrze jest, gdy wynosi około 30 — 100%, co pomaga uniknąć przedwczesnego wyjścia z trybu optymalizacji przy krótkoterminowych skokach.
hbase.lru.cache.heavy.eviction.overhead.coefficient — ustawia, jak szybko chcielibyśmy uzyskać wyniki. Jeśli wiemy na pewno, że nasze odczyty są w większości długoterminowe i nie chcemy czekać, możemy zwiększyć ten współczynnik i uzyskać wyższą wydajność szybciej.
Na przykład, ustaliliśmy ten współczynnik = 0,01. Oznacza to, że Overhead (patrz powyżej) zostanie pomnożony przez tę liczbę, a procent buforowanych bloków zostanie zmniejszony. Załóżmy, że Overhead = 300%, a współczynnik = 0,01, wtedy procent buforowanych bloków zostanie zmniejszony o 3%.
Podobna logika „Backpressure” została wdrożona również dla ujemnych wartości Overhead (overshooting). Ponieważ zawsze mogą wystąpić krótkoterminowe wahania objętości odczytów-usunięć, ten mechanizm pozwala unikać przedwczesnego wyjścia z trybu optymalizacji. Backpressure ma odwróconą logikę: im większe overshooting, tym więcej bloków jest buforowanych.

Kod realizacji
LruBlockCache cache = this.cache.get();
if (cache == null) {
break;
}
freedSumMb += cache.evict() / 1024 / 1024;
/*
* Czasami odczytujemy więcej danych, niż może pomieścić BlockCache
* i to prowadzi do wysokiego wskaźnika usunięcia.
* To z kolei powoduje intensywną pracę Garbage Collector.
* W związku z tym dużo bloków trafia do BlockCache, ale nigdy nie jest odczytywanych,
* co wiąże się z dużym zużyciem zasobów CPU.
* Tutaj przeanalizujemy, ile bajtów zostało zwolnionych i zdecydujemy,
* czy nadszedł czas, aby zmniejszyć liczbę bloków w pamięci podręcznej.
* To pomoże uniknąć umieszczania zbyt wielu bloków w BlockCache,
* gdy evict() działa bardzo intensywnie i zaoszczędzi CPU na inne zadania.
* Więcej szczegółów: https://issues.apache.org/jira/browse/HBASE-23887
*/
// Przede wszystkim musimy kontrolować, ile czasu
// upłynęło od ostatniego uruchomienia evict()
// Powinno to być prawie ten sam czas (+/- 10s)
// ponieważ każdego razu uzyskujemy porównywalne ilości zwolnionych bajtów.
// 10s ponieważ to domyślny okres uruchamiania evict() (patrz powyżej this.wait)
long stopTime = System.currentTimeMillis();
if ((stopTime - startTime) > 1000 * 10 - 1) {
// Tutaj musimy obliczyć, jaką sytuację mamy.
// Mamy limit "hbase.lru.cache.heavy.eviction.bytes.size.limit"
// i możemy obliczyć dodatkowe obciążenie.
// Użyjemy tych informacji, aby zdecydować,
// jak zmienić procent bloków w pamięci podręcznej.
freedDataOverheadPercent =
(int) (freedSumMb * 100 / cache.heavyEvictionMbSizeLimit) - 100;
if (freedSumMb > cache.heavyEvictionMbSizeLimit) {
// Teraz jesteśmy w sytuacji, w której jesteśmy powyżej limitu
// Ale może będziemy to ignorować, ponieważ to się skończy dość szybko
heavyEvictionCount++;
if (heavyEvictionCount > cache.heavyEvictionCountLimit) {
// To trwa długo i musimy teraz zmniejszyć liczbę bloków w pamięci podręcznej
// Więc tutaj obliczamy, ile bloków chcemy pominąć.
// To zależy od:
// 1. Dodatkowe obciążenie - jeśli obciążenie jest duże, możemy być bardziej agresywni
// w redukcji liczby bloków w pamięci podręcznej.
// 2. Jak szybko chcemy uzyskać wynik. Jeśli wiemy, że nasz
// intensywny odczyt trwa długo, nie chcemy czekać i możemy
// zwiększyć współczynnik, aby uzyskać dobrą wydajność dość szybko.
// Ale jeśli nie jesteśmy pewni, możemy to robić powoli i to może zapobiec
// przedwczesnemu wyjściu z tego trybu. Tak więc, gdy współczynnik jest
// wyższy, możemy uzyskać lepszą wydajność, gdy intensywny odczyt jest stabilny.
// Ale kiedy odczyt się zmienia, możemy się do niego dostosować i ustawić
// niższy współczynnik.
int change =
(int) (freedDataOverheadPercent * cache.heavyEvictionOverheadCoefficient);
// Ale praktyka pokazuje, że redukcja o 15% jest wystarczająca.
// Nie jesteśmy chciwi (to mogłoby prowadzić do przedwczesnego wyjścia).
change = Math.min(15, change);
change = Math.max(0, change); // Myślę, że to nigdy się nie zdarzy, ale warto sprawdzić
// Więc to jest kluczowy moment, tutaj redukujemy % bloków w pamięci podręcznej
cache.cacheDataBlockPercent -= change;
// Jeśli zredukujemy zbyt mocno, musimy się tutaj zatrzymać, 1% w każdym razie powinno być.
cache.cacheDataBlockPercent = Math.max(1, cache.cacheDataBlockPercent);
}
} else {
// Cóż, mamy nadmierne osiągnięcie.
// Może to tylko krótkoterminowa fluktuacja i możemy pozostać w tym trybie.
// To pomoże uniknąć przedwczesnego wyjścia podczas krótkoterminowej fluktuacji.
// Jeśli nadmierne osiągnięcie jest mniejsze niż 90%, postaramy się zwiększyć procent
// bloków w pamięci podręcznej i mamy nadzieję, że to wystarczy.
if (freedSumMb >= cache.heavyEvictionMbSizeLimit * 0.1) {
// Prosta logika: więcej nadmiernego osiągnięcia - więcej bloków w pamięci podręcznej (nacisk zwrotny)
int change = (int) (-freedDataOverheadPercent * 0.1 + 1);
cache.cacheDataBlockPercent += change;
// Ale nie może to być więcej niż 100%, więc sprawdźmy to.
cache.cacheDataBlockPercent = Math.min(100, cache.cacheDataBlockPercent);
} else {
// Wygląda na to, że intensywny odczyt się zakończył.
// Po prostu wyjdź z tego trybu.
heavyEvictionCount = 0;
cache.cacheDataBlockPercent = 100;
}
}
LOG.info("BlockCache usunięto (MB): {}, nadwyżka (%): {}, " +
"licznik intensywnego usunięcia: {}, " +
"aktualny procent bloków danych w pamięci podręcznej (%): {}",
freedSumMb, freedDataOverheadPercent,
heavyEvictionCount, cache.cacheDataBlockPercent);
freedSumMb = 0;
startTime = stopTime;
}
Rozważmy teraz wszystko to na realnym przykładzie. Mamy następujący scenariusz testowy:
- Zaczynamy wykonanie skanowania (25 wątków, pakiet = 100)
- Po 5 minutach dodajemy multi-gets (25 wątków, pakiet = 100)
- Po 5 minutach wyłączamy multi-gets (pozostaje znowu tylko skanowanie)
Przeprowadzamy dwa przebiegi, najpierw ustawiając hbase.lru.cache.heavy.eviction.count.limit = 10000 (co w zasadzie wyłącza tę funkcję), a następnie ustawiamy limit = 0 (włącza).
W poniższych logach widzimy, jak włącza się funkcja, resetując Overshooting do 14-71%. Czasami obciążenie maleje, co włącza Backpressure, a HBase ponownie buforuje więcej bloków.
Log RegionServer
evicted (MB): 0, ratio 0.0, overhead (%): -100, heavy eviction counter: 0, current caching DataBlock (%): 100
evicted (MB): 0, ratio 0.0, overhead (%): -100, heavy eviction counter: 0, current caching DataBlock (%): 100
evicted (MB): 2170, ratio 1.09, overhead (%): 985, heavy eviction counter: 1, current caching DataBlock (%): 91 < start
evicted (MB): 3763, ratio 1.08, overhead (%): 1781, heavy eviction counter: 2, current caching DataBlock (%): 76
evicted (MB): 3306, ratio 1.07, overhead (%): 1553, heavy eviction counter: 3, current caching DataBlock (%): 61
evicted (MB): 2508, ratio 1.06, overhead (%): 1154, heavy eviction counter: 4, current caching DataBlock (%): 50
evicted (MB): 1824, ratio 1.04, overhead (%): 812, heavy eviction counter: 5, current caching DataBlock (%): 42
evicted (MB): 1482, ratio 1.03, overhead (%): 641, heavy eviction counter: 6, current caching DataBlock (%): 36
evicted (MB): 1140, ratio 1.01, overhead (%): 470, heavy eviction counter: 7, current caching DataBlock (%): 32
evicted (MB): 913, ratio 1.0, overhead (%): 356, heavy eviction counter: 8, current caching DataBlock (%): 29
evicted (MB): 912, ratio 0.89, overhead (%): 356, heavy eviction counter: 9, current caching DataBlock (%): 26
evicted (MB): 684, ratio 0.76, overhead (%): 242, heavy eviction counter: 10, current caching DataBlock (%): 24
evicted (MB): 684, ratio 0.61, overhead (%): 242, heavy eviction counter: 11, current caching DataBlock (%): 22
evicted (MB): 456, ratio 0.51, overhead (%): 128, heavy eviction counter: 12, current caching DataBlock (%): 21
evicted (MB): 456, ratio 0.42, overhead (%): 128, heavy eviction counter: 13, current caching DataBlock (%): 20
evicted (MB): 456, ratio 0.33, overhead (%): 128, heavy eviction counter: 14, current caching DataBlock (%): 19
evicted (MB): 342, ratio 0.33, overhead (%): 71, heavy eviction counter: 15, current caching DataBlock (%): 19
evicted (MB): 342, ratio 0.32, overhead (%): 71, heavy eviction counter: 16, current caching DataBlock (%): 19
evicted (MB): 342, ratio 0.31, overhead (%): 71, heavy eviction counter: 17, current caching DataBlock (%): 19
evicted (MB): 228, ratio 0.3, overhead (%): 14, heavy eviction counter: 18, current caching DataBlock (%): 19
evicted (MB): 228, ratio 0.29, overhead (%): 14, heavy eviction counter: 19, current caching DataBlock (%): 19
evicted (MB): 228, ratio 0.27, overhead (%): 14, heavy eviction counter: 20, current caching DataBlock (%): 19
evicted (MB): 228, ratio 0.25, overhead (%): 14, heavy eviction counter: 21, current caching DataBlock (%): 19
evicted (MB): 228, ratio 0.24, overhead (%): 14, heavy eviction counter: 22, current caching DataBlock (%): 19
evicted (MB): 228, ratio 0.22, overhead (%): 14, heavy eviction counter: 23, current caching DataBlock (%): 19
evicted (MB): 228, ratio 0.21, overhead (%): 14, heavy eviction counter: 24, current caching DataBlock (%): 19
evicted (MB): 228, ratio 0.2, overhead (%): 14, heavy eviction counter: 25, current caching DataBlock (%): 19
evicted (MB): 228, ratio 0.17, overhead (%): 14, heavy eviction counter: 26, current caching DataBlock (%): 19
zwolnione (MB): 456, wskaźnik 0.17, narzut (%): 128, licznik intensywnego zwolnienia: 27, bieżący procent buforowanego DataBlocka: 18 < dodane pobrania (ale tabela ta sama)
zwolnione (MB): 456, wskaźnik 0.15, narzut (%): 128, licznik intensywnego zwolnienia: 28, bieżący procent buforowanego DataBlocka: 17
zwolnione (MB): 342, wskaźnik 0.13, narzut (%): 71, licznik intensywnego zwolnienia: 29, bieżący procent buforowanego DataBlocka: 17
zwolnione (MB): 342, wskaźnik 0.11, narzut (%): 71, licznik intensywnego zwolnienia: 30, bieżący procent buforowanego DataBlocka: 17
zwolnione (MB): 342, wskaźnik 0.09, narzut (%): 71, licznik intensywnego zwolnienia: 31, bieżący procent buforowanego DataBlocka: 17
zwolnione (MB): 228, wskaźnik 0.08, narzut (%): 14, licznik intensywnego zwolnienia: 32, bieżący procent buforowanego DataBlocka: 17
zwolnione (MB): 228, wskaźnik 0.07, narzut (%): 14, licznik intensywnego zwolnienia: 33, bieżący procent buforowanego DataBlocka: 17
zwolnione (MB): 228, wskaźnik 0.06, narzut (%): 14, licznik intensywnego zwolnienia: 34, bieżący procent buforowanego DataBlocka: 17
zwolnione (MB): 228, wskaźnik 0.05, narzut (%): 14, licznik intensywnego zwolnienia: 35, bieżący procent buforowanego DataBlocka: 17
zwolnione (MB): 228, wskaźnik 0.05, narzut (%): 14, licznik intensywnego zwolnienia: 36, bieżący procent buforowanego DataBlocka: 17
zwolnione (MB): 228, wskaźnik 0.04, narzut (%): 14, licznik intensywnego zwolnienia: 37, bieżący procent buforowanego DataBlocka: 17
zwolnione (MB): 109, wskaźnik 0.04, narzut (%): -46, licznik intensywnego zwolnienia: 37, bieżący procent buforowanego DataBlocka: 22 < ciśnienie wsteczne
zwolnione (MB): 798, wskaźnik 0.24, narzut (%): 299, licznik intensywnego zwolnienia: 38, bieżący procent buforowanego DataBlocka: 20
zwolnione (MB): 798, wskaźnik 0.29, narzut (%): 299, licznik intensywnego zwolnienia: 39, bieżący procent buforowanego DataBlocka: 18
zwolnione (MB): 570, wskaźnik 0.27, narzut (%): 185, licznik intensywnego zwolnienia: 40, bieżący procent buforowanego DataBlocka: 17
zwolnione (MB): 456, wskaźnik 0.22, narzut (%): 128, licznik intensywnego zwolnienia: 41, bieżący procent buforowanego DataBlocka: 16
zwolnione (MB): 342, wskaźnik 0.16, narzut (%): 71, licznik intensywnego zwolnienia: 42, bieżący procent buforowanego DataBlocka: 16
zwolnione (MB): 342, wskaźnik 0.11, narzut (%): 71, licznik intensywnego zwolnienia: 43, bieżący procent buforowanego DataBlocka: 16
zwolnione (MB): 228, wskaźnik 0.09, narzut (%): 14, licznik intensywnego zwolnienia: 44, bieżący procent buforowanego DataBlocka: 16
zwolnione (MB): 228, wskaźnik 0.07, narzut (%): 14, licznik intensywnego zwolnienia: 45, bieżący procent buforowanego DataBlocka: 16
zwolnione (MB): 228, wskaźnik 0.05, narzut (%): 14, licznik intensywnego zwolnienia: 46, bieżący procent buforowanego DataBlocka: 16
zwolnione (MB): 222, wskaźnik 0.04, narzut (%): 11, licznik intensywnego zwolnienia: 47, bieżący procent buforowanego DataBlocka: 16
zwolnione (MB): 104, wskaźnik 0.03, narzut (%): -48, licznik intensywnego zwolnienia: 47, bieżący procent buforowanego DataBlocka: 21 < przerywane pobrania
zwolnione (MB): 684, wskaźnik 0.2, narzut (%): 242, licznik intensywnego zwolnienia: 48, bieżący procent buforowanego DataBlocka: 19
zwolnione (MB): 570, wskaźnik 0.23, narzut (%): 185, licznik intensywnego zwolnienia: 49, bieżący procent buforowanego DataBlocka: 18
zwolnione (MB): 342, wskaźnik 0.22, narzut (%): 71, licznik intensywnego zwolnienia: 50, bieżący procent buforowanego DataBlocka: 18
zwolnione (MB): 228, wskaźnik 0.21, narzut (%): 14, licznik intensywnego zwolnienia: 51, bieżący procent buforowanego DataBlocka: 18
zwolnione (MB): 228, wskaźnik 0.2, narzut (%): 14, licznik intensywnego zwolnienia: 52, bieżący procent buforowanego DataBlocka: 18
zwolnione (MB): 228, wskaźnik 0.18, narzut (%): 14, licznik intensywnego zwolnienia: 53, bieżący procent buforowanego DataBlocka: 18
zwolnione (MB): 228, wskaźnik 0.16, narzut (%): 14, licznik intensywnego zwolnienia: 54, bieżący procent buforowanego DataBlocka: 18
zwolnione (MB): 228, wskaźnik 0.14, narzut (%): 14, licznik intensywnego zwolnienia: 55, bieżący procent buforowanego DataBlocka: 18
zwolnione (MB): 112, wskaźnik 0.14, narzut (%): -44, licznik intensywnego zwolnienia: 55, bieżący procent buforowanego DataBlocka: 23 < ciśnienie wsteczne
zwolnione (MB): 456, wskaźnik 0.26, narzut (%): 128, licznik intensywnego zwolnienia: 56, bieżący procent buforowanego DataBlocka: 22
zwolnione (MB): 342, wskaźnik 0.31, narzut (%): 71, licznik intensywnego zwolnienia: 57, bieżący procent buforowanego DataBlocka: 22
usunięto (MB): 342, stosunek 0.33, narzut (%): 71, licznik intensywnego usuwania: 58, bieżący procent bloków danych w pamięci podręcznej: 22
usunięto (MB): 342, stosunek 0.33, narzut (%): 71, licznik intensywnego usuwania: 59, bieżący procent bloków danych w pamięci podręcznej: 22
usunięto (MB): 342, stosunek 0.33, narzut (%): 71, licznik intensywnego usuwania: 60, bieżący procent bloków danych w pamięci podręcznej: 22
usunięto (MB): 342, stosunek 0.33, narzut (%): 71, licznik intensywnego usuwania: 61, bieżący procent bloków danych w pamięci podręcznej: 22
usunięto (MB): 342, stosunek 0.33, narzut (%): 71, licznik intensywnego usuwania: 62, bieżący procent bloków danych w pamięci podręcznej: 22
usunięto (MB): 342, stosunek 0.33, narzut (%): 71, licznik intensywnego usuwania: 63, bieżący procent bloków danych w pamięci podręcznej: 22
usunięto (MB): 342, stosunek 0.32, narzut (%): 71, licznik intensywnego usuwania: 64, bieżący procent bloków danych w pamięci podręcznej: 22
usunięto (MB): 342, stosunek 0.33, narzut (%): 71, licznik intensywnego usuwania: 65, bieżący procent bloków danych w pamięci podręcznej: 22
usunięto (MB): 342, stosunek 0.33, narzut (%): 71, licznik intensywnego usuwania: 66, bieżący procent bloków danych w pamięci podręcznej: 22
usunięto (MB): 342, stosunek 0.32, narzut (%): 71, licznik intensywnego usuwania: 67, bieżący procent bloków danych w pamięci podręcznej: 22
usunięto (MB): 342, stosunek 0.33, narzut (%): 71, licznik intensywnego usuwania: 68, bieżący procent bloków danych w pamięci podręcznej: 22
usunięto (MB): 342, stosunek 0.32, narzut (%): 71, licznik intensywnego usuwania: 69, bieżący procent bloków danych w pamięci podręcznej: 22
usunięto (MB): 342, stosunek 0.32, narzut (%): 71, licznik intensywnego usuwania: 70, bieżący procent bloków danych w pamięci podręcznej: 22
usunięto (MB): 342, stosunek 0.33, narzut (%): 71, licznik intensywnego usuwania: 71, bieżący procent bloków danych w pamięci podręcznej: 22
usunięto (MB): 342, stosunek 0.33, narzut (%): 71, licznik intensywnego usuwania: 72, bieżący procent bloków danych w pamięci podręcznej: 22
usunięto (MB): 342, stosunek 0.33, narzut (%): 71, licznik intensywnego usuwania: 73, bieżący procent bloków danych w pamięci podręcznej: 22
usunięto (MB): 342, stosunek 0.33, narzut (%): 71, licznik intensywnego usuwania: 74, bieżący procent bloków danych w pamięci podręcznej: 22
usunięto (MB): 342, stosunek 0.33, narzut (%): 71, licznik intensywnego usuwania: 75, bieżący procent bloków danych w pamięci podręcznej: 22
usunięto (MB): 342, stosunek 0.33, narzut (%): 71, licznik intensywnego usuwania: 76, bieżący procent bloków danych w pamięci podręcznej: 22
usunięto (MB): 21, stosunek 0.33, narzut (%): -90, licznik intensywnego usuwania: 76, bieżący procent bloków danych w pamięci podręcznej: 32
evicted (MB): 0, ratio 0.0, overhead (%): -100, heavy eviction counter: 0, current caching DataBlock (%): 100
evicted (MB): 0, ratio 0.0, overhead (%): -100, heavy eviction counter: 0, current caching DataBlock (%): 100
Skany były potrzebne, aby pokazać ten sam proces w postaci wykresu proporcji między dwoma obszarami pamięci podręcznej — single (gdzie trafiają bloki, które nigdy nie były wcześniej żądane) i multi (tutaj przechowywane są „żądane” dane przynajmniej raz):

I w końcu, jak wyglądają działania parametrów w formie wykresu. Dla porównania, pamięć podręczna była całkowicie wyłączona na początku, a następnie uruchomiono HBase z pamięcią podręczną i opóźnionym rozpoczęciem optymalizacji o 5 minut (30 cykli usuwania).
Pełny kod można znaleźć w Pull Request na githubie.
Jednak 300 tys. odczytów na sekundę to nie wszystko, co można wydobyć z tego sprzętu w tych warunkach. Chodzi o to, że kiedy trzeba uzyskać dostęp do danych przez HDFS, używany jest mechanizm ShortCircuitCache (dalej SSC), który umożliwia bezpośredni dostęp do danych, omijając interakcje sieciowe.
Profilowanie wykazało, że ten mechanizm, mimo że przynosi dużą korzyść, w pewnym momencie staje się wąskim gardłem, ponieważ praktycznie wszystkie ciężkie operacje odbywają się wewnątrz lock, co prowadzi do blokad przez większą część czasu.

Uświadomiwszy to, zrozumieliśmy, że problem można obejść, tworząc tablicę niezależnych SSC:
private final ShortCircuitCache[] shortCircuitCache;
...
shortCircuitCache = new ShortCircuitCache[this.clientShortCircuitNum];
for (int i = 0; i < this.clientShortCircuitNum; i++)
this.shortCircuitCache[i] = new ShortCircuitCache(…);
I następnie będziemy z nimi pracować, wykluczając przecięcia również według ostatniej cyfry offsetu:
public ShortCircuitCache getShortCircuitCache(long idx) {
return shortCircuitCache[(int) (idx % clientShortCircuitNum)];
}
Teraz możemy przystąpić do testów. W tym celu będziemy czytać pliki z HDFS za pomocą prostego wielowątkowego programu. Ustawiamy parametry:
conf.set("dfs.client.read.shortcircuit", "true");
conf.set("dfs.client.read.shortcircuit.buffer.size", "65536"); // domyślnie = 1 MB i to znacznie spowalnia odczyt, dlatego lepiej dostosować do rzeczywistych potrzeb
conf.set("dfs.client.short.circuit.num", num); // od 1 do 10
I po prostu czytamy pliki:
FSDataInputStream in = fileSystem.open(path);
for (int i = 0; i 900000000)
position = 0L;
int res = in.read(position, byteBuffer, 0, 65536);
}
Ten kod wykonuje się w oddzielnych wątkach i będziemy zwiększać liczbę jednocześnie czytanych plików (od 10 do 200 — oś pozioma) oraz liczbę cache'ów (od 1 do 10 — wykresy). Oś pionowa wskazuje przyspieszenie, które daje zwiększenie SSC w porównaniu do przypadku, gdy jest tylko jeden cache.

Jak czytać wykres: czas wykonania 100 tysięcy odczytów blokami po 64 KB z jednym cache'em wynosi 78 sekund. Podczas gdy z 5 cache'ami wykonuje się to w 16 sekund. Tzn. mamy przyspieszenie ~5 razy. Jak widać na wykresie, przy małej liczbie równoległych odczytów efekt nie jest bardzo widoczny, zaczyna to odgrywać znaczącą rolę, gdy liczba odczytów wątków przekracza 50. Zauważalne jest także, że zwiększenie liczby SSC od 6 i więcej przynosi znacznie mniejszy wzrost wydajności.
Uwaga 1: ponieważ wyniki testów są dość zmienne (patrz poniżej), przeprowadzono 3 uruchomienia i uzyskane wartości zostały uśrednione.
Uwaga 2: Wzrost wydajności z ustawień dla dostępu losowego jest taki sam, chociaż sam dostęp jest nieco wolniejszy.
Niemniej jednak należy zauważyć, że w przeciwieństwie do przypadku z HBase, to przyspieszenie nie zawsze jest bezpłatne. Tutaj bardziej "odblokowujemy" możliwości CPU do wykonywania pracy, zamiast utknąć w blokadach.

Widać tutaj, że ogólnie zwiększenie liczby pamięci podręcznej daje w przybliżeniu proporcjonalny wzrost wykorzystania CPU. Jednak istnieje kilka bardziej korzystnych kombinacji.
Na przykład przyjrzyjmy się bliżej konfiguracji SSC = 3. Wzrost wydajności w tym zakresie wynosi około 3.3 razy. Poniżej przedstawione są wyniki wszystkich trzech osobnych testów.

Podczas gdy zużycie CPU rośnie o około 2.8 razy. Różnica nie jest zbyt duża, ale mała Greta już się cieszy, a być może znajdzie czas na szkołę i lekcje.
W ten sposób będzie to miało pozytywny wpływ na każde narzędzie korzystające z masowego dostępu do HDFS (np. Spark itp.), pod warunkiem, że kod aplikacyjny jest lekki (tj. wąskie gardło ma miejsce po stronie klienta HDFS) i dostępne są wolne zasoby CPU. Aby to sprawdzić, przetestujmy, jaki efekt da wspólne zastosowanie optymalizacji BlockCache i tuningu SSC do czytania z HBase.

Widać tutaj, że w takich warunkach efekt nie jest tak duży, jak w rafinowanych testach (czytanie bez jakiejkolwiek obróbki), jednak wydobycie dodatkowych 80K jest całkiem możliwe. Obie optymalizacje razem dają przyspieszenie do 4 razy.
Również w tej optymalizacji złożono PR , który został włączony i ta funkcjonalność będzie dostępna w kolejnych wersjach.
I w końcu było ciekawie porównać wydajność odczytu z podobnej bazy danych o szerokich kolumnach, jak Cassandra i HBase.
Do tego uruchamiano instancje standardowego narzędzia do testowania obciążenia YCSB z dwóch hostów (łącznie 800 wątków). Po stronie serwera znajdowały się po 4 instancje RegionServer i Cassandra na 4 hostach (nie tych, na których uruchomione były klienci, aby uniknąć ich wpływu). Odczyty były przeprowadzane z tabel o rozmiarze:
HBase — 300 GB na HDFS (100 GB czystych danych)
Cassandra — 250 GB (współczynnik replikacji = 3)
Tzn. objętość była mniej więcej taka sama (w HBase nieco większa).
Parametry HBase:
dfs.client.short.circuit.num = 5 (optymalizacja klienta HDFS)
hbase.lru.cache.heavy.eviction.count.limit = 30 — oznacza to, że poprawka zacznie działać po 30 wykluczeniach (~5 minut)
hbase.lru.cache.heavy.eviction.mb.size.limit = 300 — docelowa objętość pamięci podręcznej i wykluczeń
Logi YCSB zostały zparsowane i zestawione w wykresy Excel:

Jak widać, dane optymalizacji pozwalają wyrównać wydajność tych baz danych w tych warunkach i osiągnąć 450 tysięcy odczytów na sekundę.
Mamy nadzieję, że te informacje mogą być przydatne dla kogoś w pasjonującej rywalizacji o wydajność.
Źródło: habr.com
