Jak poprawić wydajność aplikacji ASP.NET w farmie sieci Web za pomocą buforowania?
Opublikowany: 2022-03-11W informatyce są tylko dwie trudne rzeczy: unieważnianie pamięci podręcznej i nazywanie rzeczy.
- Autor: Phil Karlton
Krótkie wprowadzenie do buforowania
Buforowanie to potężna technika zwiększania wydajności dzięki prostej sztuczce: zamiast wykonywania kosztownej pracy (takiej jak skomplikowane obliczenia lub złożone zapytania do bazy danych) za każdym razem, gdy potrzebujemy wyniku, system może przechowywać – lub buforować – wynik tej pracy i po prostu dostarczyć go następnym razem, gdy zostanie o to poproszony, bez konieczności ponownego wykonywania tej pracy (i dlatego może reagować ogromnie szybciej).
Oczywiście cała idea buforowania działa tylko tak długo, jak długo buforowany wynik pozostaje aktualny. I tutaj dochodzimy do najtrudniejszej części problemu: jak ustalić, kiedy element w pamięci podręcznej stał się nieważny i musi zostać odtworzony?
i idealny do rozwiązania problemu buforowania rozproszonej farmy internetowej.
Zazwyczaj typowa aplikacja internetowa musi radzić sobie ze znacznie większą liczbą żądań odczytu niż żądania zapisu. Dlatego typowa aplikacja internetowa zaprojektowana do obsługi dużego obciążenia jest zaprojektowana tak, aby była skalowalna i rozproszona, wdrażana jako zestaw węzłów warstwy internetowej, zwykle nazywanych farmą. Wszystkie te fakty mają wpływ na możliwość zastosowania buforowania.
W tym artykule skupimy się na roli, jaką buforowanie może odegrać w zapewnieniu wysokiej przepustowości i wydajności aplikacji internetowych zaprojektowanych do obsługi dużego obciążenia, a ja zamierzam wykorzystać doświadczenie z jednego z moich projektów i dostarczyć rozwiązanie oparte na ASP.NET jako ilustracja.
Problem z obsługą dużego obciążenia
Rzeczywisty problem, który musiałem rozwiązać, nie był oryginalny. Moim zadaniem było stworzenie prototypu monolitycznej aplikacji webowej ASP.NET MVC zdolnej do obsługi dużego obciążenia.
Niezbędne kroki w kierunku poprawy przepustowości monolitycznej aplikacji internetowej to:
- Włącz możliwość równoległego uruchamiania wielu kopii aplikacji internetowej za systemem równoważenia obciążenia i efektywnej obsługi wszystkich współbieżnych żądań (tj. spraw, aby była skalowalna).
- Profiluj aplikację, aby odkryć obecne wąskie gardła wydajności i je zoptymalizować.
- Użyj buforowania, aby zwiększyć przepustowość żądań odczytu, ponieważ zwykle stanowi to znaczną część ogólnego obciążenia aplikacji.
Strategie buforowania często wymagają użycia serwera buforowania oprogramowania pośredniego, takiego jak Memcached lub Redis, do przechowywania buforowanych wartości. Pomimo wysokiego rozpowszechnienia i udowodnionej przydatności, istnieją pewne wady tych podejść, w tym:
- Opóźnienia sieciowe wprowadzone przez dostęp do oddzielnych serwerów pamięci podręcznej mogą być porównywalne z opóźnieniami w dotarciu do samej bazy danych.
- Struktury danych warstwy sieci Web mogą być nieodpowiednie do serializacji i deserializacji po wyjęciu z pudełka. Aby korzystać z serwerów pamięci podręcznej, te struktury danych powinny obsługiwać serializację i deserializację, co wymaga ciągłego dodatkowego wysiłku programistycznego.
- Serializacja i deserializacja zwiększają obciążenie środowiska wykonawczego, co ma negatywny wpływ na wydajność.
Wszystkie te kwestie były istotne w moim przypadku, więc musiałem zbadać alternatywne opcje.
Wbudowana pamięć podręczna ASP.NET ( System.Web.Caching.Cache
) jest niezwykle szybka i może być używana bez narzutów związanych z serializacją i deserializacją, zarówno podczas programowania, jak i w czasie wykonywania. Jednak pamięć podręczna ASP.NET ma również swoje wady:
- Każdy węzeł warstwy internetowej potrzebuje własnej kopii wartości z pamięci podręcznej. Może to spowodować większe zużycie warstwy bazy danych podczas zimnego startu lub recyklingu węzła.
- Każdy węzeł warstwy sieciowej powinien być powiadamiany, gdy inny węzeł powoduje, że jakakolwiek część pamięci podręcznej jest nieważna, zapisując zaktualizowane wartości. Ponieważ pamięć podręczna jest rozproszona i bez odpowiedniej synchronizacji, większość węzłów zwróci stare wartości, co jest zwykle niedopuszczalne.
Jeśli dodatkowe obciążenie warstwy bazy danych samo w sobie nie doprowadzi do wąskiego gardła, to wdrożenie odpowiednio rozproszonej pamięci podręcznej wydaje się łatwym zadaniem, prawda? Cóż, nie jest to łatwe zadanie, ale jest możliwe . W moim przypadku testy porównawcze wykazały, że warstwa bazy danych nie powinna stanowić problemu, ponieważ większość pracy odbywała się w warstwie sieciowej. Postanowiłem więc skorzystać z pamięci podręcznej ASP.NET i skupić się na zaimplementowaniu właściwej synchronizacji.
Przedstawiamy rozwiązanie oparte na ASP.NET
Jak wyjaśniono, moim rozwiązaniem było użycie pamięci podręcznej ASP.NET zamiast dedykowanego serwera pamięci podręcznej. Oznacza to, że każdy węzeł farmy sieciowej ma własną pamięć podręczną, bezpośrednio wysyła zapytania do bazy danych, wykonuje niezbędne obliczenia i przechowuje wyniki w pamięci podręcznej. W ten sposób wszystkie operacje na pamięci podręcznej będą błyskawiczne dzięki wbudowanej w pamięć naturze pamięci podręcznej. Zazwyczaj elementy w pamięci podręcznej mają wyraźny czas życia i stają się nieaktualne po zmianie lub zapisaniu nowych danych. Tak więc z logiki aplikacji sieci Web zwykle jasno wynika, kiedy element pamięci podręcznej powinien zostać unieważniony.
Jedynym problemem, jaki pozostał tutaj, jest to, że gdy jeden z węzłów unieważni element pamięci podręcznej we własnej pamięci podręcznej, żaden inny węzeł nie będzie wiedział o tej aktualizacji. Tak więc kolejne żądania obsługiwane przez inne węzły będą dostarczać nieaktualne wyniki. Aby rozwiązać ten problem, każdy węzeł powinien dzielić swoje unieważnienia pamięci podręcznej z innymi węzłami. Po otrzymaniu takiego unieważnienia inne węzły mogą po prostu porzucić swoją wartość w pamięci podręcznej i otrzymać nową przy następnym żądaniu.
Tutaj Redis może wejść do gry. Siła Redis, w porównaniu z innymi rozwiązaniami, wynika z możliwości Pub/Sub. Każdy klient serwera Redis może stworzyć kanał i opublikować na nim pewne dane. Każdy inny klient może nasłuchiwać tego kanału i odbierać powiązane dane, bardzo podobnie do każdego systemu sterowanego zdarzeniami. Ta funkcja może być wykorzystana do wymiany komunikatów unieważniających pamięć podręczną między węzłami, dzięki czemu wszystkie węzły będą mogły unieważnić swoją pamięć podręczną, gdy jest to potrzebne.
Pamięć podręczna ASP.NET jest pod pewnymi względami prosta, a pod innymi skomplikowana. W szczególności jest to proste, ponieważ działa jako mapa par klucz/wartość, ale istnieje wiele złożoności związanych z jej strategiami unieważniania i zależnościami.
Na szczęście typowe przypadki użycia są wystarczająco proste i możliwe jest użycie domyślnej strategii unieważniania dla wszystkich elementów, dzięki czemu każdy element pamięci podręcznej może mieć najwyżej jedną zależność. W moim przypadku skończyłem z następującym kodem ASP.NET dla interfejsu usługi buforowania. (Zauważ, że to nie jest rzeczywisty kod, ponieważ pominąłem niektóre szczegóły dla uproszczenia i licencji własnościowej.)
public interface ICacheKey { string Value { get; } } public interface IDataCacheKey : ICacheKey { } public interface ITouchableCacheKey : ICacheKey { } public interface ICacheService { int ItemsCount { get; } T Get<T>(IDataCacheKey key, Func<T> valueGetter); T Get<T>(IDataCacheKey key, Func<T> valueGetter, ICacheKey dependencyKey); }
Tutaj usługa pamięci podręcznej zasadniczo pozwala na dwie rzeczy. Po pierwsze, umożliwia przechowywanie wyniku jakiejś funkcji pobierającej wartości w sposób bezpieczny dla wątków. Po drugie, zapewnia, że aktualna wartość jest zawsze zwracana na żądanie. Gdy element pamięci podręcznej stanie się przestarzały lub zostanie jawnie usunięty z pamięci podręcznej, funkcja pobierająca wartość jest wywoływana ponownie w celu pobrania bieżącej wartości. Klucz pamięci podręcznej został wyabstrahowany przez interfejs ICacheKey
, głównie po to, aby uniknąć zakodowania ciągów kluczy pamięci podręcznej w całej aplikacji.
Aby unieważnić elementy pamięci podręcznej, wprowadziłem osobną usługę, która wyglądała tak:

