消息的去处

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 请求创建一个回调队列,则是非常低效的。但是幸运的是这里有一个通用的解决方案——可以为每个客户端创建一个单一的回调队列。

我们应该为每一个请求设置一个唯一的 orrelationId,对于回调队列而言,在其接收到一条回复的消息之后,它可以根据这个属性匹配到相应的请求。如果回调队列接收到一条未知 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 之后做进一步的处理,比如在消息体内添加全局有序标识来实现。


与昊
225 声望636 粉丝

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


引用和评论

0 条评论