Gli algoritmi che copiano il vero

Sin dal mio primo corso di informatica all’università, ho sviluppato un’interesse per il modo in cui gli algoritmi agiscono nel mondo reale. Rispetto ad alcune delle cose che succedono nel mondo reale, possiamo pensare ad algoritmi capaci di imitarle. È un’attività che pratico specialmente quando sono fermo in coda al supermercato, nel traffico o in aeroporto. Ho scoperto che annoiarsi mentre si fa la fila offre enormi opportunità per riflettere sulla teoria delle code.

Oltre 10 anni fa, ho trascorso una giornata di lavoro in un Amazon fulfillment center. Ero guidato da un algoritmo mentre raccoglievo gli articoli dai ripiani, li spostavo da una scatola all’altra e spostavo i cestini di qua e di là. Lavorando in parallelo con così tante altre persone, mi è sembrato straordinario far parte di ciò che appare essenzialmente come un’operazione di smistamento e fusione fisica splendidamente orchestrata.

Nella teoria delle code, il comportamento delle code brevi è relativamente poco interessante. Dopo tutto, quando una coda è breve, siamo tutti contenti. È solo quando la coda si allunga eccessivamente, quando la fila per un evento arriva fino alla porta e prosegue intorno al palazzo, è solo allora che si comincia a pensare al throughput e all’assegnazione di priorità.

In questo articolo, parlerò delle strategie che utilizziamo ad Amazon per affrontare e risolvere questi scenari di backlog delle code: gli approcci di progettazione che adottiamo per evadere le code rapidamente e assegnare le priorità ai carichi di lavoro. In particolare, descriverò come evitare che i backlog delle code si formino. Nella prima parte, illustrerò gli scenari che portano ai backlog e nella seconda parte descriverò i numerosi approcci adottati ad Amazon per evitare i backlog o per risolverli con eleganza.

La natura ipocrita delle code

Le code sono strumenti potenti che consentono di creare sistemi asincroni affidabili. Le code consentono a un sistema di accettare un messaggio da un altro sistema e fanno durare il messaggio fino a quando non è completamente elaborato, anche a fronte di lunghe interruzioni dell’attività, guasti del server o problemi con i sistemi dipendenti. Anziché lasciare cadere i messaggi quando si verifica un guasto, la code li riorienta fino a quando non vengono correttamente elaborati. Alla fine, una coda incrementa la resistenza e la disponibilità di un sistema, al costo di un occasionale aumento della latenza dovuta ai tentativi ripetuti.
 
Ad Amazon, creiamo molti sistemi asincroni che sanno sfruttare le code. Alcuni di questi sistemi elaborano flussi di lavoro che potrebbero richiedere tempo e che riguardano lo spostamento fisico di oggetti nel mondo, come l’evasione di ordini inoltrati su amazon.com. Altri sistemi coordinano le fasi che potrebbero richiedere un lasso di tempo non trascurabile. Ad esempio, Amazon RDS richiede le istanze EC2, attende il loro avvio e quindi configura il database per l’utente. Altri sistemi sfruttano il batching. Ad esempio, i sistemi coinvolti nell’acquisizione dei parametri e dei registri CloudWatch assorbono una quantità di dati e quindi li aggregano e li “appiattiscono” in chunk.
 
Anche se è semplice vedere i benefici di una coda per l’elaborazione asincrona dei messaggi, i rischi insiti nell’uso di una coda sono più discreti. Negli anni abbiamo visto che le code che hanno la funzione di migliorare la disponibilità possono ritorcersi contro. Di fatto, può aumentare drasticamente i tempi di recupero dopo un’interruzione dell’attività.
 
In un sistema basato su code, quando l’elaborazione si arresta ma i messaggi continuano ad arrivare, il debito di messaggi può accumularsi in un consistente backlog, incrementando i tempi di elaborazione. È possibile quindi che il lavoro venga completato troppo in ritardo per essere utile, causando essenzialmente il calo di disponibilità che la coda aveva la funzione di evitare.
 
In altri termini, un sistema basato su code ha due modalità operative, o un comportamento bimodale. Quando non vi è alcun backlog nella coda, la latenza del sistema è bassa e il sistema è in modalità veloce. Ma se un guasto o un modello di carico imprevisto fanno sì che il tasso di arrivo superi il tasso di elaborazione, la modalità operativa assume immediatamente un carattere più sinistro. In questo modo, la latenza end-to-end cresce sempre più e smaltire il backlog per tornare alla modalità veloce può richiedere una notevole quantità di tempo.

Sistemi basati su code