public interface ICacheInvalidator { bool IsSessionOpen { get; } void OpenSession(); void CloseSession(); void Drop(IDataCacheKey key); void Touch(ITouchableCacheKey key); void Purge(); }
Poza podstawowymi metodami upuszczania elementów z danymi i dotykania klawiszy, które miały tylko zależne elementy danych, istnieje kilka metod związanych z pewnego rodzaju „sesją”.
Nasza aplikacja internetowa wykorzystywała Autofac do wstrzykiwania zależności, które jest implementacją wzorca projektowego odwrócenia kontroli (IoC) do zarządzania zależnościami. Ta funkcja umożliwia programistom tworzenie własnych klas bez konieczności martwienia się o zależności, ponieważ kontener IoC zarządza tym obciążeniem za nich.
Usługa pamięci podręcznej i unieważniacz pamięci podręcznej mają drastycznie różne cykle życia pod względem IoC. Usługa pamięci podręcznej została zarejestrowana jako singleton (jedna instancja, współużytkowana przez wszystkich klientów), natomiast unieważniacz pamięci podręcznej został zarejestrowany jako instancja na żądanie (dla każdego przychodzącego żądania została utworzona osobna instancja). Czemu?
Odpowiedź ma związek z dodatkową subtelnością, z którą musieliśmy sobie poradzić. Aplikacja internetowa korzysta z architektury Model-View-Controller (MVC), która pomaga głównie w rozdzieleniu problemów związanych z interfejsem użytkownika i logiką. Tak więc typowa akcja kontrolera jest opakowana w podklasę ActionFilterAttribute
. W strukturze ASP.NET MVC takie atrybuty C# są używane do dekorowania logiki działania kontrolera w pewien sposób. Ten konkretny atrybut odpowiadał za otwarcie nowego połączenia z bazą danych i uruchomienie transakcji na początku akcji. Również pod koniec akcji podklasa atrybutu filtra była odpowiedzialna za zatwierdzenie transakcji w przypadku powodzenia i wycofanie jej w przypadku niepowodzenia.
Jeśli unieważnienie pamięci podręcznej nastąpi w samym środku transakcji, może wystąpić sytuacja wyścigu, w której następne żądanie do tego węzła pomyślnie umieści starą (wciąż widoczną dla innych transakcji) wartość z powrotem do pamięci podręcznej. Aby tego uniknąć, wszystkie unieważnienia są odraczane do momentu zatwierdzenia transakcji. Po tym czasie elementy pamięci podręcznej można bezpiecznie eksmitować, a w przypadku niepowodzenia transakcji w ogóle nie ma potrzeby modyfikacji pamięci podręcznej.
Taki właśnie był cel części związanych z „sesją” w unieważniaczu pamięci podręcznej. Również taki jest cel jego życia związany z żądaniem. Kod ASP.NET wyglądał tak:
class HybridCacheInvalidator : ICacheInvalidator { ... public void Drop(IDataCacheKey key) { if (key == null) throw new ArgumentNullException("key"); if (!IsSessionOpen) throw new InvalidOperationException("Session must be opened first."); _postponedRedisMessages.Add(new Tuple<string, string>("drop", key.Value)); } ... public void CloseSession() { if (!IsSessionOpen) return; _postponedRedisMessages.ForEach(m => PublishRedisMessageSafe(m.Item1, m.Item2)); _postponedRedisMessages = null; } ... }
Metoda PublishRedisMessageSafe
jest tutaj odpowiedzialna za wysłanie komunikatu (drugi argument) do określonego kanału (pierwszy argument). W rzeczywistości istnieją oddzielne kanały dla upuszczania i dotykania, więc obsługa wiadomości dla każdego z nich dokładnie wiedziała, co zrobić - upuść/dotknij klawisz równy odebranej wiadomości.
Jedną z trudnych części było prawidłowe zarządzanie połączeniem z serwerem Redis. W przypadku awarii serwera z jakiegokolwiek powodu, aplikacja powinna nadal działać poprawnie. Gdy Redis znów będzie online, aplikacja powinna bezproblemowo zacząć z niego ponownie korzystać i ponownie wymieniać wiadomości z innymi węzłami. Aby to osiągnąć, użyłem biblioteki StackExchange.Redis i powstałą logikę zarządzania połączeniami zaimplementowałem w następujący sposób:
class HybridCacheService : ... { ... public void Initialize() { try { Multiplexer = ConnectionMultiplexer.Connect(_configService.Caching.BackendServerAddress); ... Multiplexer.ConnectionFailed += (sender, args) => UpdateConnectedState(); Multiplexer.ConnectionRestored += (sender, args) => UpdateConnectedState(); ... } catch (Exception ex) { ... } } private void UpdateConnectedState() { if (Multiplexer.IsConnected && _currentCacheService is NoCacheServiceStub) { _inProcCacheInvalidator.Purge(); _currentCacheService = _inProcCacheService; _logger.Debug("Connection to remote Redis server restored, switched to in-proc mode."); } else if (!Multiplexer.IsConnected && _currentCacheService is InProcCacheService) { _currentCacheService = _noCacheStub; _logger.Debug("Connection to remote Redis server lost, switched to no-cache mode."); } } }
W tym przypadku ConnectionMultiplexer
jest typem z biblioteki StackExchange.Redis, która jest odpowiedzialna za przejrzystą pracę z bazowym Redis. Ważną częścią tego jest to, że gdy konkretny węzeł traci połączenie z Redis, powraca do trybu bez pamięci podręcznej, aby upewnić się, że żadne żądanie nie otrzyma przestarzałych danych. Po przywróceniu połączenia węzeł zaczyna ponownie korzystać z pamięci podręcznej w pamięci.
Oto przykłady akcji bez użycia usługi pamięci podręcznej ( SomeActionWithoutCaching
) oraz identycznej operacji, która z niej korzysta ( SomeActionUsingCache
):
class SomeController : Controller { public ISomeService SomeService { get; set; } public ICacheService CacheService { get; set; } ... public ActionResult SomeActionWithoutCaching() { return View( SomeService.GetModelData() ); } ... public ActionResult SomeActionUsingCache() { return View( CacheService.Get( /* Cache key creation omitted */, () => SomeService.GetModelData() ); ); } }
Fragment kodu z implementacji ISomeService
może wyglądać tak:
class DefaultSomeService : ISomeService { public ICacheInvalidator _cacheInvalidator; ... public SomeModel GetModelData() { return /* Do something to get model data. */; } ... public void SetModelData(SomeModel model) { /* Do something to set model data. */ _cacheInvalidator.Drop(/* Cache key creation omitted */); } }
Analiza porównawcza i wyniki
Po skonfigurowaniu buforowania kodu ASP.NET nadszedł czas, aby użyć go w istniejącej logice aplikacji sieci Web, a testy porównawcze mogą być przydatne, aby zdecydować, gdzie należy włożyć najwięcej wysiłku w przepisanie kodu w celu wykorzystania buforowania. Bardzo ważne jest, aby wybrać kilka najczęstszych lub krytycznych przypadków użycia, które mają zostać porównane. Następnie narzędzie takie jak Apache jMeter może być używane do dwóch rzeczy:
- Aby przetestować te kluczowe przypadki użycia za pomocą żądań HTTP.
- Aby zasymulować duże obciążenie testowanego węzła sieciowego.
Aby uzyskać profil wydajności, można użyć dowolnego profilera, który może dołączyć do procesu roboczego usług IIS. W moim przypadku zastosowałem JetBrains dotTrace Performance. Po pewnym czasie spędzonym na eksperymentowaniu w celu określenia poprawnych parametrów jMeter (takich jak współbieżność i liczba żądań), możliwe staje się zbieranie migawek wydajności, które są bardzo pomocne w identyfikowaniu hotspotów i wąskich gardeł.
W moim przypadku niektóre przypadki użycia pokazały, że około 15%-45% całkowitego czasu wykonania kodu zostało spędzone w odczytach bazy danych z oczywistymi wąskimi gardłami. Po zastosowaniu buforowania wydajność prawie się podwoiła (tj. była dwukrotnie szybsza) dla większości z nich.
Wniosek
Jak widać, mój przypadek może wydawać się przykładem tego, co zwykle nazywa się „wymyślaniem koła na nowo”: po co zawracać sobie głowę próbą stworzenia czegoś nowego, skoro istnieją już powszechnie stosowane najlepsze praktyki? Po prostu skonfiguruj Memcached lub Redis i odpuść.
Zdecydowanie zgadzam się, że korzystanie z najlepszych praktyk jest zwykle najlepszą opcją. Ale zanim ślepo zastosuje się jakąkolwiek najlepszą praktykę, należy zadać sobie pytanie: na ile odpowiednia jest ta „najlepsza praktyka”? Czy pasuje do mojego przypadku?
Sposób, w jaki to widzę, odpowiednie opcje i analiza kompromisów jest koniecznością przy podejmowaniu jakiejkolwiek istotnej decyzji, a to podejście wybrałem, ponieważ problem nie był taki łatwy. W moim przypadku było wiele czynników do rozważenia i nie chciałem brać jednego uniwersalnego rozwiązania, gdy mogłoby to nie być właściwe podejście do danego problemu.
W końcu, przy odpowiednim buforowaniu, uzyskałem prawie 50% wzrost wydajności w stosunku do początkowego rozwiązania.