RabbitMQ學習筆記03:Work Queues

參考資料:RabbitMQ tutorial - Work Queues — RabbitMQ 

 

 

前言

這篇文章我們會創建一個Work Queue,它會在多個worker(即消費者 consumer)中分發耗時的任務。Work Queue也叫做Task Queue是爲了避免當處理一個佔用資源的任務時必須等待它完成。相反,我們調度這個任務晚點再完成。我們將任務封裝成消息送入隊列中。worker進程會在後臺工作直到最終完成這個任務。當運行許多worker的時候,這個任務會在它們之間共享。

這個概念在web應用程序中特別有用,這使得在一次短的HTTP請求窗口中處理複雜的任務成爲了可能。

之前的任務我們發送給消息隊列的消息是簡單的Hello World!,這次我們會發送一些字符串來表示複雜任務。我們並沒有一個真實的任務,比如需要resized的圖片或者需要渲染的PDF文件,因此我們只能用time.sleep()函數來表示我們在處理一個複雜任務處於忙碌的狀態。我們使用小數點來表示問題的複雜程度,小數點越多,問題越複雜,耗時越久。每個小數點表示耗時1秒鐘。例如如果有一個任務是Hello...,那麼這個任務就耗時3秒。

我們需要稍微修改一下send.py,允許從CLI發送任意的消息。這個程序會安排任務到我們的隊列中,因此我們將其命名爲new_task.py 

import sys

message = ' '.join(sys.argv[1:]) or "Hello World!"
channel.basic_publish(exchange='',
                      routing_key='hello',
                      body=message)
print(" [x] Sent %r" % message)

這裏我們貼一下當前的new_task.py完整代碼。

#!/usr/bin/env python
import pika
import sys

connection = pika.BlockingConnection(
    pika.ConnectionParameters(host='localhost'))
channel = connection.channel()

channel.queue_declare(queue='hello')

message = ' '.join(sys.argv[1:]) or "Hello World!"

channel.basic_publish(exchange='',
                      routing_key='hello',
                      body=message)
print(" [x] Sent %r" % message)
connection.close()

receive.py同樣需要修改:它需要根據小數點的數量來模擬複雜程序的處理。它會從隊列中彈出消息並處理任務。我們將其命名爲worker.py

import time

def callback(ch, method, properties, body):
    print(" [x] Received %r" % body.decode())
    time.sleep(body.count(b'.'))
    print(" [x] Done")

這裏我們貼一下當前的worker.py完整代碼。

#!/usr/bin/env python
import pika, sys, os
import time

def main():
    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.decode())
        time.sleep(body.count(b'.'))
        print(" [x] Done")

    channel.basic_consume(queue='hello', on_message_callback=callback, auto_ack=True)

    print(' [*] Waiting for messages. To exit press CTRL+C')
    channel.start_consuming()

if __name__ == '__main__':
    try:
        main()
    except KeyboardInterrupt:
        print('Interrupted')
        try:
            sys.exit(0)
        except SystemExit:
            os._exit(0)

 

 

Round-robin dispatching

使用Task Queue的一個優點就是實現簡單的並行工作能力,如果我們在處理大量堆積工作的時候,我們可以通過簡單地增加worker進程來擴展。

這裏我們開啓3個終端,前2個終端都運行python worker.py用來等待消息。

在第三個終端,我們輸入如下。也就是說我們一共發送了5條消息。

[root@rabbitmq-01 code]# python new_task.py First message.
 [x] Sent 'First message.'
[root@rabbitmq-01 code]# python new_task.py Second message..
 [x] Sent 'Second message..'
[root@rabbitmq-01 code]# python new_task.py Third message...
 [x] Sent 'Third message...'
[root@rabbitmq-01 code]# python new_task.py Fourth message....
 [x] Sent 'Fourth message....'
[root@rabbitmq-01 code]# python new_task.py Fifth message.....
 [x] Sent 'Fifth message.....'

隨後我們回到前兩個終端查看結果。

第一個終端。

[root@rabbitmq-01 code]# python worker.py 
 [*] Waiting for messages. To exit press CTRL+C
 [x] Received 'First message.'
 [x] Done
 [x] Received 'Third message...'
 [x] Done
 [x] Received 'Fifth message.....'
 [x] Done
^CInterrupted

第二個終端。

[root@rabbitmq-01 code]# python worker.py 
 [*] Waiting for messages. To exit press CTRL+C
 [x] Received 'Second message..'
 [x] Done
 [x] Received 'Fourth message....'
 [x] Done
^CInterrupted

這5條消息會依次以輪詢round-robin的方式被2個worker(消費者)處理掉。

woker.py中我們設置了time.sleep(),通過等待來模擬處理複雜任務,因此在前2個終端,小數點越多的消息我們等待其出現done的時長越久。

 

 

Message acknowledgment

某些任務可能需要執行幾分鐘或者更長的時間,如果我們在任務執行完畢之前就停止消費者的運行,會發生什麼?

