SSD Nodes Learn 🎉 VPS da $5.50/mese
Guide Matt ConnorDi Matt Connor

NATS vs RabbitMQ vs Kafka su un solo VPS

Confronta NATS, RabbitMQ e Kafka su un solo server: garanzie di consegna, costi di RAM e disco, riavvio, controllo degli arretrati e quando conviene Postgres.

La risposta breve per un solo server

Una coda di messaggi su un singolo VPS è una scelta relativa alle garanzie di consegna, non alla velocità. Su un solo server il broker è raramente il collo di bottiglia, perché il codice dell'applicazione, il database e l'unico disco arrivano prima al limite. Scegli lo strumento il cui comportamento in caso di errore sei disposto ad accettare, quindi misura il server che hai realmente a disposizione.

Quattro opzioni, nell'ordine in cui la maggior parte dei lettori dovrebbe valutarle.

  • Usa il database che esegui già. Postgres con SELECT ... FOR UPDATE SKIP LOCKED è una coda di job funzionante e non aggiunge nuovi processi da monitorare.
  • Usa RabbitMQ quando ogni messaggio è un'unità di lavoro che deve essere confermata, ritentata per un numero limitato di volte e poi spostata in un'area che una persona possa esaminare.
  • Usa NATS quando i messaggi sono eventi a cui reagiscono più componenti del sistema. Abilita JetStream per gli eventi che devono sopravvivere a un riavvio.
  • Usa Kafka quando uno strumento downstream comunica soltanto tramite il protocollo Kafka. Su un solo server, questo è quasi l'unico motivo rimasto.

Il resto di questa guida spiega il ragionamento: quanto costa ogni opzione in termini di memoria e disco su un VPS di piccole dimensioni, cosa accade quando il server si riavvia e il comando esatto che mostra un arretrato prima che gli utenti ne risentano.

Che cosa significa realmente una garanzia di consegna

Al massimo una volta significa che il broker consegna il messaggio e poi lo elimina. Se non è connesso alcun consumer oppure un consumer si interrompe durante l'elaborazione, il messaggio viene perso e nessun componente lo segnala.

Almeno una volta significa che il consumer invia una conferma (un ack) dopo che l'elaborazione è riuscita. Finché il broker non riceve l'ack, conserva il messaggio e lo consegnerà nuovamente. La ridistribuzione impone che i gestori siano idempotenti: elaborare due volte lo stesso messaggio non deve addebitare due volte la carta. La consegna esattamente una volta, dall'origine alla destinazione, non è una garanzia fornita dal broker. Deriva da una chiave univoca nel database dell'applicazione.

Il replay è una proprietà distinta. Una coda elimina un messaggio dopo aver ricevuto l'ack. Un log conserva i messaggi per un periodo di retention, quindi un nuovo consumer può iniziare dall'inizio e leggere l'intera cronologia. Kafka e NATS JetStream sono log. RabbitMQ è una coda. Questa differenza influenza più architetture delle prestazioni in termini di throughput.

Il dead lettering si verifica quando un messaggio continua a non riuscire. Senza questo meccanismo, un messaggio avvelenato entra in un ciclo infinito, che appare come un worker occupato invece che come un worker in errore.

Inizia con Postgres e lascia che sia il broker a dimostrare il proprio valore

La maggior parte dei carichi di lavoro costituiti da una sola applicazione genera alcune migliaia di job in background al giorno. Questo volume può essere gestito da una tabella.

CREATE TABLE job (
  id        bigserial   PRIMARY KEY,
  payload   jsonb       NOT NULL,
  run_after timestamptz NOT NULL DEFAULT now(),
  attempts  int         NOT NULL DEFAULT 0
);
CREATE INDEX job_ready_idx ON job (run_after, id);

Un worker acquisisce un job all'interno di una transazione.

BEGIN;
SELECT id, payload
  FROM job
 WHERE run_after <= now()
 ORDER BY id
   FOR UPDATE SKIP LOCKED
 LIMIT 1;
-- run the work, then remove the row
DELETE FROM job WHERE id = $1;
COMMIT;

