Ott 2024 | 8 min di lettura

Introduzione
Una delle esigenze più comuni di uno sviluppatore di applicazioni è quella di ottimizzare le performance delle varie operazioni che hanno a che fare con l’accesso dei dati al database.
Tali dati, in alcuni scenari, non cambiano per ore, giorni o anche mesi/anni.
Quindi la domanda che ci si deve porre è: ha senso che, se si ha la necessità di recuperali, questo vada fatto sempre effettuando una query sul database, con un costo che potrebbe essere più o meno oneroso e che potrebbe influenzare le performance generali dell’applicazione?
Prendiamo l’esempio di una tabella delle nazioni “Nation”, contenente la lista dei Paesi del mondo.
Ogni volta che devo ottenere tale lista devo effettuare una query sul database, appesantendo lo stesso e con dei tempi di risposta della query che potrebbero non essere rapidissimi, anche a seconda del carico del database in quel momento.
L’esempio delle nazioni probabilmente non generà mai una problematica reale di performance (stiamo parlando di circa 200 record in tabella), ma la tematica è generale e la domanda da porsi è: possiamo evitare di passare sempre dal database per ottenere dati statici?
La risposta è: Si! Con la Cache!
Affronteremo l’argomento nel mondo di Microsoft .Net e presenteremo i vari meccanismi oggi esistenti che consentono di ottenere alcuni dati da un “contenitore” differente rispetto al database.
Cache locale – MemoryCache
MemoryCache è l’implementazione standard di in-memory caching in .Net, progettata per memorizzare i dati direttamente nella memoria del server che ospita l’applicazione.
Questo consente di eseguire una query sul database solo la prima volta, mentre le richieste successive recuperano i dati direttamente dalla memoria del server. La cache rimane valida per un periodo predefinito, stabilito dalla sua configurazione, a meno che non venga rimossa manualmente o forzatamente.
Esempio pratico
Torniamo all’esempio della tabella “Nation”: come possiamo effettuare concretamente un caching di tale tabella utilizzando MemoryCache di .Net?
Vediamo il codice:public IMemoryCache Cache { get; }private const string NATIONS_CACHE_KEY = "NATIONS";private async Task<List<Nation>> DbGetNations(){ return await dbContext.Nation.ToListAsync();}public async Task<List<Nation>> GetNations(){ if (!Cache.TryGetValue(NATIONS_CACHE_KEY, out _)) { var nations = await DbGetNations(); var cacheEntryOptions = new MemoryCacheEntryOptions() .SetAbsoluteExpiration(TimeSpan.FromHours(24)); Cache.Set(NATIONS_CACHE_KEY, nations, cacheEntryOptions); } return Cache.Get<List<Nation>>(NATIONS_CACHE_KEY).ToList();}
Come funziona il metodo “GetNations”:
- Controlla se la chiave NATIONS_CACHE_KEY esiste in cache.
1 - Se esiste, andiamo a prelevare i dati dalla cache stessa.
2 - Se non esiste, esegue una query sul database settiamo i dati sulla cache alla chiave NATIONS_CACHE_KEY.
- Le chiamate successive utilizzano i dati memorizzati in memoria.
Nel nostro caso la cache ha una scadenza di 24 ore, quindi la chiave e il suo contenuto saranno automaticamente rimossi dalla cache dopo 24 ore, con l’effetto che la successiva chiamata “entrerà nell’if” e quindi rieseguirà la query sul database.
Ovviamente stiamo presentando un utilizzo semplice della MemoryCache su .net
Nel caso reale, potremmo mantenere i dati in cache per più di 24 ore o senza scadenza. In tal caso, se aggiungiamo una nuova nazione, possiamo fare un reset esplicito della cache (ad esempio, con Cache.Remove(NATIONS_CACHE_KEY)) o semplicemente riavviare il server.
Alternative alle librerie ufficiali: Z.EntityFramework.Plus
Per semplificare l'uso di MemoryCache con Entity Framework, esistono librerie di terze parti, come Z.EntityFramework.Plus, che offrono metodi per gestire il caching in modo più semplice.
Tornando all’esempio di primaprivate async Task<List<Nation>> GetNations(){ return (await dbContext.Nation.FromCacheAsync(new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24) }, NATIONS_CACHE_KEY)).ToList();}
Come potete notare, invece di eseguire direttamente il “ToList” che avrebbe effettuare la query sul database, viene prima utilizzato il metodo FromCacheAsync di Z.EntityFramework.Plus, con la sua configurazione.
Sarà il metodo della libreria a sapere se effettuare la query sul database (e salvare il risultato per successive consultazioni) o prelevare il risultato dalla MemoryCache.
In realtà il FromCacheAsync è molto più evoluto di una semplice utilizzo di MemoryCache.
Se siamo ad esempio in uno scenario in cui il risultato da salvare non è soltanto una tabella anagrafica, ma vanno salvati anche i filtri per ottenere tale query.
Facciamo l’esempio di una tabella “Province” che contiene le province dei vari Paesi nel mondo.
Tale tabella ovviamente conterrà anche il riferimento alla Nation a cui appartengono.
Se vogliamo cachare di volta in volta la lista di province dato in input un “CountryCode” dovremmo di volta in volta costruire una chiave che tenga in considerazione del filtro CountryCode utilizzato:
esempio PROVINCES_IT_CACHE_KEY, PROVINCES_ES_CACHE_KEY, etc etc, complicando l’implementazione.
Col metodo FromCache/FromCacheAsync di Z.EntityFramework.Plus il sistema è in grado di leggere la differente parametrizzazione dei filtri di input e costruire chiavi specifiche su cui salvare il risultato specifico, il tutto in maniera trasparente per lo sviluppatore.
In questo caso, la "chiave" nel metodo FromCacheAsync (come NATIONS_CACHE_KEY o PROVINCE_CACHE_KEY) ha una funzione diversa rispetto a quella usata nell'implementazione personalizzata della cache. Qui, la chiave serve per gestire la scadenza della cache: i risultati delle query legate a quella chiave scadranno dopo il tempo impostato. Se necessario, un reset esplicito su quella chiave farà scadere tutti i risultati memorizzati, anche quelli con parametri diversi.
Efficacia della MemoryCache
Quanto detto finora risolve la problematica della cache?
Non del tutto purtroppo.
Supponiamo che la nostra applicazione sia bilanciata su più server (2,4,6,8, etc).
Supponiamo che un utente effettua la chiamata al GetCountries.
Il bilanciatore fa atterrare la richiesta sul Server 1, effettua la query sul database e salva il risultato sulla memoria del server.
Arriva una successiva chiamata alla GetCountries.
Anche in questo caso il bilanciatore fa atterrare la richiesta sul Server 1, i dati in memoria esistono e vengono restituiti i risultati evitando di effettuare una ulteriore query sul database.
Ma supponiamo che arriva una ulteriore chiamata alla GetCountries.
Il bilanciatore questa volta fa atterrare la richiesta sul Server2, ma qui la cache non è stata mai riempita e quindi il sistema rieffettua una query sul database e salva il risultato nella memoria del Server 2.
In sostanza al crescere del numero di server bilanciati, il meccanismo di caching spiegato finora verrà sempre meno.
Qual è la soluzione a questa problematica? La risposta è: Cache distribuita!
Cache distribuita – IDistribuitedCache
La cache distribuita presenta un grande vantaggio, ovvero che le query e relativi risultati non vengono memorizzati all’interno della memoria del singolo server in cui viene eseguita l’app, ma viene utilizzata una “memoria condivisa esterna” su cui cui l’app attingerà indipendentemente del server in cui è in esecuzione.
Inoltre, essendo una memoria esterna dall’app, un riavvio della stessa non porterà al reset della cache.
In questo modo, riprendendo l’esempio precedente, avendo la prima volta salvato query/risultato in memoria condivisa, la richiesta del GetCountries eseguita sul Server 2 continuerà ad attingere alla cache “condivisa” senza dover effettuare una ulteriore query sul database per ottenere la lista delle nazioni, come invece avveniva nell’esempio mostrato sulla MemoryCache.
L’interfaccia IDistribuitedCache di .Net fornisce alcuni metodi per manipolare gli elementi nell'implementazione della cache distribuita.
Una comune implementazione di IDistributedCache utilizza Redis, un sistema di archiviazione dati in memoria open source, frequentemente impiegato come cache distribuita. In questo articolo non presenteremo esempi pratici di implementazione di cache distribuita tramite Redis. Gli esempi finora trattati con MemoryCache servono principalmente per guidare la discussione, evidenziare le problematiche e proporre soluzioni adeguate.
IDistributedCache: è davvero la soluzione?
Quindi la domanda che dobbiamo porci è: la cache distribuita ha risolto definitivamente le varie problematiche relative all’implementazione di meccanismi di caching di dati?
Anche qui, non del tutto.
Se è vero che avere a disposizione una cache condivisa permette di centralizzare in un unico “contenitore” che memorizza i dati, rendendo efficace il caching rispetto alla scalabilità dell’applicazione, sul rovescio della medaglia, ad esempio, i tempi di accesso a questa struttura condivisa non saranno mai performanti come quelli su una memoria sul server.
Il rischio è quindi di perdere parte del tempo guadagnato per il fatto di non effettuare la query sul database.
Basandoci sempre sull’esempio precedente, la seconda richiesta del GetCountries sul Server 1 sebbene non andrebbe ad effettuare una query sul database, attingerebbe ad una cache condivisa, che non sarà sicuramente veloce come la seconda richiesta sul Server 1 che pesca il dato dalla memoria del server stesso, come spiegato nel caso utilizzo di MemoryCache.
Un ulteriore aspetto da considerare nell'implementazione della cache distribuita è la necessità di serializzare e deserializzare i dati.
A differenza della cache locale, come MemoryCache, dove gli oggetti possono essere gestiti direttamente in memoria senza la necessità di questi passaggi, nella cache distribuita il dato deve essere convertito in un formato che può essere condiviso tra i vari nodi, introducendo quindi un ulteriore overhead nelle operazioni di lettura e scrittura.
Esiste quindi un modo per unire i vantaggi della cache locale e cache distribuita?
La risposta è sì: la Cache Ibrida!
Cache ibrida – FusionCache/HybridCache
Il meccanismo di cache ibrida combina MemoryCache e IDistributedCache, seguendo questi passaggi:
- Alla richiesta, il sistema verifica prima se il dato è presente nella MemoryCache locale.
- Se il dato è trovato, viene restituito rapidamente.
- Se non è in MemoryCache, il sistema cerca nella cache distribuita (se configurata).
- Se il dato è presente nella cache distribuita, viene restituito e copiato nella MemoryCache locale per future richieste.
- Se il dato non è in nessuna delle due cache, viene eseguita una query sul database, e il dato viene salvato sia nella cache distribuita che nella MemoryCache locale.
Questo disegno, al netto di un po’ di overhead causato dalla copia dei dati, consente di volta in volta di ottenere il dato con la modalità di accesso più performante in quel momento e garantire un accesso ancora più rapido su richieste successive (nel caso, ad esempio, il dato venga clonato dalla cache distribuita a quella locale).
Ripercorrendo l’esempio precedente:
La prima richiesta della GetCountries viene effettuata su Server 1:
- il sistema non trova nulla né nella cache locale né in quella distribuita ed effettua la query sul database per ritornare il dato
- il dato viene salvato sia sulla cache distribuita/condivisa sia in quella locale/MemoryCache
La seconda richiesta del GetCountries viene sempre effettuata su Server 1:
- il sistema trova il dato sulla cache locale e lo restituisce ottenendo le migliori performance
La terza richiesta del GetCountries viene effettuata stavolta sul Server 2:
- il sistema non trova nulla sulla cache locale e prova su quella distribuita
- il dato viene trovato (perché scritto dalla prima richiesta atterrata sul Server 1) e lo restituisce al chiamante
- contestualmente tale dato viene clonato sulla cache locale/MemoryCache sul server 2
Effettuiamo una quarta richiesta del GetCountries che atterra aqncora una volta sul Server 2:
- il sistema trova il dato sulla cache locale e lo restituisce ottenendo le migliori performance
Implementazione cache distribuita
Per l’implementazione della cache ibrida abbiamo a disposizione due metodi:
- libreria di terze parti: “FusionCache”
- dalla versione 9 di .net sarà presente HybridCache che ha il ruolo principale di uniformare/rimpiazzare IMemoryCache + IDistribuitedCache
Anche in questo caso non mostreremo dettagli implementativi, facilmente reperibili dal web, ma lo scopo dell’articolo è stato quello di spiegare le varie problematiche dell’implementazione ed utilizzo del caching, con un punto di vista specifico sul mondo .Net, e descrivere i vari approcci che servono a migliorare passo dopo passo la soluzione.
Conclusioni – “Non è tutto oro quel che luccica”
Concludiamo dicendo che non è detto che una soluzione sia migliore in termini assoluti rispetto ad un’altra.
Il tutto deve essere considerato nel contesto: se è vero che la cache locale è limitata in alcuni contesti, la cache distribuita risolve determinate problematiche della prima ma ne porta altri, e la cache ibrida cerca di mettere assieme gli aspetti positivi dei 2 meccanismi, è anche vero che un meccanismo più “completo” è anche più “complicato” da gestire sia in termini di utilizzo/implementazione che di ulteriori strutture da gestire (vedi la parte di server condiviso per la cache distribuita).
Quindi, in determinati contesti (esempio piccole applicazioni non bilanciate), potrebbe essere sufficiente e ottimale il puro semplice utilizzo della MemoryCache.