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

单台VPS如何选择NATS、RabbitMQ、Kafka或Postgres

比较单台VPS上的NATS、RabbitMQ、Kafka与Postgres:投递保证、内存和磁盘成本、重启行为、积压检查命令,以及何时Postgres更合适。

单台服务器的简短结论

在一台 VPS 上部署消息队列,关键是消息投递保证,而不是速度。在单台服务器上,broker 很少是瓶颈,因为应用代码、数据库和唯一的磁盘通常会先达到瓶颈。选择故障行为在可接受范围内的工具,然后测量实际服务器的性能。

以下按大多数读者应考虑的顺序列出 4 个选项。

  • 使用现有数据库。Postgres 配合 SELECT ... FOR UPDATE SKIP LOCKED 可以实现任务队列,而且无需增加新的进程进行监控。
  • 当每条消息都是必须确认的工作单元,并且需要限制重试次数,最终将无法处理的消息放到人工可检查的位置时,使用 RabbitMQ。
  • 当消息是由系统多个部分处理的事件时,使用 NATS。对于必须在重启后保留的事件,启用 JetStream。
  • 当下游工具只支持 Kafka 协议时,使用 Kafka。在单台服务器上,这几乎是目前唯一还应选择 Kafka 的理由。

本指南的其余部分将解释选择依据:每个选项在小型 VPS 上会占用多少内存和磁盘资源,服务器重启时会如何处理,以及在用户察觉积压之前,用于查看积压的确切命令。

实际的投递保证意味着什么

至多一次表示消息代理将消息交给消费者后即不再保留。如果没有消费者连接,或者消费者在处理过程中途退出,消息就会丢失,也不会有任何组件报告这一情况。

至少一次表示消费者在处理成功后发送确认(ack)。在收到该确认之前,消息代理会保留消息,并再次投递。由于可能重复投递,处理程序必须具备幂等性:同一条消息处理两次时,不能将同一张卡扣款两次。端到端的恰好一次处理不是消息代理直接提供的能力,而是通过您自己的数据库中的唯一键实现的。

重放是另一项独立属性。队列在消息获得确认后会丢弃该消息。日志会在保留窗口内保存消息,因此新消费者可以从开头开始读取完整历史记录。Kafka 和 NATS JetStream 属于日志。RabbitMQ 属于队列。这种差异对架构的影响通常超过吞吐量。

死信用于处理持续失败的消息。如果没有死信机制,有问题的消息会无限循环,循环看起来像工作进程繁忙,而不是工作进程出现故障。

从 Postgres 开始,让消息代理证明其价值

大多数单应用工作负载每天只有几千个后台任务。用一张表就能满足需求。

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 实现的重试,以及死信表,同时使用已有的持久性保证。积压任务只需通过一个查询获取:SELECT count(*) FROM job WHERE run_after <= now();

它在以下情况下会失效。每次认领和删除都是写操作,因此高任务速率会留下大量死行版本;队列表是膨胀速度超过 autovacuum 处理能力的典型场景。长时间运行的任务会使问题更严重,因为持续整个任务执行时间的未提交事务,也会阻滞整个数据库的 vacuum horizon。轮询会增加延迟,而 LISTEN 配合 NOTIFY 可以消除轮询,但不能消除写操作。当任务表成为最繁忙的表,或第二个服务也需要相同的事件时,应将任务移出数据库。这个选择还取决于数据库本身的部署方式,因此在旁边添加消息代理前,先确定数据库运行在 Docker 中还是主机上

Redis 是另一种你可能已经在运行的组件。Redis Streams 通过 XADDXREADGROUP 提供 consumer group,为每个 group 维护 pending list,并通过 XAUTOCLAIM 从已崩溃的 consumer 取回任务。它体积小、速度快。在单机上需要明确注意一点:使用常见的 appendfsync everysec 设置时,断电可能导致约 1 秒的写入丢失。这对于缓存失效处理可以接受,但不适用于支付。如果你的应用是基于VPS 上的生产环境 SQLite构建的单进程应用,同样可以使用认领并删除的模式。不过,SQLite 没有 SKIP LOCKED 的等价功能,而且所有 worker 都会在同一个写锁上串行执行。

