消息队列选型和踩坑

消息队列选型和踩坑

•约 11.7 千字

后端服务引入消息队列,开始时往往只是为了把一段耗时操作放到后台执行。后来又会加入事件通知、失败重试、广播、顺序消费和历史重放。看起来都在发消息,实际需要的存储方式和消费模型并不相同。

选型时最容易比较的是吞吐和延迟,这个数字很重要,但有些问题更值得关注,例如消息丢失后如何恢复、扩容 Consumer 是否需要提前增加 Partition。以至于部署多少组件、如何升级、磁盘故障后怎样恢复,可能系统没上线之前都被忽略掉了。

NATS、NSQ、Kafka、RabbitMQ 和 Pulsar 都能在网络两端传递消息,却沿着不同方向发展。有的偏向轻量的实时通信,有的擅长任务路由,有的把消息保存为可以反复读取的日志,还有的面向大型多租户平台。把它们放在一起对比吞吐延迟,意义不大。

下面先拆开几种常见需求,再比较各系统的交付保证、消费方式和运维成本。除了功能本身,也会关注 Go 客户端的使用体验,以及这些有状态组件放进 Kubernetes 后需要承担的工作。

一、先确定需要哪一种消息系统

消息队列这个名称把几类不同系统放在了一起。异步任务、发布订阅、事件日志和流式处理都可以传递消息,但它们对存储、顺序和消费状态的要求并不相同。

异步任务

异步任务通常只需要一组 Worker 共同处理一份工作,例如发送邮件、生成缩略图或调用第三方接口。一条任务由一个 Worker 成功处理即可。处理失败后需要重试,超过次数后进入死信处理或人工检查。

这类负载更关心:

  • 消费者能否水平扩展;
  • 是否支持显式确认、超时和重新投递;
  • 慢消费者是否会拖垮生产者;
  • 如何限制重试和处理毒消息;
  • Worker 重启后是否继续处理积压。

RabbitMQ 的 Queue、NSQ 的 Channel、NATS Queue Group 和 JetStream Durable Consumer 都能表达这种模型。Kafka 也能通过 Consumer Group 分配 Partition,但它的存储模型更接近日志,而不是任务处理器。

事件通知

订单创建以后,库存、营销、审计和风控可能都要收到事件。这里不是多个 Worker 竞争同一条消息,而是多个下游各自获得一份。每个下游还可能有多实例共同处理自己的副本。

系统需要同时支持广播和组内负载均衡:

text
order.created
  ├─ inventory 组:实例 A / B 竞争处理
  ├─ audit 组:实例 A / B 竞争处理
  └─ marketing 组:实例 A / B 竞争处理

NATS 用 Subject、多个 Consumer 和 Queue Group 表达;NSQ 用 Topic 到多个 Channel 的复制,再把 Channel 内消息分给多个 Consumer;RabbitMQ 用 Exchange 把消息路由到不同 Queue;Kafka 和 Pulsar 则让不同 Subscription 或 Consumer Group 各自维护读取位置。

可重放的事件日志

有些数据不仅要送达,还要保留一段时间供新消费者回放。例如重新计算推荐特征、修复 Consumer Bug 后重跑数据,或者让数据仓库从历史事件重新构建结果。

此时,消息在第一次消费后不能立即删除。系统需要按时间或容量保留,Consumer 只保存读取位置。Kafka 的核心设计就是分区日志;Pulsar 和 JetStream 也支持保留与重放。RabbitMQ Stream 可以处理这类负载,但经典 Queue 和 Quorum Queue 仍更偏向消息被确认后离开队列。NSQ 可以积压消息,却不适合作为长期、可寻址的事件存储。

流式数据平台

当需求进一步扩大到持续的海量事件、多个独立 Consumer Group、长时间保留、批量回放、CDC、流式 Join 和数据湖接入时,系统已经不只是业务消息队列。Kafka 或 Pulsar 的生态价值会明显增加。此时需要独立考虑磁盘、网络、分区规划、Schema、跨集群复制和流式计算框架。

不能因为未来也许会有大数据就提前让自己背上枷锁,重方案的选型带来的代价远比换个名字高的多。绝大多数订单通知和异步任务不会自然长成每秒数百万条的事件平台,如果出现了这个情况,大概率不是业务量的增长,而是架构的不合理性导致的。

二、选型时真正需要比较什么

我们用下面几个维度比较五套系统。

维度关注点
消息模型是任务队列、发布订阅,还是可回放日志?
交付保证最多一次、至少一次如何实现?重复由谁处理?
顺序全局、分区、Subject、Queue 还是不保证顺序?
持久化数据写在哪里,如何复制,故障时会丢多少?
扩展方式增加 Consumer、Partition、Broker 或存储节点分别有什么限制?
流量控制慢消费者、积压和突发流量如何处理?
Go 体验客户端是否成熟,API 是否符合 Go 使用习惯?
Kubernetes是否适合以 StatefulSet 运行,升级、磁盘和调度是否可控?
可观测性能否看到积压、重投递、消费延迟和集群状态?
维护成本团队实际需要管理多少类进程、状态和故障模式?

还有三条原则需要先说清楚。

第一,至少一次意味着业务必须幂等。 Broker 收到处理成功之前 Consumer 可能已经完成数据库写入;此时确认消息失败,Broker 会再次投递。同一条消息处理两次不是罕见异常,而是至少一次协议允许的结果。

第二,所谓 Exactly Once 都有边界。 Kafka Transaction 可以把输出到 Kafka 的记录和输入 Offset 原子提交,NATS JetStream 可以利用消息 ID 去重并使用双重确认减少重复,但它们没有帮你将数据库、HTTP API 和消息确认自动组装为事务。业务写库时如果没有使用唯一约束、幂等键、Inbox/Outbox 等方法来处理,消息队列并不能帮到你什么。