Per illustrare i sistemi basati su code in questo articolo, analizzo brevemente come lavorano realmente due servizi AWS: AWS Lambda, un servizio che esegue il codice in risposta ad eventi senza che l’utente si debba preoccupare dell’infrastruttura su cui viene eseguito e AWS IoT Core, un servizio gestito che consente ai dispositivi connessi di interagire facilmente e in modo sicuro con applicazioni cloud e altri dispositivi.

Con AWS Lambda, l’utente carica la coda delle funzioni e quindi invoca le funzioni in uno dei due seguenti modi:

• In modo sincrono: laddove l’output della funzione viene restituito all’utente nella risposta HTTP.
• In modo asincrono: laddove la risposta HTTP viene restituita immediatamente e la funzione dell’utente viene eseguita e riprovata dietro le quinte.

Lambda si accerta che la funzione dell’utente venga eseguita, anche a fronte di guasti del server, e necessita pertanto di una coda durevole in cui archiviare la richiesta. Con una coda durevole, è possibile riorientare la richiesta se la funzione non riesce al primo colpo.

Con AWS IoT Core, i dispositivi e le applicazioni dell’utente si collegano e possono iscriversi agli argomenti dei messaggi PubSub. Quando un dispositivo o un’applicazione pubblica un messaggio, le applicazioni con iscrizione corrispondente ricevono la propria copia del messaggio. La maggior parte della messaggistica PubSub avviene in maniera asincrona, perché un dispositivo IoT vincolato non intende dedicare le sue limitate risorse in attesa di garantire che tutti i suoi dispositivi, le applicazioni e i sistemi sottoscritti ricevano una copia. Ciò è particolarmente importante perché un dispositivo sottoscritto può essere offline quando un altro dispositivo pubblica un messaggio a cui il primo è interessato. Quando il dispositivo offline si ricollega, si aspetta di essere subito messo alla pari e quindi che i suoi messaggi vengano recapitati successivamente (per informazioni sulla codifica del sistema in uso per gestire il recapito dei messaggi dopo la riconnessione, consulta MQTT Persistent Sessions nella AWS IoT Developer Guide). Esiste una vasta gamma di generi di persistenza ed elaborazione asincrona che proseguono dietro le quindi affinché ciò si verifichi.

I sistemi basati su code come questi vengono spesso implementati con una coda durevole. SQS offre una semantica di recapito dei messaggi durevole, scalabile almeno una tantum, per consentire ai team Amazon compresi Lambda e IoT di utilizzarla regolarmente nella creazione dei propri sistemi asincroni scalabili. Nei sistemi basati su code, un componente produce dati inserendo un messaggio nella coda e un altro componete utilizza tali dati richiedendo periodicamente messaggi, elaborando i messaggi e infine eliminandoli una volta completa l’operazione.

Guasti nei sistemi asincroni

In AWS Lambda, se un’invocazione della funzione dell’utente è più lenta del normale (ad esempio a causa di una dipendenza), o se subisce transitoriamente un guasto, non vengono persi dati e Lambda riprova la funzione dell’utente. Lambda accoda le chiamate di invocazione dell’utente e quando la funzione riprende la sua attività, Lambda si occupa di smaltire il backlog della funzione. Ma consideriamo l tempo che occorre per smaltire il backlog e tornare nella norma.

Immaginiamo che un sistema subisca un interruzione dell’attività di un’ora nel corso dell’elaborazione dei messaggi. A prescindere dal tasso dato e dalla capacità di elaborazione, il ripristino da un’interruzione dell’attività richiede il doppio della capacità del sistema per un’altra ora dopo il ripristino. In pratica, il sistema potrebbe avere una capacità disponibile più che doppia, specie con servizi elastici come Lambda, e il ripristino potrebbe avvenire più rapidamente. D’altro canto, altri sistemi con cui interagisce la funzione dell’utente potrebbero non essere pronti a gestire un forte aumento di elaborazione mentre si lavora per smaltire il backlog. In tal caso, portarsi in pari può richiedere ancora più tempo. I servizi asincroni portano all’accumulo di backlog durante le interruzioni di alimentazione, generando tempi di ripristino più lunghi, a differenza dei servizi sincroni, che durante le interruzioni dell’attività lasciano sì cadere le richieste ma hanno poi tempi di ripristino più rapidi.