FOR UPDATE SKIP LOCKED è il punto essenziale. Blocca la riga restituita e ignora le righe già bloccate da un'altra transazione, quindi due worker non acquisiscono mai lo stesso job. Se un worker va in crash, Postgres annulla la transazione, rilascia il lock e rende la riga disponibile al worker successivo. Ottieni una consegna at-least-once, retry incrementando attempts e una tabella per i dead letter, usando la stessa durabilità per cui stai già pagando. Il backlog si verifica con una sola query: SELECT count(*) FROM job WHERE run_after <= now();

Dove smette di funzionare. Ogni acquisizione e ogni eliminazione sono operazioni di scrittura, quindi un'elevata frequenza di job lascia dietro di sé versioni obsolete delle righe; una tabella usata come coda è il caso classico in cui il bloat cresce più rapidamente di quanto autovacuum riesca a gestirlo. I job di lunga durata peggiorano il problema, perché una transazione mantenuta aperta per tutta la durata dell'elaborazione ritarda anche il vacuum horizon dell'intero database. Il polling aggiunge latenza e LISTEN con NOTIFY elimina il polling, ma non le scritture. Quando la tabella dei job è la più utilizzata oppure un secondo servizio deve ricevere gli stessi eventi, sposta il lavoro all'esterno. Questa scelta dipende anche da come viene eseguito il database, quindi stabilisci se il database è eseguito in Docker o sull'host prima di aggiungere un broker accanto al database.

Redis è l'altra soluzione che potresti già eseguire. Redis Streams offre consumer group con XADD e XREADGROUP, una lista dei messaggi pendenti per ogni gruppo e XAUTOCLAIM per riprendere il lavoro da un consumer che è terminato in modo anomalo. È compatto e rapido. Il limite concreto su un singolo server è il seguente: con l'impostazione comune appendfsync everysec, una perdita di alimentazione può causare la perdita di circa un secondo di scritture. Questo è accettabile per l'invalidazione della cache, ma non per i pagamenti. Se l'applicazione è costituita da un solo processo basato su SQLite in produzione su un VPS, lo stesso schema di acquisizione ed eliminazione funziona, anche se SQLite non dispone di un equivalente di SKIP LOCKED e ogni worker viene serializzato sull'unico lock di scrittura.

Routing dei subject in NATS core: nessuna persistenza

docker run -d --name nats \
  -p 4222:4222 -p 127.0.0.1:8222:8222 \
  nats:2.14 -m 8222

Ad agosto 2026 la linea corrente del server è la 2.14. -m 8222 abilita la porta di monitoraggio HTTP, disabilitata per impostazione predefinita e priva di autenticazione. Per questo va associata a localhost, come indicato sopra.

NATS core offre una consegna al massimo una volta e non memorizza nulla. Un publisher invia un messaggio a un subject come orders.created e ogni subscriber il cui filtro corrisponde ne riceve una copia. Se non ci sono subscriber, il messaggio viene scartato e il publisher non riceve alcun errore, perché il compito del publisher termina quando il server accetta i byte. Un queue group, composto da più subscriber che condividono lo stesso nome di gruppo, fa sì che il server scelga un solo membro per ogni messaggio. In questo modo il lavoro viene distribuito senza memorizzare una coda.

L'occupazione di risorse consiste nello stato delle subscription e in un buffer di scrittura per ogni connessione. Dipende quindi dal numero di connessioni, non dal volume dei messaggi, e non si accumula nulla su disco. Anche il comportamento dopo un riavvio deriva da questo: i messaggi in transito vengono persi, i client si riconnettono autonomamente e non è necessario attendere alcuna fase di ripristino.

Non esiste un backlog da monitorare. È invece necessario monitorare le perdite. Quando un subscriber legge il proprio socket più lentamente di quanto il server vi scriva, il buffer del server per quel client si riempie. Se il client non si mette al passo entro il termine di scrittura, il server chiude l'intera connessione e incrementa un contatore.

curl -s http://localhost:8222/varz | jq '.slow_consumers, .connections, .in_msgs, .out_msgs'