第三,Benchmark 只能用于验证自己的负载。 消息大小、批次、确认策略、复制因子、磁盘类型、网络和 Consumer 行为都会改变结果。

三、NATS:适合作为默认选择

NATS Server 使用 Go 编写,核心协议和部署结构比较简单。它同时提供 Core NATS 与 JetStream。两者共用 Subject 和客户端,但交付语义不同,不能混为一谈。

Core NATS 适合短暂的实时消息

Core NATS 的消息只发送给当前在线的订阅者,不负责持久化。发布时没有订阅者,消息就不会在服务器中等待。它提供最多一次交付,适合:

  • 服务间低延迟通知;
  • 可以丢失的缓存失效事件;
  • 在线状态和控制信号;
  • 请求—响应调用;
  • 实时数据扇出。

Subject 使用点分层级,例如:

text
orders.created
orders.paid
orders.cancelled

订阅者可以使用 * 匹配一级,使用 > 匹配剩余层级。与提前创建大量 Topic 相比,Subject 更轻量,也便于建立统一命名规则。

Queue Group 用于组内负载均衡:

go
_, err := nc.QueueSubscribe("email.send", "email-workers", func(msg *nats.Msg) {
    // 同一个 Queue Group 中只有一个实例处理这条消息。
})

不同 Queue Group 各自收到一份,同组中的实例竞争处理。这恰好符合 Kubernetes Deployment 水平扩容的方式:多个 Pod 使用相同 Queue Group,不需要预先分配 Partition。

JetStream 增加持久化和重放

JetStream 是 NATS Server 内置的持久化层。Stream 绑定一个或多个 Subject,并配置保留策略、容量、时间和副本数。Consumer 保存读取和确认状态。官方 JetStream 文档明确区分:Core NATS 提供最多一次,JetStream Consumer 可以提供至少一次。

一个订单 Stream 可以这样定义:

go
stream, err := js.CreateStream(ctx, jetstream.StreamConfig{
    Name:      "ORDERS",
    Subjects:  []string{"orders.>"},
    Storage:   jetstream.FileStorage,
    Retention: jetstream.LimitsPolicy,
    MaxAge:    7 * 24 * time.Hour,
    Replicas:  3,
})

Worker 使用 Durable Pull Consumer:

go
consumer, err := stream.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{
    Durable:       "billing",
    FilterSubject: "orders.paid",
    AckPolicy:     jetstream.AckExplicitPolicy,
    AckWait:       30 * time.Second,
    MaxDeliver:    5,
})

messages, err := consumer.Messages(jetstream.PullMaxMessages(100))
for msg := range messages.Messages() {
    if err := handle(msg); err != nil {
        _ = msg.Nak()
        continue
    }
    _ = msg.Ack()
}

Pull Consumer 让客户端决定一次拉取多少消息,更容易控制并发和背压。NATS 官方也建议新项目在需要扩展、流量控制或精细错误处理时优先使用 Pull Consumer。

为什么适合 Go 与 Kubernetes 团队

NATS 的 Go 客户端由项目官方维护,API 覆盖 Core、JetStream、KV 和 Object Store。服务端本身也是 Go 程序,镜像和进程结构简单。一个三节点 NATS 集群就能同时提供路由与 JetStream,不需要额外部署 ZooKeeper、BookKeeper 或独立元数据数据库。

在 Kubernetes 中,可以使用官方 Helm Chart 部署 StatefulSet,给 JetStream 节点挂载持久卷,并通过 Pod Anti-Affinity 或 Topology Spread 把副本分散到不同 Node 或可用区。Stream 的三个副本应落在不同故障域,否则三个 Pod 共用一块故障磁盘出问题时一块儿起飞。

NATS 使用 Raft 管理 JetStream Stream 和 Consumer 状态,因此至少两个副本可用的情况下 Stream 才能写入。Kubernetes 滚动升级时要控制并发中断,避免同时驱逐多数节点。

对应用团队而言,Subject 与 Queue Group 比 Partition 更容易使用。增加 Worker Pod 时无需提前增加分区,也不会因为 Consumer 数超过 Partition 数而出现空闲实例。多数业务消息吞吐没有高到必须通过分区日志扩展,这种简单性比理论峰值更重要。

NATS 的限制

NATS 不是缩小版 Kafka。JetStream 可以保留和重放消息,但如果需求是长期保存数十 TB 事件、数百个独立 Consumer Group、复杂流式计算和成熟的数据连接器,Kafka 生态通常更合适。

Subject 不提供 Kafka 那种固定分区内的日志顺序。多个发布者、多个 Consumer 和重新投递都会影响处理顺序。如果业务要求同一订单严格串行,可以按业务键设计 Subject、限制 Consumer 并发,或在应用侧做版本检查,但这会降低吞吐并增加设计成本。

JetStream 的每个 Consumer 都有状态和 Raft 成本。为每个临时用户创建 Durable Consumer,可能迅速扩大元数据和资源消耗。Stream、Consumer、保留时间和副本数都应设置预算。

因此,NATS 的默认优势不是所有指标都更快,而是它在业务需要的范围内提供了较完整的消息能力,同时保持较低的部署和开发复杂度。

四、NSQ:简单,但持久性边界太明显

NSQ 同样使用 Go 编写,部署由 nsqd、nsqlookupd 和 nsqadmin 组成,它是跟着 Go 语言一起流行的,不像 NATS 是云原生的宠儿。Topic 表示消息流,Channel 表示一个下游订阅。每个 Channel 收到 Topic 的完整副本,Channel 内的多个 Consumer 分担消息。

