之前项目用的是 Kafka,三台机器组集群,啥都不干就吃掉 8G 多内存。峰值 QPS 也就每秒三四千条,用 Kafka 杀鸡用牛刀了。纠结了一段时间,换成 NATS 单实例,200M 内存扛同样流量,如今稳了半年多。
迁移过程摔了几次。记录一下。
为什么不选 RabbitMQ
讲选型之前先把话说在前面,我不替任何人推荐选型,下面只是我家的情况。
RabbitMQ 评估了一阵。它的路由模型偏 AMQP 那个路子,exchange/binding 一套全要定义,我们家就两三种消息场景,把 exchange 和 queue 绑起来再维护这套拓扑,显得挺重。NATS 的 subject 语义直接很多,orders.created、payments.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 用了两年才发现其实是过度选型。
评论一下?