Kategorie
java

Caffeine cache, super szybka pamięć podręczna

Caffeine cache to bardzo fajna biblioteka dla Javy, której głównym zadaniem jest zapewnienie nam, Developerom, sprawnego mechanizmu pamięci podręcznej. Ta nieduża biblioteka doskonale sprawdza się w mniejszych, ale i tych nieco większych projektach, gdzie cache’owanie danych ma dla nas znaczenie.

Osobiście bardzo ją lubię, bo ma przyjemne API i niejednokrotnie korzystałem z niej w różnych projektach.

Zobaczmy zatem, co możemy dzięki niej uzyskać. Lecimy ❗

Junior Java Developer Handbook

Co to jest cache?

Zacznijmy od rzeczy podstawowych. Czym jest cache?

Praktycznie każdy programista prędzej, czy później spotka się z pojęciem cache’u. Czasem chodzi o rozwiązanie sprzętowe, a czasem, tak jaka w tym przypadku, o rozwiązanie programistyczne.

Cache możemy zdefiniować jako mechanizm, który pozwoli nam na szybszy dostęp do danych, które przechowywane są normalnie w miejscu o wolniejszym dostępie.

O czym należy tutaj pamiętać? Zwykle naszych danych nie ma na początku w cache i trzeba je najpierw załadować. I zwykle też dane w cache po pewnym czasie ulegają czyszczeniu zgodnie z wybraną polityką (ang. eviction policy).

Przykłady zastosowań cache

Definicję warto wesprzeć przykładem.

Przykład #1

Popatrzmy na zagadnienia sprzętowe. Wyobraźmy sobie dysk twardy SSD, który odczytuje dane w około 50 – 150 mikrosekund. Wydaje się, że to całkiem szybko. Ale z drugiej strony … RAM jest szybszy. Tutaj przykładowo dane możemy uzyskać 50 nanosekund.

Jak to się może przełożyć na nasz program. Ok, na przykład tak. Nasza usługa odczytuje dane z bazy danych. Powiedzmy, że pewne rekordy z bazy odczytywane są często. A każdy odczyt z bazy to de facto odczyt z dysku twardego. Wydaje się wieć zasadne, żeby nasz program zoptymalizował ten odczyt i np. odczytał te dane raz, a potem zapisał je do pamięci RAM. Każdy następny odczyt będzie już dokonywany z pamięci RAM.

Widać zysk? No jasne, że widać. Ale możemy tutaj trafić na przypadek, gdy dane w bazie danych się zmienią … a my w naszym cache’u będziemy mieli cały czas dane odczytane za pierwszym razem. Zgadza się, dlatego możemy przyjąć różne strategie do zarządzania naszym cache. O tym później.

Przykład #2

Tym razem przykład z podwórka integracyjnego. Jakiś czas temu integrowałem usługę, która dostarczała informację o koncie danego użytkownika. Informacje te były potrzebne w kilku miejscach procesu biznesowego. Dodatkowo usługa, która dostarczała te dane generalnie działała z opóźnieniem 150 – 200 milisekund, ale w peek’ach potrafiła generować opóźnienie nawet do 10 sekund. I to już nie było fajne dla użytkowników końcowych.

Naturalnym rozwiązaniem stał się tutaj cache. Z jednej strony nie musiałem pobierać tych samych danych wielokrotnie. Z drugiej przy okazji odciążona została usługa dostarczająca te dane, dzięki czemu zespół po drugiej stronie mógł trochę odsapnąć i poszukać miejsca, które powodowało tak duży problem wydajnościowy.

Zarówno w pierwszy, jak i drugim przypadku, rozwiązaniem które zastosowano był Caffeine cache.

Caffeine Cache

Przejdźmy do tego, co tygryski lubią najbardziej. Czyli do kodu 😊

Przygotowanie danych

Na początek przygotujemy sobie metodę zwracającą mapę przykładowych użytkowników. Kluczem jest identyfikator użytkownika, wartością jego login.

  private Map<Integer, String> veryLongUserGenerator(int howMany) {
    Map<Integer, String> users = IntStream.range(0, 10)
        .mapToObj(i -> i)
        .collect(Collectors.toMap(i -> i, i -> "user-" + i));
    return users;
  }

Dla uproszczenia traktujemy tę metodę na początku jako generator danych. Do prezentacji API Caffeine cache nie będziemy tutaj dodawać rzeczywistych opóźnień wczytywania danych. Po prostu zakładamy, że te już mamy i chcemy je w cache umieścić.

Podstawowe operacje Caffeine cache

Prezentację Caffeine cache zacznijmy od przykładu utworzenia podstawowego cache.

 @Test
  public void caffeineExpireTest() throws InterruptedException {
    Cache<Integer, String> cache = Caffeine.newBuilder()
        .expireAfterWrite(1, TimeUnit.SECONDS)
        .maximumSize(10)
        .build();

    cache.putAll(
        veryLongUserGenerator(10)
    );

    String user9 = cache.getIfPresent(9);
    assertEquals("user-9", user9);

    Thread.sleep(2_000);

    String awaitUser9 = cache.getIfPresent(9);
    assertNull(awaitUser9);
  }

