Come migliorare le prestazioni dell'app ASP.NET nella Web farm con la memorizzazione nella cache

Pubblicato: 2022-03-11

Ci sono solo due cose difficili in Informatica: l'invalidazione della cache e la denominazione delle cose.

  • Autore: Phil Karlton

Una breve introduzione alla memorizzazione nella cache

La memorizzazione nella cache è una tecnica potente per aumentare le prestazioni attraverso un semplice trucco: invece di fare un lavoro costoso (come un calcolo complicato o una query di database complessa) ogni volta che abbiamo bisogno di un risultato, il sistema può memorizzare - o memorizzare nella cache - il risultato di quel lavoro e semplicemente fornirlo la prossima volta che viene richiesto senza dover ripetere quel lavoro (e può, quindi, rispondere tremendamente più velocemente).

Naturalmente, l'intera idea alla base della memorizzazione nella cache funziona solo finché il risultato che abbiamo memorizzato nella cache rimane valido. E qui arriviamo alla parte più difficile del problema: come determiniamo quando un elemento memorizzato nella cache è diventato non valido e deve essere ricreato?

La memorizzazione nella cache è una tecnica potente per aumentare le prestazioni

La cache in memoria di ASP.NET è estremamente veloce
e perfetto per risolvere il problema della memorizzazione nella cache della web farm distribuita.
Twitta

Di solito, una tipica applicazione Web deve gestire un volume di richieste di lettura molto più elevato rispetto alle richieste di scrittura. Ecco perché una tipica applicazione Web progettata per gestire un carico elevato è progettata per essere scalabile e distribuita, distribuita come un insieme di nodi di livello Web, generalmente chiamati farm. Tutti questi fatti hanno un impatto sull'applicabilità della memorizzazione nella cache.

In questo articolo, ci concentreremo sul ruolo che la memorizzazione nella cache può svolgere nell'assicurare un throughput elevato e prestazioni elevate di applicazioni Web progettate per gestire un carico elevato e utilizzerò l'esperienza di uno dei miei progetti e fornirò una soluzione basata su ASP.NET Come un'illustrazione.

Il problema della gestione di un carico elevato

Il vero problema che dovevo risolvere non era originale. Il mio compito era rendere un prototipo di applicazione Web monolitica ASP.NET MVC in grado di gestire un carico elevato.

I passaggi necessari per migliorare le capacità di throughput di un'applicazione Web monolitica sono:

  • Abilitarlo per eseguire più copie dell'applicazione Web in parallelo, dietro un servizio di bilanciamento del carico, e servire tutte le richieste simultanee in modo efficace (ad esempio, renderlo scalabile).
  • Profila l'applicazione per rivelare gli attuali colli di bottiglia delle prestazioni e ottimizzarli.
  • Utilizzare la memorizzazione nella cache per aumentare il throughput delle richieste di lettura, poiché in genere costituisce una parte significativa del carico complessivo delle applicazioni.

Le strategie di memorizzazione nella cache spesso implicano l'uso di alcuni server di memorizzazione nella cache del middleware, come Memcached o Redis, per archiviare i valori memorizzati nella cache. Nonostante la loro elevata adozione e la loro comprovata applicabilità, questi approcci presentano alcuni aspetti negativi, tra cui:

  • Le latenze di rete introdotte dall'accesso a server cache separati possono essere paragonabili alle latenze di raggiungimento del database stesso.
  • Le strutture di dati del livello Web possono non essere adatte per la serializzazione e la deserializzazione immediata. Per utilizzare i server cache, tali strutture di dati devono supportare la serializzazione e la deserializzazione, il che richiede uno sforzo di sviluppo aggiuntivo continuo.
  • La serializzazione e la deserializzazione aggiungono un sovraccarico di runtime con un effetto negativo sulle prestazioni.

Tutti questi problemi erano rilevanti nel mio caso, quindi ho dovuto esplorare opzioni alternative.

Come funziona la memorizzazione nella cache

La cache in memoria ASP.NET incorporata ( System.Web.Caching.Cache ) è estremamente veloce e può essere utilizzata senza sovraccarico di serializzazione e deserializzazione, sia durante lo sviluppo che in fase di runtime. Tuttavia, la cache in memoria di ASP.NET ha anche i suoi svantaggi:

  • Ogni nodo del livello Web necessita della propria copia dei valori memorizzati nella cache. Ciò potrebbe comportare un consumo più elevato del livello di database all'avvio a freddo o al riciclo del nodo.
  • Ogni nodo del livello Web deve essere avvisato quando un altro nodo rende non valida una qualsiasi parte della cache scrivendo valori aggiornati. Poiché la cache è distribuita e senza un'adeguata sincronizzazione, la maggior parte dei nodi restituirà vecchi valori, il che è generalmente inaccettabile.

Se il carico aggiuntivo del livello di database non comporta di per sé un collo di bottiglia, l'implementazione di una cache correttamente distribuita sembra un compito facile da gestire, giusto? Beh, non è un compito facile , ma è possibile . Nel mio caso, i benchmark hanno mostrato che il livello database non dovrebbe essere un problema, poiché la maggior parte del lavoro è avvenuto nel livello Web. Quindi, ho deciso di utilizzare la cache in memoria di ASP.NET e di concentrarmi sull'implementazione della corretta sincronizzazione.