Nel corso del tempo, pensando alle code, siamo talvolta stati tentati di ritenere che la latenza non fosse importante per i sistemi asincroni. I sistemi asincroni vengono spesso creati per la durabilità, o per isolare l’intermediario diretto dalla latenza. Tuttavia, in pratica, abbiamo visto che i tempi di elaborazione sono importanti e spesso anche dai sistemi asincroni ci si aspetta una latenza inferiore al secondo o persino migliore. Quando vengono introdotte le code ai fini della durabilità, è facile non accorgersi del compromesso che causa una così alta latenza di elaborazione a fronte del backlog. Il rischio nascosto con i sistemi asincroni è il doversi occupare di backlog consistenti.

Come misuriamo disponibilità e latenza

Il dibattito sullo scambio tra latenza e disponibilità solleva un’interessante questione: come possiamo misurare e fissare gli obiettivi in materia di latenza e disponibilità per un servizio asincrono? Misurare i tassi di errore dal punto di vista del produttore ci fornisce una parte del quadro della disponibilità, ma non è completo. La disponibilità del produttore è proporzionale alla disponibilità in coda del sistema che stiamo utilizzando. Così, quando costruiamo a partire da SQS, la disponibilità del nostro produttore corrisponde alla disponibilità di SQS.

D’altra parte, se misuriamo la disponibilità dal lato del consumatore, questo può rendere la disponibilità del sistema peggiore di quanto non sia in realtà, perché i guasti potrebbero essere ripetuti e quindi riuscire al tentativo successivo.

Otteniamo anche misurazioni di disponibilità dalle code di messaggi non recapitabili (DLQ). Se un messaggio non ha più tentativi disponibili, viene lasciato cadere o inserito nella DLQ. Una DLQ è semplicemente una coda separata utilizzata per memorizzare messaggi che non possono essere elaborati per indagini e interventi successivi. Il tasso di messaggi lasciati cadere o inseriti nella DLQ è una misura valida della disponibilità, ma può rilevare il problema troppo tardi. Anche se è una buona idea allarmarsi sui volumi DLQ, le informazioni DLQ arriverebbero troppo tardi perché potessimo usufruirne esclusivamente per rilevare i problemi.

E la latenza? Anche in questo caso, la latenza osservata dal produttore rispecchia la latenza del nostro stesso servizio di coda. Ci concentriamo pertanto maggiormente sulla misurazione dell’età dei messaggi in coda. In tal modo si individuano rapidamente i casi in cui i sistemi sono in ritardo o sono spesso in errore e causano nuovi tentativi. Servizi come SQS forniscono il timestamp di quando ogni messaggio ha raggiunto la coda. Con le informazioni del timestamp, ogni volta che togliamo un messaggio dalla coda, possiamo registrare e produrre parametri su quanto sono indietro i nostri sistemi.

Il problema della latenza può tuttavia essere un po’ più sfumato. Dopo tutto, i backlog sono da prevedere e, per alcuni messaggi, è persino bene che vi siano. Ad esempio, in AWS IoT, ci sono momenti in cui si aspetta che un dispositivo vada offline o che diventi lento per leggere i suoi messaggi. Ciò è dovuto al fatto che molti dispositivi IoT sono a bassa potenza e hanno una connettività internet sporadica. Come operatori di AWS IoT Core, dobbiamo essere in grado di distinguere tra un piccolo backlog previsto causato da dispositivi offline o dalla scelta di leggere i messaggi lentamente e un backlog imprevisto a livello di sistema.

In AWS IoT, abbiamo dotato il servizio di strumentazione con un altro parametro: AgeOfFirstAttempt. Questa misurazione registra ora meno il tempo di inserimento in coda dei messaggi, ma solo se questa è la prima volta che l’AWS IoT ha tentato di inviare un messaggio a un dispositivo. In tal modo, quando si esegue il backup dei dispositivi, abbiamo un parametro pulito che non è inquinato da dispositivi che fanno nuovi tentativi con i messaggi o li inseriscono in coda. Per rendere il parametro ancora più pulito, emettiamo un secondo parametro - AgeOfFirstSubscriberFirstAttempt. In un sistema PubSub come AWS IoT, non esiste un limite pratico al numero di dispositivi o applicazioni che possono iscriversi a un particolare argomento, quindi la latenza è maggiore quando si invia il messaggio a un milione di dispositivi rispetto a quando lo si invia a un unico dispositivo. Per dotarci di un parametro stabile, emettiamo un parametro di timer al primo tentativo di pubblicare un messaggio al primo iscritto a quell’argomento. Abbiamo poi altri parametri per misurare il progresso del sistema nella pubblicazione dei rimanenti messaggi.

