Jak cachować dane w .NET? Kilka słów o CacheManager oraz Redis

Wprowadzenie

Wraz z rozwojem aplikacji oraz zwiększaniem się liczby użytkowników, stajemy przed coraz większymi problem związanymi z wydajnością. Szczególnie istotne jest to w momencie, gdy na przykład ruch na stronie jest na tyle duży, że serwer, z którego korzystamy jest wykorzystywany w 100%. Jednym z sposobów, który w miarę szybko daje spore efekty, jest dodanie mechanizmu cachowania często używanych danych. W tym wpisie pokaże Ci bibliotekę CacheManager (https://github.com/MichaCo/CacheManager), która ułatwia dodawanie cachowania w aplikacjach .NET.

W przykładzie zobaczysz w jaki sposób dodać do aplikacji ASP.NET MVC wielopoziomowy cache (pierwszy poziom to pamięć procesu, natomiast drugi to Redis) z wykorzystaniem CacheManagera, dzięki któremu cały proces jest bardzo łatwy. Pokazany mechanizm można również wykorzystać do innych typów aplikacji.

Czym jest cachowanie danych?

W aplikacji część danych jest wykorzystywana częściej niż inne. Dlatego można rozważyć zapisanie ich w jakimś tymczasowym miejscu (np. pamięć RAM), z którego odczyt danych jest dużo szybszy niż na przykład z bazy danych. Dzięki temu aplikacja działa szybciej z punktu widzenia użytkownika, a z drugiej strony możemy odciążyć część serwerów poprzez zminimalizowanie ilości wykonywanych operacji (np. zapytań do bazy).

W cache możemy zapisywać różne rzeczy. Mogą to być dane pobrane bezpośrednio z bazy lub jakieś usługi. Mogą to być jakieś przetworzone dane. W jednej aplikacji zapisywaliśmy informacje o menu oraz strukturze strony dla użytkownika, ponieważ mechanizm uprawnień był dość rozbudowany i sprawdzanie tego za każdym razem było kosztowne. Mogą to być również wyniki operacji, na przykład html wygenerowanej strony lub jej fragment.

Cykl życia informacji w cache może być różny. Dane mogą być w nim przez określony czas (np. 5 minut). Czas ten może się resetować przy każdym pobraniu danych lub być absoluty, dane zostaną usunięte po określonym czas po dodaniu. W sytuacji, gdy kontrolujemy edycję oraz usuwanie danych lub mamy o tym powiadomienia, możemy usuwać z cache informację o zmodyfikowanych elementach, aby zminimalizować wyświetlanie nieaktualny danych.

Dane możemy zapisywać w różnych miejscach. Może to być pamięć RAM dla najczęściej używanych informacji. Dysk twardy dla wygenerowanego html. W rozbudowanych systemach, gdzie aplikacja działa na kilku serwerach możemy skorzystać w rozproszonych systemów cachowania, taki jak Redis. Wtedy użyjemy wielopoziomowego cachowania danych oraz jakiś mechanizmów, które umożliwią nam synchronizowanie informacji z Redisa z informacjami w lokalnym cache w pamięci RAM.

Opcji i możliwości jest dużo. W zależności od specyfiki aplikacji oraz środowiska, na którym jest wdrożona możemy wybrać, to co najlepiej spełni nasze wymagania. Mając już obraną strategię cachowania danych możemy przejść do właściwej implementacji.

CacheService

W swoich aplikacjach staram się (o ile to możliwe oraz mało czasochłonne) odizolowywać używanie bibliotek od reszty systemu. Tak jest w przypadku obsługi cachowania. Mam własny interfejs, który używam w całej aplikacji i nie korzystam bezpośrednio z CacheManagera. W tym przypadku głównym powodem takiej podejścia jest to, że CacheManager ma trochę inne API niż te, które chce używać. Sam interfejs dla CacheService wygląda tak:

Jak widać nie jest on skomplikowany. Tak naprawdę są to trzy metody (każda po dwa warianty). Najczęściej używaną metodą jest GetOrAdd. W pierwszej kolejności ma ona sprawdzić, czy w cache jest wartość pod określonym kluczem i ją zwrócić. W przeciwnym przypadku wywołuje przekazaną funkcję jako drugi parametr, cachuje jej wynik i zwraca wartość.

Dwie pozostałe metody służą do usuwania danych z cache. Remove usuwa konkretny klucz. Natomiast Clear czyści wszystkie dane z cache.

W cachowaniu często wykorzystuje regiony (dlatego każda z metod ma dwie wersje). Regiony służą do grupowania danych w cache (np. oddzielne regiony dla każdego z użytkowników). Dzięki czemu możemy trochę różne dane trzymać dla tych samych kluczy, ale w różnych regionach (np. inna cena produktu dla poszczególnych użytkowników w zależności od udzielonego rabatu). Możemy również później łatwo usunąć wszystkie powiązane wartości w cache dla danego regionu (np. w trakcie wylogowywania użytkownika usuwany jego wszystkie dane).

CacheService a czas życia danych w cache

Możesz również zauważyć, że w interfejsie nie ma możliwości określenia czasu jak długo dane informacja ma być przechowywana w cache. Jest to celowe działanie. Z czasem zauważyłem, że lepiej nie określać czasu życia danych w cache na poziomie kodu. Późniejsza zmiana wymaga przekompilowania aplikacji, co w przypadku produkcji może nie być takie proste. Dużo lepiej jest konfigurować to na poziomie web.config lub app.config. Dzięki temu możemy to dużo łatwiej zmienić na produkcji. Dodatkowo możemy mieć różne konfiguracje w zależności od środowiska. Na przykład na produkcji 10 minut. Staging to 5 minut. Środowisko testerskie 1m, a na środowisko developerskim 0 minut.

W sytuacji, gdy jednak potrzebuje mieć różne konfiguracja w zależności od danych, wtedy w aplikacji mam kilka instancji usługi (zamiast jednego singletona). CacheManager umożliwia zdefiniowanie wielu konfiguracji cachowania w pliku konfiguracyjnym i możemy podczas tworzenia instancji CacheService załadować odpowiednią w zależności od tego co potrzebujemy.

CacheManager

CacheManager (https://github.com/MichaCo/CacheManager) jest jedną z dostępnym bibliotek, którą możemy wykorzystać. Ma kilka fajnych funkcji, które warto przejrzeć.

Po pierwsze wpiera kilka różnych providerów, których możemy użyć w aplikacji. Wszystko może zostać skonfigurowane w web.config lub app.config, dzięki czemu wystarczy, że raz napiszemy kod. Dodatkowo biblioteka wpiera wielopoziomowy cache, który przydaje się w środowisku rozproszonym. To też jest konfigurowalne. Przy tym samym kodzie możemy w wersji testowej mieć tylko cache w ramie, natomiast na produkcji dodatkowo dorzucić drugi poziom cache w Redis.

Biblioteka wpiera kilka różnych sposobów serializacji danych w cache. Domyślnie serializuje dane binarnie, ale możemy użyć też innego sposobu. Jak przykład Json, Protobuf lub inne.

Inną ciekawą funkcjonalnością jest metoda Update, która przydaje się szczególnie w środowisku wielowątkowym. Za jej pomocą trochę inaczej aktualizujemy dane w cache. Zamiast przekazać nową wartość, która zastąpi starą, przekazujemy po prostu delegat, który wykona się i zaktualizuje wartość w cache. Warto przejrzeć przykład z licznikiem w dokumentacji, który pokazuje działanie tej metody – http://cachemanager.michaco.net/documentation/CacheManagerUpdateOperations

Implementacja CacheService

Poniżej znajduje się implementacja CacheService z wykorzystaniem biblioteki CacheManager:

W przykładzie wykorzystuje tylko jedną instancje usługi, która podczas tworzenia wczytuje konfigurację „defaultCache” z web.configu, dodaje logowanie i tworzy instancje CacheManagera.

Tutaj właśnie widać różnicę w api między moją usługą, a biblioteką. CacheManager jest generycznym typem, gdzie dla każdego typu, który chcemy przechowywać tworzymy nową instancję. Natomiast ja w przypadku CacheService wole jednak określać typ na poziomie parametru generycznego metody GetOrAdd.

CacheManager korzysta z Microsoft.Extensions.Logging do logowania informacji. Są już dostępne adaptery, które umożliwiają zapis generowanych logów z biblioteki Microsoftu na przykład do nloga. Wystarczy w konfiguracji CacheManagera użyć odpowiedniego adapteru – w przykładzie logi są przekazywane do nloga i zapisywane w plikach w App_Data/logs.

Użycie CacheService

W przykładzie użyłem CacheService na poziomie logiki biznesowej. Przez nią przechodzą takie operacja jaka edycja, czy usuwanie, dzięki czemu mogę po ich wykonaniu usuwać klucze z cache. W zależności od potrzeb możemy używać usługi również w innych częściach aplikacji.

W tym momencie usługa jest użyta bezpośrednio w kodzie logiki. W przyszłości pokaże jeszcze jak to można zrobić transparentnie, bez pisania dodatkowego kodu w logice biznesowej.

Jak widać na listingu, w logice biznesowej obok standardowego repozytorium wstrzykujemy również CacheService. Znajduje się również prywatna metoda CacheKey, która jest odpowiedzialna za przygotowanie klucza pod, którym będzie cachowany obiektu produktu. W metodzie GetById dodajemy produkt do cache za pomocą metody GetOrAdd z CacheService, natomiast w Update oraz Delate usuwany dane z wykorzystaniem metody Remove.

CacheService a EntityFramework

Mechanizm śledzenia zmian w Entity Framework jest bardzo fajny. Ułatwia one aktualizacje danych w bazie. Niestety pojawiają się z nim problemy w momencie, kiedy zaczniemy wrzucać do cache obiekty pobrane z bazy, tak jak to jest w przykładzie. Problem polega na tym, że obiekt w cache żyje dużo dłużej niż obiekt kontekstowy, który na ogół jest tworzony na potrzeby pojedynczego żądania HTTP. Dlatego musimy pamiętać, aby przed aktualizacją obiektu powiązać go z nowym obiektem kontekstowym, tak jak to widać w repozytorium poniżej:

Podobna sytuacja jest z lazy loadingiem. Bez podłączenia do nowego obiektu kontekstowego powiązane obiekty nie zastaną załadowane. Dlatego warto w cache trzymać już obiekty, które zawierają wszystkie informacje. Wystarczy podczas ich pobierania skorzystać z metody Include.

CacheManager konfiguracja

Ostatnim elementem całej układanki jest konfiguracja biblioteki. Jak wspomniałem wcześniej, w swoich projektach dodaje ją w web.config lub app.config, aby móc łatwo to zmieniać. Poniżej jest fragment web.configu, który konfiguruje wielopoziomowy cache w testowej aplikacji. W pierwszym poziomie dane są zapisywane w pamięci ram, natomiast drugi poziom jest to redis:

W pierwszej części konfiguracji jest zapisana informacja o połączeniu do redisa (np. usługa cache z azure). W drugiej części jest konfiguracja domyślnego cache, który zapisuje dane do dwóch miejsc. Każda z lokalizacji ma swoją konfigurację czasu przechowywania danych. W ramie dane są przechowywane przez 1 minutę w sposób absolutny (czyli dane zostaną usunięte po 1 minucie od ich dodania), natomiast w redis przez 10 minut, gdzie każde pobranie danych resetuje ten czas (opcja sliding).

Dodatkowo redis jest skonfigurowany jako backplane. Oznacza to, że CacheManager będzie synchronizował dane w cache miedzy poziomami. Jest to szczególnie istotne, gdy aplikacja jest hostowana na kilka serwerach. Wtedy zmiana danych w redis zostanie rozpropagowana do wszystkich serwerów.

W przykładzie dane do cache są serializowane do json, co widać na zrzucie poniżej z danymi dla Product-1 w redis. Użyłem tej serializacji, aby było łatwiej sprawdzić, że w redis faktycznie są te dane. W realnej aplikacji lepiej skorzystać z serializacji binarnej, która jest wydajniejsza.

redis product 1

Przykład

W repozytorium na github (https://github.com/danielplawgo/CacheExample) znajduje się przykład do tego wpisu. Aby go uruchomić trzeba zmienić dwie rzeczy w web.config:

  • Ustawić connection stringa do bazy danych
  • Dodać dane do połączenia do redisa (można skorzystać z redisa w azure) – lub w konfiguracji CacheManager usunąć używanie redisa.

Po tych dwóch krokach można uruchomić przykład i sprawdzić w praktyce jak dała CacheManager.

Podsumowanie

Dodanie cachowanie do aplikacji nie jest czymś skomplikowanym. W szczególności, gdy korzystamy z jakieś gotowej biblioteki, która to ułatwia. Warto zainteresować się CacheManagerem, który jest łatwy w użyciu, a daje sporo możliwości, taki jak wielopoziomowy cache.

Warto również pamiętać, o tym, że czasami dodanie cache do aplikacji może powodować drobne problem. Tak jak jest to w przypadku Entity Framework. Dlatego warto już wcześniej myśleć o cachowaniu, aby później nie mieć niespodziewanych sytuacji.

A jakie są Twoje doświadczenia z cachowaniem danych?

 

1 thought on “Jak cachować dane w .NET? Kilka słów o CacheManager oraz Redis

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *