【RabbitMQ—进阶】高级用法

与昊
English

消息的去处

mandatory和immediate是发送消息方法中的两个参数,它们都有当消息传递过程中目的地不可达时将消息返回给生产者的功能。RabbitMQ提供的备份交换器可以将未能被交换器路由的消息存储起来,而不用返回给客户端。

mandatory参数

当mandatory参数设为true时,交换器无法根据自身的类型和路由键找到一个符合条件的队列,那么 RabbitMQ会将消息返回给生产者。当mandatory参数设置为false时,则消息直接被丢弃。

那么生产者如何获取到没有被正确路由到合适队列的消息呢?这时候可以通过注册消息返回监听器来实现:

func (ch *Channel) NotifyReturn(c chan Return) chan Return

immediate参数

当immediate参数设为true时,如果交换器在将消息路由到队列时发现队列上并不存在任何消费者,那么这条消息将不会存入队列中。当与路由键匹配的所有队列都没有消费者时,该消息会返回至生产者。

概括来说,mandatory参数告诉服务器至少将该消息路由到一个队列中,否则将消息返回给生产者。immediate参数告诉服务器,如果该消息关联的队列上有消费者,则立刻消费;如果所有匹配的队列上都没有消费者,则直接将消息返还给生产看,不用将消息存入队列而消费者了

备份交换器

生产者在发送消息的时候如果不设置mandatory参数,那么消息在未被路由的情况下将会丢失。如果设置了mandatory参数,那么需要添加ReturnListener的编程逻辑,生产者的代码将变得复杂。如果既不想复杂化生产者的编程逻辑,又不想消息丢失,那么可以使用备份交换器,这样可以将未被路由的消息存储在RabbitMQ中,再在需要的时候去处理这些消息。

可以通过在声明交换器的时候设置args参数中的alternate-exchange选项来实现,也可以通过策略的方式实现。如果两者同时使用,则前者的优先级更高,会覆盖掉Policy的设置。

备份交换器其实和普通的交换器没有太大的区别,需要注意的是,消息被重新发送到备份交换器时的路由键和从生产者发出的路由键是一样的。

对于备份交换器,总结了以下几种特殊情况:

  • 如果设置的备份交换器不存在,客户端和RabbitMQ服务端都不会有异常出现,此时消息会丢失。
  • 如果备份交换器没有绑定任何队列,客户端和RabbitMQ服务端都不会有异常出现,此时消息会丢失。
  • 如果备份交换器没有任何匹配的队列,客户端和RabbitMQ服务端都不会有异常出现,此时消息会丢失。
  • 如果备份交换器和mandatory参数一起使用,那么mandatory参数无效。

过期时间(TTL)

设置消息的TTL

目前有两种方法可以设置消息的TTL。第一种方法是通过队列属性设置,队列中所有消息都有相同的过期时间。第二种方法是对消息本身进行单独设置,每条消息的TTL可以不同。如果两种方法一起使用,则消息的TTL以两者之间较小的那个数值为准。消息在队列中的生存时间一旦超过设置的TTL值时,就会变成“死信”。

通过队列属性设置消息TTL的方法是在QueueDeclare方法的args参数中加入x-message-ttl选项实现的,这个参数的单位是毫秒。同时也可以通过Policy或HTTP API接口设置。

如果不设置TTL,则表示此消息不会过期。如果将TTL设置为0,则表示除非此时可以直接将消息投递到消费者,否则该消息会被立即丢弃。

针对每条消息设置TTL的方法是在Publish方法的消息Publishing结构体中设置Expiration属性,单位为毫秒。也可以通过HTTP API接口设置。

对于第一种设置队列TTL属性的方法,一旦消息过期,就会从队列中删除,而在第二种方法中,即使消息过期,也不会马上从队列中删除,因为每条消息是否过期是在即将投递到消费者之前判定的。

设置队列的TTL

通过QueueDeclare方法的args参数中x-expires选项可以控制队列被自动删除前处于未使用状态的最大时间,单位是毫秒,不能设置为0。未使用的意思是队列上没有任何的消费者,队列也没有被重新声明,并且在过期时间段内也未调用过Get方法。

设置队列里的TTL可以应用于类似RPC方式的回复队列,在RPC中,会创建很多未被使用的队列。

RabbitMQ会确保在过期时间到达后将队列删除,但是不保障删除的动作有多及时。在RabbitMQ重启后,持久化的队列的过期时间会被重新计算。