NATS 核心:无持久化的主题路由

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 之类的主题发送消息,所有筛选条件匹配的订阅者都会收到一份副本。如果没有订阅者,消息会被丢弃,发布者不会收到错误,因为服务器接受字节后,发布者的工作就已完成。队列组由多个共享同一组名的订阅者组成。服务器会为每条消息选择其中一个成员,从而实现工作分担,但不会存储队列。

资源占用主要包括订阅状态,以及每个连接的写缓冲区。因此,资源占用取决于连接数,而不是消息量,磁盘上不会累积任何内容。重启行为也由此决定:正在传输的消息会丢失,客户端会自行重新连接,不需要等待任何恢复步骤。

由于不存在待处理消息积压,因此不需要监控积压,而应监控消息丢失。当订阅者读取 socket 的速度低于服务器写入速度时,该客户端的服务器端缓冲区会填满。如果客户端在写入截止时间前仍未追上,服务器会关闭整个连接,并递增一个计数器。

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

持续增长的 slow_consumers 值表示消息正在被丢弃,因此应针对它配置告警,而不是只读取一次。Core NATS 适合承载价值会快速过期的消息,例如指标、在线状态更新,以及下一条事件就会取代其作用的缓存失效通知。

NATS JetStream:在同一进程中使用持久化流和消息重放

JetStream 不是第二个产品。它是同一二进制文件中的一个子系统,只需启用一个标志即可。

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 下,其持久性正如名称所示。使用 CLI 创建流;该 CLI 包含在 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

在小型服务器上,每个限制都很有必要。--storage file 决定崩溃后哪些数据可以保留,因为内存流无法做到这一点。--max-bytes=1073741824 将流限制为 1 GiB,值以字节数表示;达到上限后,--discard old 会丢弃最旧的消息,而不是拒绝新的写入。省略容量上限后,某个失控的发布者可能填满磁盘,数据库也会因此停止运行,因为它们共用同一块磁盘。

持久化消费者会在流中保存自己的位置,并在重启后继续保留该位置。在消费者上设置 --max-deliver,这样始终处理失败的消息就不会被无限重新投递。消息达到最大投递次数后,JetStream 会在 $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.> 上发布一条通知。订阅该主题即可构建死信路径;RabbitMQ 会将此功能直接提供给您。在 JetStream 中,这部分工作需要自行实现。

要查看积压消息,请运行 nats stream report 查看已存储的消息数量,并运行 nats consumer report ORDERS 查看每个消费者待确认的消息和未处理的消息。应针对未处理消息数量设置告警。使用 du -sh 检查存储目录占用的磁盘空间;该空间会持续增长,直到保留限制将其清理。

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

截至 2026 年 8 月,当前系列为 4.3。端口 5672 用于 AMQP(高级消息队列协议),端口 15672 用于管理界面。将管理界面限制为 localhost,并通过 SSH 隧道访问。

声明队列时,将 x-queue-type 参数设置为 quorum;默认值仍为 classic。Quorum 队列始终持久化,并会先将数据写入磁盘,因此在单节点上可以获得明确一致的行为,而不必组合多种持久化和临时选项。使用策略设置死信目标。

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

消息会因以下四种原因进入死信:消费者使用 basic.rejectbasic.nack 拒绝消息,且 requeue 设置为 false;消息自身的 TTL(生存时间)到期;队列超过长度限制;或消息超过 Quorum 队列的投递限制。从 RabbitMQ 4.0 开始,该限制默认为 20。因此,处理程序抛出异常并发送 nack 后,消息会重试 20 次,然后交给死信交换机,而不是无限循环。

在小型 VPS 上,内存问题最容易让人误判。可用 RAM 的默认高水位为 0.6。节点超过该水位后,RabbitMQ 会阻塞所有正在发布消息的连接。应用程序不会收到错误,而是会遇到一个始终不返回的发布操作,于是看起来像是自身代码卡住了。启动日志会打印节点计算出的数值:

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

