Cloud Intelligence™Cloud Intelligence™

Cloud Intelligence™

Event-Driven Architecture su AWS, Parte III: le basi difficili

By Vlad KhononovDec 16, 20248 min read

Questa pagina è disponibile anche in English, Deutsch, Español, Français, 日本語 e Português.

Nel post precedente di questa serie ho affrontato i problemi di affidabilità tipici dei sistemi basati su messaggistica e come risolverli implementando il pattern outbox. In questo post lo sguardo si sposta dalla pubblicazione all'elaborazione dei messaggi pubblicati dai vari componenti di un sistema. Come sempre, parto definendo il problema da affrontare.

Il problema

Torniamo un attimo al pattern outbox. Risolve un problema ricorrente: la pubblicazione dei messaggi dopo che la transazione di business sottostante è già stata committata, con il rischio che gli eventi non vengano pubblicati affatto. Implementando questo pattern si ha la garanzia che tutti gli eventi verranno pubblicati almeno una volta. Mi soffermo proprio su quest'ultimo punto.

Figura 1: il pattern outbox

Il publishing relay recupera gli eventi dal database operativo e, una volta pubblicati, li conferma rimuovendo i record o contrassegnandoli come pubblicati. Ma cosa succede se il processo si interrompe proprio tra la pubblicazione e la marcatura dell'evento come pubblicato? Semplice: l'evento verrà ripubblicato al tentativo successivo e i subscriber riceveranno lo stesso evento più di una volta. È un problema serio? Assolutamente no.

Consegna at-least-once

Se utilizza AWS SQS per elaborare gli eventi a cui un servizio è iscritto (e dovrebbe farlo), tenga presente che il servizio offre comunque garanzie di "at-least-once delivery":

"Amazon SQS memorizza copie dei messaggi su più server per garantire ridondanza e alta disponibilità. In rare occasioni, uno dei server che contiene una copia di un messaggio potrebbe non essere disponibile al momento della ricezione o dell'eliminazione del messaggio… Quando ciò accade, è possibile ricevere nuovamente quella copia del messaggio."

Questo vale non solo per AWS SQS, ma per qualsiasi (onesto) bus di messaggistica distribuito. A questo punto potrebbe chiedersi: e le SQS FIFO Queues? Stando alla documentazione, dovrebbero risolvere proprio questo problema:

"A differenza delle code standard, le code FIFO non introducono messaggi duplicati. Le code FIFO aiutano a evitare l'invio di duplicati a una coda. Se si ritenta l'azione SendMessage entro l'intervallo di deduplicazione di 5 minuti, Amazon SQS non introduce duplicati nella coda."

Ma il problema è risolto solo in parte. Se l'outbox pubblica lo stesso messaggio più volte (entro 5 minuti), una coda FIFO riconoscerà il duplicato e lo ignorerà. Spostiamoci però sul lato consumer. L'elaborazione dei messaggi da una coda distribuita prevede tre passaggi:

  1. Recuperare il messaggio successivo disponibile.
  2. Elaborare il messaggio.
  3. Confermare il messaggio contrassegnandolo come elaborato o, nel caso di SQS, eliminandolo dalla coda.

Ora indossi i panni del pessimista più estremo e si ponga le tre domande che le ho già rivolto nel post precedente:

  1. Cosa può andare storto?
  2. Quali sono le conseguenze?
  3. Si può davvero definire affidabile questo flusso di elaborazione dei messaggi?

Si fermi un attimo a riflettere prima di continuare a leggere.

Bene, confrontiamo le risposte.

Se qualcosa va storto tra l'elaborazione di un messaggio e la sua conferma, il messaggio verrà recuperato di nuovo all'esecuzione successiva e quindi elaborato più di una volta. Anche se le code FIFO promettono "exactly-once processing", la garanzia riguarda l'elaborazione lato SQS, non la sua applicazione. E quale dei due conta davvero per lei? Ovviamente la seconda. Per progettare un sistema distribuito affidabile, deve quindi partire dal presupposto che ogni messaggio può essere consegnato ai subscriber più di una volta.

La soluzione

Tornando alla pagina della documentazione SQS che riconosce la possibilità di consegne duplicate, la stessa pagina indica anche come affrontare il problema:

Progetti applicazioni idempotenti (che non subiscano effetti negativi quando elaborano lo stesso messaggio più di una volta).

Un ottimo consiglio. Se passare lo stesso evento o comando alla logica applicativa più volte produce sempre lo stesso risultato, non deve preoccuparsi dei casi limite legati ai messaggi duplicati.

È facile implementare una logica di elaborazione dei messaggi idempotente, affidabile e a prova di errore? Tutt'altro! Ma prima di spiegare come si fa, voglio dire qualcosa su come non farlo.

Elaborazione idempotente degli eventi: il modo facile e sbagliato

Molti cercano di ottenere un'elaborazione idempotente implementando il pattern idempotent consumer. L'idea alla base è valida, ma la stragrande maggioranza delle implementazioni manca proprio il punto per cui il pattern serve. Ecco come viene tipicamente realizzato:

Anzitutto, a ogni comando o evento elaborato dalla sua applicazione va assegnato un identificatore univoco. In secondo luogo, serve un "idempotency store": un database key/value che mappa gli ID delle richieste ai relativi risultati (ad esempio Redis, DynamoDB, mappe in-memory, ecc.).

Soddisfatti questi due requisiti, l'elaborazione di una richiesta in arrivo segue una logica semplice:

