SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

單一 VPS 訊息佇列怎麼選?NATS、RabbitMQ、Kafka 比較

比較單一 VPS 上的 NATS、RabbitMQ、Kafka 與 Postgres:交付保證、記憶體與磁碟成本、重開機行為、待處理訊息查詢命令,以及適用時機。

單一伺服器的簡短答案

在單一 VPS 上使用訊息佇列,考量的是交付保證,而不是速度。在單一伺服器上,broker 很少會成為瓶頸,因為應用程式程式碼、資料庫和唯一的磁碟通常會先達到限制。請選擇你能接受其故障行為的工具,然後測量實際使用的伺服器。

以下列出 4 個選項,順序是大多數讀者應考慮的順序。

  • 使用現有的資料庫。Postgres 搭配 SELECT ... FOR UPDATE SKIP LOCKED 可以作為可用的工作佇列,而且不會增加需要監控的新程序。
  • 當每則訊息都是必須確認、在限定次數內重試,最後停放到人員可以檢視的位置的工作單位時,使用 RabbitMQ。
  • 當訊息是系統多個部分都會處理的事件時,使用 NATS。對於必須在重新啟動後保留的事件,啟用 JetStream。
  • 當下游工具只支援 Kafka protocol 時,使用 Kafka。在單一伺服器上,這幾乎是剩下的唯一理由。

本指南其餘內容會說明背後的理由:每個選項在小型 VPS 上需要多少記憶體與磁碟資源、伺服器重新開機時的行為,以及能在使用者察覺之前顯示待處理訊息數量的確切命令。

實際上的傳遞保證代表什麼

至多一次表示 broker 將訊息交給消費者後便不再保留。若沒有消費者連線,或消費者在處理途中終止,訊息就會遺失,且不會有任何回報。

至少一次表示消費者在工作成功後傳送確認(ack)。在收到 ack 之前,broker 會保留訊息,並再次傳遞。重複傳遞就是處理常式必須具備冪等性的原因:處理同一則訊息 2 次時,不得將同一張卡片扣款 2 次。端到端的恰好一次並不是 broker 能提供的功能,而是透過自有資料庫中的唯一鍵實現。

重播是另一項獨立特性。佇列在訊息獲得確認後就會丟棄該訊息。日誌則會在保留期間內保存訊息,因此新的消費者可以從開頭開始讀取完整歷史。Kafka 和 NATS JetStream 是日誌。RabbitMQ 是佇列。這項差異對架構的影響,往往比吞吐量更大。

移入 dead letter是指訊息持續處理失敗時的結果。若沒有這項機制,有問題的訊息會無限迴圈,表面上看起來像 worker 很忙,實際上卻是系統已故障。

從 Postgres 開始,讓 broker 證明自己的價值

多數單一應用程式工作負載每天只有幾千個背景工作。用一張資料表就能處理。

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 會在交易中認領一個工作。

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 是整個技巧的核心。它會鎖定所回傳的資料列,並跳過其他交易已鎖定的資料列,因此兩個 worker 不會認領同一個工作。如果 worker 當機,Postgres 會中止其交易、釋放鎖定,該資料列就會重新對下一個 worker 可見。這樣可取得至少一次投遞、透過增加 attempts 實作重試,以及 dead letter table,而且都建立在你已經支付成本的持久性上。待處理工作只需執行一個查詢即可查看:SELECT count(*) FROM job WHERE run_after <= now();

它何時會失效。每次認領與刪除都是寫入操作,因此工作速率高時會留下大量無效資料列版本;佇列表正是膨脹速度超過 autovacuum 處理能力的典型案例。長時間工作會讓問題更嚴重,因為交易若在整個工作期間保持開啟,也會延後整個資料庫的 vacuum horizon。輪詢會增加延遲,而 LISTEN 搭配 NOTIFY 雖然能移除輪詢,卻無法減少寫入。當工作資料表成為最繁忙的資料表,或第二個服務需要相同事件時,就應將工作移出資料庫。這項選擇會受到資料庫部署方式影響,因此請先確定 資料庫是在 Docker 中執行,還是在主機上執行,再在旁邊加入 broker。

Redis 是另一個你可能已經在執行的元件。Redis Streams 提供 consumer groups,以及 XADDXREADGROUP、每個群組的 pending list,還能使用 XAUTOCLAIM 從已停止的 consumer 取回工作。它體積小且速度快。在單一主機上,必須如實說明其限制:使用常見的 appendfsync everysec 設定時,電源中斷可能遺失約 1 秒的寫入。這對快取失效處理沒有問題,但不適用於付款。如果你的應用程式是建立在 VPS 上的正式環境 SQLite 單一程序,則相同的認領與刪除模式仍然可行;但 SQLite 沒有 SKIP LOCKED 的等效功能,而且每個 worker 都會在同一個寫入鎖上序列化。