可用磁盘空间默认低于 50 MB 时,也会触发磁盘告警,并以相同方式阻塞发布者。Quorum 队列还会增加额外的内存需求:文档建议每条消息至少为内存中的元数据预留 32 bytes,约每 30,000 条消息占用 1 MB,并建议 RAM 至少达到有效预写日志大小的 3 倍。WAL 限制默认为 512 MiB,因此仅这一项建议就需要 1.5 GB。在 2 GB 服务器上,应在 rabbitmq.conf 中调低该值,不要假设默认值一定适用。

raft.wal_max_size_bytes = 64000000
vm_memory_high_watermark.relative = 0.5

积压由两个数字表示。这两个数字可以说明发生了哪类故障。

docker exec rabbitmq rabbitmqctl list_queues name messages messages_ready messages_unacknowledged

messages_ready 表示正在等待消费者。messages_unacknowledged 表示消息已经投递但从未收到 ack。未确认消息数量持续上升,而 ready 数量保持不变,说明工作进程已经接收任务但未完成处理。这与队列单纯处理滞后是两种不同的故障。

单机运行 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 的快速入门配置,截至 2026 年 8 月有效,运行在 KRaft 模式下(Kafka Raft,即 Kafka 4.0 中取代 ZooKeeper 的内置控制器)。容器版本是 apache/kafka:4.3.1

如果您未自行设置 export KAFKA_HEAP_OPTS="-Xmx1G -Xms1G",启动脚本会为其设置默认值。因此,broker 在存储第一条消息之前就会预留 1 GB 的 Java 堆内存,并且还需要额外的可用 RAM 作为读取数据所需的页缓存。在 2 GB VPS 上,您的应用随后会与 JVM 竞争剩余内存。

保留策略是下一个容易出问题的地方。log.retention.hours 的默认值是 168,即 7 天;log.retention.bytes 的默认值是 -1,表示完全不限制大小。无论每个消费者是否已读取消息,Kafka 都会在整个保留窗口内保留这些消息。这正是您选择 Kafka 的功能,但在单块小磁盘上也可能导致故障。因此,请在问题发生前为每个主题设置字节数上限。

下面说明实际情况。单个 broker 意味着副本因子为 1,因此 acks=all 最终只会在一块磁盘上执行一次 fsync。您获得的持久性仅相当于一台机器,同时还要承担 JVM broker 和控制器的运行开销。分区只有在多个 broker 之间才能提供并行处理能力,而您并没有多个 broker。副本、机架感知以及其他集群功能也都无法发挥作用。在同一台机器上,JetStream 可以用更少的内存提供同样的持久回放能力。以下两种情况仍然适合使用 Kafka:下游工具只支持 Kafka 协议(例如使用 Debezium 进行变更数据捕获,或使用分析数据加载器),或者您需要在小规模环境中复现生产拓扑。计划日后扩展为集群,就意味着需要购买更多机器。在此之前,这与 在单节点上运行 k3s 的取舍相同:为了单个节点的可靠性,您承担了集群复杂性。

Kafka 中的积压称为消费者延迟。

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

查看 LAG 列。它等于每个分区的 LOG-END-OFFSET 减去 CURRENT-OFFSET。如果一个分区的延迟持续增加,而其他分区保持不变,通常表示键分布不均。具有相同键的所有消息都会进入同一个分区,并由一个消费者单独处理。

重启服务器后会发生什么

Core NATS 会丢失所有正在传输中的内容,并立即恢复,因为它没有需要恢复的数据。JetStream 会从存储目录重新加载 stream 和 consumer 位置,因此 consumer 会从之前的偏移量继续处理。RabbitMQ 会从磁盘恢复 quorum queue,而 classic transient queue 以及所有未使用持久化 delivery mode 发布的消息都会丢失。Kafka 会在启动时重新扫描 log segment;如果上次是非正常关闭,恢复扫描在小容量磁盘上可能需要数分钟,broker 在此期间不会接受连接。

有两项设置值得提前完成。为容器设置 restart policy(restart: unless-stopped),或启用 systemd unit。这样内核升级导致服务器重启后,broker 会自动恢复运行。然后处理启动顺序:如果 broker 比应用晚二十秒进入 ready 状态,它会拒绝应用的首次连接,而某些客户端库会直接退出,不会重试。使用 让依赖服务等待 broker 就绪的 Compose healthcheck,让应用依赖 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 和生成器的综合性能。只要报告数据时明确说明这一点即可。