Un valore slow_consumers in continuo aumento indica che i messaggi vengono scartati. È quindi necessario generare un alert su questo valore, invece di leggerlo una sola volta. NATS core è adatto ai messaggi il cui valore scade rapidamente: una metrica, un aggiornamento della presenza o un'invalidazione della cache che verrà comunque sostituita dall'evento successivo.

NATS JetStream: persistenza degli stream e replay nello stesso processo

JetStream non è un prodotto separato. È un sottosistema nello stesso binario, abilitato da un'unica opzione.

docker run -d --name nats \
  -p 4222:4222 -p 127.0.0.1:8222:8222 \
  -v nats-data:/data \
  nats:2.14 -js -sd /data -m 8222

-sd /data imposta la directory di archiviazione. Se la ometti, JetStream salva i dati in /tmp, con una persistenza esattamente pari a quella indicata dal nome. Crea uno stream con la CLI, inclusa nell'immagine nats-box.

docker run --rm -it --network host natsio/nats-box:latest \
  nats stream add ORDERS \
    --subjects 'orders.>' \
    --storage file \
    --retention limits \
    --max-age 72h \
    --max-bytes=1073741824 \
    --discard old \
    --defaults

Ogni limite è utile su un server di piccole dimensioni. --storage file determina quali dati sopravvivono a un arresto anomalo, perché uno stream in memoria non li conserva. --max-bytes=1073741824 limita lo stream a 1 GiB, espresso come quantità di byte scritti, mentre --discard old elimina i messaggi meno recenti quando viene raggiunto il limite, invece di rifiutare le nuove scritture. Se non imposti il limite, un publisher fuori controllo può riempire il disco. A quel punto si arresta anche il database, perché i due servizi condividono il disco.

Un consumer durevole conserva la propria posizione nello stream e la mantiene dopo un riavvio. Imposta --max-deliver sul consumer, in modo che un messaggio che fallisce sempre non venga riconsegnato indefinitamente. Quando un messaggio esaurisce i tentativi di consegna, JetStream pubblica un advisory su $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>. La sottoscrizione a quel subject consente di creare il percorso della dead letter queue che RabbitMQ fornisce come funzionalità integrata. In questo caso devi implementarlo direttamente.

Per visualizzare gli arretrati, esegui nats stream report per ottenere il numero dei messaggi archiviati e nats consumer report ORDERS per visualizzare, per ogni consumer, gli acknowledgement ancora attesi e i messaggi non elaborati. Il numero dei messaggi non elaborati è quello su cui impostare l'alert. Puoi verificare lo spazio occupato su disco con du -sh sulla directory di archiviazione. L'occupazione cresce finché un limite di retention non elimina i dati più vecchi.

RabbitMQ: confermare ogni messaggio, accantonare gli errori

docker run -d --name rabbitmq \
  -p 5672:5672 -p 127.0.0.1:15672:15672 \
  -v rabbitmq-data:/var/lib/rabbitmq \
  rabbitmq:4-management

Ad agosto 2026 la serie corrente è la 4.3. La porta 5672 usa AMQP (advanced message queuing protocol), mentre la porta 15672 espone l'interfaccia di gestione. Mantieni l'interfaccia in ascolto su localhost e raggiungila tramite un tunnel SSH.

Dichiara le code impostando l'argomento x-queue-type su quorum; il valore predefinito è ancora classic. Le quorum queue sono sempre durabili e scrivono i dati su disco prima di eseguire qualsiasi altra operazione. Su un singolo nodo ottieni quindi un comportamento univoco, senza una combinazione di opzioni durabili e transitorie. Imposta la destinazione dei messaggi non recapitabili tramite una policy.

docker exec rabbitmq rabbitmqctl set_policy DLX ".*" \
  '{"dead-letter-exchange":"my-dlx", "dead-letter-routing-key":"my-routing-key"}' \
  --apply-to queues --priority 7

