Hazelcast - aktualizacja zamiast inwalidacji

06.07.2020 | Krzysztof Nieszporek

Wstęp

Hazelcast to narzędzie open source, które pozwala na rozproszone przetwarzanie danych w pamięci. Dostarcza nam dobrze znane struktury danych przystosowane do działania w środowisku rozproszonym, co czyni ich użycie stosunkowo prostym i pozwala nam często zapomnieć, że używamy rozproszonej wersji mapy czy list.

Najczęstszym zastosowaniem Hazelcasta, z jakim się do tej pory spotkałem, jest jednak wykorzystanie go jako rozproszonego cache. Interfejs IMap wydaje się być do tego idealny. Aktualizacja obiektu zapisanego w mapie jest operacją stosunkowo prostą. Sprawa może się nieco skomplikować, jeżeli mamy mapę, do której dostęp ma wiele wątków, ale i tu dorobiliśmy się sprawdzonych wzorców. Dodatkowo mamy gotowe kolekcje, które zdejmują z programisty ciężar synchronizacji. 

​Podejście to sprawdza się całkiem dobrze, jednak co się stanie, gdy do naszej mapy zaczniemy wkładać duże, złożone obiekty, których zbudowanie jest kosztowne czasowo? Możemy przestać odbudowywać cache na rzecz jego aktualizacji po zmianie danych źródłowych co wyraźnie skraca gotowość cache do odczytu. Zastanówmy się, co się stanie, jeśli naszą mapa jest strukturą rozproszoną, a dostęp do niej mają już nie tylko wątki naszego procesu a wiele instancji aplikacji znajdujących się na różnych hostach lub lokalizacjach. Hazelcast daje nam wsparcie dla rozproszonych blokad pozwalających podmienić obiekt pod kluczem, jednak podejście typu odczyt/aktualizacja/zapis niekoniecznie będzie najlepsze, gdyż komunikacja pomiędzy serwerami nie jest za darmo. O samych blokadach możecie przeczytać w tym artykule.

We wpisie przybliżę dwa sposoby do modyfikacji danych w IMap. Pierwsze, tradycyjne, które jest nierzadko pierwszym wyborem, możemy zastąpić równie prostym sposobem, wymagającym jedynie zapoznania się z prostym interfejsem jakim jest EntryProcessor, który pozwala modyfikować dane zapisane w IMap w sposób szybki i bezpieczny.

Podejście pierwsze

Pierwsze rozwiązanie, jakie przychodzi na myśl to odczytać dane, zmodyfikować i ponownie zapisać. Wydaje się proste, ale korzystamy z rozproszonej wersji mapy, dlatego musimy pamiętać, że w tym samym czasie inny węzeł może również chcieć zmodyfikować obiekt zapisany pod tym samym kluczem. Jeśli o tym zapomnimy, może dojść do nadpisania zawartości mapy nieprawidłowymi danymi - konieczne będzie zadbanie o synchronizację poprzez założenie blokady na kluczu, aby inny węzeł nie mógł zmodyfikować danych, dopóki my nie zaktualizujemy obiektu.

Listing 1. Przykładowa aktualizacji danych w mapie z blokadą na kluczu.

Kod, choć krótki, ma kilka wad. Pierwszą jest wspomniana blokada, która mimo, że pozwala nam uchronić się przed zmodyfikowaniem obiektu przez inny węzeł, to jest kosztowna. Metoda lock wstrzymuje nasz wątek do czasu uzyskania blokady i nie jesteśmy w stanie przewidzieć, jak długo będziemy czekać na jej uzyskanie. Alternatywą może być użycie któregoś z wariantów metody tryLock, gdzie jeżeli nie uzyskamy blokady w wyznaczonym czasie odzyskujemy sterowanie, a metoda zwraca false. Na programiście pozostaje również obowiązek zwolnienia blokady, jeżeli o tym zapomni, będziemy mieli klasyczne zakleszczenie, które dotknie cały klaster. Musimy również pamiętać, że dane niekoniecznie znajdują się w naszej lokalnej pamięci, co wiąże się z koniecznością pobrania ich do lokalnego węzła i wysłania (zapisania) do instancji Hazelcasta, gdzie znajduje się właściwa partycja. Dane przesyłamy po sieci, tak więc będą one serializowane i deserializowane, co w przypadku złożonych struktur i częstych aktualizacji może mieć istotny wpływ na czas całego procesu aktualizacji danych w ​cache tym bardziej, że całość odbywa się w czasie założenia blokady na kluczu.

A gdyby tak zmodyfikować dane tam gdzie są zapisane?

Hazelcast to nie tylko rozproszony cache: zgodnie ze stwierdzeniem autorów to In-Memory Data Grid a co za tym idzie API oferuje możliwości manipulowania danymi, które przechowujemy w IMap – warto poświęcić chwilę aby się z nimi zapoznać, gdyż projektanci IMap przygotowali kilka przydatnych mechanizmów, pozwalających na prostsze i efektywniejsze przetwarzanie zapisanych tam danych.

