In questo post analizzeremo le tre principali differenze che, dal punto di vista di Dhiria, distinguono NATS da Kafka (e derivati, come Redpanda).
NATS è un sistema pub-sub (publish-subscribe) ad alte prestazioni, progettato per essere semplice, leggero e veloce. A differenza di Kafka, che nasce come sistema di log distribuito con forte orientamento alla persistenza e all’elaborazione di flussi di dati, NATS è pensato principalmente per la comunicazione tra servizi, con latenza minima e throughput elevato. Nella sua versione base, NATS non prevede la persistenza dei messaggi: i messaggi vengono consegnati ai subscriber attivi e poi scartati. Tuttavia, grazie all’estensione JetStream, è possibile ottenere funzionalità avanzate come la persistenza, i consumer durabili, i replay, la retention configurabile e molto altro, portandolo a coprire molti dei casi d’uso tipici di Kafka o Redpanda.

Subjects
Una delle parti più interessanti di NATS è la possibilità di usare il subject-wildcarding, ovvero il matching sul nome di un subject per poter filtrare messaggi in maniera dinamica, senza la necessità di creare topic in modo statico.
Infatti, in maniera gerarchica, un subject name può essere composto da diverse parti divise da .
:
mainname.<par1>.<par2>.<par3>.<par4>...
Posso, ad esempio, produrre un messaggio sul subject mainname.impianto1.finito
, e diversi consumatori si comporteranno in maniera diversa. Chi è iscritto a mainname.>
riceverà tutti i messaggi prodotti su subjects che iniziano per mainname
. Chi è iscritto a mainname.impianto1.*
riceverà tutti i messaggi relativi a impianto1
. Chi è iscritto a mainname.*.finito
riceverà i messaggi di tutti gli impianti, ma solo se finito
è il secondo parametro.
Questo riduce il carico sui client (in quanto riceveranno solo i messaggi che fanno match con il subject scelto) e rende più semplice osservare tutti i messaggi relativi a un determinato evento o entità.
Se so che un subject è solo “di passaggio”, ad esempio per il trasferimento dati, è possibile configurare da client la persistenza dei suoi messaggi con vari approcci (numero massimo di messaggi, età massima…):
from nats.js.apiimport StreamConfig await js.add_stream( name="file_data", subjects=["file.broadcast.*"],config=StreamConfig( max_age=60# 60 seconds ) )
Per chiarezza: uno stream di JetStream è la struttura che gestisce la persistenza dei dati di una collezione di subjects. Nel codice di esempio, lo stream file_data
racchiuderà i dati di tutti i subjects che corrispondono con file.broadcast.*.
.
Oltre al fatto che Kafka non supporta nativamente questa caratteristica (per quanto esista il concetto di Pattern subscription), è importante notare che Kafka non è progettato per creare e distruggere numerosi topic (specialmente con ZooKeeper) in breve tempo, in quanto ciò comporta continui ribilanciamenti nel cluster. NATS, invece, supporta anche decine di migliaia di subjects diversi.
Code
Di recente è stato annunciato il concetto di share group in Kafka, che permette di coprire molti casi d'uso delle code, pur non implementando una coda vera e propria. Questa possibilità è supportata nativamente in NATS.
Una coda in un sistema di streaming permette a più consumatori di ricevere, in maniera bilanciata, messaggi destinati a uno stesso “consumer group”. Pensiamo, ad esempio, a una coda di task da eseguire e a un gruppo di esecutori. Questi vogliono:
- Recuperare i task dalla coda per eseguirli, quando disponibili;
- Farlo in maniera continua: mentre un consumatore esegue il task N, gli altri consumatori devono poter accedere al task N+1;
- Possibilmente fare
ack
di messaggi singoli (differenza sostanziale dal meccanismo di ack tipico di Kafka).
Sia Kafka che NATS supportano il concetto di consumer group, ma con una differenza importante. In Kafka, un solo consumatore per consumer group può essere in ascolto su una partizione. Quindi, per parallelizzare la ricezione (e nel nostro caso i task) su un topic, devo:
- Creare più partizioni, con tutte le implicazioni del caso;
- Utilizzare un trick: eseguire
.unsubscribe()
mentre processo un messaggio. Questo provoca un ribilanciamento e, finché non chiamo di nuovo.subscribe()
, il client risulta offline.
Questo approccio non è ottimale: stabilire a priori il numero di partizioni non è banale, ed eseguire continue .subscribe()
e .unsubscribe()
stressa il cluster Kafka.
NATS non ha semplicemente questo problema: i consumatori ricevono il messaggio e, mentre lo processano, altri consumatori possono leggere i messaggi successivi (senza dover attendere l’ack). Se il messaggio viene ackato entro un tempo configurabile (es. qualche secondo), è considerato letto; altrimenti viene reinviato a un altro consumatore.
Nel caso dei consumatori pull, valgono considerazioni simili, con alcune differenze:
- La modalità pull è preferita rispetto alla push;
- In modalità push, NATS può “ignorare” un consumatore se non risponde abbastanza velocemente. Se arrivano molti messaggi contemporaneamente, i consumatori DEVONO implementare parallelismo, altrimenti verranno esclusi. La modalità pull consente ai consumatori di imporre il proprio ritmo.
Per implementare il meccanismo di coda in NATS:
- in modalità push è sufficiente usare lo stesso parametro queue per tutti i consumatori del gruppo;
- in modalità pull, basta specificare un nome comune durante la creazione (ovvero creare un durable consumer, specificando il parametro durable).
Anche qui, a differenza di Kafka, non esistono né partizioni né blocchi: quando un consumatore richiede un messaggio, questo gli viene assegnato direttamente.
Trasporto di dati
Trasferire file composti da molteplici chunks in Kafka non è banale. Infatti, l’uso di un topic di trasporto dati comporta la necessità, sia per il produttore che per il consumatore, di gestire la sincronizzazione.
Per esempio, un produttore che scrive un file su un topic deve tenere traccia della partizione, dello start_offset
e dell’end_offset
dei messaggi che compongono il file. Questo può avvenire tramite callback. A quel punto, il produttore comunica questi parametri al consumatore, che si posizionerà sulla partizione corretta e leggerà i messaggi da start_offset
a end_offset
, filtrando solo quelli relativi a quel file.
In NATS, tutto questo non serve: posso creare un subject dedicato, ad esempio data.<TASK_ID>
. Il consumatore che vuole scaricare i messaggi crea un consumer effimero su quel subject, legge tutti i messaggi e ricostruisce il file.
Se l’ordine è importante, basta abilitare il parametro ordered_consumer
, che garantisce che i messaggi vengano letti nello stesso ordine in cui sono stati scritti.
I messaggi in NATS vengono sempre prodotti in ordine sullo stesso subject; non esistono i concetti di Producer o TransactionalProducer tipici di Kafka o Redpanda.
In aggiunta, NATS supporta anche il paradigma Request/Reply.
Si tratta, in sostanza, di syntactic sugar: consente di inviare delle richieste su un subject, includendo un subject temporaneo per la risposta. I subscriber ricevono la richiesta, elaborano e rispondono sul subject indicato.
Il tutto richiede pochissime righe di codice. Per casi più complessi (es. risposte suddivise in più chunks), è comunque possibile implementare il paradigma manualmente, senza vincoli.
NATS fornisce anche Micro, che permette di creare microservizi basati su NATS. Questi si comportano come microservizi REST, ma sono discoverable via NATS e implementano, con sintactic sugar, il meccanismo di request-reply. L’aggiunta principale è la possibilità di renderli discoverable in modo programmatico.
Per quanto Kafka/Redpanda siano gli standard de facto nel mondo dello streaming in ambito industriale, NATS costituisce una promettente novità che, grazie alla sua flessibilità, permette di realizzare applicazioni che, con Kafka, risulterebbero quantomeno “cumbersome”, ovvero macchinose o complesse.
Le considerazioni in questo post sono il risultato dello studio che Dhiria ha dedicato a NATS, nell’ottica di individuare lo strumento giusto per il giusto problema.