Il parametro AgeOfFirstAttempt serve come allarme precoce per un problema a livello di sistema, in gran parte perché filtra il rumore proveniente dai dispositivi che scelgono di leggere i loro messaggi più lentamente. Vale la pena ricordare che sistemi come AWS IoT sono dotati di strumentazione con molti più parametri di così. Ma con tutti i parametri relativi alla latenza disponibili, la strategia di classificare la latenza dei primi tentativi separatamente dalla latenza dei tentativi successivi è comunemente utilizzata in tutta Amazon.

Misurare la latenza e la disponibilità di sistemi asincroni è impegnativo, e anche il debug può risultare difficile, perché le richieste non vengono recapitate tra i server e possono essere ritardate in luoghi esterni a ciascun sistema. Per agevolare il tracciamento distribuito, propaghiamo nei nostri messaggi in coda un ID di richiesta per poter mettere insieme le cose. Di solito usiamo sistemi come X-Ray per dare un aiuto anche in questo senso.

Backlog in sistemi asincroni multitenant

Molti sistemi asincroni sono multitenant e gestiscono lavori per conto di molti clienti diversi. Questo aggiunge una dimensione complicata alla gestione della latenza e della disponibilità. Il vantaggio della multitenancy è che ci consente di risparmiare le spese di gestione operativa di dover gestire separatamente più flotte nonché di eseguire carichi di lavoro combinati con un utilizzo molto più elevato delle risorse. Tuttavia, i clienti si aspettano che si comporti come il proprio sistema single-tenant, con una latenza prevedibile e un’elevata disponibilità, a prescindere dal carico di lavoro degli altri clienti.

I servizi AWS non espongono direttamente le loro code interne affinché gli intermediari possano inserirvi i messaggi. Implementano invece API leggere per autenticare gli intermediari e aggiungere informazioni sull’intermediario ad ogni messaggio prima di inserirlo nella coda. Questo è simile all’architettura Lambda descritta in precedenza: quando si invoca una funzione in modo asincrono, Lambda mette il messaggio in una coda di proprietà di Lambda e ritorna subito, invece di esporre direttamente all’utente le code interne di Lambda.

Queste API leggere ci permettono anche di aggiungere l’equità di throttling. L’equità in un sistema multitenant è importante per evitare che il carico di lavoro di un cliente abbia un impatto su un altro cliente. Un modo comune con cui AWS implementa l’equità è quello di fissare limiti basati sulla tariffa per cliente, con una certa flessibilità per il bursting. In molti dei nostri sistemi, ad esempio nella stessa SQS, aumentiamo i limiti per cliente man mano che i clienti crescono in modo organico. I limiti servono da guardrail per i picchi inaspettati e consentono di fare aggiustamenti per il provisioning dietro le quinte.

Per certi versi, l’equità nei sistemi asincroni funziona proprio come il throttling nei sistemi sincroni. Tuttavia, pensiamo che sia ancora più importante pensare ai sistemi asincroni a causa dei consistenti backlog che possono accumularsi così rapidamente.

Per illustrare la situazione, consideriamo cosa succederebbe se un sistema asincrono non avesse sufficienti protezioni incorporate contro i noisy neighbor. Se un cliente del sistema causa improvvisamente un picco nel traffico che non viene regolato con il throttling, generando così un backlog a livello di sistema, potrebbero occorrere circa 30 minuti per coinvolgere un operatore, perché l’operatore capisca cosa sta succedendo e per mitigare il problema. Durante questi 30 minuti, il lato produttore del sistema può avere eseguito un ridimensionamento efficace e messo in coda tutti i messaggi. Ma se il volume dei messaggi in coda fosse stato 10 volte la capacità di ridimensionamento del lato consumer, occorrerebbero 300 minuti perché il sistema esaurisca il backlog e ripristini la normale attività. Anche picchi di carico brevi possono causare tempi di ripristino di più ore e quindi interruzioni dell’attività di più ore.

In pratica, i sistemi in AWS hanno numerosi fattori di compensazione per ridurre al minimo o prevenire gli impatti negativi dovuti ai backlog delle code. Ad esempio, il ridimensionamento automatico aiuta a mitigare i problemi quando il carico aumenta. Ma è utile considerare i soli effetti dell’inserimento in coda, senza considerare i fattori di compensazione, perché ciò consente di progettare sistemi affidabili a più livelli. Ecco alcuni modelli di progettazione che abbiamo trovato e che possono consentire di evitare grandi code di backlog e lunghi tempo di ripristino:

