NATS, RabbitMQ hay Kafka trên một VPS?
So sánh NATS, RabbitMQ, Kafka và Postgres trên một VPS: bảo đảm giao nhận, chi phí memory/disk, cách restart, lệnh kiểm tra backlog và lúc Postgres phù hợp.
Câu trả lời ngắn gọn cho một server
Message queue trên một VPS là quyết định về bảo đảm giao nhận, không phải về tốc độ. Trên một máy đơn, broker hiếm khi là nút thắt cổ chai vì code ứng dụng, database và một disk duy nhất của bạn thường chạm giới hạn trước. Hãy chọn công cụ có cách xử lý khi lỗi mà bạn chấp nhận được, sau đó đo trên chính máy bạn đang có.
Bốn lựa chọn, theo thứ tự mà hầu hết người đọc nên cân nhắc.
- Dùng database bạn đã chạy sẵn. Postgres với
SELECT ... FOR UPDATE SKIP LOCKEDlà một job queue có thể vận hành được và không thêm process mới cần monitor. - Dùng RabbitMQ khi mỗi message là một đơn vị công việc cần được acknowledge, retry số lần có giới hạn, rồi chuyển đến một nơi mà con người có thể kiểm tra.
- Dùng NATS khi message là event mà nhiều phần trong hệ thống phản ứng theo. Bật JetStream cho những event phải tồn tại qua một lần restart.
- Dùng Kafka khi một tool downstream chỉ giao tiếp bằng Kafka protocol. Trên một server, đây gần như là lý do duy nhất còn lại.
Phần còn lại của guide giải thích lý do: mỗi lựa chọn tiêu tốn bao nhiêu memory và disk trên một VPS nhỏ, xử lý thế nào khi máy reboot, và command chính xác để hiển thị backlog trước khi người dùng nhận thấy vấn đề.
Ý nghĩa thực tế của cơ chế bảo đảm phân phối
At most once nghĩa là broker chuyển message đi rồi không theo dõi nữa. Nếu không có consumer đang kết nối, hoặc consumer dừng giữa chừng khi xử lý, message sẽ mất và không có thành phần nào báo lỗi.
At least once nghĩa là consumer gửi acknowledgement (ack) sau khi xử lý thành công. Trước khi nhận được ack, broker vẫn giữ message và sẽ phân phối lại message đó. Đây là lý do handler phải idempotent: xử lý cùng một message 2 lần không được khiến thẻ bị trừ tiền 2 lần. Exactly once từ đầu đến cuối không phải là cơ chế broker có thể cung cấp. Cơ chế này đến từ một unique key trong database của bạn.
Replay là một thuộc tính khác. Queue xóa message sau khi message được acknowledge. Log giữ message trong một retention window, nên consumer mới có thể bắt đầu từ đầu và đọc toàn bộ lịch sử. Kafka và NATS JetStream là log. RabbitMQ là queue. Khác biệt này ảnh hưởng đến nhiều kiến trúc hơn cả throughput.
Dead lettering xảy ra với message liên tục xử lý thất bại. Nếu không có cơ chế này, poison message sẽ lặp vô hạn, và vòng lặp đó trông giống một worker đang bận thay vì một worker bị lỗi.
Bắt đầu với Postgres và để broker tự chứng minh giá trị
Hầu hết workload của một ứng dụng đơn lẻ chỉ có vài nghìn background job mỗi ngày. Một table có thể đáp ứng mức này.
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);Worker nhận một job bên trong một transaction.
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 là toàn bộ điểm mấu chốt. Nó khóa row được trả về và bỏ qua mọi row mà transaction khác đã khóa, nên hai worker không bao giờ nhận cùng một job. Nếu worker bị crash, Postgres abort transaction của nó, lock được giải phóng và row trở nên khả dụng cho worker tiếp theo. Bạn có at-least-once delivery, cơ chế retry bằng cách tăng attempts và một dead letter table, tất cả đều dựa trên độ bền dữ liệu mà bạn vốn đã trả phí. Backlog chỉ cần một query: SELECT count(*) FROM job WHERE run_after <= now();
Khi nào cách này không còn phù hợp. Mỗi lần nhận và xóa job đều là một thao tác ghi, nên tốc độ job cao sẽ để lại nhiều phiên bản row đã chết. Queue table là trường hợp điển hình khiến bloat tăng nhanh hơn autovacuum. Job chạy lâu làm tình hình tệ hơn, vì transaction được giữ mở trong suốt thời gian xử lý cũng giữ lùi vacuum horizon của toàn bộ database. Polling làm tăng độ trễ, còn LISTEN kết hợp với NOTIFY loại bỏ polling nhưng không loại bỏ các thao tác ghi. Khi job table là table bận nhất của bạn, hoặc service thứ hai cần cùng các event đó, hãy đưa phần xử lý ra ngoài. Lựa chọn này phụ thuộc vào cách triển khai chính database, vì vậy hãy xác định database chạy trong Docker hay trên host trước khi thêm broker cạnh nó.
Redis là thành phần khác mà có thể bạn đã chạy sẵn. Redis Streams cung cấp consumer group với XADD và XREADGROUP, một pending list cho mỗi group, cùng XAUTOCLAIM để lấy lại công việc từ consumer đã bị dừng. Nó nhỏ gọn và nhanh. Điểm cần nói rõ khi chạy trên một máy: với setting appendfsync everysec phổ biến, mất điện có thể làm mất khoảng một giây dữ liệu ghi. Điều này phù hợp với cache invalidation nhưng không phù hợp với payment. Nếu application của bạn là một process đơn được xây dựng quanh SQLite trong môi trường production trên VPS, cùng pattern nhận và xóa vẫn hoạt động, dù SQLite không có cơ chế tương đương SKIP LOCKED và mọi worker phải lần lượt chờ cùng một write lock.
NATS core: định tuyến subject không lưu trữ
docker run -d --name nats \
-p 4222:4222 -p 127.0.0.1:8222:8222 \
nats:2.14 -m 8222Tính đến tháng 8 năm 2026, dòng server hiện tại là 2.14. -m 8222 bật cổng HTTP monitoring. Cổng này mặc định bị tắt và không có authentication, vì vậy hãy bind nó vào localhost như ở trên.
Core NATS có cơ chế gửi nhiều nhất một lần và không lưu trữ gì. Publisher gửi message đến một subject như orders.created, và mọi subscriber có filter khớp đều nhận được một bản sao. Nếu không có subscriber nào, message sẽ bị bỏ và publisher không nhận được lỗi, vì nhiệm vụ của publisher kết thúc khi server chấp nhận các byte. Queue group (nhiều subscriber dùng chung một tên group) khiến server chọn một member cho mỗi message. Cách này chia sẻ công việc mà không lưu trữ queue.
Mức sử dụng tài nguyên gồm subscription state và write buffer cho mỗi connection. Vì vậy, mức sử dụng tăng theo số connection chứ không theo khối lượng message, và không có gì tích lũy trên disk. Hành vi sau khi restart cũng xuất phát từ đặc điểm đó: các message đang in-flight sẽ mất, client tự reconnect và không có bước recovery nào cần chờ.
Không có backlog để theo dõi, vì vậy hãy theo dõi việc mất message. Khi một subscriber đọc socket chậm hơn tốc độ server ghi vào đó, buffer của server dành cho client sẽ đầy. Nếu client chưa bắt kịp trước write deadline, server sẽ đóng toàn bộ connection và tăng một counter.
curl -s http://localhost:8222/varz | jq '.slow_consumers, .connections, .in_msgs, .out_msgs'Một giá trị slow_consumers liên tục tăng có nghĩa là message đang bị bỏ, vì vậy hãy alert dựa trên giá trị này thay vì chỉ đọc một lần. Core NATS phù hợp với các message có giá trị hết hạn nhanh: metric, presence update hoặc cache invalidation mà event tiếp theo ohnehin sẽ thay thế.
NATS JetStream: stream bền vững và phát lại trong cùng một tiến trình
JetStream không phải là một sản phẩm thứ hai. Đây là một subsystem trong cùng binary, được bật bằng một flag.
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 đặt thư mục lưu trữ. Nếu bỏ qua tùy chọn này, JetStream sẽ lưu dữ liệu trong /tmp, đúng như tên gọi: độ bền dữ liệu rất kém. Tạo stream bằng CLI đi kèm trong image 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 \
--defaultsMỗi giới hạn ở đó đều cần thiết trên một server nhỏ. --storage file quyết định dữ liệu nào còn tồn tại sau crash, vì memory stream thì không. --max-bytes=1073741824 giới hạn stream ở mức 1 GiB, được ghi dưới dạng số byte, còn --discard old xóa các message cũ nhất khi đạt giới hạn thay vì từ chối các lần ghi mới. Nếu bỏ giới hạn này, một publisher bị runaway có thể làm đầy disk. Khi đó database cũng dừng vì dùng chung disk.
Durable consumer giữ vị trí riêng trong stream và giữ vị trí đó qua mỗi lần restart. Đặt --max-deliver cho consumer để một message luôn fail không bị redeliver vô hạn. Khi một message hết số lần delivery, JetStream publish một advisory lên $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>. Subscribe vào subject này là cách bạn tự xây dựng dead letter path, trong khi RabbitMQ cung cấp sẵn tính năng này. Phần đó bạn phải tự triển khai.
Để xem backlog, chạy nats stream report để xem số lượng message đã lưu và nats consumer report ORDERS để xem số acknowledgement còn chờ và số message chưa xử lý của từng consumer. Số message chưa xử lý là chỉ số cần đặt alarm. Dung lượng disk có thể xem bằng du -sh trên thư mục lưu trữ. Dung lượng này tăng cho đến khi retention limit dọn bớt dữ liệu.
RabbitMQ: acknowledge từng message, đưa các message lỗi vào hàng chờ riêng
docker run -d --name rabbitmq \
-p 5672:5672 -p 127.0.0.1:15672:15672 \
-v rabbitmq-data:/var/lib/rabbitmq \
rabbitmq:4-managementTính đến tháng 8 năm 2026, series hiện tại là 4.3. Cổng 5672 dùng cho AMQP (advanced message queuing protocol), còn cổng 15672 là giao diện quản trị. Giữ giao diện này chỉ trên localhost và truy cập qua SSH tunnel.
Khai báo queue với tập đối số x-queue-type đặt thành quorum; giá trị mặc định vẫn là classic. Quorum queue luôn durable và ghi dữ liệu xuống đĩa trước khi thực hiện việc khác. Vì vậy, trên một node, bạn có một hành vi nhất quán thay vì phải xử lý nhiều tổ hợp durable và transient. Đặt dead letter target bằng policy.
docker exec rabbitmq rabbitmqctl set_policy DLX ".*" \
'{"dead-letter-exchange":"my-dlx", "dead-letter-routing-key":"my-routing-key"}' \
--apply-to queues --priority 7Một message bị dead letter vì 4 lý do: consumer reject message bằng basic.reject hoặc basic.nack khi requeue được đặt thành false; TTL (time to live) của message hết hạn; queue vượt giới hạn độ dài; hoặc message vượt quá delivery limit của quorum queue. Từ RabbitMQ 4.0 trở đi, giới hạn này mặc định là 20. Vì vậy, handler bị exception rồi nack sẽ thử lại 20 lần, sau đó chuyển message đến dead letter exchange thay vì lặp vô hạn.
Memory là chỗ RabbitMQ thường gây bất ngờ trên VPS nhỏ. High watermark mặc định là 0.6 RAM khả dụng. Khi node vượt mức này, RabbitMQ block mọi connection đang publish. Ứng dụng của bạn không nhận được error. Nó nhận một lệnh publish không bao giờ return, khiến code của chính bạn trông như bị hang. Startup log in ra giá trị node đã tính:
Memory high watermark set to 1024 MiB (1073741824 bytes) of 8192 MiB (8589934592 bytes) totalDisk alarm cũng block publisher theo cách tương tự khi dung lượng trống giảm xuống dưới 50 MB theo mặc định. Quorum queue còn có phần tính toán riêng: tài liệu ước tính cần ít nhất 32 byte metadata trong memory cho mỗi message, tương đương khoảng 1 MB cho mỗi 30,000 message, và khuyến nghị RAM ít nhất gấp 3 lần kích thước write-ahead log hiệu dụng. Giới hạn WAL mặc định là 512 MiB, nên riêng khuyến nghị này đã yêu cầu 1.5 GB. Trên server 2 GB, hãy giảm giá trị này trong rabbitmq.conf thay vì hy vọng giá trị mặc định sẽ phù hợp.
raft.wal_max_size_bytes = 64000000
vm_memory_high_watermark.relative = 0.5Backlog gồm 2 con số. Cặp số này cho biết bạn đang gặp loại lỗi nào.
docker exec rabbitmq rabbitmqctl list_queues name messages messages_ready messages_unacknowledgedmessages_ready đang chờ consumer. messages_unacknowledged đã được deliver nhưng chưa bao giờ được ack. Số message chưa được acknowledge tăng trong khi số message ready không đổi nghĩa là worker đã nhận job nhưng không hoàn tất. Đây là một bug khác với trường hợp queue chỉ đang xử lý chậm.
Kafka trên một máy và khi nào nó không còn hợp lý
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Đó là quickstart cho Kafka 4.3.1, được cập nhật vào tháng 8 năm 2026, chạy ở chế độ KRaft (Kafka Raft, controller tích hợp đã thay thế ZooKeeper trong Kafka 4.0). Bản tương đương dùng container là apache/kafka:4.3.1.
Script khởi động đặt export KAFKA_HEAP_OPTS="-Xmx1G -Xms1G" khi bạn chưa tự đặt giá trị này. Vì vậy, broker dành trước 1 GB Java heap trước khi lưu một message nào, đồng thời cần thêm RAM trống cho page cache mà nó đọc dữ liệu từ đó. Trên VPS 2 GB, ứng dụng của bạn sẽ phải tranh phần RAM còn lại với JVM.
Retention là bất ngờ tiếp theo. log.retention.hours mặc định là 168, tức 7 ngày, còn log.retention.bytes mặc định là -1, tức không giới hạn kích thước. Kafka giữ message trong toàn bộ khoảng thời gian này, dù mọi consumer đã đọc chúng hay chưa. Đây là tính năng bạn cần, nhưng trên một disk nhỏ, nó cũng là nguyên nhân gây lỗi. Vì vậy, hãy đặt giới hạn byte cho từng topic trước khi gặp vấn đề.
Bây giờ là phần cần nhìn nhận thực tế. Một broker duy nhất có replication factor bằng 1, nên acks=all chỉ thực hiện một fsync trên một disk. Bạn nhận được độ bền dữ liệu của một máy, cùng chi phí vận hành của một broker JVM và một controller. Partition chỉ tạo thêm khả năng xử lý song song giữa các broker mà bạn không có. Replication, rack awareness và các tính năng khác dành cho cả fleet đều không hoạt động. JetStream cung cấp khả năng replay bền vững tương tự trên cùng máy, với lượng memory chỉ bằng một phần. Vẫn có 2 lý do để dùng Kafka ở đây: một công cụ downstream chỉ hỗ trợ Kafka protocol (ví dụ change data capture với Debezium hoặc analytics loader), hoặc bạn đang dựng mô phỏng thu nhỏ của topology production. Nếu dự định mở rộng thành cluster, bạn đang dự định mua thêm máy. Cho đến lúc đó, đánh đổi này giống với chạy k3s trên một node duy nhất, tức là bạn chịu độ phức tạp của cluster để có độ tin cậy của một node.
Backlog trong Kafka được gọi là consumer lag.
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group my-groupĐọc cột LAG. Giá trị này bằng LOG-END-OFFSET trừ CURRENT-OFFSET cho từng partition. Nếu lag tăng ở một partition trong khi các partition khác không đổi, nguyên nhân có thể là key bị phân bổ lệch. Mọi message có cùng key đều đi vào cùng một partition, và chỉ một consumer xử lý chúng.
Điều gì xảy ra khi máy chủ khởi động lại
Core NATS mất toàn bộ dữ liệu đang xử lý và khởi động lại ngay vì không có gì cần khôi phục. JetStream tải lại các stream và vị trí của consumer từ thư mục lưu trữ, nên consumer tiếp tục từ offset trước đó. RabbitMQ khôi phục quorum queue từ ổ đĩa, còn transient queue kiểu classic và mọi message được publish mà không bật persistent delivery mode sẽ bị mất. Kafka phát lại các log segment khi khởi động. Sau khi shutdown không sạch, quá trình quét để khôi phục có thể mất vài phút trên ổ đĩa nhỏ trước khi broker chấp nhận kết nối.
Có 2 việc nên cấu hình một lần. Đặt restart policy cho container (restart: unless-stopped) hoặc enable systemd unit để broker tự khởi động lại sau khi reboot do nâng cấp kernel. Sau đó xử lý thứ tự khởi động. Nếu broker sẵn sàng sau application 20 giây, broker sẽ từ chối các kết nối đầu tiên. Một số client library sẽ thoát thay vì retry. Dùng healthcheck của Compose để giữ service phụ thuộc chưa khởi động cho đến khi broker sẵn sàng để ràng buộc application với broker.
Chi phí trên VPS của bạn, đo thay vì lấy số liệu được công bố
Các số liệu throughput được công bố được đo trên phần cứng mà bạn không có, thường là server nhiều lõi với NVMe cục bộ. Hãy xem chúng là giới hạn trên và tự đo trên máy của bạn.
docker stats --no-stream
free -m
sudo du -sh /var/lib/docker/volumes/*/_dataHãy chạy các bài kiểm tra đó khi broker không có tải, sau đó chạy lại với lưu lượng thực tế của bạn. Khoảng chênh lệch giữa hai lần đo cho biết broker có thể chạy cùng ứng dụng của bạn hay không. Để ước tính mức throughput tối thiểu, hãy dùng load generator của chính từng project thay vì số liệu từ một bài blog nào đó: nats bench pub test --msgs 100000 --clients 2 cho NATS, bin/kafka-producer-perf-test.sh cho Kafka và PerfTest cho RabbitMQ. Chạy generator trên cùng VPS sẽ đo broker và generator cùng lúc. Cách này vẫn phù hợp nếu bạn ghi rõ điều đó khi báo cáo số liệu.
Tất cả các broker này đều có cùng một giới hạn. Mọi tùy chọn durable ở đây đều phải chờ fsync. Vì vậy, trên VPS dùng storage gắn qua network, disk sẽ quyết định giới hạn và đổi broker cũng không thay đổi được giới hạn đó.
Ba workload và message queue phù hợp với từng workload
- Job chạy nền cho một web application, chẳng hạn gửi email, resize ảnh hoặc gửi webhook. Bắt đầu với Postgres và
SKIP LOCKED. Chuyển sang RabbitMQ với quorum queue khi bạn cần ack cho từng message, giới hạn số lần delivery và dead letter queue có thể kiểm tra mà không phải tự viết logic đó, hoặc khi bảng job đã trở thành bảng bận nhất trong database. - Event mà nhiều service nội bộ cùng xử lý, trong đó message bị mất sẽ nhanh chóng được thay thế bằng message mới hơn. Dùng Core NATS, với subject làm cơ chế định tuyến và queue group khi cần chia sẻ việc xử lý. Thêm stream JetStream cho nhóm subject giới hạn cần được giữ lại sau khi restart, còn các subject khác để trong memory.
- Event log mà consumer đọc từ đầu, dùng cho audit trail, dựng lại read model hoặc cung cấp dữ liệu cho analytics về sau. Dùng JetStream với file storage và giới hạn byte rõ ràng. Chỉ chọn Kafka khi một công cụ downstream yêu cầu Kafka protocol, và chấp nhận JVM heap là chi phí cho khả năng tương thích đó.
Trên một server đơn lẻ, chọn sai không làm ảnh hưởng chủ yếu đến throughput. Vấn đề là quá trình recovery lúc 3 giờ sáng, khi bạn cần biết message còn tồn tại hay không. Hãy chọn theo tiêu chí đó.
FAQ
Tôi có thể chạy Kafka trên VPS 2 GB không?
Kafka sẽ khởi động được, nhưng tài nguyên sẽ rất hạn chế. bin/kafka-server-start.sh đặt KAFKA_HEAP_OPTS="-Xmx1G -Xms1G" khi bạn chưa ghi đè giá trị này, nên JVM chiếm 1 GB trước khi lưu bất kỳ message nào. Kafka cũng cần phần memory còn trống bên ngoài mức đó cho page cache. Nếu chạy thêm application và database trên cùng máy, hệ thống sẽ bắt đầu dùng swap. Bạn cũng chỉ có replication factor 1. Điều đó nghĩa là acks=all chỉ là một fsync trên một disk. Bạn phải chịu chi phí vận hành của Kafka nhưng không có mô hình durability của nó. NATS JetStream cung cấp khả năng replay durable trên cùng phần cứng với mức sử dụng memory thấp hơn nhiều.
Tôi có cần message queue nếu đã chạy Postgres không?
Thường là không. Đọc bảng job bằng SELECT ... FOR UPDATE SKIP LOCKED bên trong một transaction cung cấp cơ chế phân phối at-least-once, worker chạy đồng thời an toàn, retry và dead letter table. Bạn không cần thêm service để monitor và có thể dùng các bản backup đang chạy sẵn. Các dấu hiệu cho thấy cần tách queue ra là: bảng queue trở thành workload ghi nặng nhất và autovacuum bị chậm, các job chạy lâu giữ transaction mở và chặn vacuum của toàn bộ database, hoặc service thứ hai cần độc lập consume cùng các event.
Tôi nên dùng NATS JetStream hay RabbitMQ cho background job?
Dùng RabbitMQ nếu bạn cần acknowledgement theo từng message, delivery limit và dead letter routing được tích hợp sẵn. Quorum queue luôn durable. Delivery limit mặc định là 20 từ RabbitMQ 4.0. Một policy có thể chuyển các message đã vượt giới hạn sang dead letter exchange để bạn drain và kiểm tra. Dùng JetStream nếu các event tương tự cũng cần được replay bởi consumer khác sau này, vì stream giữ message sau khi acknowledgement còn queue thì không. Với JetStream, bạn đặt --max-deliver và tự xây dựng dead letter path từ advisory $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>.
Làm cách nào để biết consumer đang bị chậm bao xa?
Mỗi broker có một command riêng. Với RabbitMQ, rabbitmqctl list_queues name messages messages_ready messages_unacknowledged phân biệt công việc đang chờ consumer với công việc đã được deliver nhưng chưa được ack. Với JetStream, nats consumer report <stream> hiển thị số message chưa xử lý và số acknowledgement đang chờ theo từng consumer. Với Kafka, kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group <group> in một cột LAG cho mỗi partition. Core NATS không có backlog để đọc vì nó không lưu gì cả. Thay vào đó, hãy theo dõi counter slow_consumers trên http://localhost:8222/varz. Counter này đếm số connection mà server đã đóng vì consumer bị chậm, tức là message đã bị mất.