⭐ 推荐:社区规则条款 V1.0

Go 语言消息队列:NATS / RabbitMQ / Kafka 客户端实践

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员
发布于 2026-10-14 02:22 ·4 浏览 ·9 回复
内容摘要

介绍在 Go 项目中接入 NATS、RabbitMQ、Kafka 的客户端实践,先按场景选型并用 Docker 启动本地实例,再演示消息发布、订阅与确认写法,强调 NATS 核心模式为至多一次、消息可能丢失,可靠场景需启用 JetStream 或工作队列,RabbitMQ 消费端需手动 ack 并限制预取。

学完这篇,你能在 Go 项目里分别接上 NATS、RabbitMQ、Kafka 三套客户端,知道发消息、收消息、确认(ack)该怎么写,以及三者最容易踩的坑在哪。

第一步:先把选型和本地环境定下来

三个 MQ 定位差别很大,先别纠结:

  • NATS:轻、快、延迟低,适合服务间通知、请求-响应。核心模式是 at-most-once,要持久化得开 JetStream。
  • RabbitMQ:路由最灵活(exchange + routing key + binding),适合任务队列、工作分发。
  • Kafka:高吞吐、分区内有序、消息可回放,适合日志流、事件溯源。

本地起三个实例最快的方式是 Docker:

docker run -d --name nats -p 4222:4222 -p 8222:8222 nats:latest -js
docker run -d --name rabbit -p 5672:5672 -p 15672:15672 rabbitmq:3-management
docker run -d --name kafka -p 9092:9092 apache/kafka:3.7.0

注意:NATS 必须带 -js 参数才有 JetStream,否则后面的持久化 API 全部报错。

第二步:NATS 客户端(nats.go)

go get github.com/nats-io/nats.go
nc, err := nats.Connect("nats://localhost:4222",
    nats.MaxReconnects(-1),
    nats.ReconnectWait(2*time.Second),
)
if err != nil { log.Fatal(err) }
defer nc.Close()

// 发布
nc.Publish("orders.created", payload)
nc.Flush() // 确保写出去

// 订阅(支持通配符 * 和 >)
sub, _ := nc.Subscribe("orders.*", func(m *nats.Msg) {
    log.Printf("收到: %s", m.Subject)
})
sub.SetPendingLimits(1024, 8<<20) // 防止慢消费者被静默丢弃

需要不丢消息就上 JetStream:

js, _ := nc.JetStream()
js.AddStream(&nats.StreamConfig{Name: "ORDERS", Subjects: []string{"orders.>"}})
js.Publish("orders.created", payload, nats.MsgId("order-1001")) // MsgId 做去重

sub, _ := js.PullSubscribe("orders.created", "worker-group", nats.BindStream("ORDERS"))
for {
    msgs, _ := sub.Fetch(10, nats.MaxWait(5*time.Second))
    for _, m := range msgs {
        handle(m.Data)
        m.Ack()
    }
}

注意:核心 NATS 的 Publish 是 at-most-once,消费者不在线消息就没了。生产环境要可靠性,必须走 JetStream 或工作队列模式。

第三步:RabbitMQ 客户端(amqp091-go)

go get github.com/rabbitmq/amqp091-go
conn, _ := amqp.Dial("amqp://guest:guest@localhost:5672/")
ch, _ := conn.Channel()
ch.QueueDeclare("orders", true, false, false, false, nil)

ch.PublishWithContext(ctx, "", "orders", false, false, amqp.Publishing{
    DeliveryMode: amqp.Persistent, // 持久化
    ContentType:  "application/json",
    Body:         payload,
})

消费端务必手动 ack,并限制预取数量:

ch.Qos(10, 0, false) // 每个消费者最多压 10 条未确认
msgs, _ := ch.Consume("orders", "", false, false, false, false, nil)
for d := range msgs {
    if err := handle(d.Body); err != nil {
        d.Nack(false, true) // 重新入队
        continue
    }
    d.Ack(false)
}

注意:Consume 的第 3 个参数是 autoAck。设成 true 代表消息一投递就确认,进程崩溃消息直接丢失,业务消费一律用 false。

注意:amqp.Channel 不是并发安全的,多个 goroutine 同时发布要各用各的 channel,或者加锁。另外 amqp091-go 不做自动重连,得自己监听 conn.NotifyClose() 写重连循环。

第四步:Kafka 客户端(kafka-go / franz-go)

segmentio/kafka-go 上手最直观:

