Cloud Intelligence™Cloud Intelligence™

Cloud Intelligence™

Event-Driven Architecture su AWS, Parte II: le basi avanzate

By Vlad KhononovDec 16, 20246 min read

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

Nel post precedente ho illustrato i componenti fondamentali per implementare un'architettura event-driven (EDA) con i servizi gestiti AWS. Questo post è invece dedicato alle basi avanzate. Le definisco avanzate perché, in base alla mia esperienza di consulenza, sono pochissime le aziende che applicano le pratiche di cui voglio parlare. Le considero comunque delle basi, però, perché non si tratta di pratiche opzionali: sono essenziali per realizzare sistemi affidabili basati sulla messaggistica.

Sebbene gli esempi di questo post utilizzino i servizi AWS, i contenuti si applicano a qualsiasi sistema, indipendentemente dall'infrastruttura su cui gira. L'implementazione può variare, ma i principi di fondo restano gli stessi.

Poiché il tema è un pattern - e i pattern, per definizione, sono soluzioni ripetibili a problemi ricorrenti - voglio partire da un problema spesso trascurato nei sistemi basati su EDA.

Il problema

Nel post precedente ho suggerito di utilizzare AWS SNS per pubblicare i messaggi in un sistema event-driven. Ecco un esempio tipico di come avviene questa pubblicazione:

...

sns.publish(
    TopicArn=users_topic_arn,
    Message=json.dumps({
        'event_type': 'user_registered',
        'event_id': str(uuid.uuid4()),
        'user_id': 'USER12345',
        'name': 'John Doe',
        'email': '[email protected]',
        'source': 'mobile_app',
        'registration_date': '2024-10-11T20:01:00Z'
    })
)

...

Nell'esempio qui sopra, un evento di tipo user_registered viene pubblicato su un topic SNS. Ma quell'evento è spuntato dal nulla? È tutto ciò che fanno i servizi, limitarsi a pubblicare messaggi? Ovviamente no. L'evento fa parte di un processo di business più ampio, che di solito prevede l'aggiornamento di uno stato in un database operativo prima di notificarlo ai componenti esterni. Una rappresentazione più realistica del processo è la seguente:

...

# Persist state changes
users_table.put_item(
    Item={
        'user_id': user_id,
        'name': name,
        'password_hash': password_hash,
        'email': email,
        'source': source,
        'registration_date': registration_date,
        'created_at': datetime.utcnow().isoformat()
    }
)

# Publish corresponding events
sns.publish(
    TopicArn=users_topic_arn,
    Message=json.dumps({
        'event_type': 'user_registered',
        'event_id': str(uuid.uuid4()),
        'user_id': user_id,
        'name': name,
        'email': email,
        'source': source,
        'registration_date': registration_date
    })
)

...

Prima viene salvato il nuovo utente, poi viene pubblicata la notifica. Il codice sembra lineare, ma proviamo a riflettere su queste tre domande:

  1. Cosa può andare storto?
  2. Quali ne sono le conseguenze?
  3. Si può davvero dire che questo codice sia affidabile?

Fermatevi un attimo a ragionarci, prima di proseguire la lettura.

Bene, confrontiamo le risposte.

Se qualcosa va storto tra la scrittura sul database e la pubblicazione del messaggio, il sistema finirà in uno stato incoerente. L'utente potrebbe ricevere un errore e dare per scontato che l'intera operazione sia fallita, mentre in realtà il record è già stato creato nel database. I sottoscrittori dell'evento user_registered, però, non riceveranno alcuna notifica, perché il messaggio non è mai stato pubblicato. Perché può succedere? Il server può riavviarsi, la Lambda può andare in timeout, possono verificarsi partizioni di rete e una miriade di altre cause - in cloud, soprattutto.

La consistenza di un sistema event-driven dipende dalla sua capacità di consegnare i messaggi in modo affidabile tra i vari componenti. Qui non è così. Nonostante l'apparente semplicità, il codice qui sopra non è affidabile.

Cosa fare, allora? Possiamo racchiudere la scrittura sul database e la pubblicazione dell'evento in un'unica transazione atomica? No. In passato ci sono stati tentativi in questa direzione (ad esempio DTC), ma non sono finiti bene. E il two-phase commit? Non aiuta neanche lui, perché soffre di condizioni di fallimento analoghe.

La soluzione affidabile è trasformare due transazioni in una sola. Come? Parliamo del pattern outbox.

La soluzione: outbox