在我們目前的代碼基礎上,一旦RabbitMQ把消息投遞給消費者,或者說一旦消費者從隊列中消費掉消息,那麼這條消息就會被標記爲已刪除(deletion)。如果消費者程序還在處理消息我們就終止了消費者程序,那麼這條消息就會丟失。分配給消費者的消息但是沒有被處理,那麼這條消息也會丟失。

RabbitMQ 支持消息確認機制message acknowledgements),通過此機制消費者可以告訴RabbitMQ消息是否已經收到、處理,這樣子RabbitMQ就可以放心地刪除這條消息了。

如果消費者掛了(比如channel、connection關閉或者TCP連接丟失了)導致沒有發送ack,RabbitMQ就會知道那條消息沒有被完全的處理就會把它重新放回隊列中。如果此時有其他的消費者連接着隊列,那麼這條消息就可以被其他的消費者處理掉。

消費者必須返回ack確認,否則就會超時。默認的超時時長是30分鐘。這樣就避免了消費者程序“卡住了”導致沒有返回ack。ack超時時長的修改請參考Delivery Acknowledgement Timeout

手動的消息確認默認是啓用的。在之前的代碼中,我們通過auto_ack=True將它關閉了。現在我們要啓用消息確認了。

修改callback函數和channel.basic_consume 

def callback(ch, method, properties, body):
    print(" [x] Received %r" % body.decode())
    time.sleep(body.count(b'.') )
    print(" [x] Done")
    ch.basic_ack(delivery_tag = method.delivery_tag)

channel.basic_consume(queue='hello', on_message_callback=callback)

參考上面的測試示例,我們打開2個終端,運行worker.py。隨後在第三個終端執行一個10秒的長任務python new_task.py Long message.......... 

此時第一個終端的worker.py就會收到並處理消息,同時第二個終端依然處於等待消息的狀態。隨後我們在第一個終端終止worker.py

[root@rabbitmq-01 code]# python worker.py 
 [*] Waiting for messages. To exit press CTRL+C
 [x] Received 'Long message..........'
^CInterrupted

 此時第二個終端就會有消息進來了,證明我們的消息確認機制有正常工作,等待10秒,任務完畢。

[root@rabbitmq-01 code]# python worker.py 
 [*] Waiting for messages. To exit press CTRL+C
 [x] Received 'Long message..........'
 [x] Done

即使在消息抵達隊列然後被消費者消費之後,我們關閉所有終端的worker.py。消息依然會存在於隊列中,等待新的消費者連接到隊列上。

消息的確認必須和消息送達消費者的channel一致,嘗試不同的channel去確認消息會導致channel級別的異常發生。

Forgotten acknowledgment

我們很容易忘記使用basic_ack來確認消息,但是這個會導致嚴重的問題。如果我們忘記確認的話,當客戶端程序(應該是指消費者)退出的時候,消息會被重新投遞。但是RabbitMQ會喫掉越來越多的內存,因爲它無法釋放未確認的消息。

可以使用以下命令查看未確認的消息數量。

[root@rabbitmq-01 rabbitmq_server-3.11.5]# ./sbin/rabbitmqctl list_queues name messages_ready messages_unacknowledged
Timeout: 60.0 seconds ...
Listing queues for vhost / ...
name	messages_ready	messages_unacknowledged
hello	0	0

 

 

Message durability

通過消息的確認機制,我們已經學會如何確保當消費者程序出現問題的時候,消失不會丟失。但是如果RabbitMQ服務甚至服務器掛了的話,那麼我們的消息還是會丟失的。

RabbitMQ掛了的時候,默認情況下,消息和隊列都會被RabbitMQ 遺忘,除非我們讓隊列和消息都變成持久的(durable)

首先我們將隊列聲明爲持久的。

channel.queue_declare(queue='hello', durable=True)

這句代碼本身是沒有問題的,但是由於我們的環境已經存在了一個名爲hello的隊列並且其是非持久的,此時我們再聲明一個持久的同名隊列,就會報錯。因爲RabbitMQ不支持同一個隊列使用不同參數來聲明。 

我們只需要換個新名稱聲明隊列即可。

channel.queue_declare(queue='task_queue', durable=True)

因爲生產者和消費者都有聲明隊列的代碼,因此兩邊都需要修改。

worker.py 裏面還要消費者消費隊列的代碼,也要修改隊列名稱。

現在,我們就可以確保當重啓 RabbitMQ 服務或者服務器時,隊列task_queue不會丟失了。

接下來我們需要確保消息是持久的。通過提供屬性delivery_mode和值pika.spec.PERSISTENT_DELIVERY_MODE 

channel.basic_publish(exchange='',
                      routing_key="task_queue",
                      body=message,
                      properties=pika.BasicProperties(
                         delivery_mode = pika.spec.PERSISTENT_DELIVERY_MODE
                      ))

測試方式也很簡單,代碼修改好之後, 先push一條消息。

[root@rabbitmq-01 code]# python new_task.py Very Long message....................
 [x] Sent 'Very Long message....................'

查看隊列。