La protezione a ogni livello è importante nei sistemi asincroni. Poiché i sistemi sincroni non tendono ad accumulare backlog, li proteggiamo con il throttling dalla porta principale e il controllo di ingresso. Nei sistemi asincroni, ogni componente dei nostri sistemi deve proteggersi dai sovraccarichi e impedire che un carico di lavoro possa consumare una quota iniqua delle risorse. Ci sarà sempre un certo carico di lavoro che riesce ad aggirare il controllo di ingresso dalla porta principale, quindi abbiamo bisogno di una cintura, bretelle e una protezione tascabile per evitare che i servizi vengano sovraccaricati.
L’uso di più di una coda aiuta a modellare il traffico. In un certo senso, una coda singola e la multitenancy sono in contrasto tra loro. Nel momento in cui il lavoro viene inserito in coda in una coda condivisa, è difficile isolare un carico di lavoro da un altro.
I sistemi in tempo reale sono spesso implementati con code FIFO-ish, ma preferiscono il comportamento LIFO-ish. I nostri clienti ci dicono che, di fronte a un backlog, preferiscono che i loro dati nuovi vengano elaborati immediatamente. Tutti i dati accumulati durante un’interruzione dell’attività o una sovracorrente possono essere quindi elaborati in base alla capacità disponibile.

Le strategie di Amazon per la creazione di sistemi asincroni multitenant resilienti

Esistono diversi modelli che i sistemi di Amazon utilizzano per rendere i loro sistemi asincroni multitenant resilienti ai cambiamenti nei carichi di lavoro. Si tratta di una serie di tecniche, ma ci sono anche molti sistemi utilizzati nell’ambito di Amazon, ognuno con i propri requisiti di liveness e durata. Nella sezione seguente, descrivo alcuni dei modelli che usiamo e che i clienti AWS ci dicono di utilizzare nei loro sistemi.

Separazione dei carichi di lavoro in code distinte

Anziché condividere una coda tra tutti i clienti, in alcuni sistemi assegniamo ad ogni cliente la propria coda. L’aggiunta di una coda per ogni cliente o carico di lavoro non è sempre conveniente, perché i servizi sono costretti a dedicare risorse al sondaggio di tutte le code. Ma nei sistemi con pochi clienti o sistemi adiacenti, questa soluzione semplice può essere utile. D’altra parte, se un sistema ha anche decine o centinaia di clienti, code distinte possono iniziare a diventare ingombranti. Ad esempio, l’AWS IoT non utilizza una coda separata per ogni dispositivo IoT nell’universo. In tal caso i costi del sondaggio non sarebbero facilmente ridimensionabili.

Sharding casuale

AWS Lambda è un esempio di un sistema in cui il sondaggio di una coda separata per ogni cliente Lambda sarebbe troppo costoso. Tuttavia, la presenza di un’unica coda potrebbe causare alcuni dei problemi descritti in questo articolo. Quindi, anziché utilizzare una sola coda, AWS Lambda esegue il provisioning di un numero fisso di code e quindi l’hashing di ciascun cliente a un numero contenuto di code. Prima di inserire in coda un messaggio, verifica qual è la coda interessata che contiene il minor numero di messaggi, quindi inserisce il messaggio in tale coda. Quando il carico di lavoro di un cliente aumenta, comporta l’accumulo di backlog nelle relative code mappate, mentre gli altri carichi di lavoro vengono automaticamente instradati lontano da tali code. Non occorre un numero consistente di code per creare in un magico isolamento delle risorse. Questa è solo una delle tante protezioni incorporate in Lambda, ma è una tecnica che viene utilizzata anche in altri servizi di Amazon.

Estromettere il traffico in eccesso in una coda separata

In un certo senso, quando in una coda si è accumulato un backlog, è già troppo tardi per dare priorità al traffico. Tuttavia, se l’elaborazione del messaggio è relativamente costosa o richiede molto tempo, potrebbe comunque essere utile poter spostare i messaggi in una coda separata di spillover. In alcuni sistemi di Amazon, il servizio consumatori implementa il throttling distribuito e, quando vengono tolti da una coda i messaggi di un cliente che ha superato un tasso configurato, tali messaggi in eccesso vengono inseriti in code di spillover separate e vengono eliminati i messaggi dalla coda primaria. Il sistema funziona comunque sui messaggi nella coda di spillover non appena le risorse si rendono disponibili. In sostanza, questo si avvicina a una coda di priorità. Una logica analoga viene talvolta attuata dal lato del produttore. Così, se un sistema accetta un grande volume di richieste da un singolo carico di lavoro, tale carico di lavoro non toglie spazio ad altri carichi di lavoro nella coda dal percorso più gettonato.

Estromettere il traffico obsoleto in una coda separata