Presentazione di una soluzione basata su ASP.NET

Come spiegato, la mia soluzione era utilizzare la cache in memoria di ASP.NET invece del server di memorizzazione nella cache dedicato. Ciò implica che ogni nodo della web farm abbia la propria cache, interroghi direttamente il database, esegua tutti i calcoli necessari e memorizzi i risultati in una cache. In questo modo, tutte le operazioni della cache saranno velocissime grazie alla natura in memoria della cache. In genere, gli elementi memorizzati nella cache hanno una durata chiara e diventano obsoleti in seguito a modifiche o scritture di nuovi dati. Quindi, dalla logica dell'applicazione Web, di solito è chiaro quando l'elemento della cache deve essere invalidato.

L'unico problema rimasto qui è che quando uno dei nodi invalida un elemento della cache nella propria cache, nessun altro nodo sarà a conoscenza di questo aggiornamento. Pertanto, le richieste successive servite da altri nodi forniranno risultati non aggiornati. Per risolvere questo problema, ogni nodo dovrebbe condividere le sue invalidazioni della cache con gli altri nodi. Dopo aver ricevuto tale invalidamento, altri nodi potrebbero semplicemente eliminare il loro valore memorizzato nella cache e ottenerne uno nuovo alla richiesta successiva.

Qui, Redis può entrare in gioco. La potenza di Redis, rispetto ad altre soluzioni, deriva dalle sue capacità Pub/Sub. Ogni client di un server Redis può creare un canale e pubblicare alcuni dati su di esso. Qualsiasi altro client è in grado di ascoltare quel canale e ricevere i relativi dati, in modo molto simile a qualsiasi sistema basato su eventi. Questa funzionalità può essere utilizzata per scambiare messaggi di invalidamento della cache tra i nodi, in modo che tutti i nodi possano invalidare la propria cache quando necessario.

Un gruppo di nodi di livello Web ASP.NET che utilizzano un backplane Redis

La cache in memoria di ASP.NET è semplice in alcuni modi e complessa in altri. In particolare, è semplice in quanto funziona come una mappa di coppie chiave/valore, ma c'è molta complessità correlata alle sue strategie e dipendenze di invalidamento.

Fortunatamente, i casi d'uso tipici sono abbastanza semplici ed è possibile utilizzare una strategia di invalidamento predefinita per tutti gli elementi, consentendo a ciascun elemento della cache di avere al massimo una singola dipendenza. Nel mio caso, ho terminato con il seguente codice ASP.NET per l'interfaccia del servizio di memorizzazione nella cache. (Si noti che questo non è il codice effettivo, poiché ho omesso alcuni dettagli per motivi di semplicità e licenza proprietaria.)

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

Qui, il servizio cache consente sostanzialmente due cose. In primo luogo, consente di archiviare il risultato di una funzione getter di valore in modo thread-safe. In secondo luogo, garantisce che il valore allora corrente venga sempre restituito quando viene richiesto. Una volta che l'elemento della cache diventa obsoleto o viene rimosso in modo esplicito dalla cache, il valore getter viene chiamato di nuovo per recuperare un valore corrente. La chiave della cache è stata sottratta dall'interfaccia ICacheKey , principalmente per evitare l'hardcoding delle stringhe delle chiavi della cache in tutta l'applicazione.

Per invalidare gli elementi della cache, ho introdotto un servizio separato, simile a questo:

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

Oltre ai metodi di base per rilasciare elementi con dati e toccare i tasti, che avevano solo elementi di dati dipendenti, ci sono alcuni metodi relativi a una sorta di "sessione".

La nostra applicazione Web utilizzava Autofac per l'inserimento delle dipendenze, che è un'implementazione del modello di progettazione dell'inversione del controllo (IoC) per la gestione delle dipendenze. Questa funzionalità consente agli sviluppatori di creare le proprie classi senza doversi preoccupare delle dipendenze, poiché il contenitore IoC gestisce tale onere per loro.