Un messaggio viene spostato nella dead letter exchange per quattro motivi: un consumer lo rifiuta con basic.reject o basic.nack e requeue è impostato su false; scade il TTL (time to live) del messaggio; la coda supera un limite di lunghezza; oppure viene superato il delivery limit della quorum queue. Da RabbitMQ 4.0 questo limite è 20 per impostazione predefinita. Un handler che genera un'eccezione e invia un nack ritenta quindi il messaggio 20 volte, poi lo inoltra alla dead letter exchange invece di entrare in un ciclo infinito.

La memoria è uno degli aspetti con cui RabbitMQ sorprende più spesso su un VPS di piccole dimensioni. L'high watermark predefinito è 0.6 della RAM disponibile. Quando il nodo supera questa soglia, RabbitMQ blocca ogni connessione che pubblica messaggi. L'applicazione non riceve un errore. Riceve un'operazione di pubblicazione che non restituisce mai il controllo, e il problema appare come un blocco nel codice dell'applicazione. Il log di avvio stampa il valore calcolato dal nodo:

Memory high watermark set to 1024 MiB (1073741824 bytes) of 8192 MiB (8589934592 bytes) total

Anche l'allarme relativo al disco blocca i publisher nello stesso modo quando lo spazio libero scende sotto 50 MB, valore predefinito. Le quorum queue aggiungono un proprio fabbisogno: la documentazione calcola almeno 32 byte di metadati in memoria per messaggio, circa 1 MB ogni 30.000 messaggi, e raccomanda una quantità di RAM pari ad almeno tre volte la dimensione effettiva del write-ahead log. Il limite del WAL è 512 MiB per impostazione predefinita, quindi questa sola raccomandazione richiede 1.5 GB. Su un server con 2 GB, riduci questo valore in rabbitmq.conf invece di fare affidamento sul fatto che il valore predefinito sia sufficiente.

raft.wal_max_size_bytes = 64000000
vm_memory_high_watermark.relative = 0.5

Il backlog è composto da due numeri. La coppia indica quale problema si è verificato.

docker exec rabbitmq rabbitmqctl list_queues name messages messages_ready messages_unacknowledged

messages_ready indica i messaggi in attesa di un consumer. messages_unacknowledged indica i messaggi consegnati ma mai confermati. Un conteggio crescente dei messaggi non confermati accanto a un conteggio stabile dei messaggi pronti indica che i worker hanno prelevato i processi ma hanno smesso di completarli. Si tratta di un problema diverso da una coda semplicemente in ritardo.

Kafka su un singolo server e quando non conviene più

KAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)"
bin/kafka-storage.sh format --standalone -t $KAFKA_CLUSTER_ID -c config/server.properties
bin/kafka-server-start.sh config/server.properties

Questa è la guida rapida per Kafka 4.3.1, aggiornata ad agosto 2026, eseguito in modalità KRaft (Kafka Raft, il controller integrato che ha sostituito ZooKeeper in Kafka 4.0). L'equivalente in un container è apache/kafka:4.3.1.

Lo script di avvio imposta export KAFKA_HEAP_OPTS="-Xmx1G -Xms1G" se non l'hai configurata manualmente. Di conseguenza, il broker riserva 1 GB di heap Java prima di archiviare un solo messaggio e richiede altra RAM libera per la page cache da cui legge. Su una VPS da 2 GB, la tua applicazione deve quindi condividere la memoria rimanente con la JVM.

La retention è la sorpresa successiva. log.retention.hours ha come valore predefinito 168, cioè sette giorni, mentre log.retention.bytes ha come valore predefinito -1, che significa nessun limite di dimensione. Kafka conserva i messaggi per l'intera finestra, indipendentemente dal fatto che ogni consumer li abbia letti. Questa è la funzionalità che ti serve, ma su un disco di piccole dimensioni diventa anche la causa del problema. Imposta quindi un limite in byte per ogni topic prima di verificarlo sul campo.