[root@rabbitmq-01 rabbitmq_server-3.11.5]# ./sbin/rabbitmqctl list_queues
Timeout: 60.0 seconds ...
Listing queues for vhost / ...
name	messages
task_queue	1
hello	0

但是我們不啓用worker.py來處理消息,我們直接停止服務或者重啓服務器(記得確保開機啓動)。

./sbin/rabbitmqctl stop
./sbin/rabbitmq-server -detached

再次查看,就會發現隊列和消息都還在。

[root@rabbitmq-01 rabbitmq_server-3.11.5]# ./sbin/rabbitmqctl list_queues
Timeout: 60.0 seconds ...
Listing queues for vhost / ...
name	messages
task_queue	1

此時再啓用worker.py,確保可以正常消費消息。

[root@rabbitmq-01 code]# python worker.py 
 [*] Waiting for messages. To exit press CTRL+C
 [x] Received 'Very Long message....................'
 [x] Done

Note on message persistence

消息和隊列的持久化並不能100%確保消息和隊列不會丟失。當消息抵達RabbitMQ的時候,可能還沒來得及將其送內存寫入磁盤,服務或者服務器就掛了。

又或者還沒有到寫入磁盤的時間。這個和fsync(2)有關係。

如果需要更強的持久化機制,請參考 publisher confirms 

 

 

Fair dispatch

目前我們的消費者的工作方式是輪詢的,終端1和終端2的worker.py輪流去隊列中獲取消息並處理。假設某個終端的worker.py 已經空閒同時另一個終端的worker.py 處於忙碌狀態,即便此時隊列中有消息,只要這條消息輪到了忙碌的那個worker.py ,那麼空閒的worker.py 也不會去搶這條消息,而是等待忙碌的worker.py 忙完了,讓它去處理。

我們來演示一下。首先同樣是打開兩個終端,運行worker.py 。

隨後在第三個終端運行new_task.py,發送4條消息,奇數消息執行時間短(1秒),偶數消息執行時間長(24秒)。

每次發佈完消息我們都等待差不多2秒的時間,再發佈下一條。

[root@rabbitmq-01 code]# python new_task.py Odd message .
 [x] Sent 'Odd message .'
[root@rabbitmq-01 code]# python new_task.py Even message ........................
 [x] Sent 'Even message ........................'
[root@rabbitmq-01 code]# python new_task.py Odd message .
 [x] Sent 'Odd message .'
[root@rabbitmq-01 code]# python new_task.py Even message ........................
 [x] Sent 'Even message ........................'

我們來看第一個終端。

[root@rabbitmq-01 code]# python worker.py 
 [*] Waiting for messages. To exit press CTRL+C
 [x] Received 'Odd message .'
 [x] Done
 [x] Received 'Odd message .'
 [x] Done

第二個終端。

[root@rabbitmq-01 code]# python worker.py 
 [*] Waiting for messages. To exit press CTRL+C
 [x] Received 'Even message ........................'
 [x] Done
 [x] Received 'Even message ........................'
 [x] Done

實際在發佈第四條消息的時候,第一個終端已經處理完2條Odd message .了,已經處於空閒狀態。而第二個終端還在處理第一條Even message ........................ 

即便如此,第四條消息依然會等待第二個終端空閒了再給它處理。

這樣就很不智能,效率低下。

我們可以使用basic_qos方法加上參數prefetch_count=1來解決這個問題。它會告訴RabbitMQ,在同一時間只能提供1條消息給消費者。換句話說,不要將消息發送給消費者,除非它們已經處理並確認了手頭的消息。

channel.basic_qos(prefetch_count=1)

也就是說,現在可以實現智能分配消息了,根據消費者的忙碌情況,而不再是死板的輪詢了。

測試結果和我們預想的一樣。

# 終端一
[root@rabbitmq-01 code]# python worker.py 
 [*] Waiting for messages. To exit press CTRL+C
 [x] Received 'Odd message .'
 [x] Done
 [x] Received 'Odd message .'
 [x] Done
 [x] Received 'Even message ........................'
 [x] Done
 
# 終端二
 [root@rabbitmq-01 code]# python worker.py 
 [*] Waiting for messages. To exit press CTRL+C
 [x] Received 'Even message ........................'
 [x] Done

Note about queue size

如果所有的消費者都處於忙碌的狀態,那麼隊列中的消息就會一點點積累,可能會導致隊列被填滿、堵塞。

此時需要考慮增加額外的消費者或者使用 message TTL

 

 

Putting it all together

最後把代碼整合一下,整合後的代碼和之前的有點不同,比如woker.py沒有定義main函數,沒有定義異常捕獲。不過不會影響實際的功能。

worker.py

#!/usr/bin/env python
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.decode())
    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()

new_task.py

#!/usr/bin/env python
import pika
import sys

connection = pika.BlockingConnection(
    pika.ConnectionParameters(host='localhost'))
channel = connection.channel()

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=pika.spec.PERSISTENT_DELIVERY_MODE,
    ))
print(" [x] Sent %r" % message)
connection.close()

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章