Przełamywanie barier skali: Jak zoptymalizowaliśmy wykorzystanie Elasticsearch w Intercomie

Opublikowany: 2022-09-22

Elasticsearch jest nieodzowną częścią Interkomu.

Stanowi podstawę podstawowych funkcji interkomu, takich jak skrzynka odbiorcza, widoki skrzynki odbiorczej, interfejs API, artykuły, lista użytkowników, raportowanie, bot rozwiązywania problemów i nasze wewnętrzne systemy rejestrowania. Nasze klastry Elasticsearch zawierają ponad 350 TB danych klientów, przechowują ponad 300 miliardów dokumentów i obsługują ponad 60 tysięcy żądań na sekundę w szczycie.

Wraz ze wzrostem wykorzystania Elasticsearch przez Intercom, musimy zapewnić skalowanie naszych systemów, aby wspierać nasz ciągły rozwój. Wraz z niedawnym uruchomieniem naszej skrzynki odbiorczej nowej generacji niezawodność Elasticsearch jest bardziej krytyczna niż kiedykolwiek.

Zdecydowaliśmy się rozwiązać problem z naszą konfiguracją Elasticsearch, który stwarzał ryzyko dostępności i groził przestojem w przyszłości: nierównomierny rozkład ruchu/pracy między węzłami w naszych klastrach Elasticsearch.

Wczesne oznaki nieefektywności: brak równowagi obciążenia

Elasticsearch umożliwia skalowanie w poziomie poprzez zwiększenie liczby węzłów przechowujących dane (węzły danych). Zaczęliśmy zauważać nierównowagę obciążenia między tymi węzłami danych: niektóre z nich były pod większą presją (lub „gorętsze”) niż inne z powodu większego zużycia dysku lub procesora.

Rys. 1 Nierównowaga w wykorzystaniu procesora
(Rys. 1) Nierównowaga w wykorzystaniu procesora: Dwa węzły aktywne o ~20% wyższym wykorzystaniu procesora niż średnia.

Wbudowana logika rozmieszczania fragmentów Elasticsearch podejmuje decyzje na podstawie obliczeń, które z grubsza szacują dostępną przestrzeń dyskową w każdym węźle i liczbę fragmentów indeksu na węzeł. Wykorzystanie zasobów przez fragment nie jest uwzględniane w tym obliczeniu. W rezultacie niektóre węzły mogą otrzymywać więcej zasobożernych odłamków i stać się „gorące”. Każde żądanie wyszukiwania jest przetwarzane przez wiele węzłów danych. Węzeł aktywny, który przekracza swoje limity podczas szczytowego ruchu, może spowodować pogorszenie wydajności całego klastra.

Częstą przyczyną węzłów aktywnych jest logika rozmieszczania fragmentów przypisująca duże fragmenty (na podstawie wykorzystania dysku) do klastrów, co zmniejsza prawdopodobieństwo zrównoważonej alokacji. Zazwyczaj do węzła może być przypisany jeden duży fragment więcej niż do pozostałych, co powoduje, że jest on bardziej gorący pod względem wykorzystania dysku. Obecność dużych fragmentów utrudnia również naszą zdolność do stopniowego skalowania klastra, ponieważ dodanie węzła danych nie gwarantuje zmniejszenia obciążenia ze wszystkich węzłów gorących (rys. 2).

Rys. 4 Dodawanie węzłów

(Rys. 2) Dodanie węzła danych nie spowodowało zmniejszenia obciążenia na hoście A. Dodanie kolejnego węzła zmniejszyłoby obciążenie na hoście A, ale klaster nadal będzie miał nierównomierny rozkład obciążenia.

W przeciwieństwie do tego, posiadanie mniejszych fragmentów pomaga zmniejszyć obciążenie wszystkich węzłów danych w miarę skalowania klastra – w tym „gorących” (ryc. 3).

Rys. 3 Wiele mniejszych odłamków

(Rys. 3) Posiadanie wielu mniejszych fragmentów pomaga zmniejszyć obciążenie wszystkich węzłów danych.

Uwaga: problem nie ogranicza się do klastrów z odłamkami o dużych rozmiarach. Podobne zachowanie zaobserwowalibyśmy, gdybyśmy zastąpili „rozmiar” „wykorzystaniem procesora” lub „ruchem wyszukiwania”, ale porównanie rozmiarów ułatwia wizualizację.

