工作队列
在这篇教程中,我们将创建一个工作队列(Work Queue),它会发送一些耗时的任务给多个工作者(Worker)。
工作队列(又称:任务队列——Task Queues)是为了避免等待一些占用大量资源、时间的操作。当我们把任务(Task)当作消息发送到队列中,一个运行在后台的工作者(worker)进程就会取出任务然后处理。当你运行多个工作者(workers),任务就会在它们之间共享。
这个概念在网络应用中是非常有用的,它可以在短暂的 HTTP 请求中处理一些复杂的任务。
参考代码
cd /home/shiyanlou/
# 下载
wget https://labfile.oss.aliyuncs.com/courses/630/Code.zip
# 解压
unzip Code.zip
下载并解压后,本小节的参考代码在 /home/shiyanlou/Code/3/ 中,你编写的代码需要存放在 /home/shiyanlou/Code/ 中。
准备
现在,我们将发送一些字符串,把这些字符串当作复杂的任务(我们没有真实的例子,例如图片缩放、pdf 文件转换,所以使用 time.sleep() 函数来模拟这种情况)。我们在字符串中加上点号(.)来表示任务的复杂程度,一个点(.)将会耗时 1 秒钟。比如 Hello... 就会耗时 3 秒钟。
编写 new_task.py
我们对之前教程的 send.py 做些简单的调整,以便可以发送随意的消息。这个程序会按照计划发送任务到我们的工作队列中。我们把它命名为 new_task.py,编写源代码文件 /home/shiyanlou/Code/new_task.py:
#!/usr/bin/env python3
import pika
import sys
connection = pika.BlockingConnection(pika.ConnectionParameters(
host='localhost'))
channel = connection.channel()
message = ' '.join(sys.argv[1:]) or "Hello World!"
channel.basic_publish(exchange='',
routing_key='hello',
body=message)
print(" [x] Sent %r" % message)
编写 worker.py
我们之前的脚本(receive.py)同样需要做一些改动:它需要为消息体中每一个点号(.)模拟 1 秒钟的操作。它会从队列中获取消息并执行,我们把它命名为 worker.py,编写源代码文件 /home/shiyanlou/Code/worker.py:
#!/usr/bin/env python3
import pika
import time
connection = pika.BlockingConnection(pika.ConnectionParameters(
host='localhost'))
channel = connection.channel()
channel.queue_declare(queue='hello')
def callback(ch, method, properties, body):
print(" [x] Received %r" % body)
time.sleep(body.count(b'.') )
print(" [x] Done")
channel.basic_consume(queue='hello',
auto_ack=True,
on_message_callback=callback)
print(' [*] Waiting for messages. To exit press CTRL+C')
channel.start_consuming()
循环调度
使用工作队列的一个好处就是它能够并行的处理队列。如果堆积了很多任务,我们只需要添加更多的工作者(workers)就可以了,扩展很简单。
首先,我们先同时运行两个 worker.py 脚本,它们都会从队列中获取消息,到底是不是这样呢?我们看看。
你需要打开三个终端,两个用来运行 worker.py 脚本,这两个终端就是我们的两个消费者(Consumers)—— C1 和 C2。
# 确保服务已打开
sudo service rabbitmq-server start
# 第一个终端
python3 worker.py
# 第二个终端
python3 worker.py
第三个终端,我们用来发布新任务。你可以发送一些消息给消费者(consumers)
python3 new_task.py 1st.
python3 new_task.py 2nd..
python3 new_task.py 3rd...
python3 new_task.py 4th....
python3 new_task.py 5th.....
python3 new_task.py 6th......
当我们进入之前运行 worker 的终端中,我们会看到这样的一些变化:
默认来说,RabbitMQ 会按顺序得把消息发送给每个消费者(consumer)。平均每个消费者都会收到同等数量得消息。这种发送消息得方式叫做——轮询(round-robin)。
我们可以尝试着添加到三个或更多得工作者(Workers)。
消息确认
当处理一个比较耗时得任务的时候,你也许想知道消费者(consumers)是否运行到一半就挂掉。当前的代码中,当消息被 RabbitMQ 发送给消费者(consumers)之后,马上就会在内存中移除。这种情况,你只要把一个工作者(worker)停止,正在处理的消息就会丢失。同时,所有发送到这个工作者的还没有处理的消息都会丢失。
我们不想丢失任何任务消息。如果一个工作者(worker)挂掉了,我们希望任务会重新发送给其他的工作者(worker)。
为了防止消息丢失,RabbitMQ 提供了消息响应(acknowledgments)。消费者会通过一个 ack(响应),告诉 RabbitMQ 已经收到并处理了某条消息,然后 RabbitMQ 就会释放并删除这条消息。
如果消费者(consumer)挂掉了,没有发送响应,RabbitMQ 就会认为消息没有被完全处理,然后重新发送给其他消费者(consumer)。这样,及时工作者(workers)偶尔的挂掉,也不会丢失消息。
消息是没有超时这个概念的;当工作者与它断开连的时候,RabbitMQ 会重新发送消息。这样在处理一个耗时非常长的消息任务的时候就不会出问题了。
消息响应默认是开启的。之前的例子中我们可以使用 auto_ack=True 标识把它关闭。是时候移除这个标识了,当工作者(worker)完成了任务,就发送一个响应。
#!/usr/bin/env python3
import pika
import time
connection = pika.BlockingConnection(pika.ConnectionParameters(
host='localhost'))
channel = connection.channel()
channel.queue_declare(queue='hello')
print('[*] Waiting for messages. To exit press CTRL+C')
def callback(ch, method, properties, body):
print(" [x] Received %r" % body)
time.sleep( body.count('.') )
print(" [x] Done")
ch.basic_ack(delivery_tag = method.delivery_tag)
channel.basic_consume(queue='hello',
on_message_callback=callback)
在原来的 worker.py 基础上进行修改:
运行上面的代码,我们发现即使使用 CTRL+C 结束了一个工作者(worker)进程,消息也不会丢失。当工作者(worker)挂掉这后,所有没有响应的消息都会重新发送。
确认 RabbitMQ 是否及时释放
通过 basic_ack() 告诉 RabbitMQ 已经收到并处理了某条消息,然后 RabbitMQ 就会释放并删除这条消息。一个很容易犯的错误就是忘了使用 basic_ack() 响应服务端,后果很严重。消息在你的程序退出之后就会重新发送,如果它不能够释放没响应的消息,成为死信,RabbitMQ 就会占用越来越多的内存。
为了排除这种错误,你可以使用 rabbitmqctl 命令,输出 messages_unacknowledged 字段:
sudo rabbitmqctl list_queues name messages_ready messages_unacknowledged
这里执行了三次 new_task.py,所以这里显示 hello 为 3 次。
消息持久化
如果你没有特意告诉 RabbitMQ,那么在它退出或者崩溃的时候,将会丢失所有队列和消息。为了确保信息不会丢失,有两个事情是需要注意的:我们必须把 “队列” 和 “消息” 设为持久化。
首先,为了不让队列消失,需要把队列声明为持久化(durable)。
由于之前已经使用过 hello 这个队列了,因此我们重新创建队列 task_queue。
# 持久化一个队列,名为 task_queue
channel.queue_declare(queue='task_queue', durable=True)
并且这个 queue_declare 必须在生产者(producer)和消费者(consumer)对应的代码中修改。这时候,我们就可以确保在 RabbitMq 重启之后 queue_declare 队列不会丢失。另外,我们需要把我们的消息也要设为持久化——将 delivery_mode 的属性设为 2:
#!/usr/bin/env python3
import pika
import sys
connection = pika.BlockingConnection(pika.ConnectionParameters(
host='localhost'))
channel = connection.channel()
# 持久化一个队列,名为 task_queue
channel.queue_declare(queue='task_queue', durable=True)
message = ' '.join(sys.argv[1:]) or "Hello World!"
channel.basic_publish(exchange='',
routing_key='task_queue',
body=message,
properties=pika.BasicProperties(
# 消息持久化
delivery_mode = 2,
))
print(" [x] Sent %r" % message)
# 关闭连接
connection.close()
在原来的 new_task.py 基础上进行修改:
注意:消息持久化
将消息设为持久化并不能完全保证不会丢失。以上代码只是告诉了 RabbitMq 要把消息存到硬盘,但从 RabbitMq 收到消息到保存之间还是有一个很小的间隔时间。因为 RabbitMq 并不是所有的消息都使用 fsync(2) —— 它有可能只是保存到缓存中,并不一定会写到硬盘中。并不能保证真正的持久化,但已经足够应付我们的简单工作队列。如果你一定要保证持久化,你需要改写你的代码来支持事务(transaction)。
公平调度
你应该已经发现,它仍旧没有按照我们期望的那样进行分发。比如有两个工作者(workers),处理奇数消息的比较繁忙,处理偶数消息的比较轻松。然而 RabbitMQ 并不知道这些,它仍然一如既往的派发消息。
这时因为 RabbitMQ 只管分发进入队列的消息,不会关心有多少消费者(consumer)没有作出响应。它盲目的把 n-th 条消息发给第 n-th 个消费者。
我们可以使用 basic.qos 方法,并设置 prefetch_count=1。这样是告 RabbitMQ ,再同一时刻,不要发送超过 1 条消息给一个工作者(worker),直到它已经处理了上一条消息并且作出了响应。这样,RabbitMQ 就会把消息分发给下一个空闲的工作者(worker):
#!/usr/bin/env python3
import pika
import time
connection = pika.BlockingConnection(pika.ConnectionParameters(
host='localhost'))
channel = connection.channel()
channel.queue_declare(queue='task_queue', durable=True)
print(" [*] Waiting for messages. To exit press CTRL+C")
def callback(ch, method, properties, body):
print(" [x] Received %r" % body)
time.sleep(body.count(b'.') )
print(" [x] Done")
ch.basic_ack(delivery_tag = method.delivery_tag)
channel.basic_qos(prefetch_count=1)
channel.basic_consume(queue='task_queue',
on_message_callback=callback)
channel.start_consuming()
在原来的 worker.py 基础上进行修改:
关于队列大小
如果所有的工作者都处理繁忙状态,你的队列就会被填满。你需要留意这个问题,要么添加更多的工作者(workers),要么使用其他策略。