这个模型非常直接:

text
topic: order-created
  ├─ channel: inventory -> worker A / B
  ├─ channel: audit     -> worker A / B
  └─ channel: marketing -> worker A / B

nsqlookupd 只保存发现信息,不在多个实例之间同步。Consumer 轮询多个 nsqlookupd,合并它们返回的 nsqd 列表。这样的设计避免了集中协调,组件也很容易理解。

NSQ 提供至少一次投递。Consumer 处理成功后发送 FIN,失败发送 REQ 重新入队;超过超时时间未确认也会重新投递。官方设计文档明确要求 Consumer 能处理重复消息。

它的问题也写在官方特性与保证中:消息默认并非持久化;超过内存水位后才写盘;没有内建复制;不保证消息顺序。即使把 mem-queue-size 设为 0 强制写盘,单个 nsqd 节点或磁盘故障仍可能丢失该节点的数据。官方给出的高可靠思路之一是向冗余 nsqd 写两份,再依赖幂等 Consumer 消除重复,这会把复制责任交给应用和部署拓扑。

Kubernetes 能帮助重启 nsqd,却不能补上协议中缺少的复制。使用 StatefulSet 和 PersistentVolume 可以降低 Pod 重建导致的数据丢失,但 Node、磁盘和可用区故障仍要另行设计。nsqlookupd 的发现模型也来自主机时代,在 Kubernetes 已经有 Service 和 EndpointSlice 的环境中,它增加的价值没有过去那么明显。

NSQ 适合消息允许少量丢失、团队追求极简、已经积累成熟运维经验的系统。若是新项目,NATS Core 覆盖低延迟发布订阅,JetStream 又补上复制、持久化和重放,继续选择 NSQ 的理由已经不多。

五、RabbitMQ:路由能力强,适合传统任务与集成

RabbitMQ 的核心抽象是 Producer 把消息发到 Exchange,再由 Binding 规则路由到 Queue,Consumer 从 Queue 中取消息。Exchange 有 Direct、Topic、Fanout、Headers 等类型,可以表达较复杂的路由关系。

例如支付事件可以同时进入审计和通知 Queue,而失败事件单独进入告警 Queue。路由规则由 Broker 管理,不必让每个 Consumer 自己过滤全部消息。AMQP 生态成熟,很多企业软件和语言都有稳定客户端。

Queue 类型决定可靠性

RabbitMQ 不能只写一句消息持久化就认为数据安全。Producer 需要使用 Publisher Confirm,消息要标记为持久化,Queue 也要选择合适类型。当前需要高可用的普通任务队列通常使用 Quorum Queue。它以 Raft 复制消息,默认组大小可为三个成员。官方 Quorum Queue 文档同时说明了成员、确认和故障行为。

Consumer 仍要在业务处理完成后手动 Ack。若 Ack 前连接断开,消息会重新投递。Prefetch 决定 Broker 最多向 Consumer 推送多少条未确认消息;值太大时,单个 Consumer 会囤积任务并影响负载均衡,值太小则限制吞吐。

RabbitMQ 还提供 Stream,适合追加日志和多次读取。它改善了大积压和重放能力,但 Queue 与 Stream 使用方式不同。团队如果主要需要事件日志,选择 RabbitMQ 后再全面使用 Stream,不一定比直接选 NATS JetStream 或 Kafka 更简单。

Kubernetes 中可以运行,但需要认真维护

RabbitMQ 官方提供 Cluster Operator 和 Messaging Topology Operator。前者管理 RabbitMQ 集群,后者管理 Virtual Host、User、Permission、Queue 和 Exchange。Operator 能处理常见部署工作,但无法消除有状态系统的磁盘、网络分区、Quorum 和升级问题。

在 Kubernetes 中运行 Quorum Queue,需要保证副本分散、Pod 有稳定身份和持久卷,并设置 PodDisruptionBudget。缩容不能简单删除 StatefulSet 尾部 Pod,因为某些 Queue 的成员分布可能需要先调整。磁盘告警、内存水位和连接风暴也要纳入监控。

什么时候选择 RabbitMQ

如果系统已经围绕 AMQP、Exchange、Routing Key、死信 Exchange 和 Priority Queue 建立大量逻辑,RabbitMQ 是合理选择。需要与只支持 AMQP 的商业系统集成时,它也有明显优势。

对于以 Go 和 Kubernetes 为主的新系统,NATS Subject 与 JetStream Consumer 通常已经足够。RabbitMQ 的 Exchange、Binding、Virtual Host 和多种 Queue 类型提供了更丰富的 Broker 端路由,同时也带来更多配置和故障状态。没有明确使用这些能力时,不需要为了成熟默认选择它。

六、Kafka:极大吞吐与事件平台

Kafka 把 Topic 分为多个 Partition。每个 Partition 是有序追加日志,消息在保留期内不会因为某个 Consumer 已经读取就立即删除。Consumer Group 为每个 Partition 分配一个 Consumer,同组内一个 Partition 在同一时间只由一个 Consumer 处理。

这个模型带来三个重要结果。

第一,顺序只在 Partition 内成立。生产者使用订单 ID 作为 Key,可以让同一订单进入同一 Partition,从而维持该订单的事件顺序,但不同订单之间没有全局顺序。

第二,Consumer Group 的并行度受到 Partition 数限制。一个 Topic 有 12 个 Partition,同组增加到 20 个 Consumer 时,至少 8 个不会获得 Partition。扩容前需要规划 Partition 数;后续增加 Partition 还可能改变 Key 到 Partition 的映射。