Come per il traffico in eccesso, è possibile estromettere anche il traffico obsoleto. Quando rimuoviamo un messaggio dalla coda, possiamo controllarne l’età. Anziché limitarsi a registrare l’età, possiamo utilizzare le informazioni per decidere se spostare il messaggio in una coda di backlog che evadiamo solo dopo essere tornati in pari con la coda in tempo reale. Se c’è un picco di carico dove acquisiamo una ingente quantità di dati, e restiamo indietro, possiamo estromettere quell’ondata di traffico in un’altra coda con la stessa velocità con cui rimuoviamo e reinseriamo in coda il traffico. In questo modo i consumatori possono liberare risorse per lavorare sui nuovi messaggi più rapidamente che se ci i fosse semplicemente occupati del backlog in ordine. Questo è uno dei modi per avvicinarsi all’ordine LIFO.

Lasciare cadere i messaggi obsoleti (time to live - TTL - del messaggio)

Alcuni sistemi possono tollerare l’abbandono di messaggi obsoleti. Ad esempio, alcuni sistemi elaborano rapidamente i delta dei sistemi, ma eseguono anche periodicamente la sincronizzazione completa. Spesso chiamiamo questi sistemi di sincronizzazione periodica dei sistemi strumenti di pulizia anti-entropia. In questi casi, anziché estromettere il traffico obsoleto in coda, possiamo farlo cadere in modo economico se è arrivato prima dell’ultima pulizia.

Limitare i thread (e altre risorse) per carico di lavoro

Come avviene nei nostri servizi sincroni, progettiamo i nostri sistemi asincroni per evitare che un carico di lavoro utilizzi più della sua quota equa di thread. Un aspetto di AWS IoT di cui non si è ancora parlato è il motore delle regole. I clienti possono configurare AWS IoT per instradare i messaggi dai loro dispositivi a un cluster Amazon Elasticsearch di proprietà del cliente, Kinesis Stream e così via. Se la latenza delle risorse di proprietà del cliente diventa lenta, ma il tasso della messaggistica in entrata rimane costante, la quantità di simultaneità nel sistema aumenta. E poiché la quantità di simultaneità che un sistema è in grado di gestire è limitata in qualsiasi momento temporale, il motore delle regole impedisce a qualsiasi carico di lavoro di consumare più della sua quota equa di risorse relative alla simultaneità.

La forza al lavoro è descritta dalla legge di Little, che stabilisce che la simultaneità in un sistema è pari al tasso di arrivo moltiplicato per la latenza media di ogni richiesta. Ad esempio, se un server elaborasse 100 messaggi/s a 100 ms in media, consumerebbe in media 10 thread. Se la latenza improvvisamente salisse a 10 secondi, utilizzerebbe improvvisamente 1.000 thread (in media, in pratica quindi sarebbero di più), il che potrebbe facilmente esaurire un pool di thread.

Il motore delle regole utilizza diverse tecniche per evitare che ciò accada. Utilizza I/O non bloccanti per evitare l’esaurimento dei thread, anche se ci sono ancora altri limiti che stabiliscono quanto lavoro abbia un dato server (ad esempio, memoria e descrittori di file quando il client sta passando in rassegna le connessioni e la dipendenza si sta esaurendo). Una seconda protezione della simultaneità che può essere utilizzata è un semaforo che misura e limita la quantità di simultaneità che può essere utilizzata per un singolo carico di lavoro in qualsiasi momento. Il motore delle regole utilizza anche la limitazione dell’equità in base al tasso. Tuttavia, poiché è perfettamente normale che i carichi di lavoro cambino nel corso del tempo, il motore delle regole ridimensiona automaticamente anche i limiti nel corso tempo per adattarsi alle variazioni dei carichi di lavoro. E poiché il motore delle regole è basato sulla coda, funge da buffer tra i dispositivi IoT e il ridimensionamento automatico delle risorse e i limiti di salvaguardia dietro le quinte.

Attraverso i servizi di Amazon, usiamo pool di thread separati per ogni carico di lavoro per evitare che un solo carico di lavoro consumi tutti i thread disponibili. Usiamo anche un AtomicInteger per ogni carico di lavoro per limitare la simultaneità consentita a ciascuno, e gli approcci di throttling basati sul tasso per isolare le risorse basate sul tasso.

Invio della congestione a monte

Se un carico di lavoro causa un backlog irragionevole con cui il consumatore non è in grado di tenere il passo, molti dei nostri sistemi iniziano automaticamente a rifiutare il lavoro in modo più aggressivo nel produttore. È facile accumulare un backlog di un giorno per un carico di lavoro. Anche se quel carico di lavoro è isolato, può essere accidentale e costoso da passare in rassegna. Un’implementazione di questo approccio potrebbe essere semplice come misurare occasionalmente la profondità della coda di un carico di lavoro (supponendo che un carico di lavoro sia nella propria coda) e ridimensionare un limite di throttle in entrata (inversamente) in proporzione alle dimensioni del backlog.