Nierównowaga obciążenia wpływa nie tylko na stabilność klastra, ale także na naszą zdolność do ekonomicznego skalowania. Zawsze będziemy musieli zwiększać pojemność niż to konieczne, aby utrzymać cieplejsze węzły poniżej niebezpiecznych poziomów. Naprawienie tego problemu oznaczałoby lepszą dostępność i znaczne oszczędności dzięki wydajniejszemu wykorzystaniu naszej infrastruktury.

Nasze głębokie zrozumienie problemu pomogło nam zdać sobie sprawę, że obciążenie mogłoby być rozłożone bardziej równomiernie, gdybyśmy mieli:

  • Więcej fragmentów w stosunku do liczby węzłów danych. Zapewniłoby to, że większość węzłów otrzyma taką samą liczbę odłamków.
  • Mniejsze fragmenty w stosunku do rozmiaru węzłów danych. Gdyby niektóre węzły otrzymały kilka dodatkowych fragmentów, nie spowodowałoby to znaczącego wzrostu obciążenia tych węzłów.

Rozwiązanie z babeczkami: mniej większych węzłów

Ten stosunek liczby fragmentów do liczby węzłów danych oraz rozmiar fragmentów do rozmiaru węzłów danych można dostosować, mając większą liczbę mniejszych fragmentów. Ale można go łatwiej dostosować, przechodząc do mniejszej liczby, ale większych węzłów danych.

Postanowiliśmy zacząć od babeczki, aby zweryfikować tę hipotezę. Przeprowadziliśmy migrację kilku naszych klastrów do większych, wydajniejszych instancji z mniejszą liczbą węzłów — zachowując tę ​​samą całkowitą pojemność. Na przykład przenieśliśmy klaster z 40 instancji 4xlarge do 10 instancji 16xlarge, zmniejszając nierównowagę obciążenia poprzez bardziej równomierne rozłożenie odłamków.

Rys. 4 Lepszy rozkład obciążenia na dysk i procesor

(Rys. 4) Lepszy rozkład obciążenia na dysku i procesorze dzięki przejściu do mniejszej liczby większych węzłów.

Zmniejszenie liczby większych węzłów potwierdziło nasze założenia, że ​​zmiana liczby i rozmiaru węzłów danych może poprawić rozkład obciążenia. Mogliśmy się tam zatrzymać, ale podejście miało kilka wad:

  • Wiedzieliśmy, że nierównowaga obciążenia pojawi się ponownie, gdy fragmenty będą z czasem powiększać się lub jeśli do klastra zostanie dodanych więcej węzłów, aby uwzględnić zwiększony ruch.
  • Większe węzły powodują, że skalowanie przyrostowe jest droższe. Dodanie pojedynczego węzła kosztowałoby teraz więcej, nawet gdybyśmy potrzebowali tylko trochę dodatkowej pojemności.

Wyzwanie: przekroczenie progu skompresowanych zwykłych wskaźników obiektów (OOP)

Przejście do mniejszej liczby większych węzłów nie było tak proste, jak zmiana rozmiaru instancji. Wąskim gardłem, z którym mieliśmy do czynienia, było zachowanie całkowitego dostępnego rozmiaru sterty (rozmiar sterty na jednym węźle x całkowita liczba węzłów) podczas migracji.

Zgodnie z sugestią Elastic, ograniczyliśmy rozmiar sterty w naszych węzłach danych do ~30,5 GB, aby upewnić się, że pozostaniemy poniżej wartości granicznej, aby JVM mogła używać skompresowanych obiektów OOP. Gdybyśmy ograniczyli rozmiar sterty do ~30,5 GB po przeniesieniu do mniejszej liczby większych węzłów, zmniejszylibyśmy ogólną pojemność sterty, ponieważ pracowalibyśmy z mniejszą liczbą węzłów.

„Instancje, do których migrowaliśmy, były ogromne i chcieliśmy przypisać dużą część pamięci RAM do sterty, aby mieć miejsce na wskaźniki, z wystarczającą ilością miejsca na pamięć podręczną systemu plików”

