Il tormento e l’estasi delle cache

In anni dedicati alla creazione di servizi in Amazon, abbiamo provato varie versioni del seguente scenario: creiamo un nuovo servizio e questo servizio deve effettuare delle chiamate di rete per adempiere alle sue richieste. Possono essere chiamate a un database relazionale, a un servizio AWS come Amazon DynamoDB o a un altro servizio interno. In test semplici o a basse frequenze delle richieste, il servizio funziona perfettamente, ma si nota un problema all’orizzonte. Il problema potrebbe essere che le chiamate a quest’altro servizio sono lente o che il database è costoso da ridimensionare man mano che il volume delle chiamate aumenta. Notiamo anche che molte richieste utilizzano la stessa risorsa a valle o gli stessi risultati delle query, quindi pensiamo che il caching di questi dati potrebbe rappresentare la soluzione ai nostri problemi. Aggiungiamo una cache e il nostro servizio appare molto migliorato. Osserviamo che la latenza della richiesta è calata, i costi si sono ridotti e i lievi cali di disponibilità a valle vengono appianati. Dopo un po’, nessuno si ricorda più di come stessero le cose prima della cache. Le dipendenze riducono le dimensioni delle loro flotte di conseguenza e il database viene ridimensionato. Ma proprio quando tutto sembra andare al meglio, il server potrebbe essere sull’orlo della catastrofe. In caso di modifiche nei modelli di traffico, errori nella flotta di cache o altre circostanze impreviste, il risultato potrebbe essere una cache vuota o non disponibile. Ciò potrebbe a sua volta causare un’impennata del traffico verso i servizi a valle, con conseguenti possibili interruzioni della disponibilità sia nelle dipendenze che nei servizi.

Abbiamo appena descritto un servizio che è diventato dipendente dalla propria cache. La cache è stata inavvertitamente elevata da utile aggiunta al servizio a un componente necessario e critico per la sua capacità di funzionare. Il nucleo centrale del problema è il comportamento modale introdotto dalla cache, che varia a seconda del fatto che un oggetto dato sia nella cache o meno. Un cambiamento non previsto nella distribuzione di questo comportamento modale può essere potenzialmente catastrofico.

Abbiamo provato tanto i vantaggi quanto le sfide del caching nel corso della creazione e del funzionamento dei servizi in Amazon. Il resto di questo articolo descrive la lezione che abbiamo appreso, le best practice e le considerazioni relative all’uso delle cache.

Quando si usa il caching

Sono diversi i fattori che ci conducono a considerare l’aggiunta di una cache al nostro sistema. Molte volte questo processo inizia da un’osservazione sulla latenza o l’efficienza di una dipendenza a una data frequenza delle richieste. Ad esempio, tale osservazione può essere fatta quando si determina che una dipendenza potrebbe avviare il throttling o essere comunque incapace di tenere il passo con il carico previsto. Ci è parso utile valutare la possibilità del caching quando incontriamo modelli di richieste irregolari che portano al throttling hot-key/hot-partition. I dati da questa dipendenza sono un ottimo candidato per il caching se una simile cache fornisce un buon rapporto di hit cache sul totale delle richieste. Ovvero, se i risultati delle chiamate alla dipendenza possono essere utilizzati per più richieste od operazioni. Se ogni richiesta richiede in genere un’unica query al servizio dipendente con risultati univoci per richiesta, allora la cache avrebbe una frequenza di hit trascurabile e non risulterebbe utile. Una seconda considerazione riguarda quanto siano tolleranti un servizio del team e i relativi client alla uniformità finale. I dati nella cache crescono necessariamente in modo non uniforme rispetto all’origine nel corso del tempo, quindi il caching può essere efficace soltanto se sia il servizio che i relativi client si compensano di conseguenza. La frequenza del cambiamento dei dati di origine, oltre alla policy della cache per l’aggiornamento dei dati, determina in che misura i dati tendono a essere non uniformi. Sono due aspetti legati reciprocamente. Ad esempio, dati relativamente statici o che cambiano lentamente possono essere inseriti nella cache per lunghi periodi di tempo.

Cache locali

