Work Queues

99 阅读7分钟

原文链接

python-two

在上一节 Hello World 中,使用了指定队列来编写消息生产者和消费者程序。在本教程中,我们将创建一个工作队列,用于在多个工作线程之间分配耗时的任务。

工作队列:为了解决单一消费者再执行耗时操作时,其他消息只能等待而造成的消息堆积问题。多个消费者之间将共享同一个队列。

这个概念在 web 应用中特别有用,因为再一次请求时,无法处理特别负责的任务。

Preparation

在上一个教程中,我们发了一个简单的文本消息 - "Hello World!"。现在我们发送一个稍微复杂一点的消息。我们没有真的那种耗时任务,比如缩放图片或者是渲染 PDF 文件,使用 time.Sleep 函数来模拟耗时操作。字符串中有几个 . 就睡眠几秒。比如 Hello... 的任务就会睡眠 3 秒。

稍微修改上一节中 send.go 中的代码,允许接收命令中的启动参数。创建 new_task.go, 该程序会将任务丢进工作队列。

ctx, cancel := context.WithTimeout(context.Background(), 5*time.second)
defer cancel()

body := bodyFrom(os.Args)

err = ch.PublishWithContext(
        ctx,
        "",		// exchange
        q.Name,		// rounting key
        false,		// mandatory
        false,
        amqp.Publishing {
            DeliveryMode:   amqp.Persistent,
            ContentType:    "text/plain",
            Body: 	    []byte(body),
        },
)
failOnError(err, "Failed to publish a message")
log.Printf(" [x] Sent %s", body)

bodyFrom 函数实现

func bodyFrom(args []string) string {
  var s string
  if len(args) < 2 || os.Args[1] == " " {
    s = "hello"
  } else {
    s = strings.Join(args[1:], " ")
  }
  return s
}

同样的 receive.go 也需要做一点小的修改: 需要根据消息中 . 的个数睡眠对应的时间来模拟耗时操作。创建 worker.go 文件

msgs, err := ch.Consume(
	q.Name, 	// queue
        "",		// consumer
        true,  		// auto-ack
        false, 		// exclusive
        false, 		// no-local
        false, 		// no-wait
        nil,			// args
)
failOnError(err, "Failed to regsiter a consumer")

var forever chan struct{}

go func() {
  for d := range msgs {
    log.Printf("Received a message: %s", d.Body)
    
    dotCount := bytes.Count(d.Body, []byte("."))
    t := time.Duration(dotCount)
    time.Sleep(t)
    log.Printf("Done")
  }
}()

log.Printf(" [*] Waiting for messages. To exit press CTRL+C")
<-forever

t 是模拟操作的耗时

启动 work.go

# shell 1
go run worker.go
# shell 2
go run worker.go

启动 new_task.go,发送任务

# shell 3
go run new_task.go First message.
go run new_task.go Second message..
go run new_task.go Third message...
go run new_task.go Fourth message....
go run new_task.go Fifth message.....

看一下 shell 1shell 2

# shell 1
go run worker.go
# => [*] Waiting for messages. To exit press CTRL+C
# => [x] Received 'First message.'
# => [x] Received 'Third message...'
# => [x] Received 'Fifth message.....'
# shell 2
go run worker.go
# => [*] Waiting for messages. To exit press CTRL+C
# => [x] Received 'Second message..'
# => [x] Received 'Fourth message....'

RabbitMQ 会交替着将消息传递给各个消费者,每一个消费者接收到的消息数量基本上是相同的,这种分发消息的方式叫做轮询分发。可以尝试下三个或者更多的消费者,看一下效果。

Message acknowledgment

消如果费者执行任务会花费几秒甚至更长的时间,此时在任务完成前服务停了,会造成怎么样的后果?按照前面的代码,一旦 RabbitMQ 将消息传递给消费者,会立即将其标记为删除。在这种情况下,如果消息还在处理时, 消费者服务终止或者闪退,那么这些消息就会丢失。同样那些已经被分发给该消费者,但还来不及处理的消息都会丢失。

我们不希望发生这样的事情,而是当消费者服务不可用时,将消息重新分发给另一个可用的消费者

RabbitMQ 支持 message acknowledgements,来保证消息不会丢失。当消费者接收到指定消息并将其处理后,会向 RabbitMQ 发送 ACK,来告诉 RabbitMQ - 你可以将这条消息删除了。

如果消费者挂掉了(channel 关闭,connect 关闭 或者 TCP 连接断掉)而没有向 RabbitMQ 发送 ACK,那么 RabbitMQ 会认为这消息没有被真正处理完成,会将其重新加入队列进行分发,如果此时存在另一个消费者,RabbitMQ 会将其分发给在线的那个消费者。通过这种方式,在消费者挂掉时也可以保证消息不丢失。

消费者有 30 分钟的时间来发送 ACK 回调。如果你想调整这个时间,看这里

在本教程中,我们会手动发送 acknowledgements,为此我们需要将 auto-ack设置为 false,还需要在处理完任务后调用 d.Ack(false)

msgs, err := ch.Consume(
	q.Name, 	// queue
  "",				// consumer
  false,		// auto-ack
  false, 		// exclusive
  false,		// no-local
  false, 		// no-wait
  nil,			// args
)
failOnError(err, "Failed to register a consumer")

var forever chan struct{}

go func() {
  for d := range msgs {
    log.Printf("Receive a message: %s", d.Body)
    dotCount := bytes.Count(d.Body, []byte("."))
    t := time.Duration(dotCount)
    time.Sleep(t * time.Second)
    log.Printf("Done")
    d.Ack(false)
  }
}()

log.Printf(" [*] Waiting for messages. To exit press CTRL+C")
<-forever

通过上面的代码,在消费者挂掉时,所有被接收到ACK的消息都会被重新分发。

发送 ACK 必须和接收消息时的 channel 保持一致。如果在不同的 channel 中发送ACK,会造成协议层面的错误,更多详情

忘记 ACK

在使用 ACK 时,常常会忘记发送。这看起来是一个小问题,但是结果却很严重。没有被 ACK 的消息会被重新分发,如果此时没有消费者退出了,那么 RabbitMQ 会占用越来越多的内存,因为消息一直被重复的加入队列重新分发,得不到释放。

可以通过下面的指令来进行调试,查看有多少没有被释放的消息

sudo rabbitmqctl list_queues name messages_ready messages_unacknowledged

Windows 可以使用这个命令

rabbitmqctl.bat list_queues name messages_ready messages_unacknowledged

Message durability

通过上面一小节,我们已经知道了当消费者服务挂掉时,如何来保证消息不丢失。但是,如果是 RabbitMQ服务挂了呢?这又该怎么处理?

RabbitMQ 如果没有使用持久化的话,那么当它挂掉时,所有的队列和消息都会丢失。为了实现这样的效果,需要将队列和消息都设置成持久化。

首先,当 RabbitMQ 重启时,要确保队列存在,将属性设置为 true

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

如果 hello 的队列不存在,那这样声明队列就可以了。但在我们在前面已经声明了一个同样叫 hello 的没有持久化的队列。RabbitMQ 不允许用不同的属性值来声明名称相同的队列。所以换个队列名称task_queue 重新声明。

q, err := ch.QueueDeclare(
	"taks_queue", 		// name
        true,		        // durable
        false,                  // delete when unused
        false, 		        // exclusive
        false, 			// no-wait
        nil,		        // args
)

使用 persistent 将消息标记为持久化

err = ch.PublishWithContext(
	ctx,			
        "",			// exchange
        q.Name,	                // rounting key
        false,	                // madatory
        false,
        amqp.Publishing {
            DeliveryMode:     amqp.Persistent,
            ContentType:      "text/plain",
            Body: 	      []byte(body),
        },
)

消息持久化

将消息标记为持久化,即使已经让RabbitMQ 将消息缓存至磁盘中,但是不能完全保证消息就不会丢失 - 因为总有在接收消息到存储消息始终会消耗一定的时间,在这个时间内发生服务停止,消息就会丢失。RabbitMQ并不会为每个消息调用fsync(2)函数(该函数会将数据存储至磁盘中)--- 可能只是将消息缓存到内存中而不是写到磁盘上。虽然上述的持久化方案还是不够完善,但也比单纯使用要好得多。如果你想要完全保证消息不丢失,可以使用 publisher confirms

Fair dispatch