Nei casi in cui condividiamo una coda SQS per carichi di lavoro multipli, questo approccio diventa difficile. Se esiste un’API di SQS che restituisce il numero di messaggi nella coda, non esiste invece un’API che può restituire il numero di messaggi nella coda con uno specifico attributo. Potremmo comunque misurare la profondità della coda e applicare la congestione di conseguenza, ma in tal modo verrebbe ingiustamente applicata la congestione su carichi di lavoro innocenti che si sono trovati a condividere la stessa coda. Altri sistemi come Amazon MQ hanno una visibilità del backlog a grana più fine.

La congestione non è adatta a tutti i sistemi Amazon. Ad esempio, nei sistemi che eseguono l’elaborazione degli ordini per amazon.com, tendiamo a preferiamo optare per l’accettazione degli ordini anche se si accumula un backlog, piuttosto che impedire l’accettazione di nuovi ordini. Naturalmente tutto ciò è accompagnato da una notevole assegnazione di priorità dietro le quinte affinché gli ordini più urgenti vengano gestiti per primi.

Utilizzare le code ritardate per posticipare il lavoro fino a un secondo momento

Quando i sistemi hanno la sensazione che il throughput per un particolare carico di lavoro debba essere ridotto, cerchiamo di utilizzare una strategia di respingimento su quel carico di lavoro. Per implementare tale operazioni, utilizziamo spesso una funzione di SQS che ritarda la consegna di un messaggio fino a un secondo momento. Quando elaboriamo un messaggio e decidiamo di salvarlo per un secondo momento, a volte resinseriamo il messaggio in una coda di attesa separata, ma impostiamo il parametro di ritardo in modo che il messaggio rimanga nascosto nella coda ritardata per diversi minuti. In questo modo il sistema ha la possibilità di lavorare su dati più recenti.

Evitare troppi messaggi in corso

Alcuni servizi di coda come SQS hanno dei limiti sul numero di messaggi in corso che possono essere consegnati al consumatore della coda. Ciò è diverso dal numero di messaggi che possono essere in coda (per i quali non c’è un limite pratico) ma piuttosto è il numero di messaggi su cui la flotte consumatori sta lavorando contemporaneamente. Questo numero può essere gonfiato se un sistema toglie i messaggi dalla coda ma poi non riesce a eliminarli. Ad esempio, abbiamo visto dei bug in cui il codice non riesce ad acquisire un’eccezione durante l’elaborazione di un messaggio e si dimentica di eliminare il messaggio. In questi casi, il messaggio rimane in corso dal punto di vista di SQS per il VisibilityTimeout del messaggio. Quando progettiamo la nostra strategia di gestione degli errori e di sovraccarico, teniamo a mente questi limiti e tendiamo a favorire lo spostamento dei messaggi in eccesso in un’altra coda anziché lasciare che rimangano visibili.

Le code di SQS FIFO hanno un limite simile ma discreto. Con SQS FIFO, i sistemi consumano i messaggi in ordine per un dato gruppo di messaggi, ma i messaggi di gruppi diversi vengono elaborati in qualsiasi ordine. Quindi, se sviluppiamo un piccolo backlog in un gruppo di messaggi, continuiamo a elaborare i messaggi in altri gruppi. Tuttavia, SQS FIFO interroga soltanto degli ultimi 20.000 messaggi non elaborati. Quindi, se ci sono più di 20.000 messaggi non elaborati in un sottoinsieme di gruppi di messaggi, altri gruppi di messaggi con nuovi messaggi verranno eliminati.

Utilizzo di code in giacenza per i messaggi che non possono essere elaborati

I messaggi che è impossibile elaborare possono contribuire al sovraccarico del sistema. Se un sistema inserisce in coda un messaggio che non può essere elaborato (forse perché innesca un caso limite di validazione in ingresso), SQS può aiutare spostando automaticamente questi messaggi in una coda separata con la funzione coda in giacenza (DLQ). Scatta un allarme se ci sono messaggi in questa coda, perché significa che è presente un bug da risolvere. Il vantaggio del DLQ è che ci permette di rielaborare i messaggi una volta risolto il bug.

Garantire un buffer aggiuntivo nel sondaggio dei thread per carico di lavoro