第三,消费进度是 Offset。Consumer 可以提交 Offset,也可以移动 Offset 重放旧消息。同一份日志可以被多个 Consumer Group 以不同速度读取,这正是 Kafka 适合数据管道和事件平台的原因。

Kafka 的优势不只在峰值吞吐

Kafka 擅长顺序磁盘 I/O、批量传输和分区并行。更重要的是,它已经形成完整生态:Kafka Connect、Kafka Streams、Flink、Debezium、Schema Registry 以及众多云数据平台都能围绕 Kafka 工作。需要把数据库 CDC 同步到搜索、仓库和实时计算时,这些生态通常比单个 Broker 的性能更有价值。

Kafka Transaction 可以原子提交写入 Kafka 的输出和 Consumer Offset,为 Kafka 内部的读取—处理—写入提供 Exactly Once 能力。官方设计文档也强调它的边界:Producer 负责事务,并把 Offset 作为事务的一部分提交。

为什么不把 Kafka 作为默认队列

普通异步任务并不需要长期日志、Partition 规划和流式生态。为了一个发送邮件队列引入 Kafka,需要处理 Topic 参数、Replication Factor、ISR、Consumer Rebalance、Offset、磁盘容量、跨 Broker 数据迁移和版本升级。系统能够承担更大的吞吐,不代表它在小规模下更省事。

Kafka Consumer 的扩容还会触发 Partition Assignment。虽然 Cooperative Rebalance 和 Static Membership 可以减少影响,应用仍要正确处理分区撤销、Offset 提交和重复消费。相比 JetStream Pull Consumer,开发者需要理解更多状态。

极大吞吐时选择 Kafka

当出现以下条件时,我们会认真评估 Kafka:

  • 持续吞吐已经达到单个 NATS 集群不适合承担的量级,并有实际压测数据;
  • 数据需要保留数天到数月,容量达到 TB 甚至更高;
  • 一个事件流有大量独立 Consumer Group;
  • 需要 CDC、流式 Join、窗口聚合或成熟 Connector;
  • 需要按 Key 保证 Partition 内顺序;
  • 团队已经具备 Kafka 容量规划和故障处理能力。

这里不设一个通用的每秒多少条阈值。1 KB 小消息、1 MB 大消息、同步三副本和异步单副本的数字没有可比性。应该用实际消息大小、压缩、生产确认、保留期和 Consumer 数量压测。

为什么 Kafka 不放在业务 Kubernetes 集群中

Kafka 可以运行在 Kubernetes 中,也有 Strimzi 等成熟 Operator。本文仍选择不在业务 Kubernetes 集群中托管,原因不是 Kafka 无法容器化,而是故障域和资源模型不合适。

Kafka 依赖稳定的磁盘吞吐、网络带宽和 Broker 身份。业务集群会频繁扩缩容、升级 Node、驱逐 Pod,并与大量无状态服务争夺 CPU、Page Cache、网络和磁盘。一次集群级网络或存储故障可能同时影响应用和消息系统,应用无法通过消息缓冲度过故障。

Kafka 的数据再均衡会产生大量网络与磁盘 I/O。若它与业务 Pod 共享 Node,扩容或 Broker 故障可能直接推高应用延迟。PersistentVolume 把磁盘生命周期与 Pod 分离,却没有消除云盘吞吐、挂载、可用区绑定和恢复时间。

我们的选择顺序是:优先使用云厂商或专业服务提供的托管 Kafka;无法使用托管服务时,在独立虚拟机或独立的专用 Kubernetes 集群中部署,并让团队明确承担 24 小时运维责任。关键是不要与业务工作负载共享控制面和主要故障域。

七、Pulsar:能力全面,但复杂度最高

Pulsar 将服务层与存储层分开。Broker 处理客户端连接、Topic 所有权和调度,BookKeeper Bookie 保存持久消息,元数据系统保存集群与 Namespace 信息。Broker 可以相对无状态地扩展,存储容量和吞吐则通过增加 Bookie 扩展。

这种结构解决了 Kafka Broker 同时承担计算与存储带来的部分扩容问题。Pulsar 还原生提供 Tenant、Namespace、多种 Subscription、分层存储和跨地域复制。一个大型平台需要为许多团队提供统一消息服务时,这些能力很有吸引力。

Subscription 模型包括 Exclusive、Failover、Shared 和 Key_Shared。Shared 适合任务分担,Key_Shared 能在共享消费时让同一个 Key 的消息交给同一 Consumer。Pulsar 同时覆盖 Queue 与 Stream 的使用方式。

代价是运行组件更多。一个可靠 Pulsar 集群不仅有 Broker,还要管理 Bookie、Journal、Ledger Storage、元数据服务、Proxy 和运维工具。BookKeeper 对磁盘布局和延迟敏感,Broker 与 Bookie 有不同的扩容指标。跨地域复制又引入一致性、带宽和恢复边界。

Kubernetes 可以部署这些组件,却会让调度、PersistentVolume、拓扑和滚动升级更加复杂。对已经决定不在业务 Kubernetes 集群托管 Kafka 的团队,更没有理由把更复杂的 Pulsar 塞进去。

Pulsar 适合平台规模足够大、明确需要存储计算分离、多租户和跨地域复制,并愿意建立专门消息平台团队的组织。单纯因为它同时拥有 Queue 和 Stream 能力,不足以抵消运维成本。

八、五套系统放在一起比较

