侧边栏壁纸
  • 累计撰写 129 篇文章
  • 累计收到 2 条评论

Kafka 太重了?我用 NATS 替换掉之后,消息系统从 8G 内存降到 200M,几个坑说清楚

2026-7-18 / 0 评论 / 3 阅读
🤖AI摘要
本文讲述了作者用NATS替换Kafka作为消息系统,从8G内存降至200M内存的经验。文章对比了Kafka和NATS的优缺点,指出NATS更适合简单场景,并分享了迁移过程中遇到的两个问题:误以为NATS默认落盘而实际不是,以及消费者ACK操作不当导致消息重复。作者针对这些问题提供了解决方案,并强调正确配置stream和ACK机制的重要性。

之前项目用的是 Kafka,三台机器组集群,啥都不干就吃掉 8G 多内存。峰值 QPS 也就每秒三四千条,用 Kafka 杀鸡用牛刀了。纠结了一段时间,换成 NATS 单实例,200M 内存扛同样流量,如今稳了半年多。

迁移过程摔了几次。记录一下。

为什么不选 RabbitMQ

讲选型之前先把话说在前面,我不替任何人推荐选型,下面只是我家的情况。

RabbitMQ 评估了一阵。它的路由模型偏 AMQP 那个路子,exchange/binding 一套全要定义,我们家就两三种消息场景,把 exchange 和 queue 绑起来再维护这套拓扑,显得挺重。NATS 的 subject 语义直接很多,orders.createdpayments.result 这种点分命名,发布者往 subject 丢,订阅者按 subject 匹配,支持通配符。orders.* 能收到 orders 下所有消息。跟我们业务划分天生对得上。

再有一件事让我对 RabbitMQ 敬而远之:消息确认机制(publisher confirm)那一套,哪个该开哪个该关我没看明白,文档分散在好几页串不起来。NATS 的 ACK 语义就两条:stream 模式有 ACK,core 模式没有。省心。

RabbitMQ 在很多复杂路由拓扑和死信队列场景里是成熟选择,不替别人背书也不替它站台。我家场景太简单了。

坑一:以为是 Kafka 那样默认落盘,其实不是

刚上手我犯了个低级错误:以为 NATS 跟 Kafka 一样消息是落盘的。NATS 实际有两种模式。core 模式纯内存 fire-and-forget,消息发出去了,有订阅者就收到,没有就丢。JetStream 才有持久化、ACK 和重投。

写了个订单服务往 orders.created 发消息,消费端正好重启的时候来了一批消息。

全没了。

nats stream info 报错说 stream 不存在。我才反应过来根本没建 stream。

JetStream 要手动创建 stream,或者让发布端在发送时指定自动建。我后来在配置里显式定义:

nats stream add ORDERS \
  --subjects "orders.>" \
  --storage file \
  --retention limits \
  --max-msgs 100000 \
  --max-age 168h

--storage file 是落盘,--retention limits 是消息到上限按策略删旧消息,--max-age 168h 留七天。

有 stream 之后发布端要换 API:用 js.Publish 而不是 core 的 nc.Publish。Go 客户端的区分这样:

// 错误:core 模式,不持久化
nc.Publish("orders.created", data)

// 正确:JetStream 模式,有 ACK 和持久化
js, _ := jetstream.New(nc)
js.Publish(ctx, "orders.created", data)

这个坑坑了我一个下午。我以为客户端只是命名差异,API 一个换一个,实际是两个完全不同的协议路径。

坑二:消费者 ACK 忘了调

JetStream 消费者手动确认模式下,处理完消息得主动 Ack。我第一次写消费者时候漏了这一步。消息处理完正常返回,过一会又收到同一条消息,NATS 认为没收到 ACK 就重投。

sub, _ := js.PullSubscribe("orders.created", "order-processor")
msgs, _ := sub.Fetch(10)
for _, msg := range msgs {
    processOrder(msg.Data)
    // 这行漏了
    msg.Ack()
}

Ack 的另一个坑是超时。默认 30 秒,处理逻辑超过这个时间没 Ack 同样被重投。我们有个对账任务跑三分钟,刚开始莫名其妙消息重复,排查一阵发现是 ack 超时。给这个 consumer 单独设了 --ack-wait 5m

nats consumer add ORDERS order-processor \
  --ack explicit \
  --ack-wait 5m \
  --max-deliver 10 \
  --pull

--max-deliver 10 最多重投十次,超过进死信。这个值因场景而异:消费端偶尔 panic 的话留够重试余量;如果是确定性的处理失败(脏数据),让它重试十次纯属浪费,重投多少次也是一样的错。

坑三:subject 通配符 > 只能放末尾

NATS subject 用 . 分段,通配符两种:

* 匹配单段。orders.* 匹配 orders.created,但匹配不了 orders.payment.confirmed

> 匹配多段,只能放在 subject 最末尾。orders.> 能匹配 orders.created 也能匹配 orders.payment.confirmed

我一开始把 subject 写成 orders.>.created,订阅器直接报错。文档里其实写了,当时急着上线没仔细看,绕了二十分钟。

