
在上一节 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 1 和 shell 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_unacknowledgedWindows 可以使用这个命令
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 的数量。

为了解决这个问题,可以将预期数设置为 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
}
// 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
}
使用 ACK 和 prefetch count可以设置一个公平分发的工作队列。队列的 durable 则可以在 RabbitMQ 重启时保持队列