L'idea alla base del pattern outbox è piuttosto semplice. Per prima cosa si persistono sia le modifiche di stato sia i messaggi in uscita nel database operativo all'interno di un'unica transazione atomica. O entrambe le operazioni vanno a buon fine, o falliscono entrambe: nessuna via di mezzo. Successivamente, un meccanismo esterno - il publishing relay - recupera i messaggi committati e li pubblica in modo asincrono su un message bus.

Figura 1: il pattern outbox

Implementazione: aspetti generali

Non esiste un modo unico per implementare il pattern outbox. I dettagli realizzativi dipendono dallo stack tecnologico in uso e, in primo luogo, dal database.

Innanzitutto, è il database a stabilire gli strumenti a disposizione (o meno) per committare in modo atomico i dati aggiornati e i messaggi in uscita. Se supporta transazioni multi-tabella (ad esempio i database relazionali, DynamoDB, ecc.), si possono persistere i messaggi in una tabella dedicata, di solito chiamata "outbox". In caso contrario, sia lo stato aggiornato sia i messaggi devono finire all'interno di un singolo record.

In secondo luogo, serve un modo affidabile per recuperare i messaggi persistiti. Alcuni database abilitano il modello push: il database stesso dispone dei meccanismi per richiamare il publishing relay e passargli i nuovi messaggi. Pensiamo, ad esempio, ai trigger Lambda di DynamoDB o al meccanismo di change data capture (CDC) nei database relazionali.

In definitiva, il modo in cui si persistono e si recuperano i messaggi determina come garantire che lo stesso messaggio non venga prelevato e ripubblicato inutilmente.

Implementazione: un esempio

Torniamo all'esempio iniziale. Dal momento che il codice utilizza DynamoDB, il modo più semplice per implementare il pattern outbox in questo caso è sfruttarne la capacità di eseguire transazioni multi-tabella e accodare gli eventi in uscita in una tabella dedicata:

...

with dynamodb.meta.client.transact_write_items(
    TransactItems=[\
        {\
            'Put': {\
                'TableName': users_table.name,\
                'Item': {\
                    'user_id': user_id,\
                    'name': name,\
                    'password_hash': password_hash,\
                    'email': email,\
                    'source': source,\
                    'registration_date': registration_date,\
                    'created_at': datetime.utcnow().isoformat()\
                }\
            }\
        },\
        {\
            'Put': {\
                'TableName': outbox_table.name,\
                'Item': {\
                    'event_id': event_id,\
                    'data': {\
                        'event_type': 'user_registered',\
                        'event_id': event_id,\
                        'user_id': user_id,\
                        'name': name,\
                        'email': email,\
                        'source': source,\
                        'registration_date': registration_date\
                    }\
                }\
            }\
        }\
    ]
)

...

A questo punto bisogna decidere come pubblicare effettivamente gli eventi sul topic SNS. La soluzione più semplice è usare i DynamoDB Streams sulla tabella outbox per attivare, a ogni nuovo record, una funzione Lambda che ne pubblichi gli eventi sul topic SNS.

Infine, occorre decidere cosa fare dei messaggi già pubblicati. La funzione di pubblicazione può eliminare i record dalla tabella outbox una volta ricevuta conferma che la pubblicazione su SNS è andata a buon fine. In alternativa, può mantenere il record aggiornandolo con il timestamp dell'effettiva pubblicazione.

La Figura 2 riassume la soluzione completa:

Figura 2: il pattern outbox implementato con AWS DynamoDB, Lambda e SNS

Rispetto alla soluzione ingenua di pubblicare gli eventi in uscita direttamente su un topic SNS, l'implementazione del pattern outbox porta a un sistema più articolato, con più parti in gioco. La soluzione che ne risulta, però, è affidabile. Se la transazione originale è stata committata, qualunque cosa accada a runtime, gli eventi corrispondenti verranno pubblicati e consegnati ai sottoscrittori.

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 (post attuale)
  3. Event-Driven Architecture su AWS, Parte III: le basi più ostiche

Pubblicato originariamente su https://vladikk.com .

La consistenza di un sistema event-driven dipende dalla sua capacità di consegnare i messaggi in modo affidabile tra i vari componenti. Il pattern outbox permette di aggiornare lo stato del sistema e di pubblicare i relativi eventi all'interno di un'unica transazione atomica, anche quando l'infrastruttura sottostante non supporta transazioni cross-service. L'affidabilità che l'outbox porta al sistema ripaga ampiamente lo sforzo richiesto per implementare il pattern.