死信队列

DLX,全称为Dead-Letter-Exchange,可以称之为死信交换器。当消息在一个队列中变成死信之后,它能被重新被发送到另一个交换器中,这个交换器就是DLX,绑定DLX的队列就称之为死信队列。

消息变成死信一般是由于以下几种情况:

  • 消息被拒绝(Reject/Nack),并且设置requeue参数为false。
  • 消息过期。
  • 队列达到最大长度。

DLX也是一个正常的交换器,和一般的交换器没有区别,它能在任何的队列上被指定(实际上就是设置某个队列的属性)。当这个队列中存在死信时,RabbitMQ就会自动地将这个消息重新发布到设置的DLX上去,进而被路由到死信队列。可以监听这个队列中的消息以进行相应的处理,这个特性与将消息的TTL设置为0配合使用可以弥补immeaiate参数的功能。

通过在QueueDeclare方法的args参数中设置x-dead-letter-exchange选项来为这个队列添加DLX:

err = ch.ExchangeDeclare(
        "dlx_exchange",
        "direct",
        true,
        false,
        false,
        false,
        nil,
    )
    if err != nil {
        log.Fatalf("%s: %s", err, "Failed to declare an exchange")
        return
    }
    args := make(map[string]interface{})
    args["x-dead-letter-exchange"] = "dlx_exchange"
    // 为队列my_queue添加DLX
    q, err := ch.QueueDeclare(
        "my_queue",
        true,
        false,
        false,
        false,
        args,
    )
    if err != nil {
        log.Fatalf("%s: %s", err, "Failed to declare a queue")
        return
    }

也可以为这个DLX指定路由键,如果没有特殊指定,则使用原队列的路由键:

args["x-dead-letter-routing-key"] = "dlx_routing_key"

对于RabbitMQ来说,DLX是一个非常有用的特性。它可以处理异常情况下,消息不能够被消费者正确消费(消费者调用了Nack或者Reject)而被置入死信队列中的情况,后续分析程序可以通过消费这个死信队列中的内容来分析当时所遇到的异常情况,进而可以改善和优化系统。

延迟队列

延迟队列存储的对象是对应的延迟消息,所谓“延迟消息”是指当消息被发送以后,并不想让消费者立刻拿到消息,而是等待特定时间后,消费者才能拿到这个消息进行消费。

延迟队列的使用场景有很多,比如:

  • 在订单系统中,一个用户下单之后通常有30分钟的时间进行支付,如果30分钟之内没有支付成功,那么这个订单将进行异常处理,这时就可以使用延迟队列来处理这些订单了。
  • 用户希望通过手机远程遥控家里的智能设备在指定的时间进行工作。这时候就可以将用户指令发送到延迟队列,当指令设定的时间到了再将指令推送到智能设备。

RabbitMQ本身没有直接支持延迟队列的功能,但是可以通过前面所介绍的DLX和TTL模拟出延迟队列的功能。

假设一个应用中需要将每条消息都设置为10秒的延迟,可以创建两组交换器和队列:常规交换器exchange.normal、常规队列queue.normal和死信交换器exchange.dlx、死信队列queue.dlx,然后为queue.normal添加死信交换器exchange.dlx。生产者将消息发送到exchange.normal并设置TTL为10秒,同时消费者订阅queue.dlx而非queue.normal。当消息从queue.normal中过期被存入queue.dlx中,消费者就恰巧消费到了延迟10秒的这条消息。

优先级队列

优先级队列,顾名思义,具有高优先级的队列具有高的优先权,优先级高的消息具备优先被消费的特权。

优先级队列可以通过在QueueDeclare方法的args参数中设置x-max-priority选项来实现。不过这只是配置一个队列的最大优先级,在此之后需要在发送时设置消息的优先级,设置方式是Publishing结构体中的Priority属性。

消息的优先级默认最低为0,最高为队列设置的最大优先级。优先级高的消息可以被优先消费,不过如果在消费者的消费速度大于生产者的速度且Broker中没有消息堆积的情况下,对发送的消息设置优先级也就没有什么实际意义。

RPC实现

RPC,是Remote Procedure Call的简称,即远程过程调用。它是一种通过网络从远程计算机上请求服务,而不需要了解底层网络的技术。RPC的主要功用是让构建分布式计算更容易,在提供强大的远程调用能力时不损失本地调用的语义简洁性。