Quando arriva una richiesta, si verifica innanzitutto se il suo ID è già presente nell'idempotency store. In caso affermativo, si restituisce il risultato salvato in tabella, senza ulteriori elaborazioni. Se invece l'ID non è presente:

  1. Si esegue la richiesta.
  2. Si salvano ID e risultato nell'idempotency store.

Rimetta il cappello del pessimista e mi dica: cosa può andare storto qui?

Naturalmente, se per qualunque motivo il processo fallisce subito dopo aver completato l'elaborazione ma prima di salvare il risultato nell'idempotency store, l'operazione finirà per essere eseguita più di una volta.

Pensa che una soluzione così ingenua non venga davvero usata? Dia un'occhiata a questo post su AWS Lambda Powertools.

Esiste una versione un po' più evoluta di questo algoritmo, che marca prima la richiesta come "in process" e ne imposta la scadenza dopo un certo intervallo, ma soffre dello stesso difetto: se il processo fallisce dopo il completamento dell'elaborazione, ma prima dell'aggiornamento dell'idempotency store, la richiesta verrà elaborata di nuovo.

In generale, finché l'operazione utilizza un database che non partecipa alla stessa transazione dell'idempotency store, una richiesta potrà sempre essere elaborata più di una volta.

A questo punto potrebbe obiettare: il caso limite di cui parla sembra talmente raro, perché preoccuparsene? Lo stesso si potrebbe dire del problema iniziale che vogliamo risolvere. Per il suo specifico sistema potrebbe essere accettabile, e in tal caso perché aggiungere altri pezzi mobili? In ogni caso, decidere se l'elaborazione duplicata sia accettabile o meno è una scelta progettuale che va fatta in modo consapevole.

Allora come si implementa una logica di elaborazione dei messaggi idempotente, affidabile e a prova di errore?

Elaborazione idempotente degli eventi: il modo affidabile e difficile

Come per il pattern outbox, un'implementazione affidabile del pattern idempotent consumer richiede una transazione atomica che comprenda sia la logica di business sia il tracciamento degli eventi elaborati. E come per l'outbox, l'implementazione del pattern dipende dallo stack tecnologico.

Idempotenza con transazioni multi-record

Se il suo database operativo supporta le transazioni multi-record, crei una tabella per le chiavi di idempotenza, ovvero gli ID delle richieste in arrivo (a me piace chiamarla "inbox"). Una volta completata la richiesta, aggiorni i dati operativi e inserisca la chiave di idempotenza in un'unica transazione atomica. Attenzione però: l'inserimento della chiave deve avvenire con una condizione, ovvero deve riuscire solo se quel valore non è già presente nella tabella. Se è presente, l'operazione deve fallire; e in tal caso sa che la richiesta in arrivo è già stata elaborata.

...

response = dynamodb.transact_write_items(
    TransactItems=[\
        {\
            'Put': {\
                'TableName': 'operational_data',\
                'Item': {\
                    ...\
                }\
            }\
        },\
        {\
            'Put': {\
                'TableName': 'inbox',\
                'Item': {\
                    'request_id': {'S': event_id},\
                },\
                'ConditionExpression': 'attribute_not_exists(request_id)'\
            }\
        }\
    ]
)

...

Come ulteriore ottimizzazione, può verificare l'esistenza della chiave di idempotenza prima di eseguire la logica di elaborazione.

Idempotenza con Optimistic Concurrency Control

Se il database con cui lavora non supporta le transazioni multi-record, può comunque ottenere un'elaborazione idempotente affidabile affidandosi all'optimistic concurrency control:

  1. Ogni operazione comporta la modifica di un singolo record nella tabella (ad esempio, un documento JSON).
  2. Ogni record ha un campo version per gestire le eccezioni di concorrenza.
  3. Ogni aggiornamento di un record incrementa il valore della sua version.
  4. In fase di aggiornamento, il database deve garantire che la version del record sovrascritto coincida con quella letta inizialmente.

Una volta in atto l'optimistic concurrency control, il record gestito può essere esteso con un campo aggiuntivo: processed_events (array). Prima di eseguire una richiesta, verifichi se il suo ID è presente nell'array processed_events. Se non lo è, gestisca l'evento e accodi il suo ID all'array.

I post della serie

  1. Event-Driven Architecture su AWS, Parte I: le basi
  2. Event-Driven Architecture su AWS, Parte II: le basi avanzate
  3. Event-Driven Architecture su AWS, Parte III: le basi difficili (post attuale)

Pubblicato originariamente su https://vladikk.com .

Con questo articolo si chiude la mia serie di tre post sulle basi della progettazione di sistemi event-driven su AWS:

  1. Nel primo post ha visto come sfruttare i servizi gestiti di AWS per separare, a livello architetturale, la pubblicazione e la sottoscrizione degli eventi.
  2. Il secondo post ha analizzato i problemi più comuni che possono emergere nella pubblicazione degli eventi e come risolverli con il pattern outbox.
  3. Infine, questo post ha mostrato come rendere il sistema più affidabile sfruttando l'idempotenza nella logica di elaborazione degli eventi.

In sintesi, quando progetta un sistema distribuito tenga a mente non solo le fallacie del calcolo distribuito, ma anche la legge di Murphy. Parta sempre dal presupposto che tutto ciò che può andare storto, andrà storto. E lo metta in conto fin dall'architettura del sistema. Affrontare per tempo tutti gli scenari di failure renderà i suoi weekend e le sue vacanze decisamente più tranquilli e rilassanti ;)