Jednym z takich mechanizmów jest EntryProcessor. Jest to prosty interfejs, pozwalający na implementacje logiki do wykonania na obiekcie zapisanym pod kluczem w IMap. Ważne jest to, że nasz kod zostanie wykonany na danych w węźle, gdzie obiekty się znajdują. Samo zadanie zostanie wykonane w ramach puli wątków dedykowanej do operacji na partycji Hazelcasta. W chwili, gdy zaimplementowana metoda process zostanie wywołana, zakładana jest blokada na czas wykonania naszego kodu.

Implementując EntryProcessor należy pamiętać o:

  1. EntryProcessor musi implementować interfejs Serializable, jest to konieczne, jeżeli wraz z naszą implementacją przesyłamy dane do węzła, gdzie jest przechowywany nasz obiekt.
  2. Ponieważ modyfikujemy obiekt w partycji primary, musimy zaimplementować również EntryBackupProcessor - Hazelcast przechowuje backup w innym węźle i innej partycji, dlatego należy zadbać, aby modyfikacja wykonała się nie tylko na podstawowym obiekcie, ale i na jej kopiach.
  3. Kod metody proccess powinien być zwięzły i wykonywać się krótko, ponieważ na czas jego wykonania zakładana jest blokada na kluczu.
  4. Aby zmiana została zapisana, konieczne jest jawne wywołanie metody setValue na obiekcie Map.Entry.

Listing 2. Przykładowa implementacja EntryProcessora.

Listing 3. Przykładowa aktualizacja danych z executeOnKey.

Powyżej przykładowa implementacja EntryProcessora. Nasze zadanie może zostać wykonane na kluczu poprzez jedną z dwóch metod dostępnych w IMap:

  1. executeOnKey – w tym wariancie EntryProcessor jest wykonywany synchronicznie na klastrze. Nasz kod jest blokowany do czasu wykonania operacji. 
  2. submitToKey – kod jest wykonywany asynchronicznie. Metoda zwraca IcompletableFuture, który dziedziczy po znanym nam Future. Ten wariant może okazać się przydatny w programowaniu reaktywnym.

Zaletą tego podejścia jest uproszczenie naszego kodu, dzięki czemu można skupić się na implementacji i przetestowaniu logiki biznesowej, pozostawiając ciężar zarządzania blokadami po stronie Hazelcasta. Drugą niewątpliwą zaletą jest szybkość - w tym podejściu nie musimy przesyłać po sieci danych przechowywanych w mapie, znika też konieczność serializacji i deserializacji obiektu zapisanego pod kluczem. Nie oznacza to, że nic nie serializujemy - jednak naszym EntryProcessorem przesyłamy tylko małą część danych wymaganą do wykonania aktualizacji obiektu, która z natury powinna być mniejsza niż sam przechowywany obiekt.

Warto wiedzieć

Hazelcast ma wbudowany i domyślnie włączony detektor wolnych operacji na klastrze. Jest on skonfigurowany tak, żeby logować operacje dłuższe niż 10 sekund. Wykorzystując EntryProcessor warto ten czas skrócić do akceptowalnej przez nas wartości, można to zrobić po przez parametr hazelcast.slow.operation.detector.threshold.millis.

Drugą rzeczą, na którą należy zwrócić uwagę, to sposób, w jaki przechowujemy dane w pamięci. W przypadku użycia EntryProcessora będzie zdecydowanie lepiej, gdy dane przechowujemy jako obiekty. Nie musimy ich wtedy deserializować do wykonania naszej logiki. Podejście to jednak nie zawsze się sprawdzi, jeśli np. mamy dużo odczytów z mapy z różnych węzłów a aktualizacja stanowi znikomy ułamek operacji na mapie. Dlatego konieczne jest rozważenie, który wariant będzie lepszy w danym zastosowaniu.

Jeżeli zależy nam na odciążeniu puli wątków dedykowanych do wykonywania operacji na partycji, możemy wykorzystać wbudowany w Hazelcasta Executor Service. W tym celu nasz EntryProcessor powinien implementować interfejs Offloadable, konieczne będzie też skonfigurowanie puli wątków dla Executor Service lub zdefiniowanie dedykowanej puli dla wielu EntryProcessorów.

Podsumowanie

W artykule przybliżyłem użycie EntryProcessora IMap jako jeden ze sposobów modyfikacji obiektu przechowywanego w mapie. Nie wyczerpuje to jednak możliwości wykorzystania EntryProcessorów, które pozwalają również na czytanie i przetwarzanie danych w mapie. Ograniczyłem się do operacji wykonywanych na jednym kluczu, gdzie Hazelcast gwarantuje wsparcie dla blokad. EntryProcessor może być wykonany na więcej niż jednym kluczu, służą do tego metody executeOnEntries oraz executeOnKeys. Należy jednak pamiętać, iż kod naszego procesora zostanie wykonany niezależnie od tego czy dane dla klucza zostały zablokowane. Tym samym to na programiście spoczywa konieczność obsługi locków. Na dodatkową uwagę zasługują również interfejsy Offloadable i ReadOnly, gdzie pierwszy pozwala na wykonanie operacji poza pulą wątków dedykowaną dla partycji. Drugi markuje procesor jako tylko do odczytu, co pozwala wykonać odczyt danych bez zakładania blokady, jednak przykłady ich użycia to materiał na osobny artykuł.