Cum se îmbunătățește performanța aplicației ASP.NET în Web Farm cu caching

Publicat: 2022-03-11

Există doar două lucruri grele în informatică: invalidarea memoriei cache și denumirea lucrurilor.

  • Autor: Phil Karlton

O scurtă introducere în caching

Memorarea în cache este o tehnică puternică de creștere a performanței printr-un truc simplu: în loc să facem o muncă costisitoare (cum ar fi un calcul complicat sau o interogare complexă a bazei de date) de fiecare dată când avem nevoie de un rezultat, sistemul poate stoca – sau stoca în cache – rezultatul acelei lucrări și pur și simplu furnizați-l data viitoare când este solicitat fără a fi nevoie să reperformeze acea lucrare (și poate, prin urmare, să răspundă enorm de mai rapid).

Desigur, întreaga idee din spatele stocării în cache funcționează doar atâta timp cât rezultatul stocat în cache rămâne valabil. Și aici ajungem la partea dificilă a problemei: Cum determinăm când un element din cache a devenit invalid și trebuie recreat?

Memorarea în cache este o tehnică puternică pentru creșterea performanței

Cache-ul ASP.NET în memorie este extrem de rapid
și perfect pentru a rezolva problema de stocare în cache a fermei web distribuite.
Tweet

De obicei, o aplicație web tipică trebuie să facă față unui volum mult mai mare de solicitări de citire decât solicitări de scriere. Acesta este motivul pentru care o aplicație web tipică care este concepută pentru a gestiona o încărcare mare este proiectată pentru a fi scalabilă și distribuită, implementată ca un set de noduri de nivel web, numite de obicei fermă. Toate aceste fapte au un impact asupra aplicabilității stocării în cache.

În acest articol, ne concentrăm asupra rolului pe care îl poate juca memoria cache în asigurarea debitului și performanței ridicate a aplicațiilor web concepute pentru a face față unei sarcini mari și voi folosi experiența dintr-unul dintre proiectele mele și voi oferi o soluție bazată pe ASP.NET. ca o ilustrare.

Problema manevrării unei sarcini mari

Problema pe care a trebuit să o rezolv nu a fost una originală. Sarcina mea a fost să fac un prototip de aplicație web monolitică ASP.NET MVC să fie capabil să gestioneze o sarcină mare.

Pașii necesari pentru îmbunătățirea capacităților de transfer ale unei aplicații web monolitice sunt:

  • Permiteți-l să ruleze mai multe copii ale aplicației web în paralel, în spatele unui echilibrator de încărcare și să servească toate solicitările concurente în mod eficient (adică, faceți-o scalabilă).
  • Profilați aplicația pentru a dezvălui blocajele actuale de performanță și pentru a le optimiza.
  • Utilizați memorarea în cache pentru a crește debitul cererii de citire, deoarece aceasta constituie de obicei o parte semnificativă a încărcării generale a aplicațiilor.

Strategiile de stocare în cache implică adesea utilizarea unui server de cache middleware, cum ar fi Memcached sau Redis, pentru a stoca valorile stocate în cache. În ciuda adoptării lor ridicate și a aplicabilității dovedite, există câteva dezavantaje ale acestor abordări, inclusiv:

  • Latentele de rețea introduse prin accesarea serverelor cache separate pot fi comparabile cu latențele de acces la baza de date în sine.
  • Structurile de date ale nivelului web pot fi nepotrivite pentru serializare și deserializare imediată. Pentru a utiliza servere cache, acele structuri de date ar trebui să accepte serializarea și deserializarea, ceea ce necesită un efort de dezvoltare suplimentar continuu.
  • Serializarea și deserializarea adaugă supraîncărcarea timpului de rulare cu un efect negativ asupra performanței.

Toate aceste probleme erau relevante în cazul meu, așa că a trebuit să explorez opțiuni alternative.

Cum funcționează memoria cache

Cache-ul în memorie ASP.NET încorporat ( System.Web.Caching.Cache ) este extrem de rapid și poate fi utilizat fără suprasarcină de serializare și deserializare, atât în ​​timpul dezvoltării, cât și în timpul rulării. Cu toate acestea, cache-ul ASP.NET în memorie are și propriile sale dezavantaje:

  • Fiecare nod de nivel web are nevoie de propria copie a valorilor din cache. Acest lucru ar putea duce la un consum mai mare de nivel de bază de date la pornirea la rece a nodului sau la reciclare.
  • Fiecare nod de nivel web ar trebui să fie notificat când un alt nod face ca orice parte a memoriei cache să fie invalidă prin scrierea valorilor actualizate. Deoarece memoria cache este distribuită și fără o sincronizare adecvată, majoritatea nodurilor vor returna valori vechi, ceea ce este de obicei inacceptabil.

Dacă încărcarea suplimentară a nivelului bazei de date nu va duce la un blocaj în sine, atunci implementarea unui cache distribuit corespunzător pare o sarcină ușor de gestionat, nu? Ei bine, nu este o sarcină ușoară , dar este posibilă . În cazul meu, benchmark-urile au arătat că nivelul bazei de date nu ar trebui să fie o problemă, deoarece cea mai mare parte a muncii s-a petrecut în nivelul web. Deci, am decis să merg cu cache-ul ASP.NET în memorie și să mă concentrez pe implementarea sincronizării adecvate.