在RabbitMQ中实现RPC也很简单。客户端发送请求消息,为了接收响应消息,我们需要在请求消息中发送一个回调队列。可以使用默认的队列:

err = ch.Publish(
    "",
    "rpc_queue",
    false,
    false,
    amqp.Publishing{
        ContentType:   "text/plain",
        CorrelationId: "",        
        ReplyTo:       "",
        Body:          []byte("rpc"),
    },
)

对于代码中涉及的Publishing结构体,这里需要用到它的两个属性:

  • ReplyTo:通常用来设置一个回调队列。
  • CorrelationId:用来关联请求和其调用RPC之后的回复。

如果像上面的代码中一样,为每个RPC请求创建一个回调队列,则是非常低效的。但是幸运的是这里有一个通用的解决方案——可以为每个客户端创建一个单一的回调队列。

我们应该为每一个请求设置一个唯一的correlationId,对于回调队列而言,在其接收到一条回复的消息之后,它可以根据这个属性匹配到相应的请求。如果回调队列接收到一条未知correlationId的回复消息,可以简单地将其丢弃。

image.png

如图所示,RPC的处理流程如下:

  1. 当客户端启动时,创建一个匿名的回调队列。
  2. 客户端为RPC请求设置replyTo和correlationId。
  3. 请求被发送到rpc_queue队列中。
  4. RPC服务端监听rpc_queue队列中的请求,当请求到来时,服务端会处理并且把带有结果的消息发送给客户端。接收的队列就是replyTo设定的回调队列。
  5. 客户端监听回调队列,当有消息时,检查correlationId属性,如果与请求匹配,进行其他回调处理。

可以参考RabbitMQ官网的示例,RPC客户端通过RPC来调用服务端的方法以便得到相应的斐波那契值:
rpc_server.go
rpc_client.go

生产者确认

在使用RabbitMQ的时候,当消息的生产者将消息发送出去之后,消息到底有没有正确地到达服务器呢?RabbitMQ针对这个问题,提供了两种解决方式:

  • 事务机制
  • 发送方确认(publisher confirm)机制

事务机制

RabbitMQ客户端中与事务机制相关的方法有三个:

func (ch *Channel) Tx() error
func (ch *Channel) TxCommit() error
func (ch *Channel) TxRollback() error

channel.Tx用于将当前的信道设置成事务模式,channel.TxCommit用于提交事务,channel.TxRollback用于事务回滚。

在通过channel.Tx方法开启事务之后,我们便可以发布消息给RabbitMQ了,如果事务提交成功,则消息一定到达了RabbitMQ,如果在事务提交执行之前由于RabbitMQ异常崩溃或者其他原因抛出了异常,这个时候我们便可以将其捕获,进而通过执行channel.txRollback方法来实现事务回滚。

如果要发送多条消息,则将channel.Publish和channel.TxCommit等方法包裹进循环内即可。

事务确实能够解决消息发送方和RabbitMQ之间消息确认的问题,只有消息成功被RabbitMQ接收,事务才能提交成功,否则便可在捕获异常之后进行事务回滚,与此同时可以进行消息重发。但是使用事务机制会严重影响RabbitMQ的性能,那么有没有更好的方法呢?RabbitMQ提供了一个改进方案,即发送方确认机制。

发送方确认机制

生产者可以将信道设置成confirm模式,一旦信道进入confirm模式,所有在该信道上面发布的消息都会被指派一个唯一的ID(从1开始),当消息被投递到所有匹配的队列之后,RabbitMQ就会发送一个确认(Ack)给生产者(包含消息ID),这就使得生产者知晓消息已经正确到达了目的地了。如果消息和队列是可持久化的,那么确认消息会在消息写入磁盘之后发出。RabbitMQ回传给生产者的确认消息中的deliveryTag包含了确认消息的序号,此外RabbitMQ也可以设置Ack方法中的multiple参数,表示在这个序号之前的所有消息都已经得到了处理。

事务机制在一条消息发送之后会使发送端阻塞,以等待RabbitMQ的回应,之后才能继续发送下一条消息。相比之下,发送方确认机制最大的好处在于它是异步的,一旦发布一条消息,生产者应用程序就可以在等待信道返回确认的同时继续发送下一条消息,当消息最终得到确认之后,生产者应用程序便可以通过回调方法来处理该确认消息,如果RabbitMQ因为自身内部错误导致消息丢失,就会发送一条Nack命令,生产者应用程序同样可以在回调方法中处理该Nack命令。

