NATS vs RabbitMQ vs Kafka op één VPS: wat kiezen?
Welke message queue past op een enkele server? Ontdek de verschillen in aflevergaranties, geheugengebruik en herstartgedrag. Leer wanneer Postgres volstaat en wanneer NATS wint.
Het korte antwoord voor één server
Een message queue op één VPS is een beslissing over aflevergaranties, niet over snelheid. Op een enkele machine is de broker zelden de bottleneck, omdat uw applicatiecode, uw database en uw schijf daar eerder tegenaan lopen. Kies de tool waarvan u het faalgedrag accepteert en meet vervolgens de prestaties van de machine die u daadwerkelijk gebruikt.
Vier opties, in de volgorde waarin de meeste lezers ze zouden moeten overwegen.
- Gebruik de database die u al draait. Postgres met
SELECT ... FOR UPDATE SKIP LOCKEDis een werkende job queue en voegt geen nieuw proces toe dat u moet monitoren. - Gebruik RabbitMQ wanneer elk bericht een werkeenheid is die moet worden bevestigd, een beperkt aantal keren opnieuw moet worden geprobeerd en vervolgens moet worden geparkeerd op een plek waar een mens ernaar kan kijken.
- Gebruik NATS wanneer berichten gebeurtenissen zijn waar verschillende onderdelen van uw systeem op reageren. Schakel JetStream in voor de gebeurtenissen die een herstart moeten overleven.
- Gebruik Kafka wanneer een downstream-tool alleen het Kafka-protocol spreekt. Op één server is dat zo ongeveer de enige overgebleven reden.
De rest van deze handleiding geeft de onderbouwing: wat elke optie kost aan geheugen en schijfruimte op een kleine VPS, wat er gebeurt wanneer de machine opnieuw opstart en het exacte commando dat een backlog toont voordat uw gebruikers er iets van merken.
Wat een aflevergarantie daadwerkelijk betekent
At most once betekent dat de broker het bericht overdraagt en vervolgens vergeet. Als er geen consumer is verbonden, of als een consumer tijdens de verwerking uitvalt, is het bericht verloren en wordt dit nergens gerapporteerd.
At least once betekent dat de consumer een bevestiging (een ack) stuurt nadat het werk succesvol is afgerond. Totdat die ack binnenkomt, behoudt de broker het bericht en zal deze het opnieuw aanbieden. Vanwege deze herhaalde aflevering moeten uw handlers idempotent zijn: het tweemaal verwerken van hetzelfde bericht mag er niet toe leiden dat een kaartbetaling twee keer wordt uitgevoerd. Exactly once, end-to-end, is geen garantie die een broker u biedt. Dit realiseert u door middel van een unieke sleutel in uw eigen database.
Replay is een afzonderlijke eigenschap. Een queue verwijdert een bericht zodra het is bevestigd. Een log bewaart het bericht gedurende een retentieperiode, zodat een nieuwe consumer bij het begin kan starten en de volledige geschiedenis kan uitlezen. Kafka en NATS JetStream zijn logs. RabbitMQ is een queue. Dat verschil is bepalender voor architecturen dan de doorvoersnelheid.
Dead lettering is wat er gebeurt met een bericht dat herhaaldelijk faalt. Zonder dit mechanisme blijft een 'poison message' eindeloos in een lus hangen, waarbij de lus eruitziet als een bezige worker in plaats van een defecte.
Begin met Postgres en laat de broker zichzelf bewijzen
De meeste workloads voor enkele applicaties verwerken enkele duizenden achtergrondtaken per dag. Dat past in één tabel.
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);Een worker claimt één taak binnen een transactie.
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 is de hele truc. Het vergrendelt de rij die het retourneert en slaat elke rij over die een andere transactie al heeft vergrendeld, zodat twee workers nooit dezelfde taak claimen. Als een worker crasht, breekt Postgres de transactie af, wordt de lock vrijgegeven en wordt de rij zichtbaar voor de volgende worker. U krijgt at-least-once delivery, retries door attempts te verhogen, en een dead letter-tabel, allemaal op basis van de duurzaamheid waar u al voor betaalt. De backlog is één query: SELECT count(*) FROM job WHERE run_after <= now();
Waar het stopt met werken. Elke claim en delete is een schrijfactie, dus een hoog aantal taken laat dode rijversies achter, en een wachtrijtabel is het klassieke voorbeeld van bloat die sneller groeit dan autovacuum. Lange taken verergeren dit, omdat een transactie die openblijft voor de duur van het werk ook de vacuum-horizon voor de gehele database tegenhoudt. Polling voegt latentie toe, en LISTEN met NOTIFY verwijdert de polling, maar niet de schrijfacties. Wanneer de taken-tabel de drukste tabel is die u heeft, of wanneer een tweede service dezelfde events nodig heeft, verplaats het werk dan. Die keuze hangt samen met hoe de database zelf is uitgerold, dus bepaal of de database in Docker of op de host draait voordat u er een broker naast plaatst.
Redis is het andere onderdeel dat u wellicht al gebruikt. Redis Streams bieden consumer groups met XADD en XREADGROUP, een pending list per groep, en XAUTOCLAIM om werk terug te nemen van een consumer die is uitgevallen. Het is klein en snel. Het eerlijke nadeel op één server: met de standaard appendfsync everysec-instelling kan een stroomstoring ongeveer een seconde aan schrijfacties doen verliezen. Dat is acceptabel voor cache-invalidation, maar onjuist voor betalingen. Als uw applicatie een enkel proces is dat is gebouwd rond SQLite in productie op een VPS, werkt hetzelfde claim-and-delete-patroon, hoewel SQLite geen equivalent heeft van SKIP LOCKED en elke worker serialiseert op de ene write lock.
NATS core: subject-routing zonder geheugen
docker run -d --name nats \
-p 4222:4222 -p 127.0.0.1:8222:8222 \
nats:2.14 -m 8222Sinds augustus 2026 is de huidige serverversie 2.14. -m 8222 schakelt de HTTP-monitoringpoort in, die standaard uitgeschakeld is en geen authenticatie heeft; bind deze daarom aan localhost zoals hierboven beschreven.
Core NATS is 'at most once' en slaat niets op. Een publisher verstuurt naar een subject zoals orders.created, en elke subscriber wiens filter overeenkomt, ontvangt een kopie. Als er niemand geabonneerd is, wordt het bericht verwijderd en ziet de publisher geen foutmelding, omdat de taak van de publisher voltooid is zodra de server de bytes heeft geaccepteerd. Een queue group (meerdere subscribers die één groepsnaam delen) zorgt ervoor dat de server per bericht één lid kiest, wat de werklast verdeelt zonder een wachtrij op te slaan.
De footprint bestaat uit de abonnementsstatus plus een schrijfbuffer voor elke verbinding; het systeem houdt dus het aantal verbindingen bij in plaats van het berichtvolume, en er hoopt zich niets op de schijf op. Het gedrag na een herstart vloeit hieruit voort: berichten die op dat moment werden verwerkt zijn verloren, clients maken zelf opnieuw verbinding en er is geen herstelstap waar op gewacht moet worden.
Er is geen backlog om te monitoren, dus controleer op verlies. Wanneer een subscriber zijn socket langzamer leest dan de server ernaar schrijft, raakt de buffer van de server voor die client vol. Als de client de achterstand niet heeft ingehaald voor de schrijfdeadline, sluit de server de volledige verbinding en verhoogt een teller.
curl -s http://localhost:8222/varz | jq '.slow_consumers, .connections, .in_msgs, .out_msgs'Een slow_consumers-waarde die blijft stijgen betekent dat er berichten verloren gaan; stel daarom een alert in op deze waarde in plaats van deze slechts eenmalig uit te lezen. Core NATS is geschikt voor berichten waarvan de waarde snel verloopt: een metriek, een aanwezigheidsupdate of een cache-invalidation die door het volgende event toch zal worden overschreven.
NATS JetStream: duurzame streams en replay in hetzelfde proces
JetStream is geen tweede product. Het is een subsysteem in hetzelfde binaire bestand, ingeschakeld met één vlag.
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 stelt de opslagmap in. Laat u dit weg, dan slaat JetStream de gegevens op onder /tmp, wat precies zo duurzaam is als het klinkt. Maak een stream aan met de CLI, die wordt meegeleverd in de nats-box image.
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 \
--defaultsElke limiet daar verdient zijn plek op een kleine server. --storage file is wat een crash overleeft, aangezien een memory stream dat niet doet. --max-bytes=1073741824 begrenst de stream op 1 GiB, geschreven als een byte-aantal, en --discard old verwijdert de oudste berichten wanneer de limiet is bereikt in plaats van nieuwe schrijfacties te weigeren. Laat u de limiet weg, dan vult een op hol geslagen publisher de schijf, waarna uw database ook stopt omdat deze dezelfde schijf deelt.
Een durable consumer behoudt zijn eigen positie in de stream en onthoudt deze na een herstart. Stel --max-deliver in op de consumer zodat een bericht dat altijd faalt niet eeuwig opnieuw wordt afgeleverd. Wanneer een bericht geen afleverpogingen meer over heeft, publiceert JetStream een adviesbericht op $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>. Door u op dat subject te abonneren, bouwt u het dead letter-pad dat RabbitMQ u als functie aanbiedt. Dat is echt werk dat u zelf schrijft.
Om een backlog te zien, voert u nats stream report uit voor het aantal opgeslagen berichten en nats consumer report ORDERS voor openstaande bevestigingen en onverwerkte berichten per consumer. Het aantal onverwerkte berichten is de waarde waarop u een alarm moet instellen. De schijfbelasting is zichtbaar met du -sh op de opslagmap, en deze groeit totdat een retentielimiet de data inkort.
RabbitMQ: bevestig elk bericht, parkeer de fouten
docker run -d --name rabbitmq \
-p 5672:5672 -p 127.0.0.1:15672:15672 \
-v rabbitmq-data:/var/lib/rabbitmq \
rabbitmq:4-managementSinds augustus 2026 is de huidige serie 4.3. Poort 5672 is AMQP (advanced message queuing protocol) en 15672 is de beheerinterface. Houd de interface op localhost en benader deze via een SSH-tunnel.
Declareer wachtrijen met het argument x-queue-type ingesteld op quorum; de standaardwaarde is nog steeds classic. Quorum queues zijn altijd persistent en schrijven gegevens naar de schijf voordat ze iets anders doen. Op één node krijgt u daardoor één duidelijk gedrag in plaats van een matrix van persistente en tijdelijke opties. Stel het doel voor dead-letter-berichten in met een policy.
docker exec rabbitmq rabbitmqctl set_policy DLX ".*" \
'{"dead-letter-exchange":"my-dlx", "dead-letter-routing-key":"my-routing-key"}' \
--apply-to queues --priority 7Een bericht wordt om vier redenen naar de dead-letter-exchange verplaatst: een consumer wijst het af met basic.reject of basic.nack waarbij requeue op false staat, de per-bericht TTL (time to live) verloopt, de wachtrij overschrijdt een lengtelimiet, of het overschrijdt de afleverlimiet van de quorum queue. Die limiet is vanaf RabbitMQ 4.0 standaard 20. Een handler die een fout genereert en het bericht afwijst (nack), probeert het twintig keer opnieuw en geeft het bericht daarna door aan de dead-letter-exchange in plaats van in een oneindige lus te blijven hangen.
Geheugengebruik is waar RabbitMQ op een kleine VPS vaak verrast. De standaard high watermark is 0,6 van het beschikbare RAM-geheugen. Wanneer de node deze grens overschrijdt, blokkeert RabbitMQ elke verbinding die berichten publiceert. Uw applicatie ontvangt geen foutmelding. De publicatie-actie keert simpelweg niet terug, wat in uw eigen code als een hangend proces wordt waargenomen. Het opstartlogboek toont het getal dat de node heeft berekend:
Memory high watermark set to 1024 MiB (1073741824 bytes) of 8192 MiB (8589934592 bytes) totalHet schijfalarm blokkeert publishers op dezelfde wijze wanneer de vrije ruimte standaard onder de 50 MB zakt. Quorum queues voegen hier hun eigen berekening aan toe: de documentatie rekent met ten minste 32 bytes aan metadata in het geheugen per bericht, ongeveer 1 MB per 30.000 berichten, en adviseert ten minste drie keer de effectieve write-ahead log-grootte in het RAM-geheugen. De WAL-limiet is standaard 512 MiB, dus alleen al dat advies vraagt om 1,5 GB. Verlaag dit op een server met 2 GB in rabbitmq.conf in plaats van te hopen dat de standaardinstelling past.
raft.wal_max_size_bytes = 64000000
vm_memory_high_watermark.relative = 0.5De backlog bestaat uit twee getallen; dit paar vertelt u welk type fout er optreedt.
docker exec rabbitmq rabbitmqctl list_queues name messages messages_ready messages_unacknowledgedmessages_ready wacht op een consumer. messages_unacknowledged is afgeleverd maar nooit bevestigd (ack). Een stijgend aantal niet-bevestigde berichten naast een stabiel aantal gereedstaande berichten betekent dat uw workers de taken hebben opgepakt maar zijn gestopt met het afronden ervan. Dit is een ander type bug dan een wachtrij die simpelweg achterloopt.
Kafka op één server, en waar het ophoudt zinvol te zijn
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.propertiesDit is de quickstart voor Kafka 4.3.1, actueel per augustus 2026, draaiend in KRaft-modus (Kafka Raft, de ingebouwde controller die ZooKeeper verving in Kafka 4.0). Het container-equivalent is apache/kafka:4.3.1.
Het startscript stelt export KAFKA_HEAP_OPTS="-Xmx1G -Xms1G" in wanneer u dit zelf niet heeft gedaan. De broker reserveert hierdoor een Java-heap van 1 GB voordat er ook maar één bericht is opgeslagen. Daarnaast is er vrije RAM nodig voor de page cache waaruit wordt gelezen. Op een VPS van 2 GB concurreert uw applicatie vervolgens met de JVM om de resterende ruimte.
Retentie is de volgende verrassing. log.retention.hours staat standaard op 168, wat zeven dagen is, en log.retention.bytes staat standaard op -1, wat betekent dat er geen enkele groottelimiet is. Kafka bewaart berichten gedurende het gehele venster, ongeacht of elke consumer ze heeft gelezen. Dat is de functionaliteit waarvoor u kwam, maar op een kleine schijf is het ook de faalmodus. Stel daarom een bytelimiet per topic in voordat u hier tegenaan loopt.
Nu het eerlijke deel. Eén enkele broker betekent een replicatiefactor van 1, waardoor acks=all neerkomt op één fsync op één schijf. U krijgt de duurzaamheid van één machine, met de operationele kosten van een JVM-broker plus een controller. Partities bieden parallellisme over brokers die u niet heeft. Replicatie, rack awareness en de overige clusterfuncties blijven inactief. JetStream biedt u dezelfde duurzame replay op dezelfde server voor een fractie van het geheugen. Twee redenen rechtvaardigen Kafka hier nog steeds: een downstream-tool spreekt alleen het Kafka-protocol (change data capture met Debezium, of een analytics-loader), of u reproduceert een productie-topologie in miniatuurvorm. Plannen om te groeien naar een cluster is een plan om meer machines aan te schaffen. Tot die tijd is de afweging dezelfde als bij het draaien van k3s op een enkele node, waarbij u de complexiteit van een cluster betaalt voor de betrouwbaarheid van één node.
Backlog in Kafka is consumer lag.
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group my-groupLees de kolom LAG, wat LOG-END-OFFSET minus CURRENT-OFFSET is voor elke partitie. Als de lag op één partitie oploopt terwijl de andere stabiel blijven, wijst dit op een ongelijke key. Alle berichten met dezelfde key komen namelijk op dezelfde partitie terecht en één consumer verwerkt deze alleen.
Wat gebeurt er bij een herstart van de server
Core NATS verliest alle data die op dat moment in het geheugen staat en start direct op, omdat er niets te herstellen valt. JetStream laadt streams en consumer-posities opnieuw vanuit de opslagmap, waardoor consumers hervatten bij de laatst bekende offset. RabbitMQ herstelt quorum queues vanaf de schijf, terwijl klassieke tijdelijke queues en berichten die zonder de persistent delivery mode zijn gepubliceerd, verloren gaan. Kafka speelt bij het opstarten zijn logsegmenten opnieuw af; na een onverwachte afsluiting kan deze herstelscan op een kleine schijf minuten duren voordat de broker verbindingen accepteert.
Twee zaken zijn het waard om eenmalig in te stellen. Geef de container een restart policy (restart: unless-stopped) of schakel de systemd unit in, zodat de broker na een herstart voor een kernel-upgrade automatisch terugkeert. Houd daarnaast rekening met de opstartvolgorde: een broker die pas twintig seconden na uw applicatie gereed is, zal de eerste verbindingen weigeren, en sommige client-libraries sluiten zichzelf af in plaats van het opnieuw te proberen. Beperk de applicatie tot de broker met Compose healthchecks die een afhankelijke service tegenhouden totdat de broker gereed is.
Kosten op uw eigen VPS, gemeten in plaats van geschat
Gepubliceerde doorvoersnelheden worden gemeten op hardware waarover u niet beschikt, meestal een multi-core server met lokale NVMe. Beschouw deze cijfers als een bovengrens en meet uw eigen server.
docker stats --no-stream
free -m
sudo du -sh /var/lib/docker/volumes/*/_dataVoer deze tests uit terwijl de broker inactief is, en vervolgens opnieuw onder uw werkelijke verkeer. Het verschil tussen beide is het getal dat bepaalt of de broker naast uw applicatie past. Gebruik voor een ruwe ondergrens van de doorvoersnelheid de eigen load generator van elk project in plaats van iemands blogpost: nats bench pub test --msgs 100000 --clients 2 voor NATS, bin/kafka-producer-perf-test.sh voor Kafka en PerfTest voor RabbitMQ. Het draaien van de generator op dezelfde VPS meet de broker en de generator samen; dit is acceptabel zolang u dit vermeldt wanneer u het resultaat rapporteert.
Eén bovengrens geldt voor allemaal. Elke duurzame optie hier wacht op fsync, dus op een VPS met netwerkopslag bepaalt de schijf de limiet, en het wisselen van broker zal daar geen verandering in brengen.
Drie workloads en de message queue die elk daarvan vereist
- Achtergrondtaken voor een webapplicatie, zoals het versturen van e-mail, het wijzigen van de grootte van afbeeldingen of het afleveren van webhooks. Begin met Postgres en
SKIP LOCKED. Stap over op RabbitMQ met quorum queues wanneer u per-bericht bevestigingen (acks), een afleveringslimiet en een dead letter queue nodig heeft die u kunt inspecteren zonder zelf die logica te hoeven schrijven, of wanneer de tabel met taken de meest belaste tabel in de database is geworden. - Gebeurtenissen waar verschillende interne services op reageren, waarbij een verloren bericht snel wordt vervangen door een nieuwere. Gebruik core NATS, met subjects als routeringsschema en queue groups waar u werkverdeling nodig heeft. Voeg een JetStream stream toe voor de beperkte set subjects die een herstart moeten overleven, en laat de rest in het geheugen staan.
- Een event log waar consumers vanaf het begin uit lezen, voor een audit trail, het opnieuw opbouwen van een read model of het later voeden van analytics. Gebruik JetStream met bestandsopslag en een expliciete byte-limiet. Kies alleen voor Kafka wanneer een downstream-tool het Kafka-protocol vereist, en accepteer de JVM heap als de prijs voor die compatibiliteit.
De kosten van de verkeerde keuze op een enkele server zijn niet de doorvoersnelheid. Het is het herstel om drie uur 's ochtends, wanneer u moet weten of de berichten nog bestaan. Kies daarop.
FAQ
Kan ik Kafka draaien op een 2 GB VPS?
Het start, maar het wordt krap. bin/kafka-server-start.sh stelt KAFKA_HEAP_OPTS="-Xmx1G -Xms1G" in wanneer u dit niet heeft overschreven, waardoor de JVM 1 GB claimt voordat er ook maar één bericht is opgeslagen. Kafka is daarnaast afhankelijk van vrij geheugen voor de page cache. Voeg uw applicatie en database toe op dezelfde machine en u zult swap gaan gebruiken. U krijgt ook een replicatiefactor van 1, wat betekent dat acks=all neerkomt op één fsync op één schijf; u betaalt dus de operationele kosten van Kafka zonder de bijbehorende duurzaamheidsgaranties. NATS JetStream biedt duurzame replay op dezelfde hardware met aanzienlijk minder geheugenverbruik.
Heb ik een message queue nodig als ik al Postgres draai?
Vaak niet. Een job tabel-read met SELECT ... FOR UPDATE SKIP LOCKED binnen een transactie biedt at-least-once delivery, veilige gelijktijdige workers, retries en een dead letter-tabel, zonder extra service om te monitoren en met back-ups die u al maakt. De signalen om over te stappen zijn specifiek: de queue-tabel wordt uw zwaarste schrijfbelasting en autovacuum loopt achter, langlopende taken houden transacties open en blokkeren vacuum voor de gehele database, of een tweede service moet onafhankelijk dezelfde events consumeren.
Moet ik NATS JetStream of RabbitMQ gebruiken voor achtergrondtaken?
Kies RabbitMQ als u per-bericht-bevestiging, een afleverlimiet en dead letter-routing als ingebouwd gedrag wenst. Quorum queues zijn altijd duurzaam, de afleverlimiet staat vanaf RabbitMQ 4.0 standaard op 20, en een policy stuurt uitgeputte berichten naar een dead letter-exchange die u kunt uitlezen en inspecteren. Kies JetStream als dezelfde events later ook door andere consumers opnieuw moeten worden afgespeeld, aangezien een stream berichten bewaart na bevestiging, terwijl een queue dat niet doet. Bij JetStream stelt u --max-deliver in en bouwt u het dead letter-pad zelf op basis van de $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.> advisory.
Hoe zie ik hoever mijn consumers achterlopen?
Elke broker heeft hiervoor een eigen commando. Voor RabbitMQ scheidt rabbitmqctl list_queues name messages messages_ready messages_unacknowledged het werk dat wacht op een consumer van het werk dat is afgeleverd maar niet is bevestigd. Voor JetStream toont nats consumer report <stream> onverwerkte berichten en openstaande bevestigingen per consumer. Voor Kafka print kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group <group> een LAG kolom per partitie. Core NATS heeft geen backlog om uit te lezen, omdat het niets opslaat; monitor daarom de slow_consumers teller op http://localhost:8222/varz: deze telt het aantal verbindingen dat de server heeft gesloten omdat ze achterliepen, wat neerkomt op berichtverlies.