Le cache del servizio possono essere implementate nella memoria o esternamente al servizio. Le cache on-box, normalmente implementate nella memoria del processo, sono relativamente rapide e semplici da implementare e possono fornire miglioramenti significativi con lavoro minimo. Le cache on-box sono spesso il primo approccio implementato e valutato una volta identificata l’esigenza di caching. A differenza delle cache esterne, le cache on-box non comportano costi di esercizio aggiuntivi, quindi sono relativamente a basso rischio per l’integrazione in un servizio esistente. Spesso implementiamo una on-box cache come tabella hash in memoria gestita tramite la logica applicativa (ad es. collocando esplicitamente i risultati nella cache dopo il completamento delle chiamate al servizio) o incorporata nel client del servizio (ad es. utilizzando un client HTTP di caching).

Nonostante i vantaggi e la semplicità seduttiva, le cache in memoria hanno comunque diversi svantaggi. Un vantaggio è che i dati nella cache non sono uniformi da server a server nell’ambito della flotta, e manifestano un problema di uniformità della cache. Se il client effettua chiamate ripetute al servizio, riceverà dati più recenti utilizzati nella prima chiamata e dati meno recenti nella seconda chiamata, a seconda di quale sia il server che gestisce la richiesta.

Un altro difetto è che il carico a valle è ora proporzionale alle dimensioni della flotta del servizio, cosicché man mano che il numero di server cresce può comunque essere possibile sopraffare i servizi dipendenti. Abbiamo rilevato che un metodo efficace per monitorare questo aspetto è emettere i parametri su hit/miss della cache e il numero di richieste effettuate ai servizi a valle.

Le cache in memoria sono anche sensibili ai problemi di “avvio a freddo”. Questi problemi si verificano quando un server viene avviato con una cache completamente vuota, il che potrebbe causare una raffica di richieste al servizio dipendente mentre riempie la cache. Ciò può rappresentare un problema significativo durante le distribuzioni o in altre circostanze in cui la cache è inondata a livello di flotta. I problemi di uniformità della cache e di cache vuota possono spesso essere risolti tramite la fusione delle richieste, descritta dettagliatamente nel seguito di questo articolo.

Cache esterne

Le cache esterne possono risolvere molti dei problemi che abbiamo appena visto. Una cache esterna archivia i dati in una flotta separata, ad esempio utilizzando Memcached o Redis. I problemi di coerenza delle cache si riducono perché la cache esterna conserva il valore utilizzato da tutti i server nella flotta. (Questi problemi non vengono completamente eliminati perché l’aggiornamento della cache può comportare casi di errore.) Il carico complessivo sui server a valle si riduce rispetto alle cache in memoria e non è proporzionale alle dimensioni della flotta. I problemi di avvio a freddo durante eventi quali le distribuzioni non sono presenti poiché la cache esterna resta popolata per tutta la durata della distribuzione. Infine, le cache esterne forniscono più spazio di storage disponibile rispetto alle cache in memoria e riducono le occorrenze di espulsione dalla cache per limiti di spazio.

Le cache esterne sono tuttavia caratterizzate da una propria serie di problematiche di cui tenere conto. La prima di queste è data dalla maggiore complessità totale e dal maggiore carico operativo dei sistemi, poiché è presente una flotta ulteriore che deve essere monitorata, gestita e ridimensionata. Le caratteristiche di disponibilità della flotta di cache saranno diverse dal servizio dipendente per cui agisce come una cache. Spesso la flotta di cache può, ad esempio, essere meno disponibile se non gode del supporto per gli aggiornamenti con tempi di inattività pari a zero e se richiede finestre di manutenzione.

Per evitare il degrado della disponibilità del servizio a causa della cache esterna, dobbiamo aggiungere codice del servizio per affrontare la mancata disponibilità della flotta di cache, l’errore dei nodi cache e gli errori put/get della cache. Una delle opzioni possibili prevede di fare affidamento sul richiamo del servizio dipendente, ma per esperienza si tratta di un approccio che va preso con le pinze. Durante un’interruzione prolungata della disponibilità della cache, si verifica un picco atipico nel traffico verso il servizio a valle, che porta al throttling o al calo di tensione di quel servizio dipendente e, in ultima analisi, alla riduzione della disponibilità. Preferiamo quindi utilizzare la cache esterna in abbinamento a una cache in memoria su cui possiamo fare affidamento se la cache esterna non è disponibile, o utilizzare la riduzione del carico e fissare la frequenza massima di richieste inviate al servizio a valle. Testiamo il comportamento del servizio con il caching disabilitato per confermare che le protezioni messe in atto per evitare i cali di tensione delle dipendenze funzionino effettivamente come previsto.

