【RabbitMQ #11】 | 消费者可靠性

一、消费者确认机制 Consumer Acknowledgement

为了确认消费者是否成功处理消息,RabbitMQ 提供了消费者确认机制(Consumer Acknowledgement)。 当消费者处理消息结束后,向 RabbitMQ 发送回执,告知消息处理状态。回执分为 3 种:

  • ack:成功处理消息,RabbitMQ 从队列删除该消息
  • nack:消息处理失败,RabbitMQ 可再次投递消息
  • reject:消息处理失败并拒绝该消息,RabbitMQ 直接删除消息

Spring AMQP 封装了确认功能,配置文件指定 ack 模式,共 3 种:

  1. none :自动 ack。消息投递到消费者立刻 ack,MQ 直接删除消息。不推荐,业务处理失败消息已经丢失,无法补救。
  2. manual:手动 ack。业务代码手动调用 ack/nack/reject,灵活,需要开发者自己处理异常。
  3. auto :自动模式(推荐)。Spring 利用 AOP 环绕拦截消息处理逻辑:
    • 业务正常执行 → 自动返回ack
    • 业务异常 → 自动返回nack
    • 消息校验 / 解析异常 → 自动返回reject

重点:Go amqp091-go SDK 没有 Spring 那种开箱即用的 auto 自动 AOP 模式 Spring 的auto是框架封装能力,原生 AMQP 协议底层只有两种:

  1. autoAck=true ≈ Spring none:消息投递直接 ack,MQ 删消息,不安全
  2. autoAck=false ≈ Spring manual:手动 ack,所有 ack/nack/reject 全部手写代码,自己捕获 panic、区分异常类型,模拟 Spring auto 逻辑

Go 代码:模拟 Spring auto 自动确认

Go 复制代码
package main
import (
	"log"
	"github.com/rabbitmq/amqp091-go"
)

func main() {
	conn, err := amqp091.Dial("amqp://guest:guest@127.0.0.1:5672/")
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	ch, err := conn.Channel()
	if err != nil {
		log.Fatal(err)
	}
	defer ch.Close()

	// autoAck=false 开启手动ack,模拟Spring auto模式
	msgs, err := ch.Consume(
		"test_queue", // queue
		"",           // consumer
		false,        // autoAck=false【核心】
		false,        // exclusive
		false,        // noLocal
		false,        // noWait
		nil,          // args
	)
	if err != nil {
		log.Fatal(err)
	}

	forever := make(chan struct{})
	go func() {
		for d := range msgs {
			// 模拟Spring AOP环绕,捕获panic
			func() {
				defer func() {
					// 捕获panic
					if r := recover(); r != nil {
						log.Printf("panic异常: %v", r)
						// 业务panic → Nack,消息重新入队
						_ = d.Nack(false, true)
					}
				}()

				// ========== 业务处理逻辑 ==========
				err := handleMsg(d.Body)
				if err != nil {
					// 判断异常类型
					if isMsgCheckErr(err) {
						// 消息校验异常 → reject,丢弃消息(不重投)
						log.Println("消息校验失败,reject丢弃")
						_ = d.Reject(false)
					} else {
						// 业务异常 → nack,消息重新投递
						log.Println("业务异常,nack重入队列")
						_ = d.Nack(false, true)
					}
				} else {
					// 正常处理成功 → ack,MQ删除消息
					log.Println("处理成功,ack")
					_ = d.Ack(false)
				}
			}()
		}
	}()

	log.Println("消费者启动,等待消息")
	<-forever
}

// handleMsg 业务处理函数
func handleMsg(body []byte) error {
	// 业务逻辑
	return nil
}

// isMsgCheckErr 判断是否为消息校验类异常(自行实现)
// 例如:json解析失败、参数非法这类不可恢复的消息异常
func isMsgCheckErr(err error) bool {
	return false
}

二、消费失败处理 & 本地重试

问题:消费失败后如果直接 nack (requeue=true),消息会不断重新入队,无限重试,造成 MQ 消息堆积、压力飙升。 解决方案:本地重试 :在当前 goroutine 内循环重试业务,重试全部失败之后,再决定消息去向,不要把消息丢回 MQ 无限循环。

Go 本地重试完整代码(对齐 Spring 重试配置)

Go 复制代码
package main
import (
	"fmt"
	"github.com/streadway/amqp"
	"log"
	"time"
)

// 本地重试配置,对应spring配置
const (
	maxAttempts    = 3           // 最大重试次数,对应max-attempts
	initialInterval = 1 * time.Second // 初始等待时间,initial-interval
	multiplier      = 1.0         // 时间倍数 multiplier
)

// 模拟业务消费函数
func handleMsg(msg *amqp.Delivery) error {
	// 模拟业务异常
	return fmt.Errorf("业务处理失败")
}

// 判断是否是不可恢复异常(这类不重试,直接丢弃)
func isMsgCheckErr(err error) bool {
	// json解析失败、参数非法这类,不可恢复,不重试
	return false
}