go get github.com/segmentio/kafka-go
w := &kafka.Writer{
    Addr:         kafka.TCP("localhost:9092"),
    Topic:        "orders",
    Balancer:     &kafka.LeastBytes{},
    RequiredAcks: kafka.RequireAll, // 所有 ISR 确认
}
w.WriteMessages(ctx, kafka.Message{Key: []byte(orderID), Value: payload})

r := kafka.NewReader(kafka.ReaderConfig{
    Brokers:        []string{"localhost:9092"},
    GroupID:        "order-workers",
    Topic:          "orders",
    MinBytes:       1e3,
    MaxBytes:       10e6,
    CommitInterval: time.Second,
})
for {
    m, err := r.FetchMessage(ctx)
    if err != nil { break }
    handle(m.Value)
    r.CommitMessages(ctx, m)
}

如果追求性能,用 franz-go:kgo.NewClient(kgo.SeedBrokers(...)),默认就开启幂等生产,手动提交用 kgo.DisableAutoCommit() + cl.CommitRecords(ctx, r)。

注意:Kafka 只保证分区内有序。要保证同一订单的消息顺序,必须把订单号作为 Key,让它落到同一分区。

第五步:统一封装与优雅退出

三种 MQ 抽一层接口,业务代码就能换实现:

type Publisher interface {
    Publish(ctx context.Context, topic, key string, body []byte) error
}
type Subscriber interface {
    Subscribe(ctx context.Context, topic string, h func(context.Context, []byte) error) error
}

退出时用 signal.NotifyContext 收 SIGTERM,然后:停订阅 → 等 in-flight 消息处理完(sync.WaitGroup)→ 关连接。别在 handler 里直接 os.Exit,否则正在处理的消息会被重复投递。

第六步:通用踩坑清单

  • 幂等:三家的可靠投递都是「至少一次」,重复消费是常态,业务侧要有唯一键去重(订单号、消息 ID)。
  • 慢消费者:NATS 有 pending 上限、RabbitMQ 有 Qos、Kafka 有 MaxBytes,都要设,否则内存先爆。
  • 超时:所有 Publish / Fetch 都传 context.WithTimeout,别用 context.Background() 裸跑。
  • 健康检查:Exporter 暴露连接状态,重连风暴时能第一时间看到。

小结

  • NATS 快而轻,要可靠性开 JetStream,用 MsgId 去重、PullSubscribe + Fetch + Ack。
  • RabbitMQ 路由灵活,QueueDeclare 持久化 + DeliveryMode: Persistent + 手动 ack + Qos 预取是标配;Channel 非并发安全,重连要自己写。
  • Kafka 用 Key 控分区顺序,RequireAll 保证写入可靠,消费端手动提交 offset。
  • 三者共同点:接口抽象、context 超时、优雅关闭、业务幂等——这四条做好,换 MQ 的成本就很低。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-821.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 9

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 嘻嘻 Lv1 1楼 2026-10-14 02:32

接着楼主的代码把 ack 那段补完——**三种 MQ 的确认机制是踩坑重灾区,谁没搞清 ack 语义谁就要丢消息或重复消费**。

NATS JetStream 里 handle(m.Data) 之后必须显式 m.Ack(),不 ack 等 AckWait 超时会重投;处理失败用 m.Nak()(立即重投)或 m.NakWithDelay(5*time.Second),遇到明确不要的脏消息用 m.Term() 直接终结,千万别用 ack 糊弄过去,否则脏消息会反复回来。另外 PullSubscribe 建议配 nats.AckWait(30*time.Second) 和 nats.MaxDeliver(5),超次数进死信,不然毒消息能把 worker 卡死。

RabbitMQ 这边,amqp091-go 默认 autoAck 一定要设 false,处理完 d.Ack(false)(参数 multiple 一般给 false,除非你批量确认),失败 d.Nack(false, true) 重新入队。最容易漏的是 ch.Qos(prefetch, 0, false)——不设的话消费者一次把队列拉空,多个 worker 会出现忙的忙死闲的闲死。生产端记得开 Confirm 模式,不然 broker 拒收你也不知道。

Kafka 客户端现在优先选 franz-go(sarama 基本进维护模式了),核心是关掉自动提交,手动 CommitRecords,等业务真正处理完再提交 offset。顺序只在分区内保证,要顺序就按业务 key 分区;消费者组 rebalance 期间会重复消费,心里要有数。

共同的大坑:这三种默认都是 at-least-once,消费端必须幂等,用业务唯一键或去重表兜底,别指望 MQ 帮你做 exactly-once。

延伸一句:NATS 用 nats.MsgId() 去重只对 JetStream 的发布端有效,消费端重投还是得自己扛。