在confirm模式中,所有发送的消息都会被Ack或者Nack一次,不会出现一条消息既被Ack又被Nack的情况,不过RabbitMQ也并没有对消息被confirm的快慢做任何保证。

下面是一个官网上的代码示例:

package main

import (
    "log"

    amqp "github.com/rabbitmq/amqp091-go"
)

func failOnError(err error, msg string) {
    if err != nil {
        log.Fatalf("%s: %s", msg, err)
    }
}

func main() {
    conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
    failOnError(err, "Failed to connect to RabbitMQ")
    defer conn.Close()

    ch, err := conn.Channel()
    failOnError(err, "Failed to open a channel")
    defer ch.Close()
    
    // 初始化接收确认消息的通道
    confirms := make(chan amqp.Confirmation)
    // 注册通道使之监听发布的确认消息
    ch.NotifyPublish(confirms)
    go func() {
        for confirm := range confirms {
            // 返回确认消息
            if confirm.Ack {
                // code when messages is confirmed
                log.Printf("Confirmed")
            } else {
                // code when messages is nack-ed
                log.Printf("Nacked")
            }
        }
    }()

    // 将信道设置成confirm模式
    err = ch.Confirm(false)
    failOnError(err, "Failed to confirm")

    q, err := ch.QueueDeclare(
        "",    // name
        false, // durable
        false, // delete when unused
        false, // exclusive
        false, // no-wait
        nil,   // arguments
    )
    failOnError(err, "Failed to declare a queue")

    consume(ch, q.Name)
    publish(ch, q.Name, "hello")

    log.Printf(" [*] Waiting for messages. To exit press CTRL+C")
    forever := make(chan bool)
    <-forever
}

func consume(ch *amqp.Channel, qName string) {
    msgs, err := ch.Consume(
        qName, // queue
        "",    // consumer
        true,  // auto-ack
        false, // exclusive
        false, // no-local
        false, // no-wait
        nil,   // args
    )
    failOnError(err, "Failed to register a consumer")

    go func() {
        for d := range msgs {
            log.Printf("Received a message: %s", d.Body)
        }
    }()
}

func publish(ch *amqp.Channel, qName, text string) {
    err := ch.Publish("", qName, false, false, amqp.Publishing{
        ContentType: "text/plain",
        Body:        []byte(text),
    })
    failOnError(err, "Failed to publish a message")
}

注:事务机制和publisher confirm机制两者是互斥的,不能共存。

消费端要点

消息分发

当RabbitMQ队列拥有多个消费者时,队列收到的消息将以轮询的方式分发给消费者。每条消息只会发送给订阅列表里的一个消费者。如果现在负载加重,那么只需要创建更多的消费者来消费处理消息即可。

不过很多时候轮询的分发机制不那么优雅。如果某些消费者任务繁重,来不及消费那么多的消息,而某些其他消费者由于某些原因(比如业务逻辑简单、机器性能卓越等)很快地处理完了所分配到的消息,进而进程空闲,这样就会造成整体应用吞吐量的下降。

这里就要用到Qos这个方法,它可以限制信道上的消费者所能保持的最大未确认消息的数量:

func (ch *Channel) Qos(prefetchCount, prefetchSize int, global bool) error
  • prefetchCount:消费者未确认消息的个数上限。设置为0表示没有上限。
  • prefetchSize :消费者未确认消息的大小上限,单位为B。设置为0表示没有上限。
  • global :是否全局生效,true表示是。全局生效指的是信道上所有的消费者都受到prefetchCount和prefetchSize的限制(否则只有新消费者才会受到限制)。

注:Qos方法对于拉模式无效。

消息顺序性

RabbitMQ在不使用任何高级特性,也没有消息丢失、网络故障之类异常的情况发生,并且只有一个消费者的情况下,最好也只有一个生产者的情况下可以保证消息的顺序性。如果有多个生产者同时发送消息,无法确定消息到达 Broker的前后顺序,也就无法验证消息的顺序性。

在很多情形下,都会导致RabbitMQ消息错序。如果要保证消息的顺序性,需要业务方使用RabbitMQ之后做进一步的处理,比如在消息体内添加全局有序标识来实现。

阅读 269

IT民工,主要从事web方向,喜欢研究技术和投资之道

138 声望
612 粉丝
0 条评论
你知道吗?

IT民工,主要从事web方向,喜欢研究技术和投资之道

138 声望
612 粉丝
宣传栏