维度NATSNSQRabbitMQKafkaPulsar
主要模型Subject、发布订阅、Queue Group、StreamTopic、ChannelExchange、Binding、Queue、StreamPartitioned LogTopic、Subscription、分层存储
默认交付Core 最多一次;JetStream 至少一次至少一次,但节点故障有丢失边界Ack 后至少一次通常至少一次,可用事务加强 Kafka 内处理至少一次,可配置消费模式
持久化复制JetStream 内置 Raft 副本无内建复制Quorum Queue 使用 RaftPartition ReplicaBookKeeper Ledger 副本
顺序受 Subject、Consumer 和重投递影响不保证单 Queue 有条件保持,重投递会影响Partition 内有序Partition 或 Key_Shared 范围内
消费扩展Queue Group / Pull Consumer,较灵活Channel 内分担Queue Consumer + Prefetch受 Partition 数限制多种 Subscription
历史重放JetStream 支持较弱Stream 支持,Queue 不适合作长期日志核心能力核心能力
Go 客户端官方维护,体验好官方 go-nsq社区成熟多个成熟实现,配置较多官方 Go Client
Kubernetes 运维相对简单,仍需持久卷与多数派简单但可靠性需自行补足官方 Operator,状态较多Operator 成熟但资源和磁盘要求高组件最多,运维复杂
适合场景多数业务消息、任务、事件通知简单且能接受边界的旧系统AMQP 集成、复杂路由、传统任务队列极大吞吐、长保留、数据平台大型多租户、跨地域消息平台
本文结论默认选择新项目通常不选有明确 AMQP/路由需求再选大规模需求时选择,外部托管没有平台级需求不选

这个表没有列统一的吞吐数字,因为不同持久化和确认设置下的数字会误导。选型阶段更应该先排除不需要的模型,再对剩余方案进行实际压测。

九、Go 应用如何正确使用消息队列

Broker 选对了,不代表应用就可靠。五套系统都可能重复投递,Consumer 必须从业务层处理幂等、超时和毒消息。

消息需要稳定的身份和版本

建议使用统一 Envelope:

go
type Event[T any] struct {
    ID            string    `json:"id"`
    Type          string    `json:"type"`
    SchemaVersion int       `json:"schema_version"`
    OccurredAt    time.Time `json:"occurred_at"`
    TraceID       string    `json:"trace_id,omitempty"`
    Data          T         `json:"data"`
}

ID 用于幂等,Type 与 Subject/Topic 的命名对应,SchemaVersion 支持演进,OccurredAt 是业务事件时间而不是 Broker 收到时间。不要直接序列化数据库 ORM 对象;表结构变化不应自动变成消息契约变化。

使用 Transactional Outbox

最常见的一致性问题是:数据库写成功,消息发布失败。反过来先发消息再写数据库,也可能让 Consumer 读到尚不存在的状态。

Outbox 在同一数据库事务中写业务表与待发送事件:

sql
BEGIN
  UPDATE orders SET status = 'paid' WHERE id = ?;
  INSERT INTO outbox(id, topic, payload) VALUES (?, 'orders.paid', ?);
COMMIT

独立 Publisher 扫描 Outbox,发布成功后标记完成。它仍可能在发布成功、标记完成前崩溃,因此消息可能重复,Consumer 仍需幂等。Kafka、NATS 或 RabbitMQ 的确认机制都无法替代跨数据库边界的 Outbox。

Consumer 使用 Inbox 或唯一约束

Consumer 可以在业务数据库中记录已经处理的 Event ID,并与业务修改放进同一事务。再次收到相同 ID 时直接返回成功。数据量很大时要设计过期和分区策略,不能让 Inbox 无限增长。

如果业务操作本身有天然唯一键,例如订单只能退款一次,数据库唯一约束通常比单独的去重表更可靠。幂等要落在最终副作用上,而不是只放一个易失的内存 Cache。

重试需要退避和上限

立即无限重试会让故障下游持续过载。Consumer 应按错误类型区分:临时网络错误可以指数退避;格式错误、缺少必要字段等永久错误直接进入告警或死信流程;需要人工补数据的错误要保留原始消息和上下文。

NATS JetStream 的 AckWait、MaxDeliver,RabbitMQ 的 Dead Letter Exchange,NSQ 的 Requeue,Kafka 的 Retry Topic 都只是实现手段。团队还要定义谁处理失败消息、保留多久、修复后怎样重放。

优雅退出和背压

Kubernetes 终止 Pod 时,Consumer 应先停止获取新消息,再等待当前处理结束,最后关闭连接。terminationGracePeriodSeconds 必须覆盖正常任务时长。超时后进程被杀,未确认消息会重新投递。

并发数不能只按 CPU 设置。若每条任务调用数据库或第三方 API,过多 Consumer 会把瓶颈转移到下游。Pull Consumer、Prefetch、Kafka Poll Batch 等参数应与连接池、限流和处理耗时一起配置。

十、真正容易踩的坑

选型表只能说明系统具备什么能力,真正让人头疼的通常是默认值、生命周期和业务语义没有对齐。五套系统的抽象不同,坑也不只是换一个配置项:有些会丢消息,有些会阻塞整个 Queue,还有些会在扩容时突然产生巨量数据迁移。

NATS 简单不等于没有状态

Publish 成功并不是成功

应用调用 Publish 没有报错,很容易被理解成消息已经可靠保存。对 Core NATS 来说,这通常只说明客户端把消息交给了连接;Core NATS 本来就不持久化。即使使用 JetStream,也要调用 JetStream 的发布 API 并等待服务端的 Publish Ack,才能确认消息已经进入匹配的 Stream。

还有一个更隐蔽的问题:Subject 没有匹配任何 Stream。若应用混用了 Core NATS 与 JetStream API,消息可能正常发出,却根本没有进入持久化流程。上线前要检查 Subject 与 Stream 的匹配关系,并把没有匹配 Stream、Publish Ack 超时当作错误处理,不能只记录一条日志后继续返回成功。