Jak widzimy w liniach 3 – 6 Caffeine ma bardzo przyjazne API. Korzystamy z buildera. Chcemy, żeby nasz cache trzymał dane przez jedną sekundę po ich zapisie. I nie chcemy mieć wielu wpisów w cache, 10 nam wystarczy.

Po zbudowaniu naszego obiektu cache możemy wstawić do niego trochę danych, w tym przypadku dziesięciu użytkowników (linie 8 – 10).

Wstawiliśmy, to szybciutko sprawdzamy, czy rzeczywiście pod kluczem 9 znajdziemy coś w cache – w linii 12 korzystamy w tym celu z metody getIfPresent(). Linia 13 właśnie to sprawdza. I wygląda na to, że jest tutaj ok.

Powiedzieliśmy sobie, że po zapisie dane powinny być w cache przez jedną sekundę. Sprawdźmy to. Zaczekajmy w okolicach dwóch sekund w linii 15.

Teraz byśmy oczekiwali, że już danych w cache nie będzie. Zatem linia 18 sprawdza, czy tym razem pod kluczem 9 nic nie znajdziemy. Czyli odczytamy null. I tak się dzieje.

PS Ten sleep w teście nie jest najładniejszy, ale … bardzo dobrze pokazuje, o co w tym wszystkim chodzi.

Bardziej życiowe zachowanie

Wszystko fajnie. Ale tak naprawdę często chcielibyśmy uzyskać inne zachowanie. Tzn. chcielibyśmy zapytać Caffeine cache, czy ma pewne dane, a gdy się okaże, że ich nie ma, to wówczas po nie sięgnąć w inne miejsce.

Zrealizujmy taki scenariusz:

  @Test
  public void caffeineOptainIfNecessaryTest() {
    Cache<Integer, String> cache = Caffeine.newBuilder()
        .expireAfterWrite(1, TimeUnit.SECONDS)
        .maximumSize(10)
        .build();

    String user0 = cache.getIfPresent(0);
    assertNull(user0);

    String thisTimeUser0 = cache.get(0, integer -> "user-0");
    assertEquals("user-0", thisTimeUser0);
    String againUser0 = cache.getIfPresent(0);
    assertEquals("user-0", againUser0);
  }

Podobnie, jak wcześniej, budujemy nasz cache. Tym razem nie dodajemy użytkowników. Ale sięgamy od razu po użytkownika z kluczem 0 i sprawdzamy, czy rzeczywiście go w cache nie ma w linii 9. A skoro go nie ma, to tym razem jeszcze raz po niego sięgniemy, ale tym razem nie za pomocą metody getIfPresent(), a metody get(). W linii 11 widzimy, że get() przyjmuje dwa argumenty. Pierwszy to nasz klucz, a drugi to funkcja, która pozwoli nam od razu wstawić do cache wartość dla tego klucza, jak jej w cache nie będzie. Sprawdzamy, czy się udało w linii 12 i jeszcze raz przy użyciu getIfPresent() linię dalej. Wygląda nieźle, działa.

Caffeine Loading Cache

Caffeine ma również tzw. loading cache. Czasem od razu wiemy, że będziemy dane umieszczać w cache i znamy metodę, jaką będziemy do tego wykorzystywać. Możemy tę wiedzę wykorzystać od razu w builderze.

  @Test
  public void caffeineLoadingCacheTest() {
    LoadingCache<Integer, String> cache = Caffeine.newBuilder()
        .expireAfterWrite(1, TimeUnit.SECONDS)
        .maximumSize(10)
        .build(key -> "user-" + key);

    String user0 = cache.getIfPresent(0);
    assertNull(user0);

    String againUser0 = cache.get(0);
    assertEquals("user-0", againUser0);
  }

Tak właśnie dzieje się w linii 6, gdzie podajemy nasz generator od razu i dzięki czemu dostajemy instancję klasy LoadingCache. I jest to bardzo pomocna strategia budowania cache.

Na co można jeszcze zwrócić uwagę

Jest kilka elementów. Na pewno jednym z nich będzie sposób usuwania z cache elementów (czyli eviction policy). Caffeine zapewnia kilka strategii:

Można również zwrócić uwagę na asynchroniczne operacje cache oraz powiadomienia, gdy np. dany element zostanie z cache usunięty.

Możliwe jest również propagowanie zapisów do zapisów do zewnętrznych repozytoriów.

I dostępny jest całkiem przystępny mechanizm zbierania statystyk.

Interesuje Cię któryś z elementów Caffeine Cache? Daj znać w komentarzu. Chętnię rozszerzę artykuł o dalsze treści 👍

Jeśli temat cache Cię zainteresował, to rzuć okiem na hasło cache na Wikipedii. Znajdziesz tam dużo różnych przykładów, gdzie cache ma zastosowanie.

Inne biblioteki, które warto znać Biblioteki Java, które warto znać

4.7 3 votes
Article Rating
Subscribe
Powiadom o
guest
0 komentarzy
Inline Feedbacks
View all comments