func consume(queueName string, ch *amqp.Channel) {
	msgs, err := ch.Consume(
		queueName,
		"",
		false, // autoAck=false,手动ack
		false,
		false,
		false,
		nil,
	)
	if err != nil {
		log.Fatal(err)
	}

	forever := make(chan bool)
	go func() {
		for d := range msgs {
			var err error
			waitTime := initialInterval
			success := false
			// ========== 本地重试逻辑 ==========
			for attempt := 0; attempt < maxAttempts; attempt++ {
				err = handleMsg(&d)
				if err == nil {
					success = true
					break
				}
				// 不可恢复异常,直接跳出,不再重试
				if isMsgCheckErr(err) {
					break
				}
				log.Printf("消费失败,第%d次重试,等待%v, err:%v", attempt+1, waitTime, err)
				time.Sleep(waitTime)
				waitTime = time.Duration(float64(waitTime) * multiplier)
			}

			if success {
				// 消费成功,ACK
				err = d.Ack(false)
				if err != nil {
					log.Println("ack失败", err)
				}
			} else {
				// 本地重试全部耗尽:Nack(false),不重新入队!!
				// 注意:第二个参数 requeue=false,千万不要写true!
				err = d.Nack(false, false)
				if err != nil {
					log.Println("nack失败", err)
				}
				log.Println("本地重试全部失败,消息丢弃/转入死信")
			}
		}
	}()
	log.Printf(" [*] Waiting for messages. To exit press CTRL+C")
	<-forever
}

func main() {
	conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	ch, err := conn.Channel()
	if err != nil {
		log.Fatal(err)
	}
	defer ch.Close()

	queueName := "test_queue"
	consume(queueName, ch)
}

三、MessageRecoverer 失败消息处理策略

Spring AMQP 在重试耗尽后,使用MessageRecoverer接口处理失败消息,共 3 种实现:

表格

Spring AMQP 类 含义 Go 代码等价操作
RejectAndDontRequeueRecoverer(默认) 重试耗尽,拒绝消息,不重新入队;队列配置死信就进 DLQ,没配置直接丢弃 d.Nack(false, false) 第二个参数 requeue=false
ImmediateRequeueMessageRecoverer 重试耗尽,消息放回原队列,重新消费(会无限循环,生产严禁使用) d.Nack(false, true) 第二个参数 requeue=true
RepublishMessageRecoverer 重试耗尽,重新发布一条新消息到指定交换机(异常交换机 error.direct),消息头附加异常信息 手动调用 ch.Publish(),body + 异常信息放入 header 投递到目标交换机

区分: 原生死信 DLQ:MQ Broker 自动转发原消息; RepublishMessageRecoverer:代码手动发布一条全新消息,原消息 ack 移除,不属于原生 DLQ,常用来做异常告警,投递到 error.queue 人工排查。

Go 实现 RepublishMessageRecoverer(复刻 Spring)

Go 复制代码
package main
import (
	"github.com/streadway/amqp"
	"log"
)

// 模仿Spring RepublishMessageRecoverer
type RepublishMessageRecoverer struct {
	ch             *amqp.Channel
	targetExchange string
	routingKey     string
}

func NewRepublishMessageRecoverer(ch *amqp.Channel, exchange, rk string) *RepublishMessageRecoverer {
	return &RepublishMessageRecoverer{
		ch:             ch,
		targetExchange: exchange,
		routingKey:     rk,
	}
}

// Recover 重试耗尽后调用
func (r *RepublishMessageRecoverer) Recover(delivery *amqp.Delivery, err error) error {
	// 复制header,追加异常信息
	headers := delivery.Headers
	if headers == nil {
		headers = make(amqp.Table)
	}
	headers["x-exception"] = err.Error()
	headers["x-original-exchange"] = delivery.Exchange
	headers["x-original-rk"] = delivery.RoutingKey

	// 手动发布一条新消息到 error.direct,和Spring逻辑一致
	errPub := r.ch.Publish(
		r.targetExchange,
		r.routingKey,
		false,
		false,
		amqp.Publishing{
			Body:        delivery.Body,
			Headers:     headers,
			ContentType: delivery.ContentType,
		},
	)
	if errPub != nil {
		log.Printf("republish失败:%v", errPub)
		return errPub
	}

	// 原消息ack,从原队列移除
	errAck := delivery.Ack(false)
	if errAck != nil {
		log.Printf("ack原消息失败:%v", errAck)
	}
	return nil
}

四、核心总结(面试背诵版)

消费者如何保证消息一定被消费?

  1. 开启消费者确认机制为 auto,由 Spring 确认消息处理成功后返回 ack,异常时返回 nack。
  2. 开启消费者失败重试机制 ,并设置 MessageRecoverer,多次重试失败后将消息投递到异常交换机,交由人工处理。
相关推荐
灯澜忆梦3 小时前
【RabbitMQ #9】 | 生产者可靠性
分布式·rabbitmq
zhangzeyuaaa4 小时前
深入理解 Ruby 可变对象与不可变对象的原理、坑点与最佳实践
开发语言·后端·ruby
银河技术4 小时前
TB级文本去重实战:从单机 OOM 到 Spark / Ray 分布式架构的工程演进
分布式·微服务·重构·架构·spark·llm·rag
deepdata_cn5 小时前
从集中式到分布式:Data Mesh颠覆传统数据架构的底层逻辑
分布式·data mesh
500846 小时前
React Native for OpenHarmony 实战:三方库 react-native-device-uptime 的鸿蒙化适配指南
javascript·分布式·react native·react.js·harmonyos
灯澜忆梦13 小时前
【RabbitMQ #10】 | MQ可靠性
分布式·rabbitmq
醉颜凉15 小时前
Kafka 与 RabbitMQ/RocketMQ 选型对比:场景匹配、性能基准与迁移成本
kafka·消息队列·rabbitmq·rocketmq·中间件选型
逐流人17 小时前
Ceph分布式存储集群配置与池管理:从配置优先级到PG、复本池与纠删码池
运维·分布式·ceph·云原生·云计算·rados
天天喝旺仔19 小时前
gRPC 底层原理:HTTP/2 多路复用、Protobuf 序列化与四种流模式
分布式·http·微服务·rpc