NATS 核心:無記憶體的 subject 路由

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

截至 2026 年 8 月,目前的伺服器版本線為 2.14。-m 8222 會啟用 HTTP 監控埠。此埠預設關閉且沒有驗證,因此請如上所述將其繫結至 localhost。

Core NATS 的傳遞語意至多一次,且不儲存任何資料。發布者會傳送至例如 orders.created 這類 subject,所有篩選條件相符的訂閱者都會收到一份副本。若沒有任何訂閱者,訊息會被丟棄,發布者也不會收到錯誤,因為伺服器接受位元組後,發布者的工作就已完成。Queue group 讓多個訂閱者共用同一個群組名稱,伺服器會為每則訊息選擇其中一個成員,在不儲存佇列的情況下分攤工作。

資源占用量由訂閱狀態和每個連線的寫入緩衝區組成,因此取決於連線數,而非訊息量;磁碟上也不會累積任何資料。重新啟動時的行為由此決定:傳輸中的訊息會遺失,客戶端會自行重新連線,也沒有需要等待的復原步驟。

沒有待處理佇列可供監看,因此應監控遺失情況。當訂閱者讀取 socket 的速度低於伺服器寫入速度時,該客戶端的伺服器端緩衝區會填滿。若客戶端在寫入期限內仍未追上,伺服器會關閉整個連線,並遞增計數器。

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

slow_consumers 數值持續上升,表示訊息正在被丟棄,因此應針對它設定警示,而不是只讀取一次。Core NATS 適合傳遞有效期很短的訊息,例如 metric、presence 更新,或下一個事件反正會取代的 cache invalidation。

NATS JetStream:同一程序中的持久化串流與重播

JetStream 不是另一個產品。它是相同 binary 中的子系統,只需設定一個 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 設定儲存目錄。省略此設定時,JetStream 會將資料儲存在 /tmp 下,其持久性正如名稱所示。使用隨附於 nats-box image 的 CLI 建立 stream。

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

在小型伺服器上,每個限制都有其必要性。--storage file 決定當機後仍可保留的資料,因為 memory stream 不具備這項特性。--max-bytes=1073741824 會將 stream 上限設為 1 GiB,數值以 byte count 表示;--discard old 則在達到上限時丟棄最舊的訊息,而不是拒絕新的寫入。省略這項上限時,失控的 publisher 可能填滿磁碟;由於資料庫與它共用同一個磁碟,資料庫也會隨之停止。

durable consumer 會在 stream 中保留自己的位置,並在重新啟動後持續保留。對 consumer 設定 --max-deliver,讓持續失敗的訊息不會無限期重新傳送。訊息耗盡可傳送次數後,JetStream 會在 $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.> 發布 advisory。訂閱該 subject,就能自行建立 dead letter path;RabbitMQ 則將這項功能直接提供給你。這部分必須自行實作。

若要查看 backlog,請執行 nats stream report 取得已儲存的訊息數量,並執行 nats consumer report ORDERS 取得每個 consumer 尚待確認的數量與未處理訊息數。應以未處理訊息數作為告警依據。使用 du -sh 針對儲存目錄查看磁碟用量;用量會持續增加,直到 retention limit 清理資料為止。

RabbitMQ:確認每則訊息,將失敗訊息暫存

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

截至 August 2026,目前的系列版本為 4.3。連接埠 5672 用於 AMQP(advanced message queuing protocol),15672 用於管理介面。請將管理介面限制在 localhost,並透過 SSH tunnel 存取。

宣告佇列時,將 x-queue-type 引數設為 quorum;預設值仍為 classic。Quorum queues 一律具備持久性,且會先將資料寫入磁碟,因此在單一節點上可得到明確一致的行為,不必在持久與暫存選項之間處理複雜組合。請透過 policy 設定 dead letter target。

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

訊息會因 4 種原因進入 dead letter:consumer 使用 basic.rejectbasic.nack 拒絕訊息,且 requeue 設為 false;訊息的 per-message TTL(time to live)到期;佇列超過長度限制;或超過 quorum queue 的 delivery limit。RabbitMQ 4.0 以後,該限制預設為 20。因此,會拋出例外並執行 nack 的 handler 最多重試 20 次,接著將訊息交給 dead letter exchange,而不是持續重試。

在小型 VPS 上,記憶體是 RabbitMQ 最容易造成意外的地方。可用 RAM 的預設 high watermark 為 0.6。節點超過此門檻後,RabbitMQ 會封鎖所有正在發布訊息的連線。應用程式不會收到錯誤,而是遇到永遠不返回的 publish;在自己的程式中看起來就像卡住。啟動日誌會列出節點計算出的數值:

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