Una seconda considerazione riguarda il dimensionamento e l’elasticità della flotta di cache. Man mano che la flotta di cache comincia a raggiungere i limiti di frequenza delle richieste o di memoria, sarà necessario aggiungere nodi. Siamo noi a determinare quali parametri siano gli indicatori principali di tali limiti per poter impostare monitoraggi e allarmi di conseguenza. Ad esempio, in un servizio su cui ho lavorato di recente, il nostro team ha rilevato che l’utilizzo della CPU saliva moltissimo man mano che la frequenza delle richieste Redis si avvicinava al limite. Abbiamo utilizzato i test di carico con modelli di traffico realistici per determinare il limite e individuare la soglia di allarme corretta.

Nell’aggiungere capacità alla flotta di cache, abbiamo cura di farlo in modo da non causare un’interruzione della disponibilità o una perdita in massa dei dati della cache. Tecnologie di caching diverse comportano riflessioni uniche. Ad esempio, alcuni server cache non supportano l’aggiunta di nodi a un cluster senza interruzione della disponibilità e non tutte le librerie di client cache forniscono un hashing uniforme, necessario per aggiungere nodi alla flotta di cache e ridistribuire i dati nella cache. A causa della variabilità nelle implementazioni client di un hashing uniforme e della rilevazione di nodi nella flotta di cache, testiamo accuratamente l’aggiunta e la rimozione di server cache prima di entrare in produzione.

Con una cache esterna, facciamo particolare attenzione a fare in modo che la solidità del formato di storage venga modificata. I dati nella cache vengono trattati come se si trovassero in un archivio persistente. Garantiamo che il software aggiornato possa sempre leggere i dati scritti da una versione software precedente e che le versioni precedenti possano gestire opportunamente la vista di nuovi formati/campi (ad es. durante le distribuzioni quando la flotta a un mix di codice vecchio e nuovo). Evitare le eccezioni non acquisite quando si riscontrano formati imprevisti è necessario per evitare poison pill. Ciò non è tuttavia sufficiente per evitare tutti i problemi relativi ai formati. Rilevare una mancata corrispondenza nel formato della versione ed eliminare i dati nella cache può condurre a un aggiornamento in massa delle cache, che a sua volta può portare al throttling e ai cali di tensione del servizio dipendente. I problemi del formato di serializzazione sono affrontati più nel dettaglio nell’articolo Garantire la sicurezza del rollback durante le distribuzioni.

Un’ultima considerazione per le cache esterne è che vengono aggiornate da singoli nodi nella flotta di servizio. Le cache in genere non hanno funzioni come i put o le transazioni condizionali, quindi ci occupiamo di garantire che il codice di aggiornamento della cache sia corretto e non possa mai abbandonare la cache in uno stato non valido o non uniforme.

Cache in linea e side cache a confronto

Un’altra decisione che dobbiamo prendere quando valutiamo i diversi approcci al caching riguarda la scelta tra cache in linea e side cache. Le cache in linea, o cache di tipo read-through/write-through, incorporano la gestione della cache nella API principale di accesso ai dati, rendendo così la gestione della cache un dettaglio dell’implementazione di tale API. Alcuni esempi includono implementazioni specifiche per applicazione come Amazon DynamoDB Accelerator (DAX) e implementazioni basate su standard come il caching HTTP (sia tramite un client di caching locale o un server di cache esterno come Nginx o Varnish). Le side cache, invece, sono archivi di oggetti generici come quelli forniti da Amazon ElastiCache (Memcached e Redis) o librerie come Ehcache e Google Guava per le cache in memoria. Con le side cache, il codice applicativo modifica direttamente la cache prima e dopo le chiamate all’origine dati, verificando la presenza di oggetti nella cache prima di effettuare le chiamate a valle e inserendo gli oggetti nella cache una volta completate le chiamate.