Vă prezentăm o soluție bazată pe ASP.NET

După cum am explicat, soluția mea a fost să folosesc cache-ul ASP.NET în memorie în loc de serverul de cache dedicat. Aceasta presupune ca fiecare nod al fermei web să aibă propriul cache, interogând direct baza de date, efectuând orice calcule necesare și stocând rezultatele într-un cache. În acest fel, toate operațiunile de cache vor fi fulgerătoare datorită naturii în memorie a cache-ului. În mod obișnuit, elementele din cache au o durată de viață clară și devin învechite la modificarea sau scrierea unor date noi. Deci, din logica aplicației web, este de obicei clar când ar trebui invalidat elementul din cache.

Singura problemă rămasă aici este că atunci când unul dintre noduri invalidează un element cache din propriul cache, niciun alt nod nu va ști despre această actualizare. Așadar, cererile ulterioare deservite de alte noduri vor oferi rezultate obținute. Pentru a rezolva acest lucru, fiecare nod ar trebui să-și partajeze invalidările cache-ului cu celelalte noduri. La primirea unei astfel de invalidări, alte noduri ar putea pur și simplu să-și piardă valoarea stocată în cache și să obțină una nouă la următoarea solicitare.

Aici, Redis poate intra în joc. Puterea Redis, în comparație cu alte soluții, vine din capabilitățile sale Pub/Sub. Fiecare client al unui server Redis poate crea un canal și poate publica unele date pe acesta. Orice alt client este capabil să asculte acel canal și să primească datele aferente, foarte asemănător cu orice sistem bazat pe evenimente. Această funcționalitate poate fi folosită pentru a schimba mesaje de invalidare a memoriei cache între noduri, astfel încât toate nodurile își vor putea invalida memoria cache atunci când este necesar.

Un grup de noduri de nivel web ASP.NET care utilizează un backplane Redis

Cache-ul ASP.NET în memorie este simplu în unele moduri și complex în altele. În special, este simplu prin faptul că funcționează ca o hartă a perechilor cheie/valoare, dar există multă complexitate legată de strategiile și dependențele sale de invalidare.

Din fericire, cazurile de utilizare tipice sunt destul de simple și este posibil să folosiți o strategie de invalidare implicită pentru toate elementele, permițând fiecărui element din cache să aibă cel mult o singură dependență. În cazul meu, am încheiat cu următorul cod ASP.NET pentru interfața serviciului de cache. (Rețineți că acesta nu este codul real, deoarece am omis unele detalii de dragul simplității și al licenței proprietare.)

 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); }

Aici, serviciul cache permite practic două lucruri. În primul rând, permite stocarea rezultatului unei anumite funcții de obținere a valorii într-un mod sigur. În al doilea rând, se asigură că valoarea curentă este întotdeauna returnată atunci când este solicitată. Odată ce elementul din cache devine învechit sau este eliminat în mod explicit din cache, generatorul de valori este apelat din nou pentru a prelua o valoare curentă. Cheia cache a fost extrasă de interfața ICacheKey , în principal pentru a evita codificarea tare a șirurilor de chei cache în întreaga aplicație.

Pentru a invalida elementele din cache, am introdus un serviciu separat, care arăta astfel:

 public interface ICacheInvalidator { bool IsSessionOpen { get; } void OpenSession(); void CloseSession(); void Drop(IDataCacheKey key); void Touch(ITouchableCacheKey key); void Purge(); }

Pe lângă metodele de bază de aruncare a elementelor cu date și atingerea tastelor, care aveau doar elemente de date dependente, există câteva metode legate de un fel de „sesiune”.

Aplicația noastră web a folosit Autofac pentru injectarea dependențelor, care este o implementare a modelului de proiectare inversare a controlului (IoC) pentru gestionarea dependențelor. Această caracteristică permite dezvoltatorilor să-și creeze clasele fără a fi nevoie să-și facă griji cu privire la dependențe, deoarece containerul IoC gestionează această povară pentru ei.

Serviciul cache și invalidatorul cache au cicluri de viață drastic diferite în ceea ce privește IoC. Serviciul de cache a fost înregistrat ca un singleton (o instanță, partajată între toți clienții), în timp ce invalidatorul de cache a fost înregistrat ca o instanță pentru fiecare cerere (o instanță separată a fost creată pentru fiecare cerere primită). De ce?

Răspunsul are de-a face cu o subtilitate suplimentară pe care trebuia să o gestionăm. Aplicația web folosește o arhitectură Model-View-Controller (MVC), care ajută în principal la separarea problemelor legate de UI și de logică. Deci, o acțiune tipică a controlerului este încapsulată într-o subclasă a unui ActionFilterAttribute . În cadrul ASP.NET MVC, astfel de atribute C# sunt folosite pentru a decora într-un fel logica de acțiune a controlerului. Acest atribut special a fost responsabil pentru deschiderea unei noi conexiuni la baza de date și începerea unei tranzacții la începutul acțiunii. De asemenea, la sfârșitul acțiunii, subclasa de atribute de filtru era responsabilă de comiterea tranzacției în caz de succes și de anularea acesteia în caz de eșec.