可用磁碟空間低於預設的 50 MB 時,disk alarm 也會以相同方式封鎖發布者。Quorum queues 還會增加額外的記憶體需求:文件估算每則訊息至少需要 32 bytes 的記憶體中繼資料,約每 30,000 則訊息需要 1 MB,並建議 RAM 至少為有效 write-ahead log 大小的 3 倍。WAL 限制預設為 512 MiB,因此僅這項建議就需要 1.5 GB。在 2 GB 的伺服器上,請於 rabbitmq.conf 調低此限制,不要假設預設值一定適用。

raft.wal_max_size_bytes = 64000000
vm_memory_high_watermark.relative = 0.5

Backlog 可分為 2 個數值,兩者的組合能指出發生哪一種故障。

docker exec rabbitmq rabbitmqctl list_queues name messages messages_ready messages_unacknowledged

messages_ready 表示正在等待 consumer。messages_unacknowledged 表示訊息已送出但從未收到 ack。unacknowledged 數量持續上升,而 ready 數量維持不變,表示 worker 已取得工作但停止完成處理。這與佇列單純處理落後是不同的問題。

單機執行 Kafka,以及何時不再適合

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

這是 Kafka 4.3.1 的快速入門,目前版本截至 August 2026,並以 KRaft 模式執行(Kafka Raft;Kafka 4.0 中取代 ZooKeeper 的內建 controller)。容器版本為 apache/kafka:4.3.1

如果尚未自行設定,啟動指令碼會設定 export KAFKA_HEAP_OPTS="-Xmx1G -Xms1G"。因此,broker 在儲存第一則訊息前,就會先保留 1 GB 的 Java heap,並預期還有額外的可用 RAM 作為讀取用的 page cache。在 2 GB VPS 上,應用程式只能與 JVM 競爭剩餘的記憶體。

Retention 是下一個容易出乎意料的地方。log.retention.hours 的預設值為 168,也就是 7 天;log.retention.bytes 的預設值為 -1,代表完全沒有大小限制。Kafka 會在整個保留期間保存訊息,不論每個 consumer 是否已讀取。這正是採用 Kafka 的功能,但在小型單碟環境中也可能成為故障原因。因此,應先為每個 topic 設定位元組上限。

接下來說明實際限制。單一 broker 代表 replication factor 為 1,因此 acks=all 會在單一磁碟上執行一次 fsync。你取得的是單一機器的耐久性,但必須承擔 JVM broker 加上 controller 的執行成本。Partition 無法在不存在的多個 broker 之間提供平行處理。Replication、rack awareness 及其他叢集功能也都不會發揮作用。在同一台機器上,JetStream 能以少得多的記憶體提供相同的耐久重播能力。此處仍有兩個理由足以使用 Kafka:下游工具只支援 Kafka protocol(例如使用 Debezium 進行 change data capture,或使用 analytics loader),或是要以縮小的規模重現 production topology。若計畫日後擴充為叢集,就代表需要購買更多機器。在此之前,這項取捨與 在單一節點上執行 k3s 相同:為單一節點的可靠性承擔叢集複雜度。

Kafka 中的 backlog 稱為 consumer lag。

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

查看 LAG 欄位;其值對每個 partition 而言是 LOG-END-OFFSET 減去 CURRENT-OFFSET。如果某個 partition 的 lag 持續上升,而其他 partition 維持平穩,通常表示 key 分布不均。具有相同 key 的所有訊息都會進入同一個 partition,並由單一 consumer 個別處理。

重新啟動伺服器時會發生什麼事

Core NATS 會遺失所有傳輸中的資料,並立即恢復,因為沒有任何內容需要復原。JetStream 會從 store directory 重新載入 streams 和 consumer positions,因此 consumers 會從原本的 offset 繼續。RabbitMQ 會從磁碟復原 quorum queues;classic transient queues,以及未使用 persistent delivery mode 發布的任何訊息都會遺失。Kafka 會在啟動時重新播放 log segments;若先前未正常關機,復原掃描在小容量磁碟上可能需要數分鐘,broker 才會接受連線。

有兩項設定值得預先完成。為 container 設定 restart policy(restart: unless-stopped),或啟用 systemd unit,讓 broker 在 kernel upgrade 後重新開機時自動恢復。接著處理啟動順序:如果 broker 比 application 晚 20 seconds 才就緒,第一次連線會被拒絕,而部分 client libraries 會直接結束,不會重試。使用 讓相依服務等到 broker 就緒的 Compose healthchecks,讓 app 受 broker 狀態控制。

自行 VPS 的成本:實測,不採用引用數據

已發布的吞吐量數據都是在你沒有的硬體上測得,通常是配備多核心處理器與本機 NVMe 的伺服器。請將這些數據視為上限,並測量自己的伺服器。

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