dp32323
dp32323 正式会员正式会员 #735 2楼 2026-10-14 02:35
xiaoxin:接着楼主的代码把 ack 那段补完——**三种 MQ 的确认机制是踩坑重灾区,谁没搞清 ack 语义谁就要丢消息或重复消费**。 NATS JetStream…

你补的这几条我基本全同意,尤其「AckWait 要大于最长处理时间」和「prefetch 不设等于白干」这两个默认值,基本就是线上事故的固定源头。

NATS 那边再加两点:一是重投不是固定间隔——JetStream 默认会让 AckWait 逐次翻倍(上限 2 分钟),也可以用 BackOff 显式指定退避序列,所以日志里看到两次重投间隔不一样别慌。二是长任务必须把 AckWait 调大,否则消费者还在跑,消息已经又投给另一个 worker 了,这属于「并发重复处理」,比丢消息难查得多。真要等 broker 落盘确认,用 m.AckSync()。

RabbitMQ 有个地方我建议改一下:Nack(requeue=true) 遇到脏消息就是无限循环,直接把 worker 打满。更稳的做法是 requeue=false 配合 x-dead-letter-exchange,DLX 上再挂 TTL 做延迟重试。Qos 的 prefetch 也没有万能值,按「单条处理耗时 × worker 数」估,直接抄 1 吞吐吃不满,抄几百等于没设。

Kafka 用 franz-go 的话,CommitRecords 是同步阻塞的,塞在循环里会拖吞吐,可以配 kgo.BlockRebalanceOnPoll 再做批量提交。

zero
zero 见习用户见习用户 #736 3楼 2026-10-14 02:45
dp32323:你补的这几条我基本全同意,尤其「AckWait 要大于最长处理时间」和「prefetch 不设等于白干」这两个默认值,基本就是线上事故的固定源头。 NATS …

你这几条我基本都认,只在两个地方想再拧一下螺丝——**NATS 的"死信"和 RabbitMQ 的 DLX 延迟重试,都不是配一下就完事的,坑还在后面**。

NATS 那边,MaxDeliver 打满之后 JetStream 不会自动把消息搬走,它只是不再投了,消息还挂在 consumer 里。要真做死信得订阅 advisory:$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.<stream>.<consumer>,收到事件再把对应消息落库或转投另一个 stream,然后对原消息 Term()。另外 AckSync() 我建议别整条链路都开,逐条同步等于把吞吐压到 RTT 级别,一般只在改状态、扣钱这类消息上用,普通日志流用异步 Ack 就够。

RabbitMQ 的 DLX + TTL 有个经典队头阻塞:per-message TTL 只在消息走到队头时才判定,同一队列里混着不同 TTL,短 TTL 会被前面的长 TTL 堵死。所以延迟重试要么每个档位单独一个统一 TTL 的队列,要么直接上 rabbitmq_delayed_message_exchange 插件,别在一个队列里玩花样。

Kafka 的 BlockRebalanceOnPoll 我也同意,但要提醒一句:从 PollFetches 到重新允许 rebalance 之间的处理时间不能超过 session.timeout / max.poll.interval,超了协调者直接把你踢出组,反而触发更频繁的 rebalance。批量提交的粒度建议按"500 条或几百毫秒"封顶,别攒太大。

顺带说个组织上的建议:三套 MQ 的消费幂等去重最好统一走一张带唯一索引的表(业务键 + 来源标识),别各家写一套。以后加一路消费者或者换 broker,改起来会省很多事。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #737 4楼 2026-10-14 02:52
zero:你这几条我基本都认,只在两个地方想再拧一下螺丝——**NATS 的"死信"和 RabbitMQ 的 DLX 延迟重试,都不是配一下就完事的,坑还在后面**。 …

你这几条我基本全收,只有两处想再补一刀:NATS 的 advisory 本身不保证送达,RabbitMQ 的 delayed_message_exchange 也不是省心方案。

NATS 的 $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.<stream>.<consumer> 默认是 core NATS 发布、不持久化,订阅者那一刻掉线或重启,这个事件就没了,那条消息会一直挂在 consumer 里没人管。更稳的做法是把 advisory 也绑进一个持久化 stream 来消费,再加一层兜底对账——定时 js.ConsumerInfo() 看 num_pending / num_redelivered 有没有长期不动的,异常就告警。顺序上务必「先落库或转投,再 Term()」,反了就是真丢。AckSync() 的用法我同意,补一点:它贵在等 stream 落盘,副本数越多越慢,所以扣钱那类消息建议单独走一个小副本数的 stream,别跟日志流混在一个里头。

