IMemoryCache – popularne dane pod ręką
Wyobraź sobie, że robisz jakieś danie, które masz opisane w książce kucharskiej. Wyciągasz książkę, otwierasz, szukasz przepisu, korzystasz z niego i po skończonej pracy zamykasz książkę i chowasz ją.
Teraz wyobraź sobie, że masz ten sam proces, ale okazuje się, że musisz kolejny raz sięgnąć do książki, bo przyszło więcej gości i trzeba dogotować drugi raz to samo danie. Ok.
Jednak w drugim przypadku, zwłaszcza jak wiesz, że może się okazać, że przepis będzie Ci potrzebny trzeci raz, to zamiast za każdym razem iść po książkę i szukać przepisu, to pewnie zrobisz sobie jego zdjęcie i będziesz mieć pod ręką na telefonie. Albo zwyczajnie odłożysz książkę gdzieś bliżej blatu.
Po czasie to zdjęcie usuniesz, ewentualnie zaczniesz używać innej książki kucharskiej, więc następnym razem warto by było jednak zrobić sobie zdjęcie nowego przepisu.
Brawo – właśnie użyłeś cache do wyciągania przepisu!
Cache – co to jest?
Cache, czyli pamięć podręczna, to forma przechowania „pod ręką” wcześniej otrzymanych danych.
Co oznacza „pod ręką”? Oznacza to, że trzymając dane w cache, trzymamy je blisko miejsca, które z nich korzysta. Zazwyczaj też w postaci, która najlepiej sprawdza się dla użytkownika tych danych.
Można powiedzieć, że cache to taki pojemniczek na biurku, do którego wkładamy coś, co wyciągnęliśmy np. z szafy i co przyda nam się za chwilę ponownie, więc nie ma sensu tego od razu chować.
Cache praktycznie zawsze występuje pomiędzy źródłem danych, a odbiorcą tych danych. Zwykle też jest jednak dużo bliżej odbiorcy.
W opisanym przeze mnie na wstępie przypadku, tą pamięcią podręczną był Twój telefon, albo miejsce przy blacie. Masz go ciągle przy sobie, więc wczytałeś dane z bazy danych (książki), do pamięci (telefonu) po to, żeby nie musieć za każdym razem odwoływać się do tej bazy.
Cache zawsze jest tymczasowy. I zawsze posiada dane, które są z przeszłości. Poza tym jednym, bardzo krótkim momentem zaraz po załadowaniu nowych danych, kiedy cache wygasł, albo nie był wcześniej wypełniony.
Podsumowując – cache jest pośrednikiem pomiędzy źródłem danych, a ich odbiorcą. Przechowujemy w nim informacje, które prawdopodobnie niedługo znowu nam się przydadzą, a koszt ich ciągłego pozyskiwania ze źródła może być zauważalnie duży.
Po co nam cache?
W tym tekście mówię o kontekście typowej aplikacji, czy to webowej (przede wszystkim), czy też desktopowej, albo mobilnej. Tego typu aplikacje praktycznie zawsze operują na jakichś danych, trzymanych w innym miejscu – czy to w bazie danych, czy też w zewnętrznej usłudze, albo za jakimś API.
O tym, dlaczego warto korzystać z cache’a już co nieco wspomniałem w poprzednim paragrafie. Korzystanie z pamięci podręcznej jest użyteczne kiedy często czytamy powtarzalny zestaw informacji.
Mówiąc „powtarzalny”, mam na myśli takie informacje, które same w sobie się nie zmieniają na przestrzeni wielu odczytów. Przykładowo, może mamy w naszej aplikacji jakieś dane typu słownik, czyli np. listę państw, które obsługuje nasza aplikacja. Raczej rzadko będą się one zmieniały, a jednocześnie, jeśli jest to np. element wyświetlany podczas zakupów, to dużo użytkowników będzie takie dane codziennie odczytywało. Szkoda zasobów na pytanie bazy danych o taką listę dla dosłownie każdego zamówienia. W takim przypadku można za pierwszym razem przechować te dane w cache, w pamięci, i następnym razem już tam po nie sięgnąć.
W ten sposób przyspieszamy działanie aplikacji, bo pomijamy połączenie z bazą danych, które potrafi być najkosztowniejszym elementem całego zapytania. Poza tym, być może też redukujemy koszty działania systemu, bo przy większej ilości zapytań możemy już natrafić na naliczanie opłat na bazie ilości przesłanych do i z bazy danych informacji.
Wady cache
Oczywiście cache, jak każde rozwiązanie, ma swoje wady.
Pierwszą, bardzo ważną wadą, o której zdecydowanie trzeba pamiętać, decydując się na takie rozwiązanie, jest kwestia aktualności danych. A raczej nieaktualności danych.
Pamięć podręczna zaczyna mieć sens kiedy trzymamy w niej informacje, które będą wykorzystane przez znaczną liczbę zapytań. Oznacza to, że zależnie od potrzeb, cache będzie izolował nas od bazy danych przez kilka minut, albo nawet kilka godzin, lub dni. Oznacza to, że jeżeli przy każdym zapytaniu jest możliwość, że te dane się zmienią, to stosowanie cache nie ma sensu. W dodatku może wręcz być tak, że pamięć podręczna spowoduje kłopoty, kiedy użytkownicy dostaną nieaktualne kluczowe dane. Bo o ile opóźnienie kilkunastu minut w dodaniu do sklepu obsługi kolejnego kraju nie jest jakimś gigantycznym problemem, tak kilka minut opóźnienia w aktualności zmieniających się non stop danych z giełdy, to już bardzo poważny problem.
Dlatego wybierając dane, które mają być trzymane w pamięci podręcznej, musimy albo wybrać takie, których delikatna nieaktualność nie jest problemem, albo musimy dobrze przemyśleć mechanizm odświeżania tego cache’a, kiedy w bazie pojawi się zaktualizowana wartość.
Druga wada pamięci podręcznej to pamięć. Jak wiesz, bazy danych, nawet w średniej wielkości projektach, potrafią mieć kilka, czy kilkanaście gigabajtów wielkości. Dlatego jeśli zaczniesz wkładać do pamięci podręcznej zbyt dużo różnych informacji, to może się okazać, że zużyjesz całą pamięć operacyjną, jaka jest dostępna na serwerze.
Tutaj rozwiązaniem jest po prostu rozsądek. Jeżeli jakieś dane nie są aż tak często odczytywane, to nie ma sensu robić dla nich buforu. Z drugiej strony, jeżeli jakieś dane czytasz często, to zastanów się czy potrzebujesz całego zestawu tych danych. Może jest tak, że z całego rekordu w bazie, który ma kilkanaście kolumn, aplikacja często czyta tylko identyfikator i nazwę, bo potrzebuje ich do wyświetlania listy rozwijalnej. Wtedy warto buforować tylko te dwie informacje. Dzięki temu też zaoszczędzimy trochę megabajtów pamięci.
Czy jest IMemoryCache
Cache oczywiście możemy implementować samodzielnie. Jednak nie jest to najrozsądniejsze rozwiązanie, skoro są dostępne gotowe, sprawdzone rozwiązania. Jednym z nich jest udostępnione przez Microsoft IMemoryCache
.
Więc w takim razie czym jest IMemoryCache
?
Jest to interfejs pozwalający na skorzystanie z pamięci podręcznej opartej o pamięć operacyjną.
Dostępny jest w ramach domyślnie dostępnych rozszerzeń dla platformy .NET.
Dlaczego mówię tutaj o interfejsie? Bo jak mówi sam Microsoft, korzystając z kontenera zależności, korzystanie z tego interfejsu jest zdecydowanie polecane ponad bezpośrednie użycie klasy MemoryCache
. Dzięki temu nie musimy tworzyć odpowiednich obiektów samodzielnie.
IMemoryCache
udostępnia metody do próby pobrania wartości z cache, wstawienia tam nowych wartości, albo uruchomienie czyszczenia pamięci.
Jeżeli chodzi o czas życia obiektów w pamięci podręcznej, to w dużym uproszczeniu mamy do wyboru dwa scenariusze:
- stała długość życia
- przesuwająca się długość życia
W pierwszym przypadku, ustawiając, że aktualna wartość ma być trzymana przez X czasu, to niezależnie od zainteresowania wartością, po X czasu cache zostanie usunięty i będzie trzeba pobrać nową wartość podczas kolejnej próby odczytania danej wartości. Jest to opcja, która dobrze się sprawdzi w przypadku danych, które na pewno chcemy odświeżyć co określony czas. Niezależnie od tego jak często użytkownicy po nie sięgają.
Drugie rozwiązanie polega na tym, że jeżeli nikt nie zapyta o dany klucz przez X czasu, to zostaje on usunięty. Jednak jeśli nastąpi pobranie wartości, to licznik czasu się resetuje. Przy bardzo „popularnych” danych, w skrajnych przypadkach, jakaś wartość może praktycznie nigdy nie być drugi raz pobrana z bazy. Przynajmniej dopóki tego ręcznie nie wymusimy. To rozwiązanie sprawdzi się w przypadku bardzo rzadko zmieniających się, ale też jednocześnie nie ogromnych danych. Dobrym przykładem może być wspomniana wcześniej lista obsługiwanych państw.
Korzystanie z IMemoryCache
Zakładam, że mówimy o korzystaniu z pamięci podręcznej w ramach aplikacji webowej, pisanej w ASP.NET Core. Ewentualnie po prostu z aplikacji w .NET, która korzysta z domyślnego kontenera zależności.
Użycie IMemoryCache
w aplikacji jest bardzo proste. Na tyle proste, że aż dziw, że nie jest tak powszechnie wykorzystywane.
Podstawowa sprawa jest taka, że musimy zarejestrować w kontenerze serwis związany z cachem w pamięci:
builder.Services.AddMemoryCache();
Następnie trzeba wstrzyknąć do miejsca, w którym chcemy mieć dostęp do cache, interfejs IMemoryCache
:
class MyClass { private IMemoryCache _cache; public MyClass(IMemoryCache cache) { _cache = cache; } }
Teraz w metodzie, która odpowiada za pobranie danych, możemy spróbować najpierw pobrać te dane z pamięci podręcznej, z użyciem metody TryGetValue()
, a jeżeli to się nie uda, to pobrać faktycznie dane ze źródła i zapisać je w pamięci podręcznej na przyszłość. Zakładam tutaj, że używamy stałego czasu wygaśnięcia cache:
class MyClass { private IMemoryCache _cache; public MyClass(IMemoryCache cache) { _cache = cache; } public string GetValue(string id) { if (!_cache.TryGetValue(id, out string value)) { value = LoadValue(id); _cache.Set(id, value, new MemoryCacheEntryOptions() .SetAbsoluteExpiration(TimeSpan.FromSeconds(10))); } return value; } }
I gotowe!
Od teraz, jeżeli dane będą w pamięci podręcznej, to zostaną wczytane do zmiennej value
. Jeśli ich tam nie ma, to pobieramy je skądś, a następnie ustawiamy tą wartość pod podanym kluczem.
W powyższym przykładzie założyłem, że kluczem jest id
, a zarówno klucz, jak i wartość są typu string
, jednak nie jest to wymóg i zarówno klucz i wartość mogą być dowolnego typu.
Powyższe rozwiązanie jest jak najbardziej wystarczające, jednak nie sprawdzi się w sytuacji, kiedy kilku użytkowników będzie próbowało pobrać te same dane. Jeśli kilku z nich trafi na moment, kiedy dane z pamięci podręcznej zostały usunięte i trzeba pobrać nowe, to każdy z nich będzie je pobierał i próbował nadpisać w cache.
Z tego powodu warto tutaj dodać jeszcze jeden element w postaci semafora. Skorzystamy z klasy SemaphoreSlim
dostępnego w .NET, po to, żeby tylko jeden użytkownik w danym momencie mógł pobrać nowe dane:
class MyClass { private IMemoryCache _cache; public MyClass(IMemoryCache cache) { _cache = cache; } public async Task<string> GetValue(string id) { var semaphore = new SemaphoreSlim(1,1); if (!_cache.TryGetValue(id, out string value)) { try { await semaphore.WaitAsync(); if (!_cache.TryGetValue(id, out value)) { value = LoadValue(id); _cache.SetCache(id, value, new MemoryCacheEntryOptions() .SetAbsoluteExpiration(TimeSpan.FromSeconds(10))); } finally { semaphore.Release(); } } return value; } }
Kolejny użytkownik, który trafi do bloku pobierającego dane ze źródła, będzie musiał poczekać, aż semafor zostanie zwolniony. Kiedy to się stanie, musimy jeszcze raz sprawdzić czy jednak dane nie pojawiły się już w cache, wczytane przez kogoś innego. Dopiero kiedy faktycznie ich tam nie ma, to jednak je ładujemy i zapisujemy w cache.
Przykład
Poniżej daję przykład zastosowania pamięci podręcznej na podstawie standardowego kodu, wygenerowanego przez template ASP.NET, w którym pobieramy prognozę pogody. W tym przykładzie dane pogodowe są cacheowane przez 10 sekund. Kiedy uruchomisz ten kod, to dopiero po 10 sekundach dostaniesz nowe dane, bo po takim czasie wygaśnie pamięć podręczna:
using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; var builder = WebApplication.CreateBuilder(args); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddMemoryCache(); var app = builder.Build(); app.UseSwagger(); app.UseSwaggerUI(); var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; app.MapGet("/weatherforecast", async ([FromServices] IMemoryCache cache) => { var semaphore = new SemaphoreSlim(1, 1); if (!cache.TryGetValue("forecast", out IEnumerable<WeatherForecast>? forecasts)) { try { await semaphore.WaitAsync(); if (!cache.TryGetValue("forecast", out forecasts)) { forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast ( DateOnly.FromDateTime(DateTime.Now.AddDays(index)), Random.Shared.Next(-20, 55), summaries[Random.Shared.Next(summaries.Length)] )) .ToArray(); cache.Set("forecast", forecasts, new MemoryCacheEntryOptions() .SetAbsoluteExpiration(TimeSpan.FromSeconds(10))); } } finally { semaphore.Release(); } } return forecasts; }) .WithName("GetWeatherForecast") .WithOpenApi(); app.Run(); record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) { public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); }
Leave a Comment