Nie mogliśmy znaleźć wielu porad dotyczących wpływu przekroczenia tego progu. Instancje, do których migrowaliśmy, były ogromne i chcieliśmy przypisać dużą część pamięci RAM do sterty, aby mieć miejsce na wskaźniki i wystarczająco dużo na pamięć podręczną systemu plików. Poeksperymentowaliśmy z kilkoma progami, replikując nasz ruch produkcyjny do klastrów testowych i ustaliliśmy, że wielkość sterty wynosi od około 33% do około 42% pamięci RAM dla maszyn z ponad 200 GB pamięci RAM.

Zmiana wielkości sterty w różny sposób wpłynęła na różne klastry. Podczas gdy niektóre klastry nie wykazały żadnych zmian w metrykach, takich jak „% stosu JVM w użyciu” lub „Czas zbierania młodych GC”, ogólną tendencją był wzrost. Niezależnie od tego ogólnie było to pozytywne doświadczenie, a nasze klastry działają w tej konfiguracji od ponad 9 miesięcy – bez żadnych problemów.

Poprawka długoterminowa: wiele mniejszych odłamków

Rozwiązaniem długoterminowym byłoby przejście w kierunku posiadania większej liczby mniejszych fragmentów w stosunku do liczby i rozmiaru węzłów danych. Do mniejszych odłamków możemy dostać się na dwa sposoby:

  • Migracja indeksu w celu uzyskania większej liczby fragmentów podstawowych: powoduje to dystrybucję danych w indeksie do większej liczby fragmentów.
  • Podział indeksu na mniejsze indeksy (partycje): powoduje to rozłożenie danych w indeksie na więcej indeksów.

Należy zauważyć, że nie chcemy tworzyć miliona malutkich fragmentów ani mieć setek partycji. Każdy indeks i fragment wymaga pewnej ilości zasobów pamięci i procesora.

„Skupiliśmy się na ułatwieniu eksperymentowania i naprawianiu nieoptymalnych konfiguracji w naszym systemie, zamiast skupiania się na „doskonałej” konfiguracji”

W większości przypadków mały zestaw dużych fragmentów zużywa mniej zasobów niż wiele małych fragmentów. Ale są też inne opcje – eksperymentowanie powinno pomóc w uzyskaniu bardziej odpowiedniej konfiguracji dla danego przypadku użycia.

Aby uczynić nasze systemy bardziej odpornymi, skupiliśmy się na ułatwieniu eksperymentowania i naprawianiu nieoptymalnych konfiguracji w naszym systemie, zamiast skupiania się na „doskonałej” konfiguracji.

Indeksy partycjonowania

Zwiększenie liczby podstawowych fragmentów może czasami wpłynąć na wydajność zapytań agregujących dane, czego doświadczyliśmy podczas migracji klastra odpowiedzialnego za produkt Intercom Reporting. W przeciwieństwie do tego, partycjonowanie indeksu na wiele indeksów rozkłada obciążenie na więcej fragmentów bez obniżania wydajności zapytań.

Domofon nie wymaga kolokacji danych dla wielu klientów, dlatego zdecydowaliśmy się na partycjonowanie na podstawie unikalnych identyfikatorów klientów. Pomogło nam to w szybszym dostarczaniu wartości dzięki uproszczeniu logiki partycjonowania i ograniczeniu wymaganej konfiguracji.

„Aby podzielić dane w sposób, który jak najmniej wpłynął na istniejące nawyki i metody naszych inżynierów, najpierw zainwestowaliśmy dużo czasu w zrozumienie, w jaki sposób nasi inżynierowie korzystają z Elasticsearch”

Aby podzielić dane w sposób, który jak najmniej wpłynął na istniejące nawyki i metody naszych inżynierów, najpierw zainwestowaliśmy dużo czasu w zrozumienie, w jaki sposób nasi inżynierowie korzystają z Elasticsearch. Głęboko zintegrowaliśmy nasz system obserwowalności z biblioteką klienta Elasticsearch i przeszukaliśmy naszą bazę kodu, aby poznać różne sposoby interakcji naszego zespołu z interfejsami API Elasticsearch.

Nasz tryb odzyskiwania po awarii polegał na ponawianiu żądań, więc wprowadziliśmy wymagane zmiany tam, gdzie robiliśmy żądania nie-idempotentne. Skończyło się na dodaniu kilku linterów, aby zniechęcić do korzystania z interfejsów API, takich jak `update/delete_by_query`, ponieważ ułatwiają one wysyłanie nie-idempotentnych żądań.