但 Publish Ack 仍然解决不了数据库与消息之间的原子性。订单事务提交后,进程可能在发布前崩溃;消息发布成功后,也可能在更新 Outbox 状态前崩溃。最终仍要靠 Transactional Outbox、稳定的 Event ID 和幂等 Consumer 收口。

滥用 Ack

Consumer 收到消息后立刻 Ack,再执行业务逻辑,看起来能避免超时重投,实际上把故障窗口留给了自己。Ack 之后进程崩溃、数据库写失败或下游返回错误,Broker 都不会再投递这条消息。

正确顺序通常是:完成最终副作用,提交本地事务,然后 Ack。代价是业务已经成功、Ack 尚未送达时会重复投递,所以幂等不是可选优化。若一次处理时间可能超过 AckWait,还要延长确认期限或发送处理中确认;否则第一份任务还没完成,第二个 Worker 已经开始处理同一条消息。

AckWait 也不能照抄一个固定值。它至少要覆盖正常处理耗时的高分位数,同时给进程退出和短暂网络抖动留出空间。设置太短会制造并发重复,设置太长则让真正宕机后的任务迟迟不能恢复。

Nak 不是 continue

下游故障时立即 Nak,消息会很快重新出现。如果几十个 Worker 都在做同样的事,原本一次第三方接口故障会被放大成持续请求风暴。更糟的是永久错误:反序列化失败、缺少必填字段或引用的数据根本不存在,无论重试多少次都不会自动变好。

重试必须至少区分三类结果:成功后 Ack;临时错误延迟重试并使用指数退避;永久错误停止自动重试,保存原始消息、错误和处理上下文。MaxDeliver 只是停止投递的阀门,并不会自动形成可运营的死信流程。团队还要明确失败消息存到哪里、谁会收到告警、修复后如何重新发布,以及重新发布时是否沿用原 Event ID。

死信 Consumer 本身也可能失败。若它只负责把消息从一个 Subject 搬到另一个 Subject,却没有可靠保存和告警,问题只是从主队列消失了,并没有被处理。

Pod 名作为 Durable Consumer 名

在 Kubernetes 中直接用 Pod 名称生成 Durable Consumer,例如 billing-7d9f8c6b5f-x2abc,每次发布或扩缩容都会留下新的消费状态。旧 Consumer 不再工作却继续占用资源,新 Consumer 又可能从错误的位置开始读取。时间久了,管理界面里会堆满没人敢删的 Durable Consumer。

Durable 名称应该表示稳定的业务订阅,例如 billing、inventory-projector,而不是某个实例。同一组 Pod 共享一个 Durable Pull Consumer,在组内竞争消息。只有确实需要独立读取进度的下游才创建新的 Durable。临时调试使用 Ephemeral Consumer,并设置清理策略,不要把每次排障都变成永久元数据。

修改 Consumer 配置时也不能假定所有字段都能原地更新。部署程序应把 Stream 与 Consumer 当作受版本控制的基础设施,明确哪些差异允许更新,哪些需要新建 Consumer、迁移读取位置后再删除旧对象。

关注老消息年龄

积压 10 万条不一定有问题:Consumer 可能几秒钟就能追平。只积压 100 条也不一定安全:如果最老的一条已经卡了两小时,说明某个业务键、毒消息或下游依赖可能一直失败。

监控至少要把下面几组指标放在一起:

  • Stream 的消息数、字节数、写入速率和存储使用率;
  • Consumer 的待处理数、Ack Pending、重投递数和最老未处理消息年龄;
  • 发布 Ack 与消费处理的延迟分位数、错误率;
  • Outbox 未发送数量与最老记录年龄;
  • 死信数量、最近新增时间和无人处理时长。

只监控 Broker 是否存活,最多能发现整个集群挂了。更常见的故障是集群完全健康,但某个 Consumer 因权限变化、Schema 不兼容或下游限流已经停了半天。

副本调度至同一故障域

Stream 配置 Replicas: 3 后,管理界面会显示三副本,很容易让人产生安全感。如果三个 Pod 在同一台 Node,或者三个 PersistentVolume 最终依赖同一个存储故障域,一次宿主机或可用区故障仍可能同时带走所有副本。

反过来,分散得太严格也会出问题。三节点集群跨三个可用区部署后,任意一个区域与另外两个区域失联,少数派不能继续写入,这是 Raft 多数派的正常行为,不是 Kubernetes 应该强行重启解决的异常。拓扑分散、网络延迟、可用性和成本要一起设计,并实际测试丢失一个节点、一个可用区以及磁盘写满时的行为。

PersistentVolume 也不等于备份。副本会忠实复制误删除和错误配置;容量上限则可能让所有副本在接近的时间一起写满。要为 MaxBytes、文件系统剩余空间和增长速度设置告警,并验证快照真能在另一套环境恢复,而不是只验证备份任务返回成功。

为了顺序改成串行

业务提出消息必须有序时,首先要问清楚是全局有序,还是同一个订单、账户或设备的事件有序。为了全局顺序只开一个 Consumer,确实简单,却会失去并行处理能力;任意慢任务还会阻塞后面所有消息。

多数场景真正需要的是同一个业务键串行,以及旧事件不能覆盖新状态。可以按业务键路由、限制同一键的并发,并在数据库中记录版本号。Consumer 收到版本较旧的事件时直接忽略。这样即使重投递或网络延迟改变了到达顺序,最终状态也不会倒退。

需要注意,Broker 保证的投递顺序不等于副作用完成顺序。两个消息按顺序交给两个 Goroutine,后一个仍可能先写完数据库。只要 Consumer 内部并发存在,就要让调度策略与业务键保持一致,或者由数据库的版本条件承担最后一道保护。