Veniamo ora al punto. Un singolo broker implica un replication factor pari a 1, quindi acks=all corrisponde a un solo fsync su un solo disco. Ottieni la durabilità di una singola macchina, con il costo operativo di un broker JVM e di un controller. Le partizioni consentono il parallelismo tra broker che non hai. La replica, la rack awareness e le altre funzionalità pensate per un'infrastruttura distribuita restano inutilizzate. JetStream offre lo stesso replay duraturo sulla stessa macchina usando una frazione della memoria. In questo scenario Kafka resta giustificato per due motivi: uno strumento a valle supporta solo il protocollo Kafka, ad esempio per il change data capture con Debezium o per il caricamento in un sistema di analisi, oppure vuoi riprodurre in scala ridotta una topologia di produzione. Prevedere di crescere fino a un cluster significa pianificare l'acquisto di altre macchine. Fino ad allora, il compromesso è lo stesso di eseguire k3s su un singolo nodo: paghi la complessità di un cluster per l'affidabilità di un solo nodo.

Il backlog in Kafka è il consumer lag.

bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group my-group

Leggi la colonna LAG, che per ogni partizione è LOG-END-OFFSET meno CURRENT-OFFSET. Se il lag aumenta su una partizione mentre sulle altre resta stabile, è probabile che la distribuzione delle chiavi sia sbilanciata: tutti i messaggi con la stessa chiave finiscono nella stessa partizione e vengono gestiti da un solo consumer.

Cosa accade quando il server viene riavviato

Core NATS perde tutti i dati in transito e torna operativo immediatamente, perché non c'è nulla da recuperare. JetStream ricarica gli stream e le posizioni dei consumer dalla directory di archiviazione, quindi i consumer riprendono dall'offset raggiunto. RabbitMQ recupera dal disco le quorum queue, mentre le queue transient classiche e tutti i messaggi pubblicati senza la modalità di consegna persistente vengono persi. Kafka rielabora i segmenti del log all'avvio. Dopo un arresto non regolare, questa scansione di ripristino può richiedere alcuni minuti su un disco di piccole dimensioni prima che il broker accetti una connessione.

È opportuno impostare due elementi. Assegna al container una policy di riavvio (restart: unless-stopped) oppure abilita l'unità systemd, in modo che il broker torni operativo automaticamente dopo il riavvio dovuto a un aggiornamento del kernel. Gestisci quindi l'ordine di avvio. Un broker che diventa pronto venti secondi dopo l'applicazione rifiuterà le prime connessioni. Alcune librerie client terminano il processo invece di ritentare. Imposta una dipendenza dell'applicazione dal broker tramite healthcheck di Compose che ritardano l'avvio del servizio dipendente finché il broker non è pronto.

Costi sul tuo VPS, misurati invece di citati

I valori di throughput pubblicati sono misurati su hardware che non hai a disposizione, in genere un server con più core e storage NVMe locale. Considerali un limite superiore e misura il tuo server.