Il servizio cache e l'invalidatore della cache hanno cicli di vita drasticamente diversi per quanto riguarda IoC. Il servizio cache è stato registrato come singleton (un'istanza, condivisa tra tutti i client), mentre l'invalidatore della cache è stato registrato come un'istanza per richiesta (è stata creata un'istanza separata per ogni richiesta in arrivo). Come mai?

La risposta ha a che fare con un'ulteriore sottigliezza che dovevamo gestire. L'applicazione Web utilizza un'architettura Model-View-Controller (MVC), che aiuta principalmente a separare le preoccupazioni dell'interfaccia utente e della logica. Quindi, una tipica azione del controller è racchiusa in una sottoclasse di ActionFilterAttribute . Nel framework ASP.NET MVC, tali attributi C# vengono usati per decorare in qualche modo la logica di azione del controller. Quel particolare attributo era responsabile dell'apertura di una nuova connessione al database e dell'avvio di una transazione all'inizio dell'azione. Inoltre, al termine dell'azione, la sottoclasse dell'attributo filter era responsabile del commit della transazione in caso di successo e del rollback in caso di errore.

Se l'invalidazione della cache si è verificata proprio nel mezzo della transazione, potrebbe esserci una condizione di competizione in base alla quale la richiesta successiva a quel nodo reinserirebbe correttamente il vecchio valore (ancora visibile ad altre transazioni) nella cache. Per evitare ciò, tutte le invalidazioni vengono posticipate fino al commit della transazione. Dopodiché, gli elementi della cache possono essere eliminati in sicurezza e, in caso di errore di transazione, non è necessario modificare la cache.

Questo era lo scopo esatto delle parti relative alla "sessione" nell'invalidatore della cache. Inoltre, questo è lo scopo della sua durata legata alla richiesta. Il codice ASP.NET era simile a questo:

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

Il metodo PublishRedisMessageSafe qui è responsabile dell'invio del messaggio (secondo argomento) a un canale particolare (primo argomento). In effetti, ci sono canali separati per il rilascio e il tocco, quindi il gestore del messaggio per ciascuno di essi sapeva esattamente cosa fare: rilasciare/toccare il tasto uguale al carico utile del messaggio ricevuto.

Una delle parti complicate era gestire correttamente la connessione al server Redis. Nel caso in cui il server si interrompa per qualsiasi motivo, l'applicazione dovrebbe continuare a funzionare correttamente. Quando Redis è di nuovo online, l'applicazione dovrebbe ricominciare a utilizzarlo senza problemi e scambiare nuovamente messaggi con altri nodi. Per ottenere ciò, ho utilizzato la libreria StackExchange.Redis e la logica di gestione della connessione risultante è stata implementata come segue:

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

In questo caso ConnectionMultiplexer è un tipo della libreria StackExchange.Redis, responsabile del lavoro trasparente con Redis sottostante. La parte importante qui è che, quando un particolare nodo perde la connessione a Redis, ritorna alla modalità nessuna cache per assicurarsi che nessuna richiesta riceva dati non aggiornati. Dopo aver ripristinato la connessione, il nodo ricomincia a utilizzare la cache in memoria.

Di seguito sono riportati esempi di azione senza l'utilizzo del servizio cache ( SomeActionWithoutCaching ) e un'operazione identica che lo utilizza ( 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() ); ); } }

Uno snippet di codice da un'implementazione di ISomeService potrebbe assomigliare a questo:

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

Analisi comparativa e risultati

Dopo aver impostato il codice ASP.NET per la memorizzazione nella cache, è giunto il momento di utilizzarlo nella logica dell'applicazione Web esistente e il benchmarking può essere utile per decidere dove dedicare la maggior parte degli sforzi alla riscrittura del codice per utilizzare la memorizzazione nella cache. È fondamentale selezionare alcuni casi d'uso più comuni o critici dal punto di vista operativo da confrontare. Successivamente, uno strumento come Apache jMeter potrebbe essere utilizzato per due cose:

  • Per confrontare questi casi d'uso chiave tramite richieste HTTP.
  • Per simulare un carico elevato per il nodo Web in prova.

Per ottenere un profilo delle prestazioni, è possibile utilizzare qualsiasi profiler in grado di collegarsi al processo di lavoro IIS. Nel mio caso, ho usato JetBrains dotTrace Performance. Dopo un po' di tempo speso a sperimentare per determinare i parametri jMeter corretti (come il conteggio simultaneo e delle richieste), diventa possibile iniziare a raccogliere snapshot delle prestazioni, che sono molto utili per identificare gli hotspot ei colli di bottiglia.

Nel mio caso, alcuni casi d'uso hanno mostrato che circa il 15%-45% del tempo complessivo di esecuzione del codice è stato speso nelle letture del database con gli evidenti colli di bottiglia. Dopo aver applicato la memorizzazione nella cache, le prestazioni sono quasi raddoppiate (cioè due volte più veloci) per la maggior parte di esse.

Correlati: otto motivi per cui Microsoft Stack è ancora una scelta praticabile

Conclusione

Come puoi vedere, il mio caso potrebbe sembrare un esempio di ciò che di solito viene chiamato "reinventare la ruota": perché preoccuparsi di provare a creare qualcosa di nuovo, quando ci sono già buone pratiche ampiamente applicate là fuori? Basta impostare un Memcached o Redis e lasciarlo andare.

Sono decisamente d'accordo sul fatto che l'utilizzo delle migliori pratiche sia solitamente l'opzione migliore. Ma prima di applicare ciecamente qualsiasi best practice, ci si dovrebbe chiedere: quanto è applicabile questa “best practice”? Si adatta bene al mio caso?

Per come la vedo io, opzioni adeguate e analisi dei compromessi sono un must per prendere qualsiasi decisione significativa, e questo è stato l'approccio che ho scelto perché il problema non era così facile. Nel mio caso, c'erano molti fattori da considerare e non volevo adottare una soluzione valida per tutti quando potrebbe non essere l'approccio giusto per il problema in questione.

Alla fine, con la corretta memorizzazione nella cache, ho ottenuto un aumento delle prestazioni di quasi il 50% rispetto alla soluzione iniziale.