NSQ:能重新投递,不代表能容忍节点故障

NSQ 最容易产生的误解,是把消息超过内存限制时会写盘理解成持久化队列。磁盘文件只属于当前 nsqd,没有内建副本;Pod 带着本地盘消失、磁盘损坏或节点永久下线时,消息仍然可能丢失,把 mem-queue-size 设为 0 也只能缩小进程崩溃造成的丢失窗口。

另一个坑来自拓扑发现。Topic 和 Channel 通常在第一次使用时动态创建,而 Consumer 通过 nsqlookupd 发现 nsqd。如果新 Channel 在消息发布之后才创建,它不会补收过去的消息;如果 Consumer 只连到部分 nsqlookupd,或者网络策略挡住广播地址,它看到的生产节点也可能不完整。部署成功不等于所有生产者和消费者看到的是同一张拓扑图。

NSQ 的 requeue 还会把消息放回队列尾部,因此它不保证严格顺序。用多个 nsqd、多个 Consumer 或重试以后,顺序会进一步被打散。

RabbitMQ:Ack、持久化和路由要同时成立

RabbitMQ 中声明了 Durable Queue,再发送 Persistent Message,仍不代表生产者知道消息已经安全写入副本。生产端还需要开启 Publisher Confirm,并正确处理 Nack、超时和连接断开。连接断开时,客户端可能无法判断最后一批消息究竟有没有被 Broker 接收;盲目重发会重复,盲目放弃会丢失,因此消息 ID 和幂等同样少不了。

路由失败也很安静。消息发到 Exchange 后,如果没有 Binding 匹配,默认可以直接被丢弃,Producer 却不一定报错。需要发现这类错误时,要使用 Mandatory 标志并处理 Returned Message,或者配置 Alternate Exchange。只监控 Queue 深度无法发现消息从来没有进 Queue。

RabbitMQ Consumer 常见的另一个坑是 Prefetch 设置过大。Broker 会提前把大量未确认消息推给少数 Consumer,新扩容的 Pod 看起来没有工作,旧 Pod 却囤着一堆任务。某个 Consumer 随后退出,这批消息又集中重新投递,造成瞬时尖峰。Prefetch 要根据单条任务耗时、并发数和下游容量确定,而不是为了吞吐直接设成一个很大的数。

死信队列也不是垃圾桶。若死信 Exchange 的 Routing Key 又把消息送回原 Queue,且没有重试计数或过期出口,就会形成无法从业务日志看清的循环。Quorum Queue、Classic Queue 和 Stream 的行为及适用场景又不同,升级或迁移前不能只保留相同的 Queue 名称,就假定语义没有变化。

Kafka:Partition 是扩展单元,也是长期约束

Kafka 最典型的坑是先创建很少的 Partition,等 Consumer 扩容后才发现多出来的实例一直空闲。增加 Partition 虽然容易,却会改变默认 Key Partitioner 的 Key 到 Partition 映射;同一个业务 Key 的新消息可能进入新 Partition,与旧消息失去连续顺序。Partition 数还会影响文件句柄、Controller 元数据、复制流量和故障恢复时间,也不能说多建一些总没错。

Consumer 的 Offset 也很容易提交错位置。若先提交 Offset 再写数据库,进程崩溃会跳过尚未完成的业务;若处理完一批才提交,中间崩溃又会重复整批。自动提交尤其容易让开发者误以为它代表业务处理成功。关闭自动提交、在副作用完成后提交 Offset,再通过幂等处理重复,通常更容易理清故障边界。

Rebalance 是另一个只在流量上来后明显的问题。实例频繁发布、处理时间超过 max.poll.interval.ms,或者 Poll 循环被耗时业务阻塞,都可能让 Consumer 被移出 Group。Partition 被分给新实例后,旧实例若仍在处理,就会出现并发副作用和重复消费。业务处理应与持续 Poll 解耦,同时限制在途任务,并正确处理 Partition Revoke 时的停止与提交。

保留策略也不是备份策略。retention.ms 与 retention.bytes 任一条件满足都可能删除旧 Segment;Compact Topic 则只保证最终保留每个 Key 的较新值,并不适合依赖完整事件历史的审计。磁盘告警不能只看整块盘,还要预测 Partition 增长、Replica 重建和 Consumer Lag 是否已经逼近保留窗口。

Pulsar:分层之后,故障也分层了

Pulsar 把 Broker 与 BookKeeper 分开,扩容更灵活,但排障时不能只看 Broker。Producer 超时可能来自 Topic 所属 Broker、Bookie 写入延迟、Journal 磁盘、Ledger 磁盘、元数据服务或它们之间的网络。Broker Pod 全部 Running,只能说明入口还在,不能说明 Ledger 可以稳定形成副本。

BookKeeper 对磁盘延迟很敏感。把 Journal 和 Ledger 与其他高 I/O 工作负载放在同一块云盘,平均吞吐看起来可能够用,尾延迟却会触发写入超时和 Bookie 被踢出 Ensemble。副本随后重建,又进一步吃掉磁盘和网络,形成故障放大。容量规划必须为恢复流量留余量,并分别观察 Journal、Ledger 和读写尾延迟。

Subscription 类型选错也会改变业务语义。Shared 可以并行消费,却不保证同一个 Key 由同一 Consumer 处理;Key_Shared 能按 Key 分派,但 Producer 需要使用兼容的 Key 与批处理配置。后期从 Shared 改成 Key_Shared,不会自动修复已经乱序产生的业务状态。跨地域复制同样不是全局 Exactly Once:网络恢复后可能补发和重复,应用仍要用 Event ID、版本和幂等约束保护最终状态。

十一、Kubernetes 中运行 NATS 的建议