Zbudowaliśmy dwie możliwości, które współpracowały ze sobą, aby zapewnić pełną funkcjonalność:

  • Sposób kierowania żądań z jednego indeksu do drugiego. Ten inny indeks może być partycją lub po prostu indeksem niepartycjonowanym.
  • Sposób podwójnego zapisu danych w wielu indeksach. To pozwoliło nam zachować synchronizację partycji z migrowanym indeksem.

„Zoptymalizowaliśmy nasze procesy, aby zminimalizować promień wybuchu wszelkich incydentów, bez uszczerbku dla szybkości”

W sumie proces migracji indeksu do partycji wygląda tak:

  1. Tworzymy nowe partycje i włączamy podwójne zapisywanie, aby nasze partycje były aktualne z oryginalnym indeksem.
  2. Uruchamiamy kopię zapasową wszystkich danych. Te żądania uzupełnienia zostaną zapisane podwójnie na nowych partycjach.
  3. Po zakończeniu uzupełniania sprawdzamy, czy zarówno stary, jak i nowy indeks mają te same dane. Jeśli wszystko wygląda dobrze, używamy flag funkcji, aby zacząć używać partycji dla kilku klientów i monitorować wyniki.
  4. Po uzyskaniu pewności przenosimy wszystkich naszych klientów do partycji, jednocześnie zapisując jednocześnie zarówno stary indeks, jak i partycje.
  5. Gdy mamy pewność, że migracja się powiodła, zatrzymujemy podwójne pisanie i usuwamy stary indeks.

Te pozornie proste kroki są bardzo skomplikowane. Zoptymalizowaliśmy nasze procesy, aby zminimalizować promień wybuchu wszelkich incydentów, bez uszczerbku dla szybkości.

Czerpanie korzyści

Ta praca pomogła nam poprawić równoważenie obciążenia w naszych klastrach Elasticsearch. Co ważniejsze, możemy teraz poprawić rozkład obciążenia za każdym razem, gdy staje się to nie do przyjęcia, migrując indeksy do partycji z mniejszą liczbą podstawowych fragmentów, osiągając to, co najlepsze z obu światów: coraz mniej fragmentów na indeks.

Stosując te wnioski, byliśmy w stanie odblokować istotne korzyści w zakresie wydajności i oszczędności.

  • Obniżyliśmy koszty dwóch naszych klastrów odpowiednio o 40% i 25%, a także odnotowaliśmy znaczne oszczędności w innych klastrach.
  • Zmniejszyliśmy średnie wykorzystanie procesora dla określonego klastra o 25% i poprawiliśmy medianę opóźnienia żądania o 100%. Osiągnęliśmy to, migrując indeks o dużym ruchu do partycji z mniejszą liczbą podstawowych fragmentów na partycję w porównaniu z oryginałem.
  • Ogólna możliwość migracji indeksów pozwala nam również zmienić schemat indeksu, umożliwiając inżynierom produktu tworzenie lepszych doświadczeń dla naszych klientów lub ponowne indeksowanie danych przy użyciu nowszej wersji Lucene, która odblokowuje możliwość uaktualnienia do Elasticsearch 8.

Rys. 5 Poprawa asymetrii obciążenia i wykorzystania procesora

(Rys. 5) 50% poprawa nierównowagi obciążenia i 25% poprawa wykorzystania procesora dzięki migracji wysokiego wskaźnika ruchu do partycji z mniejszą liczbą podstawowych fragmentów na partycję.

Rys.6 Mediana opóźnienia żądania

(Rys. 6) Mediana opóźnienia żądań poprawiła się średnio o 100% dzięki migracji indeksu o dużym natężeniu ruchu do partycji z mniejszą liczbą podstawowych fragmentów na partycję.

Co dalej?

Wprowadzenie Elasticsearch do obsługi nowych produktów i funkcji powinno być proste. Naszą wizją jest ułatwienie naszym inżynierom interakcji z Elasticsearch tak, jak nowoczesne frameworki internetowe umożliwiają interakcję z relacyjnymi bazami danych. Zespoły powinny łatwo tworzyć indeks, czytać lub pisać z indeksu, wprowadzać zmiany w jego schemacie i nie tylko – bez martwienia się o sposób obsługi żądań.

Czy jesteś zainteresowany sposobem, w jaki pracuje nasz zespół inżynierów w Intercom? Dowiedz się więcej i sprawdź nasze otwarte role tutaj.

Kariera CTA - Inżynieria (poziomo)