Il principale vantaggio di una cache in linea è un modello di API uniforme per i client. Il caching può essere aggiunto, rimosso o modificato lievemente senza apportare modifiche alla logica client. Una cache in linea estrae anche logica di gestione della cache dal codice applicativo, eliminando così una potenziale fonte di bug. Le cache HTTP sono particolarmente interessanti perché offrono molte opzioni già pronte, come le librerie in memoria, i proxy HTTP autonomi come quelli citati in precedenza e i servizi gestiti come le reti CDN (Content Delivery Network).

La trasparenza di una cache in linea può tuttavia essere anche un difetto della disponibilità. Le cache esterne fanno ormai parte dell’equazione della disponibilità per questa dipendenza. Non esistono possibilità per il clent di compensare a fronte di una cache temporaneamente non disponibile. Ad esempio, se disponi di una flotta Varnish che inserisce in cache le richieste di un servizio REST esterno, e se poi quella flotta di caching diventa inattiva, dal punto di vista del tuo server è come se la dipendenza stessa fosse diventata inattiva. L’altro svantaggio di una cache in linea è che deve essere creata nel protocollo o nel servizio per cui esegue il caching. Se non è disponibile una cache in linea del protocollo specificato, il caching in linea non è più un’opzione a meno che non si desideri creare personalmente un client integrato o un servizio proxy.

Scadenza della cache

Alcuni dei dettagli di implementazione delle cache più complessi riguardano la scelta delle dimensioni giuste della cache, la policy di scadenza e la policy di espulsione. La policy di scadenza determina per quanto tempo viene conservato un elemento nella cache. La policy più comune utilizza una scadenza assoluta basata sul tempo (ovvero associa un valore TTL, time-to-live, con ciascun oggetto caricato). Il TTL viene scelto in base ai requisiti del client, ad esempio in che misura il client è tollerante ai dati obsoleti e in che misura i dati sono obsoleti, perché i dati che cambiano lentamente possono essere inseriti nella cache in modo più aggressivo. Le dimensioni ideali della cache si basano su un modello del volume previsto delle richieste e sulla distribuzione di oggetti nella cache per tali richieste. Da qui, si stima una dimensione della cache che garantisca una frequenza di hit elevata della cache con questi modelli di traffico. La policy di espulsione controlla il modo in cui gli elementi vengono rimossi dalla cache quando questa raggiunge la sua massima capacità. La policy di espulsione più comune è quella basata sull’utilizzo meno recente o LRU (Least Recently Used).

Fino ad ora, questo è solo un esercizio teorico. I modelli di traffico del mondo reale possono differire dai modelli da noi creati, pertanto rileviamo le prestazioni effettive della nostra cache. Il metodo che preferiamo in tal senso è l’emissione di parametri del servizio sugli hit o i miss della cache, le dimensioni totali della cache e il numero di richieste ai servizi a valle.

Abbiamo imparato che dobbiamo deliberare sulla scelta delle dimensioni della cache e sui valori della policy di scadenza. Vogliamo evitare la situazione in cui uno sviluppatore sceglie arbitrariamente le dimensioni della cache e i valori TTL durante l’implementazione iniziale per poi non tornare mai indietro per convalidare la loro correttezza in un secondo momento. Abbiamo visto esempi del mondo reale di questa mancanza di continuità che porta a interruzioni temporanee della disponibilità del servizio e all’acutizzazione delle interruzioni in corso.

Un altro modello che usiamo per migliorare la resilienza quando i servizi a valle non sono disponibili è l’utilizzo di due TTL: un TTL soft e un TTL hard. Il client tenterà di aggiornare gli elementi della cache in base al soft TTL, ma se il servizio a valle non è disponibile o comunque non risponde alla richiesta, i dati della cache esistenti continueranno a essere utilizzati fino al raggiungimento dell’hard TTL. Un esempio di questo modello è utilizzato nel client AWS Identity and Access Management (IAM).

Utilizziamo anche l’approccio con soft e hard TTL con congestione per ridurre l’impatto dei cali di tensione del servizio a valle. Il servizio a valle può rispondere con un evento di congestione quando sta raggiungendo un calo di tensione, segnale che il servizio di chiamata deve utilizzare i dati nella cache fino al raggiungimento dell’hard TTL e fare richieste di dati che non sono nella propria cache. Si continua fino a quando il servizio a valle rimuove la congestione. Questo modello consente al servizio a valle di recuperare dal calo di tensione mantenendo al contempo la disponibilità dei servizi a monte.