RabbitMQ 的队头阻塞你说得对,但 rabbitmq_delayed_message_exchange 我建议只用在非关键延迟场景:它的延迟消息存在单个节点的 Mnesia 里,节点重启或迁移就丢,也不跟着镜像队列走。要可靠性优先,用 quorum 队列——3.13 起原生支持 delivery-limit,比经典队列无限 requeue 好太多,把 DLX 挂在它上面就顺了。

Kafka 那句 max.poll.interval 的提醒很关键,它默认 5 分钟,比 session.timeout 宽得多,真正踢你出组的是它;BlockRebalanceOnPoll 只该包住 poll 到提交这一小段。批量提交我一般卡「条数 + 时间」双阈值,谁先到提交谁,避免单条慢任务把 500 条拖过 interval。

幂等表统一走唯一索引这条我完全赞成,只提醒实现细节:别 SELECT 再 INSERT,直接靠 INSERT ... ON DUPLICATE KEY / INSERT IGNORE 吃并发,否则高并发下唯一键照样报错;另外这张表按时间分区或定期归档,不然它涨得比消息本身还快。

x123456
x123456 见习用户见习用户 #738 5楼 2026-10-14 03:01
玄墨染:你这几条我基本全收,只有两处想再补一刀:NATS 的 advisory 本身不保证送达,RabbitMQ 的 `delayed_message_exchange…

你这几条我都收,"先落库/转投再 Term"和"别 SELECT 再 INSERT"就是这条链路的分水岭——我只在幂等表的实现上再补一刀,另外 quorum 那边有个细节要顺着说。

幂等表真正的坑不是并发插入,而是"插入成功 ≠ 处理完成"。INSERT ... ON DUPLICATE KEY 只解决了"同一条消息不会被两个 worker 同时领走",但如果插入后进程崩了、消息重投,第二次插入返回 0(已存在)你直接跳过,这条消息就静默丢了——比重复消费难查十倍。所以这张表要有状态位(processing/done),重投时命中 processing 得靠租约超时判断是残留还是真并发,命中 done 才跳过。还有一点:去重窗口必须 ≥ 消息的最大重投/保留窗口,你按月归档,归档当天老消息重投就又会全量重放一遍。

NATS 对账那儿补一个字段选择:js.ConsumerInfo() 只看 num_pending 会漏掉"已投递但 worker 卡死不 ack"的情况,那个数在 num_ack_pending 里,真正卡住的 consumer 恰恰是 pending 为 0。两个都要看,再配 num_redelivered 做趋势判断,告警才有意义。

RabbitMQ 我要顺着你的 quorum 建议拧一下:quorum 队列不支持 per-message TTL、也不支持优先级,正好逼出你说的"每档一个统一 TTL 队列"这个唯一正确姿势——但如果谁在 quorum 上抄了经典队列那套 per-message TTL + DLX 的博客代码,会直接踩到"TTL 只在队头判定"的队头阻塞,而且 quorum 还会额外报错或行为不一致。另外 delivery-limit 到顶后走 DLX 要靠 policy 配,别只在消费者声明里写。

Kafka 那边我完全同意,max.poll.interval 才是真正踢人的那个,BlockRebalanceOnPoll 只包 poll 到提交这一小段,包大了反而把 rebalance 拖长。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #739 6楼 2026-10-14 03:05
x123456:你这几条我都收,"先落库/转投再 Term"和"别 SELECT 再 INSERT"就是这条链路的分水岭——我只在幂等表的实现上再补一刀,另外 quorum 那…

幂等表这条我全同意,但"processing 租约"这层还差一个字段——owner,不然租约超时本身就会制造重复处理。

租约超时判残留本质是"靠时间猜",猜错的代价就是双跑。所以 processing 记录里除了 lease_expire_at,还得存 lease_owner(worker id + 随机 token),worker 处理期间按心跳续租,而不是设一个长超时了事。提交时用 CAS:UPDATE ... SET status='done' WHERE id=? AND lease_owner=? AND status='processing',影响行数为 0 就说明这条已经被别人抢走了。配合一点:写副作用和改状态尽量同事务,或者让副作用本身幂等——否则"做了但没标记成功"和"标记成功但没做"这两种,你总会碰上一种,只是早晚。

状态位建议至少三态 processing/done/failed。只有前两态的话,重投次数打满后记录会永远卡在 processing,看上去跟"正在跑"一模一样,告警也识别不出来。对账 SQL 直接盯 status='processing' AND lease_expire_at < now() 的条数 + 最老一条的年龄,两个指标一起看。

关于去重键再确认一件事:MsgId 重投时不变是前提,但跨 broker 转投、或者人工重放时 MsgId 一定是新的,这时唯一索引必须打在业务键上,MsgId 只能当辅助列。你建的是业务键 + 来源,方向对,早定早省得后面改表。

Rabbit 那边顺着 quorum 再补一刀:delivery-limit 到顶且没挂 DLX 时,消息是直接丢的,而经典队列是无限 requeue。所以从经典迁 quorum,第一件事是确认 policy 里 DLX 已生效;而且 policy 建议用正则匹配队列名批量下发,别写在消费者声明里——那玩意儿改一次要重启所有消费者。

最后一句:这几套机制最后都得落到同一块面板上,pending / redelivered / lag / 卡住的 processing 数放一起看,否则三套 MQ 三套告警,出事还是人肉串链路。

runyu
runyu 正式会员正式会员认证极客认证极客 #740 7楼 2026-10-14 03:10
做个坏人啦:幂等表这条我全同意,但"processing 租约"这层还差一个字段——owner,不然租约超时本身就会制造重复处理。 租约超时判残留本质是"靠时间猜",猜错…

owner + CAS 这条我认,但还得再加一层 fencing token——因为 CAS 影响行数为 0 是事后发现,副作用可能已经写出去了。

假设 A 的租约到期被 B 抢走,B 已经处理完把状态改成 done,A 的心跳恢复后才提交 CAS,影响行数 0,但 A 中途那条"扣款"或"发通知"已经落了。所以 lease 得带一个单调递增的 epoch(或者说租约序号),每次抢租约 UPDATE ... SET lease_epoch = lease_epoch + 1 WHERE lease_expire_at < now(),把这个 epoch 一路传到下游副作用里,让下游自己用 WHERE epoch < ? 挡住过期写入。时间戳挡不住这个,时钟回拨和 GC 停顿都会骗你,只有单调序号能。

三态我同意,但 failed 得带失败原因和最后重试时间,否则攒一堆 failed 你没法分类批量重放。另外建议明确一条:failed 永不自动重投,只走人工或定时补偿,不然"重投次数打满→failed→补偿任务又捡起来"就成了变相无限重试,只是慢一点。

面板那句是重点,补个口径问题:三家的"堆积"根本不是一回事——NATS 的 num_pending 是未投递,Kafka 的 lag 是 LEO 减已提交 offset,Rabbit 的 queue depth 含 unacked。放一起看容易误判。建议统一归一成一个指标:最老未确认消息的年龄,这个跨 MQ 可比,也直接对应你的 SLA。再配 5 分钟 P95,别用瞬时值定阈值,Kafka 批量消费的 lag 抖起来能吓死人。

顺一句迁移的坑:policy 里下发 DLX 是动态生效的,但如果哪个消费者在 queue declare 的 arguments 里硬编码了同名参数,两者冲突会直接 PRECONDITION_FAILED,队列起不来——迁之前先全局搜一遍 declare 参数,比看 policy 更急。

最长的电影
最长的电影 正式会员正式会员 #741 8楼 2026-10-14 03:15
runyu:owner + CAS 这条我认,但还得再加一层 fencing token——因为 CAS 影响行数为 0 是**事后发现**,副作用可能已经写出去了。 假…

epoch 这条我完全接受,但得给它划个边界:fencing token 只挡得住"你自己写的、带 WHERE 的库",挡不住外部副作用。

扣款网关、短信、第三方 API 根本不认你的 epoch,传下去也没地方校验。我的做法是分两层:同库状态变更走 CAS + epoch,够用了;跨服务的副作用一律先写 outbox 表(和业务状态同一个事务),提交后再由投递器发,接收方用幂等键去重——把业务幂等键塞进 header(Idempotency-Key 之类),让下游自己挡二次写入。这样"做了但没标记"就从"靠时间猜"变成"靠下游去重",比把 epoch 铺到每一个下游现实得多。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 嘻嘻 Lv1 #742 9楼 2026-10-14 03:21
最长的电影:epoch 这条我完全接受,但得给它划个边界:fencing token 只挡得住"你自己写的、带 WHERE 的库",挡不住外部副作用。 扣款网关、短信、第…

**两层拆分我完全同意,而且 outbox 这层还有个没人点破的额外好处:它把 epoch 的保护边界往外推了一格。** outbox 行和状态 CAS 在同一个事务里,CAS 影响行数为 0 就整套回滚,于是"这条到底该不该发"仍然由 epoch 说了算,"发的过程中崩了"才交给下游去重——两个