所有这些组件都受同一个上限约束。这里的每个持久化选项都要等待 fsync 完成。因此,在使用网络连接存储的 VPS 上,磁盘会决定性能上限,更换 broker 也无法突破这一限制。

三个工作负载及其适用的消息队列

  1. 单个 Web 应用的后台任务,例如发送电子邮件、调整图像大小或发送 Webhook。先使用 Postgres 和 SKIP LOCKED。当您需要逐条消息确认、投递次数限制和可检查的死信队列,而不想自行实现这些逻辑时,或者任务表已成为数据库中最繁忙的表时,再迁移到带 quorum queues 的 RabbitMQ。
  2. 多个内部服务都会处理的事件,且丢失的消息很快会被更新消息替代。使用 Core NATS,以 subject 作为路由机制,并在需要共享任务时使用 queue groups。仅为必须在重启后保留的少量 subject 添加 JetStream stream,其余消息保留在内存中。
  3. 消费者从开头读取的事件日志,用于审计跟踪、重建读取模型或供日后分析。使用文件存储并设置明确字节上限的 JetStream。仅当下游工具要求使用 Kafka 协议时才选择 Kafka,并接受 JVM 堆内存作为兼容性的代价。

在单台服务器上选错消息队列,代价不在于吞吐量,而在于凌晨三点进行恢复时,您必须确认消息是否仍然存在。请据此选择。

FAQ

我可以在 2 GB VPS 上运行 Kafka 吗?

可以启动,但资源会非常紧张。未手动覆盖时,bin/kafka-server-start.sh 会设置 KAFKA_HEAP_OPTS="-Xmx1G -Xms1G",因此 JVM 在存储任何消息前就会占用 1 GB;Kafka 还依赖这部分内存之外的可用内存作为页缓存。如果同一台服务器还运行应用和数据库,就会开始使用 swap。此外,副本因子为 1,意味着 acks=all 只在一块磁盘上执行一次 fsync。因此,您承担了 Kafka 的运行开销,却没有获得其持久性模型。在相同硬件上,NATS JetStream 只需更少内存即可提供持久化重放。

如果我已经运行 Postgres,还需要消息队列吗?

通常不需要。在事务中读取带有 SELECT ... FOR UPDATE SKIP LOCKEDjob 表,可以实现至少一次投递、安全的并发工作进程、重试和死信表,而且无需监控额外服务,备份也可复用现有方案。需要迁移到独立队列的信号比较明确:队列表成为写入负载最高的表,而 autovacuum 已经跟不上;长时间运行的任务使事务持续打开,阻塞整个数据库的 vacuum;或者第二个服务需要独立消费相同事件。

后台任务应使用 NATS JetStream 还是 RabbitMQ?

如果您需要按消息确认、投递次数限制和内置死信路由,请使用 RabbitMQ。Quorum queues 始终持久化;从 RabbitMQ 4.0 起,投递次数限制默认为 20;策略会将达到限制的消息发送到死信交换机,您可以从中提取并检查这些消息。如果相同事件之后还需要由其他消费者重放,请使用 JetStream,因为 stream 会在确认后保留消息,而 queue 不会。使用 JetStream 时,您需要设置 --max-deliver,并根据 $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.> advisory 自行构建死信路径。

如何判断消费者落后了多少?

每个 broker 都有对应的命令。对于 RabbitMQ,rabbitmqctl list_queues name messages messages_ready messages_unacknowledged 会区分等待消费者处理的任务,以及已投递但尚未确认的任务。对于 JetStream,nats consumer report <stream> 会显示每个消费者尚未处理的消息和待确认消息。对于 Kafka,kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group <group> 会按分区输出 LAG 列。Core NATS 没有可读取的积压,因为它不存储任何内容。因此,应改为监控 http://localhost:8222/varz 上的 slow_consumers 计数器:该计数器统计服务器因消费者处理落后而关闭的连接,这表示消息已丢失。

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