通配符还有个性能坑。subject 匹配是哈希查表加层级递进,路径特别深的时候(实测过十几段以上)匹配开销会变明显。一般写不到那么深,但有人喜欢 company.team.service.module.action.subaction 这种十八层命名,那就得注意。

坑四:max_msgs 不是 max_bytes,盘会塞满

stream 容量上限我一开始设的 --max-msgs 100000,十万条。问题是消息大小不固定,我们有些事件 payload 一条好几十 KB,十万条就是好几 GB。

盘塞满了。

正确的做法是条数和字节数一起设:

nats stream edit ORDERS \
  --max-msgs 100000 \
  --max-bytes 5368709120  # 5GB

两个条件哪个先触到按哪个算。--max-age 也一并设上做时间兜底,老消息自动过期。

另外一件事让我后怕:NATS 不会主动告诉你磁盘快满了,只在写入失败时打 error 日志。我当时磁盘 90% 才发现,差点把服务搞挂。事后我加了个 Prometheus 接 node_exporter 的 disk usage 告警,阈值 75% 报 warning,85% 报 critical。这事给我的教训是,盘空间监控是基础设施,不是可选项。

坑五:客户端连接没设 timeout

NATS 默认连接行为里,server 挂了或网络分区客户端会重连。Go 客户端默认最大重连次数 60 次,间隔指数退避。

问题在连接建立阶段。server 拒连或 DNS 解析慢,nats.Connect() 会阻塞。我有一次碰到 DNS 出问题,整个服务起来卡了快一分钟才报错。从那以后我都显式设 timeout:

nc, err := nats.Connect(
    natsURL,
    nats.Timeout(5*time.Second),
    nats.ReconnectWait(2*time.Second),
    nats.MaxReconnects(-1),  // 无限重连
    nats.DisconnectErrHandler(func(nc *nats.Conn, err error) {
        log.Warn("NATS disconnected: %v", err)
    }),
    nats.ReconnectHandler(func(nc *nats.Conn) {
        log.Info("NATS reconnected to %s", nc.ConnectedUrl())
    }),
)

MaxReconnects(-1) 无限重连,2 秒间隔。生产环境我宁愿一直重试也别放弃,对端 NATS 不会无故长期下线。

坑六:cluster 配置三个 host 写成同一个

NATS cluster 配置文件里 routes 那行要注意。我当时复制粘贴改配置,结果三个节点写的全是同一个 host。跑起来一片错误的 OK 包——节点之间连不上,但单节点不报错,监听端口都活着,健康检查都过。

排查用了快半小时,最后看日志看到:

[ERR] Error trying to connect to route: dial tcp 10.0.0.2:6222: connect: connection refused

去对 --routes 那行才发现写错了。NATS cluster 配置长这样:

cluster {
  name: "my-cluster"
  routes = [
    nats-route://10.0.0.1:6222
    nats-route://10.0.0.2:6222
    nats-route://10.0.0.3:6222
  ]
}

每个节点要把除了自己以外的其他节点列上。文档说包括自己也没事,NATS 自己会跳过,但我习惯只列其他两个,干净。这个坑不太 typical,是低级人为错误,但坑住过一次就长记性。

JetStream 监控指标怎么看

JetStream 自带 /jsz 端点,输出 JSON。我接 Prometheus 用 nats-exporter,关键看几个值:

nats_stream_messages 当前队列里的消息数;nats_stream_bytes 当前字节数;nats_consumer_num_pending 每个消费者未 ACK 消息数;nats_consumer_num_ack_pending 已发出但还没收到 ACK 的消息数。

最后一个比较隐蔽。如果它持续增长,说明消费者在处理但来不及 Ack:要么消费端慢,要么卡住了。慢就扩消费端副本,卡住就得重启,或者看是不是死锁了。

关于 Kafka → NATS 迁移这一段

最折腾的是改业务代码。Kafka 的 topic、partition、consumer group 一套概念换成 NATS 的 subject、stream、consumer 概念之后,业务侧对接逻辑全要重写。光消息确认这一块,Kafka 的 offset 提交和 NATS 的 Ack 差异就让我们改了一个下午。

平滑过渡我搞了双写期:新消息同时发 Kafka 和 NATS,消费端先切到 NATS,但保留 Kafka 消费做对账。跑了一周,对账数据基本一致后停掉 Kafka。

双写期间发现过两次 NATS 那边 ACK 超时的 bug(坑二那个)。对账发现 Kafka 那边多了一些 NATS 没收到的消息,反过来用 Kafka 的消费补进了 NATS。如果没有这段双写期,那次 ACK bug 会变成数据丢失,那时再查就难了,日志早被轮转覆盖。

半年用下来,内存从 8G 降到 200M 是真实的,三机集群变单机也是真实的。流量不大单机足够,备份用文件复制到另一台机器做冷备。选型没法做普适推荐,什么样的人家适配什么样的系统。我自己就是个反例:用 Kafka 用了两年才发现其实是过度选型。

评论一下?

OωO
取消