默认方案是一套三节点 NATS 集群,启用 JetStream,每个 Pod 使用独立 PersistentVolume,并分散到不同 Node。Core NATS 消息也经过这些节点,但只有进入 Stream 的 Subject 才会持久化。

部署时至少处理下面几项:

  • StatefulSet Pod 使用稳定名称;
  • JetStream 存储使用性能可预测的 StorageClass;
  • Pod Anti-Affinity 或 Topology Spread 分散副本;
  • PodDisruptionBudget 避免同时驱逐多数节点;
  • 为 CPU、内存和存储设置 Request,谨慎设置内存 Limit;
  • TLS、User/Account 和权限限制到 Subject;
  • 监控 Stream Bytes、Message Count、Consumer Pending、Ack Pending 和 Redelivery;
  • 定期测试节点丢失、磁盘满和恢复过程。

每条 Stream 都要明确:

text
Subject 范围
Retention Policy
Max Age / Max Bytes / Max Messages
Storage 类型
Replica 数
Consumer 数量上限
消息大小上限

不要创建一个捕获 > 的永久 Stream,把所有消息无限保留。这样既失去 Subject 的隔离,也会让磁盘容量无法预测。业务通知保留 1 ~ 7 天,短任务只保留到消费完成,审计数据则进入专门存储,通常比所有数据使用同一策略更合理。

安全上,不要让所有服务都能发布和订阅全部 Subject。订单服务可以发布 orders.*,库存服务只订阅必要 Subject,平台工具才有管理 JetStream 的权限。NATS Account 还能进一步隔离团队和环境。

备份需要根据恢复目标设计。JetStream 副本解决节点故障,不等于备份;错误删除 Stream 或集群级存储故障可能影响所有副本。重要数据应使用 Stream Snapshot、异地复制或同时落入业务数据库。消息系统不能成为唯一的审计来源。

十二、什么时候从 NATS 迁移到 Kafka

不要以消息数量增长了作为迁移理由。先确认瓶颈在 Broker,而不是 Consumer、数据库、单条消息过大、Subject 设计或存储类型。

迁移信号包括:

  1. JetStream 的存储和网络已经经过合理优化,实际吞吐仍无法满足峰值并留出故障余量;
  2. 数据保留从几天增长到数月,容量与回放开始成为主要工作负载;
  3. 新增大量独立消费方,每个都要求保留自己的长期读取位置;
  4. 数据团队明确需要 Kafka Connect、Flink、Kafka Streams 或成熟 CDC 生态;
  5. 业务已经能够按 Key 设计 Partition,并接受分区级顺序和 Consumer Group 模型;
  6. 组织愿意购买托管 Kafka,或建立独立团队维护专用集群。

迁移不必一次切换所有 Subject。可以使用桥接服务把关键事件同时写入 NATS 与 Kafka,先让新 Consumer 从 Kafka 读取。双写会产生一致性问题,因此要用 Outbox、稳定 Event ID 和对账指标控制迁移,而不是在业务代码中连续调用两个 Publish 后假定它们同时成功。

Kafka 稳定以后,保留 NATS 处理请求—响应、低延迟控制消息和普通任务也完全合理。消息系统不必全公司只剩一种;需要避免的是每个团队随意选择一种,导致平台同时维护五套集群。

十三、最终选择

对于一个以 Go 后端、主要运行在 Kubernetes 的团队,推荐如下。

默认使用 NATS。 临时通知和请求—响应使用 Core NATS;异步任务、需要确认的事件和短期重放使用 JetStream。客户端统一使用官方 Go Library,平台提供 Subject 命名、Stream 模板、Consumer 参数、监控和幂等示例。

NSQ 只维护存量。 它的模型和 Go 生态仍然简单可靠,但缺少内建复制与现代持久化能力。新系统没有必要再为这些边界编写额外拓扑。

RabbitMQ 按明确需求引入。 上游系统要求 AMQP,或业务确实需要 Exchange、Binding、Priority、Dead Letter 等成熟能力时使用。不要仅因为它成熟就首选。

Pulsar 用于平台级需求。 只有明确需要大型多租户、存储计算分离和跨地域消息平台,并有专职团队时考虑。普通业务事件不会因为换成 Pulsar 自动获得更好的性能。

极大吞吐和流式数据平台选择 Kafka。 Kafka 不与业务应用共用 Kubernetes 集群。优先使用托管服务;自建时放在独立基础设施中,单独规划磁盘、网络、升级和故障恢复。

最终决策可以压缩成下面这棵树:

是是更重视多租户与跨地域平台否是否允许不允许,需要重试或重放需要消息系统是否是大规模事件日志或流式平台是否有长期保留、CDC、Connector 或极大吞吐Kafka托管或独立集群Pulsar专职团队维护是否有明确 AMQP 或复杂Broker 路由需求RabbitMQNATS默认选择消息是否允许丢失Core NATSJetStream

这套结论不是说 NATS 的性能、可靠性和功能在所有维度都优于其他方案。它只是更符合当前团队的约束:Go 客户端成熟,Kubernetes 中部署相对简单,Core 与 JetStream 能覆盖大多数业务消息,而开发者不必先学习 Partition、BookKeeper 或复杂 Exchange 拓扑。

消息系统最重要的成本通常不在选型,而在落地后的容量、升级、故障。默认方案应覆盖常见需求,并让团队能够真正理解它的运作原理。只有业务指标证明默认方案已经不够时,再去拥抱 Kafka 或 Pulsar 的复杂度。

作者Aoang
发布于2026-09-22
更新于2026-09-22
许可协议CC BY-NC-SA 4.0
转载或引用本文时请遵守许可协议,并注明出处。