Dacă invalidarea memoriei cache a avut loc chiar în mijlocul tranzacției, ar putea exista o condiție de cursă prin care următoarea solicitare către acel nod ar reintroduce cu succes valoarea veche (încă vizibilă pentru alte tranzacții) în cache. Pentru a evita acest lucru, toate invalidările sunt amânate până la efectuarea tranzacției. După aceea, elementele din cache pot fi evacuate în siguranță și, în cazul unei eșecuri a tranzacției, nu este deloc necesară modificarea cache-ului.

Acesta a fost scopul exact al părților legate de „sesiune” din invalidatorul de cache. De asemenea, acesta este scopul vieții sale fiind legat de cerere. Codul ASP.NET arăta astfel:

 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 de aici este responsabilă pentru trimiterea mesajului (al doilea argument) către un anumit canal (primul argument). De fapt, există canale separate pentru drop și atingere, astfel încât gestionarea mesajelor pentru fiecare dintre ele știa exact ce să facă - drop/atinge tasta egală cu sarcina utilă a mesajului primit.

Una dintre părțile dificile a fost gestionarea corectă a conexiunii la serverul Redis. În cazul în care serverul se defectează din orice motiv, aplicația ar trebui să continue să funcționeze corect. Când Redis este din nou online, aplicația ar trebui să înceapă să o folosească din nou și să schimbe din nou mesaje cu alte noduri. Pentru a realiza acest lucru, am folosit biblioteca StackExchange.Redis și logica de gestionare a conexiunii rezultată a fost implementată după cum urmează:

 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."); } } }

Aici, ConnectionMultiplexer este un tip din biblioteca StackExchange.Redis, care este responsabilă pentru lucrul transparent cu Redis subiacent. Partea importantă aici este că, atunci când un anumit nod pierde conexiunea la Redis, acesta revine la modul fără cache pentru a se asigura că nicio solicitare nu va primi date învechite. După ce conexiunea este restabilită, nodul începe să folosească din nou memoria cache din memorie.

Iată exemple de acțiune fără utilizarea serviciului cache ( SomeActionWithoutCaching ) și o operație identică care îl folosește ( 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() ); ); } }

Un fragment de cod dintr-o implementare ISomeService ar putea arăta astfel:

 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 */); } }

Benchmarking și rezultate

După ce codul ASP.NET de stocare în cache a fost setat, era timpul să îl utilizați în logica aplicației web existentă, iar evaluarea comparativă poate fi utilă pentru a decide unde să depuneți cele mai multe eforturi de rescrie a codului pentru a utiliza stocarea în cache. Este esențial să alegeți câteva cazuri de utilizare cele mai comune sau critice din punct de vedere operațional pentru a fi evaluate. După aceea, un instrument precum Apache jMeter ar putea fi folosit pentru două lucruri:

  • Pentru a compara aceste cazuri de utilizare cheie prin solicitări HTTP.
  • Pentru a simula sarcina mare pentru nodul web testat.

Pentru a obține un profil de performanță, poate fi utilizat orice profiler care este capabil să se atașeze la procesul de lucru IIS. În cazul meu, am folosit JetBrains dotTrace Performance. După ceva timp petrecut experimentând pentru a determina parametrii jMeter corecti (cum ar fi concurența și numărul de cereri), devine posibil să începeți să colectați instantanee de performanță, care sunt foarte utile în identificarea punctelor fierbinți și a blocajelor.

În cazul meu, unele cazuri de utilizare au arătat că aproximativ 15%-45% timpul total de execuție a codului a fost petrecut în citirile bazei de date cu blocajele evidente. După ce am aplicat memorarea în cache, performanța aproape s-a dublat (adică a fost de două ori mai rapidă) pentru majoritatea dintre ei.

Înrudit: Opt motive pentru care Microsoft Stack este încă o alegere viabilă

Concluzie

După cum puteți vedea, cazul meu ar putea părea un exemplu a ceea ce se numește de obicei „reinventarea roții”: de ce să vă obosiți să încercați să creați ceva nou, când există deja cele mai bune practici aplicate pe scară largă? Doar configurați un Memcached sau Redis și lăsați-l să plece.

Sunt cu siguranță de acord că utilizarea celor mai bune practici este de obicei cea mai bună opțiune. Dar înainte de a aplica orbește orice cea mai bună practică, ar trebui să ne întrebăm: Cât de aplicabilă este această „cea mai bună practică”? Se potrivește bine cu cazul meu?

Felul în care văd eu, opțiunile adecvate și analiza compromisului este o necesitate la luarea oricărei decizii semnificative și aceasta a fost abordarea pe care am ales-o, deoarece problema nu era atât de ușoară. În cazul meu, au fost mulți factori de luat în considerare și nu am vrut să iau o soluție unică, atunci când s-ar putea să nu fie abordarea potrivită pentru problema în cauză.

În cele din urmă, cu memorarea în cache adecvată, am obținut o creștere a performanței cu aproape 50% față de soluția inițială.