Se un carico di lavoro alimenta un throughput sufficiente, al punto che i thread di sondaggio sono sempre occupati anche in condizioni stazionarie, il sistema potrebbe aver raggiunto un punto in cui non è più presente un buffer per assorbire un picco di traffico. In questo stato, un lieve picco del traffico in arrivo comporta una quantità consistente di backlog non elaborato, con conseguente maggiore latenza. Per assorbire tali esplosioni, prevediamo di aggiungere buffer nei thread di sondaggio. Una misura è quella che comporta il rilevamento del numero di tentativi di sondaggio che danno luogo a risposte vuote. Se ogni tentativo di sondaggio verte sul recupero di un messaggio in più, possiamo contare sul numero giusto di thread di sondaggio o forse non se ne contano abbastanza per tenere il passo con il traffico in arrivo.

Messaggi heartbeat di lunga durata

Quando un sistema elabora un messaggio SQS, SQS concede al sistema un determinato lasso di tempo per concludere l’elaborazione del messaggio prima di ritenere che il sistema si sia bloccato e per consegnare il messaggio a un altro consumatore per riprovare. Se il codice continua a funzionare e dimentica questa scadenza, lo stesso messaggio può essere consegnato più volte in parallelo. Mentre il primo processore sta ancora lavorando su un messaggio dopo il relativo timeout, un secondo processore lo raccoglie e procede a lavorarci sopra oltre il timeout, e poi un terzo, e così via. Questo potenziale per il verificarsi di cali di tensione a cascata è il motivo per cui implementiamo la nostra logica di elaborazione dei messaggi per interrompere il lavoro quando un messaggio scade, o per continuare a generare l’heartbeat di quel messaggio per ricordare a SQS che ci stiamo ancora lavorando. Questo concetto è simile a quello dei lease nell’elezione dei leader.

È un problema insidioso, perché vediamo che la latenza di un sistema rischia di aumentare durante un sovraccarico, magari dalle query a un database che richiedono più tempo, o dai server che semplicemente accettano più lavoro di quanto possano gestire. Quando la latenza del sistema supera la soglia di VisibilityTimeout, il servizio già sovraccaricato sottopone se stesso a una bomba fork.

Piano per il debug cross-host

Comprendere i guasti in un sistema distribuito è già difficile. Il relativo articolo sulla strumentazione descrive diversi nostri approcci per la strumentazione dei sistemi asincroni, dalla registrazione periodica della profondità delle code alla propagazione di “trace ids” e all’integrazione con i raggi X. Oppure, quando i nostri sistemi hanno un flusso di lavoro asincrono complicato al di là di una banale coda di SQS, spesso utilizziamo un servizio di flusso di lavoro asincrono diverso, come Step Functions, che fornisce visibilità nel flusso di lavoro e semplifica il debug distribuito.

Conclusioni

In un sistema asincrono, è facile dimenticare quanto sia importante pensare alla latenza. Dopo tutto, i sistemi asincroni dovrebbero occasionalmente richiedere più tempo, perché sono preceduti da una coda per l’esecuzione di nuovi tentativi affidabili. Tuttavia, gli scenari di sovraccarichi e guasti possono portare all’accumulo di enormi backlog insormontabili dai quali un servizio non può riprendersi in un lasso di tempo ragionevole. Questi backlog possono provenire da un unico carico di lavoro o da un inserimento in una coda da parte del cliente a un tasso inaspettatamente alto, da carichi di lavoro che diventano più costosi del previsto da elaborare o da latenza o guasti in una dipendenza.

Nella creazione di un sistema asincrono, dobbiamo prevedere questi scenari di backlog e ridurli al minimo utilizzando tecniche come l’assegnazione delle priorità, l’estromissione e la congestione.

Ulteriori letture

Teoria delle code
Legge di Little
Legge di Amdahl
• Little A Proof for the Queuing Formula: L = λW, Case Western, 1961
• McKenney, Stochastic Fairness Queuing, IBM, 1990
• Nichols and Jacobson, Controlling Queue Delay, PARC, 2011

Informazioni sull’autore

David Yanacek è un ingegnere capo che lavora su AWS Lambda. David è stato sviluppatore software presso Amazon dal 2006 e ha lavorato in precedenza su Amazon DynamoDB e AWS IoT, oltre che su framework di servizi web interni e sui sistemi di automazione delle operazioni di flotta. Una delle attività preferite di David al lavoro è l’analisi dei log e il vaglio dei parametri operativi per scoprire come rendere sempre più fluida l’esecuzione dei sistemi nel tempo.

Elezione dei leader nei sistemi distribuiti Strumentazione di sistemi distribuiti per visibilità operativa