Altre considerazioni

Una considerazione importante riguarda il comportamento della cache nel momento in cui si ricevono errori dal servizio a valle. Una delle opzioni per risolvere questo problema è rispondere ai client utilizzando l’ultimo valore corretto della cache, ad esempio sfruttando il modello soft TTL/hard TTL descritto in precedenza. Un’altra opzione che adottiamo consiste nell’inserire nella cache la risposta di errore, (ovvero utilizziamo una “cache negativa”) utilizzando un diverso TTL rispetto a quello delle voci della cache positive, e nel propagare l’errore al client. L’approccio che scegliamo in una data situazione dipende dalle caratteristiche specifiche del servizio e dalla valutazione di quando sia meglio per i client vedere dati obsoleti o errori. A prescindere dall’approccio che adottiamo, è importante assicurarsi che la cache contenga qualcosa nei casi di errore. Se non è così e il servizio a valle è temporaneamente non disponibile o comunque non in grado di soddisfare determinate richieste (ad es. quando viene eliminata una risorsa a valle), il servizio a monte continuerà a bombardarlo di traffico e a causare potenzialmente un’interruzione della disponibilità o ad acutizzare quella in corso. Abbiamo osservato esempi del mondo reale in cui il mancato inserimento nella cache di risposte negative ha comportato un’intensificazione della frequenza di errori e guasti.

La sicurezza è un altro importante aspetto del caching. Quando introduciamo una cache in un servizio, valutiamo e conteniamo gli eventuali rischi per la sicurezza che questa introduce. Ad esempio, le flotte di caching esterno sono spesso prive della crittografia dei dati serializzati e della sicurezza a livello del trasporto. Ciò è particolarmente importante se nella cache vengono conservate informazioni sensibili dell’utente. Il problema può essere contenuto utilizzando ad esempio Amazon ElastiCache for Redis, che supporta la crittografia in transito e a riposo. Le cache sono anche sensibili agli attacchi di poisoning, in cui una vulnerabilità nel protocollo a valle consente a un aggressore di popolare una cache con un valore di cui detiene il controllo. L’impatto dell’attacco ne risulta amplificato, poiché tutte le richieste fatte mentre il valore resta nella cache vedono il valore dannoso. Come esempio conclusivo, le cache sono anche sensibili agli attacchi a cronometro a canale laterale. I valori nella cache vengono restituiti più rapidamente rispetto a quelli non inseriti nella cache, per cui un aggressore può sfruttare il tempo di risposta per acquisire informazioni sulle richieste che stanno facendo altri client o tenet.

Una considerazione finale riguarda la situazione denominata “thundering herd”, in cui molti client fanno richieste che richiedono la stessa risorsa a valle fuori dalla cache all’incirca nello stesso momento. La stessa circostanza può verificarsi anche quando un server si attiva e si unisce alla flotta con una cache locale vuota. Il risultato è un numero consistente di richieste da ogni server verso al dipendenza a valle, con conseguente possibile throttling/calo di tensione. Per ovviare a questo problema, utilizziamo la fusione delle richieste, dove i server o la cache esterna garantiscono che soltanto una richiesta in sospeso sia esterna per le risorse non inserite nella cache. Alcune librerie di caching forniscono il supporto per la fusione delle richieste, come pure alcune cache in linea esterne (come Nginx o Varnish). Inoltre, la fusione delle richieste può essere implementata al di sopra delle cache esistenti. 

Best practice e considerazioni di Amazon

Questo articolo ha citato diverse best practice Amazon oltre ai compromessi e ai rischi associati al caching. Sintetizziamo le best practice e le considerazioni di Amazon utilizzate dai nostri team nel momento in cui introducono una cache:

