NATS JetStream 消费者 ack 与重投递:重复消费治理实战

团队把 Kafka 换成 NATS JetStream 之后,第一周最常见的事故不是丢消息,而是同一笔订单被下游处理两次。JetStream 默认是至少一次投递。消费者崩溃、ack 超时、Nak 重试,都会让消息再次出现。如果不把 ack 语义和业务幂等绑在一起,重复消费就是必然事件。

投递保证先说清楚

三种 ack 结果对应三条路径

Ack() 告诉服务端可以删这条。Nak() 表示处理失败,立刻或延迟重投。InProgress() 只延长 ackWait,不代表成功。很多代码把耗时 RPC 塞进消费循环,却既不 InProgress 也不加大 AckWait,结果服务端按超时重投,客户端其实还在处理第一份。

错误写法:

go 复制代码
func handle(msg jetstream.Msg) {
    order := decode(msg.Data())
    charge(order) // 可能 8 秒
    _ = msg.Ack()
}

如果 AckWait 是 5 秒,charge 还没返回,JetStream 已经把消息交给下一个消费者。正确写法是先拉长窗口,再按结果选择 Ack 或 Nak:

go 复制代码
func handle(msg jetstream.Msg) {
    _ = msg.InProgress()
    order := decode(msg.Data())
    if err := charge(order); err != nil {
        _ = msg.NakWithDelay(2 * time.Second)
        return
    }
    _ = msg.Ack()
}

NakWithDelay 比无脑 Nak() 温和,能避开下游瞬时故障造成的重试风暴。

消费者参数怎么配

AckWait、MaxDeliver、MaxAckPending

一组能跑进生产的起点:

参数 建议 原因
AckWait 业务 P99 的 3 倍 覆盖慢请求,减少误重投
MaxDeliver 5 毒消息不能无限循环
MaxAckPending 等于并发数 过大导致崩溃后重放窗口膨胀
InactiveThreshold 30s 消费者掉线后尽快让别人接盘
go 复制代码
cons, err := js.CreateOrUpdateConsumer(ctx, "ORDERS", jetstream.ConsumerConfig{
    Durable:       "order-charger",
    FilterSubject: "orders.created",
    AckPolicy:     jetstream.AckExplicitPolicy,
    AckWait:       15 * time.Second,
    MaxDeliver:    5,
    MaxAckPending: 64,
    ReplayPolicy:  jetstream.ReplayInstantPolicy,
})

AckExplicitPolicy 必须开。AckNone 看起来省事,等于关闭重试,故障即丢。AckAll 会把中间失败的消息一并确认,财务类业务不能用。

重复消费只能靠业务幂等

消息层做不到精确一次。幂等键用 msg.Headers().Get("Nats-Msg-Id") 或业务订单号,写入下游唯一索引。

go 复制代码
func chargeOnce(ctx context.Context, db *sql.DB, order Order) error {
    _, err := db.ExecContext(ctx,
        `INSERT INTO charge_dedup(order_id) VALUES ($1) ON CONFLICT DO NOTHING`,
        order.ID,
    )
    if err != nil {
        return err
    }
    var inserted bool
    if err := db.QueryRowContext(ctx,
        `SELECT EXISTS(SELECT 1 FROM charge_dedup WHERE order_id=$1)`, order.ID,
    ).Scan(&inserted); err != nil {
        return err
    }
    // 更干净的做法是用 RowsAffected 判断是否首次插入
    return doCharge(ctx, order)
}

更好的 SQL 是一条语句看影响行数:插入成功才扣款,冲突就视为已经处理,直接 Ack。千万不要先扣款再写去重表,崩溃会发生在两步之间,重启后会再扣一次。

毒消息和死信

达到 MaxDeliver 后消息会停在消费者的待确认集合里,不会自动消失。需要单独的监控把 NumRedelivered 超阈值的消息转到死信科目,避免阻塞 MaxAckPending。

go 复制代码
info, _ := cons.Info(ctx)
if info.NumAckPending > 50 {
    log.Printf("ack pending too high: %d", info.NumAckPending)
}

处理死信时读 Nats-Delivery-Count 头。计数到 5 还失败,多半是代码缺陷或脏数据,继续重试只会烧 CPU。

总结

JetStream 的默认语义是至少一次。把 AckWait 设成业务 P99 的三倍,消费过程中对长任务调用 InProgress,失败用 NakWithDelay,成功才 Ack。MaxDeliver 封顶重试,下游用唯一索引做幂等,插入成功才产生副作用。按这个顺序配,重复扣款会从"隔几天爆一次"变成可观测的死信,而不是账本事故。

相关推荐
我不会起名字3221 天前
Kafka 顺序消息为什么乱了:分区数变化与重试的 3 类原因
kafka·消息队列·顺序·排查
Sayai2 天前
Kafka 磁盘空间治理实战:retention 保留策略、log.dirs 在线扩容、分区跨盘迁移与不停机整体换盘
大数据·运维·分布式·kafka·消息队列
张彦峰ZYF5 天前
一条消息的丢与重:Kafka 与 RabbitMQ 可靠投递的确认、位点、死信与幂等全链路
后端·kafka·消息队列·rabbitmq·isr·解耦与削峰·丢失与重复
做个文艺程序员6 天前
MQ第12篇:AI原生消息架构演进:从MQ到事件驱动的全链路设计
架构·消息队列·wpf·事件驱动架构·ai-native·cqrs
我不会起名字3227 天前
Kafka 分区数到底设几个:吞吐量、顺序性与 rebalance 代价的取舍
kafka·消息队列·调优·分区
我不会起名字3227 天前
Go 写 Kafka 消费者:sarama 的 3 个坑与优雅退出写法
kafka·消息队列·go·sarama
j7~11 天前
【仿 RabbitMQ 项目】发布订阅消息队列 (篇八):交换机数据管理模块,分层类设计、SQLite 回调原理与代码实现
服务器·网络·数据库·消息队列·交换机·交换机数据管理·sqlite回调原理
做个文艺程序员13 天前
MQ第04篇:ActiveMQ深度解析:JMS规范、Spring JMS整合与企业级异步通信实战
spring·消息队列·activemq·java-activemq·jms
做个文艺程序员13 天前
MQ第03篇:RabbitMQ进阶实战:Topic路由+死信队列+延迟消息,SaaS订单超时取消完整方案
分布式·消息队列·rabbitmq·死信队列·延迟队列