docker stats --no-stream
free -m
sudo du -sh /var/lib/docker/volumes/*/_data

Esegui questi test con il broker inattivo, poi ripetili con il traffico reale. La differenza tra i due risultati determina se il broker può funzionare insieme alla tua applicazione. Per una stima preliminare del throughput minimo, usa il generatore di carico del progetto interessato invece dei dati riportati in un blog: nats bench pub test --msgs 100000 --clients 2 per NATS, bin/kafka-producer-perf-test.sh per Kafka e PerfTest per RabbitMQ. Eseguire il generatore sullo stesso VPS misura insieme il broker e il generatore. È accettabile, purché sia dichiarato quando riporti il risultato.

Per tutti questi broker vale lo stesso limite massimo. Ogni opzione di persistenza attende fsync. Su un VPS con storage collegato tramite rete, il limite è quindi imposto dal disco e sostituire il broker non lo sposta.

Tre carichi di lavoro e la coda di messaggi più adatta a ciascuno

  1. Job in background per un’applicazione web, ad esempio per inviare email, ridimensionare immagini o distribuire webhook. Inizia con Postgres e SKIP LOCKED. Passa a RabbitMQ con quorum queues quando ti servono conferme per singolo messaggio, un limite di consegna e una dead letter queue che puoi ispezionare senza implementare tu questa logica, oppure quando la tabella dei job è diventata la tabella più utilizzata del database.
  2. Eventi a cui reagiscono diversi servizi interni, nei casi in cui un messaggio perso venga rapidamente sostituito da uno più recente. Usa Core NATS, con i subject come schema di routing e i queue group quando serve distribuire il lavoro. Aggiungi uno stream JetStream per il ristretto insieme di subject che deve sopravvivere a un riavvio e lascia il resto in memoria.
  3. Un log degli eventi che i consumer leggono dall’inizio, per conservare un audit trail, ricostruire un read model o alimentare in seguito sistemi di analytics. Usa JetStream con file storage e un limite esplicito in byte. Scegli Kafka solo quando uno strumento a valle richiede il protocollo Kafka e considera l’heap della JVM il costo della compatibilità.

Su un singolo server, il costo della scelta sbagliata non è il throughput. È il ripristino alle tre del mattino, quando devi sapere se i messaggi esistono ancora. Scegli in base a questo.

FAQ

Posso eseguire Kafka su un VPS da 2 GB?

Si avvia, ma le risorse sono limitate. bin/kafka-server-start.sh imposta KAFKA_HEAP_OPTS="-Xmx1G -Xms1G" quando non lo hai sovrascritto, quindi la JVM riserva 1 GB prima di memorizzare qualsiasi messaggio. Kafka si basa inoltre sulla memoria libera residua per la page cache. Se esegui anche l'applicazione e il database sullo stesso server, inizierai a usare la swap. Otterrai anche un replication factor pari a 1. Questo significa che acks=all consiste in un solo fsync su un solo disco. In questo modo sostieni i costi operativi di Kafka senza ottenere il relativo modello di durabilità. NATS JetStream offre il replay persistente sullo stesso hardware usando molta meno memoria.

Ho bisogno di una coda di messaggi se uso già Postgres?

Spesso no. Una lettura della tabella job con SELECT ... FOR UPDATE SKIP LOCKED all'interno di una transazione offre consegna at-least-once, worker concorrenti sicuri, retry e una tabella dead letter. Non richiede un servizio aggiuntivo da monitorare e usa backup che esegui già. I segnali che indicano la necessità di passare a una soluzione diversa sono specifici: la tabella della coda diventa il carico di scrittura più intenso e autovacuum rimane indietro; i job di lunga durata mantengono aperte le transazioni e bloccano vacuum per l'intero database; oppure un secondo servizio deve consumare gli stessi eventi in modo indipendente.

Devo usare NATS JetStream o RabbitMQ per i job in background?

Usa RabbitMQ se vuoi che il riconoscimento per messaggio, un limite di consegna e l'instradamento verso una dead letter siano comportamenti integrati. Le quorum queue sono sempre persistenti. A partire da RabbitMQ 4.0, il limite di consegna predefinito è 20. Una policy invia i messaggi che hanno raggiunto il limite a un dead letter exchange, che puoi svuotare e ispezionare. Usa JetStream se gli stessi eventi devono poter essere riprodotti in seguito da altri consumer, perché uno stream conserva i messaggi dopo il riconoscimento, mentre una queue non lo fa. Con JetStream devi impostare --max-deliver e creare autonomamente il percorso dead letter a partire dall'advisory $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>.

Come posso capire quanto sono in ritardo i miei consumer?

Ogni broker dispone di un comando specifico. In RabbitMQ, rabbitmqctl list_queues name messages messages_ready messages_unacknowledged separa il lavoro in attesa di un consumer da quello consegnato ma mai riconosciuto. In JetStream, nats consumer report <stream> mostra i messaggi non elaborati e i riconoscimenti ancora pendenti per ogni consumer. In Kafka, kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group <group> stampa una colonna LAG per ogni partizione. Core NATS non ha un backlog da leggere, perché non memorizza nulla. Devi quindi controllare il contatore slow_consumers su http://localhost:8222/varz: conta le connessioni chiuse dal server perché i consumer erano rimasti indietro. Questo indica una perdita di messaggi.

#nats#rabbitmq#kafka#message-queue#architecture