• Accertarsi che esista un’esigenza legittima per la cache giustificata in termini di costo, latenza e/o miglioramento della disponibilità. Garantire che i dati siano passibili di inserimento nella cache, ovvero che possano essere utilizzati su più richieste client. Conservare un certo scetticismo rispetto al valore che una cache può portare e valutare attentamente che i benefici superino i rischi ulteriori che la cache introduce.
• Programmare la gestione della cache con lo stesso rigore e gli stessi processi utilizzati per il resto della flotta di servizi e dell’infrastruttura. Non sottovalutare questa attività. Emettere parametri sull’utilizzo della cache e sulla frequenza di hit per garantire che la cache sia accuratamente regolata. Monitorare gli indicatori chiave (come CPU e memoria) per garantire che la flotta di caching esterna sia integra e correttamente dimensionata. Impostare allarmi su tali parametri. Assicurarsi che la flotta di caching possa essere ampliata senza tempi di inattività o invalidazione in massa della cache (confermare cioè che l’hashing uniforme funzioni come previsto).
• Adottare un approccio deciso ed empirico nella scelta delle dimensioni della cache, della policy di scadenza e della policy di espulsione. Eseguire i test e utilizzare i parametri citati al punto precedente per convalidare e regolare tali scelte.
• Garantire che il servizio sia resiliente a fronte dell’indisponibilità della cache, garanzia che copre una vasta gamma di circostanze che comportano l’incapacità di servire le richieste utilizzando i dati nella cache. Sono inclusi avvii a freddo, interruzioni della disponibilità delle flotte di caching, modifiche nei modelli di traffico o ampie interruzioni della disponibilità a valle. In molti casi, ciò potrebbe vole dire barattare una parte della propria disponibilità per garantire che i server e i servizi dipendenti non subiscano un calo di tensione (ad esempio riducendo il carico, mettendo un limite alle richieste dei servizi dipendenti o servendo dati obsoleti). Eseguire test di carico con le cache disabilitate per la conferma.
• Considerare gli aspetti di sicurezza del mantenimento dei dati nella cache, inclusa la crittografia, la sicurezza del trasporto nella comunicazione con una flotta di caching esterna e l’impatto degli attacchi di cache poisoning e gli attacchi a canale laterale.
• Progettare il formato dello storage per gli oggetti inseriti nella cache affinché si evolva nel tempo (ad es. utilizzando un numero di versione) e scrivere codice di serializzazione in grado di leggere le versioni precedenti. Diffidare delle poison pill nella logica di serializzazione della cache.
• Valutare come la cache gestisce gli errori a valle e prendere in considerazione la gestione di una cache negativa con un diverso TTL. Non causare né amplificare l’interruzione di disponibilità facendo ripetutamente richiesta della stessa risorsa a valle ed eliminando le risposte di errore.

Molti team di servizi ad Amazon usano le tecniche di caching. Malgrado i benefici garantiti da queste tecniche, la decisione di incorporare il caching non va presa a cuor leggero, poiché gli svantaggi possono spesso pesare di più dei vantaggi. Ci auguriamo che questo articolo possa aiutarti nella valutazione del caching nei tuoi servizi.


Informazioni sull'autore

Matt è un Principal Engineer in Emerging Devices presso Amazon, dove lavora sul software e i servizi per dispositivi consumer futuri. In precedenza lavorava presso AWS Elemental, a capo del team che ha lanciato MediaTailor, un servizio di inserzioni pubblicitarie personalizzato lato server per video live e on demand. Nel corso del suo lavoro ha anche contribuito al lancio della prima stagione in streaming dell’NFL Thursday Night Football su PrimeVideo. Prima di Amazon, Matt ha trascorso 15 anni nel settore della sicurezza, lavorando presso McAfee, Intel e in alcune startup, concentrandosi su gestione della sicurezza aziendale, tecnologie anti-malware e anti-exploit, misure di sicurezza assistite da hardware e DRM.

Jas Chhabra è un Principal Engineer in AWS. Jas ha iniziato in AWS nel 2016 e ha lavorato su AWS IAM per un paio d’anni prima di spostarsi nel suo attuale ruolo presso AWS Machine Learning. Prima di AWS, ha lavorato presso Intel in vari ruoli tecnici negli ambiti IoT, identità e sicurezza. I suoi attuali interessi professionali sono il machine learning, la sicurezza e i sistemi distribuiti su larga scala. Tra gli interessi precedenti citiamo IoT, bitcoin, identità e crittografia. Ha conseguito un Master in Informatica.

Evitare il fallback nei sistemi distribuiti Utilizzo della riduzione del carico per evitare sovraccarichi Garantire la sicurezza del rollback durante le distribuzioni