先在 broker 閒置時執行這些測試,再於實際流量下重新執行。兩次測試的差值,會決定 broker 是否能與你的應用程式共用資源。若要取得粗略的吞吐量下限,請使用各專案自己的負載產生器,不要採用他人部落格文章中的數據:NATS 使用 nats bench pub test --msgs 100000 --clients 2,Kafka 使用 bin/kafka-producer-perf-test.sh,RabbitMQ 使用 PerfTest。在同一台 VPS 上執行產生器,測得的是 broker 與產生器合計的效能。只要在回報數據時明確說明,這樣測試即可接受。

所有這些產品都受到同一項上限限制。這裡的每個 durable 選項都必須等待 fsync,因此在使用網路附加儲存的 VPS 上,磁碟效能會決定上限;更換 broker 也無法提高這個上限。

三種工作負載,以及各自需要的訊息佇列

  1. 單一 Web 應用程式的背景工作,例如寄送電子郵件、調整圖片大小或傳送 webhook。先使用 Postgres 和 SKIP LOCKED。需要逐則訊息確認、訊息傳遞上限,以及可直接檢查而不必自行實作處理邏輯的 dead letter queue 時,改用具備 quorum queues 的 RabbitMQ。若工作資料表已成為資料庫中最繁忙的資料表,也應改用 RabbitMQ。
  2. 多個內部服務都會處理的事件,而且遺失的訊息很快就會由較新的訊息取代。使用 Core NATS,以 subjects 作為路由機制;需要分攤工作時,使用 queue groups。只有少數必須在重新啟動後保留的 subjects 才加入 JetStream stream,其餘訊息留在記憶體中。
  3. 消費者會從開頭讀取的事件日誌,用於稽核軌跡、重建讀取模型,或日後提供給分析系統。使用具備檔案儲存且設定明確位元組上限的 JetStream。只有在下游工具要求 Kafka protocol 時才選擇 Kafka,並接受 JVM heap 是這項相容性的代價。

在單一伺服器上選錯訊息佇列,代價不是吞吐量,而是凌晨 3 點進行復原時,必須確認訊息是否仍然存在。請依此選擇。

FAQ

我可以在 2 GB 的 VPS 上執行 Kafka 嗎?

可以啟動,但資源會非常緊張。未覆寫設定時,bin/kafka-server-start.sh 會設定 KAFKA_HEAP_OPTS="-Xmx1G -Xms1G",因此 JVM 在儲存任何訊息前就會先占用 1 GB,而 Kafka 還需要額外的可用記憶體作為 page cache。同一台主機再執行應用程式與資料庫,就會開始使用 swap。此外,複寫因子為 1,表示 acks=all 只是在一個磁碟上執行一次 fsync。因此,你必須負擔 Kafka 的運作成本,卻沒有其 durability model。NATS JetStream 在相同硬體上只需少得多的記憶體,就能提供 durable replay。

如果我已經執行 Postgres,還需要 message queue 嗎?

通常不需要。在交易中讀取含有 job 的資料表,並搭配 SELECT ... FOR UPDATE SKIP LOCKED,即可提供 at-least-once delivery、安全的並行 worker、重試與 dead letter table,且不需要額外監控服務,也能使用你原本就會執行的備份。需要移出的訊號很明確:queue table 成為寫入負載最高的資料表,而 autovacuum 逐漸落後;長時間執行的工作讓交易持續開啟,阻礙整個資料庫執行 vacuum;或是第二個服務需要獨立消費相同事件。

背景工作應使用 NATS JetStream 還是 RabbitMQ?

如果你需要內建的每則訊息確認、delivery limit 與 dead letter routing,請使用 RabbitMQ。Quorum queues 一律具備 durability;從 RabbitMQ 4.0 起,delivery limit 預設為 20;policy 會將超過限制的訊息傳送至 dead letter exchange,供你排空與檢查。如果相同事件之後還需要由其他 consumer replay,請使用 JetStream,因為 stream 在確認後仍會保留訊息,而 queue 不會。在 JetStream 中,你需要設定 --max-deliver,並根據 $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.> advisory 自行建立 dead letter path。

如何判斷 consumer 落後多少?

每個 broker 都有對應的 command。對 RabbitMQ 而言,rabbitmqctl list_queues name messages messages_ready messages_unacknowledged 會區分等待 consumer 的工作,以及已傳送但尚未 ack 的工作。對 JetStream 而言,nats consumer report <stream> 會顯示每個 consumer 尚未處理的訊息與待處理的確認。對 Kafka 而言,kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group <group> 會針對每個 partition 印出 LAG 欄位。Core NATS 沒有可讀取的 backlog,因為它不儲存任何內容。因此,請改為監控 http://localhost:8222/varz 上的 slow_consumers 計數器:該計數器會統計伺服器因 consumer 落後而關閉的連線,這代表訊息遺失。

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