你可能注意到使用轮询分发还是有一些问题,考虑下如下场景:

有两个消费者,分发给消费者 A 的单数消息每一条都是很耗时的, 分发给消费者 B 的双数消息处理都很快。

这就会导致消费者 A 总是处于忙碌状态,消费者 B 却有大量时间都处于空闲,造成了一定的资源浪费。

因为RabbitMQ 并不知道消费者们当前的状态,处于轮询分发模式下时,一旦有消息到达队列,就会将消息平均的分发给各个消费者,并不关心每一个消费者 ACK 的数量。

prefetch-count

为了解决这个问题,可以将预期数设置为 1,告诉 RabbitMQ 在同一时间就给我一条消息,只有当消费者处理完消息并发送完 ACK 回调后,再分配新的消息给消费者。在消费者还在处理消息或者没有发送 ACK 时,如果有新消息到达队列,就分发给空闲的消费者去处理。

err = ch.Qos(
	1,			// prefetch count
        0, 			// prefetch size
        false,	                // global
)
failOnError(err, "Failed to set Qos")

这种分发的方式叫做公平分发

队列大小

如果所有消费者都处于忙碌桩体,RabbitMQ 就不会分发消息,那么队列就会被塞满。如果不想发生这样的事情,可以增加更多的消费者,或者采取其他的措施。

Putting it all together

完整代码

// new_task.go

package main

import (
	"context"
	"log"
	"os"
	"strings"
	"time"

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

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

	// 2. 创建 channel
	ch, err := conn.Channel()
	failOnError(err, "Failed to open a channel")
	defer ch.Close()

	// 3. 声明队列
	q, err := ch.QueueDeclare(
		"task_queue", // name
		true,         // durable
		false,        // delete when unused
		false,        // exclusive
		false,        // no-wait
		nil,          // args
	)

	// 4. 创建上下文
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	// 5. 构造消息
	body := bodyFrom(os.Args)

	// 6. 发送消息
	err = ch.PublishWithContext(ctx,
		"",     // exchange
		q.Name, //routing key
		false,  // mandatory
		false,
		amqp.Publishing{
			DeliveryMode: amqp.Persistent,
			ContentType:  "text/plain",
			Body:         []byte(body),
		},
	)
	failOnError(err, "Failed to publish a message")
	log.Printf("[x] Sent %s", body)
}

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

func bodyFrom(args []string) string {
	var s string
	if len(args) < 2 || os.Args[1] == " " {
		s = "hello"
	} else {
		s = strings.Join(args[1:], " ")
	}
	return s
}

new_task.go

// worker.go

package main

import (
	"bytes"
	"log"
	"time"

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

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

func main() {
	// 1. 连接 RabbitMQ
	conn, err := amqp.Dial("amqp://admin:admin@192.168.2.14:5672/")
	failOnError(err, "Failed to connect RabbitMQ")
	defer conn.Close()

	// 2. 创建 channel
	ch, err := conn.Channel()
	failOnError(err, "Failed to open a channel")
	defer ch.Close()

	// 3. 声明队列
	q, err := ch.QueueDeclare(
		"task_queue", // name
		true,         // durable
		false,        // delete when unused
		false,        // exclusive
		false,        // no-wait
		nil,          // args
	)
	failOnError(err, "Failed to declare a queue")

	err = ch.Qos(
		1,     // prefetch count
		0,     // prefetch size
		false, // global
	)
	failOnError(err, "Failed to set Qos")

	// 4. 创建消费者
	msgs, err := ch.Consume(
		q.Name, // queue
		"",     // consumer
		false,  // auto-ack
		false,  // exclusive
		false,  // no-local
		false,  // no-wait
		nil,    // args
	)
	failOnError(err, "Failed to register a consumer")

	var forever chan struct{}

	go func() {
		for d := range msgs {
			log.Printf("Received a message: %s", d.Body)
			dotCount := bytes.Count(d.Body, []byte("."))
			t := time.Duration(dotCount)
			time.Sleep(t * time.Second)
			log.Printf("Done")
			d.Ack(false)
		}
	}()

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

worker.go

使用 ACKprefetch count可以设置一个公平分发的工作队列。队列的 durable 则可以在 RabbitMQ 重启时保持队列