中介軟體---分散式任務排程---Celery

FeelTouch發表於2019-02-17

最近研究了下非同步任務神器-Celery,發現非常好用,可以說是高可用,假如你發出一個任務執行命令給 Celery,只要 Celery 的執行單元 (worker) 在執行,那麼它一定會執行;如果執行單元 (worker) 出現故障,如斷電,斷網情況下,只要執行單元 (worker) 恢復執行,那麼它會繼續執行你已經發出的命令。這一點有很強的實用價值:假如有交易系統接到了大量交易請求,主機卻掛了,但前端使用者仍可以繼續發交易請求,傳送交易請求後,使用者無需等待。待主機恢復後,已發出的交易請求可以繼續執行,只不過使用者收到交易確認的時間延長而已,但並不影響使用者體驗。

Celery 簡介

它是一個非同步任務排程工具,使用者使用 Celery 產生任務,借用中間人來傳遞任務,任務執行單元從中間人那裡消費任務。任務執行單元可以單機部署,也可以分散式部署,因此 Celery 是一個高可用的生產者消費者模型的非同步任務佇列。你可以將你的任務交給 Celery 處理,也可以讓 Celery 自動按 crontab 那樣去自動排程任務,然後去做其他事情,你可以隨時檢視任務執行的狀態,也可以讓 Celery 執行完成後自動把執行結果告訴你。

應用場景:

  1. 高併發的請求任務。網際網路已經普及,人們的衣食住行中產生的交易都可以線上進行,這就避免不了某些時間極高的併發任務請求,如公司中常見的購買理財、學生繳費,在理財產品投放市場後、開學前的一段時間,交易量猛增,確認交易時間較長,此時可以把交易請求任務交給 Celery 去非同步執行,執行完再將結果返回給使用者。使用者提交後不需要等待,任務完成後會通知到使用者(購買成功或繳費成功),提高了網站的整體吞吐量和響應時間,幾乎不需要增加硬體成本即可滿足高併發。

  2. 定時任務。在雲端計算,大資料,叢集等技術越來越普及,生產環境的機器也越來越多,定時任務是避免不了的,如果每臺機器上執行著自己的 crontab 任務,管理起來相當麻煩,例如當進行災備切換時,某些 crontab 任務可能需要單獨手工調起,給運維人員造成極大的麻煩,有了 Celery ,你可以集中管理所有機器的定時任務,而且災備無論何時切換,crontab 任務總能正確的執行。

  3. 非同步任務。 一些耗時較長的操作,比如 I/O 操作,網路請求,可以交給 Celery 去非同步執行,使用者提交後可以做其他事情,當任務完成後將結果返回使用者即可,可提高使用者體驗。

Celery 的優點

  1. 純 Python 編寫,開源。這已經是站在巨人的肩膀上了,雖然 Celery 是由純 Python 編寫的,但協議可以用任何語言實現。迄今,已有 Ruby 實現的 RCelery 、node.js 實現的 node-celery 以及一個 PHP 客戶端 ,語言互通也可以通過 using webhooks 實現。

  2. 靈活的配置。預設的配置已經滿足絕大多數需求,因此你不需要編寫配置檔案基本就可以使用,當然如果有個性化地定製,你可以選擇使用配置檔案,也可以將配置寫在原始碼檔案裡。

  3. 方便監控。任務的所有狀態,均在你的掌握之下。

  4. 完善的錯誤處理。

  5. 靈活的任務佇列和任務路由。你可以非常方便地將一個任務執行在你指定的佇列上,這叫任務路由。

Celery 的架構

學習一個工具,最好先從它的架構理解,輔以快速入門的程式碼來實踐,最深入的就是閱讀他的原始碼了,下圖是 Celery 的架構圖。

celery架構.png

任務生產者 :呼叫Celery提供的API,函式,裝飾器而產生任務並交給任務佇列的都是任務生產者。

任務排程 Beat:Celery Beat程式會讀取配置檔案的內容,週期性的將配置中到期需要執行的任務傳送給任務佇列

中間人(Broker):Celery 用訊息通訊,通常使用中間人(Broker)在客戶端和 worker 之前傳遞,這個過程從客戶端向佇列新增訊息開始,之後中間人把訊息派送給 worker。官方給出的實現Broker的工具有:

名稱 狀態 監視 遠端控制
RabbitMQ 穩定
Redis 穩定
Mongo DB 實驗性
Beanstalk 實驗性
Amazon SQS 實驗性
Couch DB 實驗性
Zookeeper 實驗性
Django DB 實驗性
SQLAlchemy 實驗性
Iron MQ 第三方

在實際使用中我們選擇 RabbitMQ 或 Redis 作為中間人即可。

執行單元 worker:worker 是任務執行單元,是屬於任務佇列的消費者,它持續地監控任務佇列,當佇列中有新地任務時,它便取出來執行。worker 可以執行在不同的機器上,只要它指向同一個中間人即可,worker還可以監控一個或多個任務佇列, Celery 是分散式任務佇列的重要原因就在於 worker 可以分佈在多臺主機中執行。修改配置檔案後不需要重啟 worker,它會自動生效。

任務結果儲存backend:用來持久儲存 Worker 執行任務的結果,Celery支援不同的方式儲存任務的結果,包括AMQP,Redis,memcached,MongoDb,SQLAlchemy等。

Celery 的使用示例:

以 Python3.6.5 版本為例。

1. 安裝 python 庫:celery,redis。

pip install celery #安裝celery 
pip install celery[librabbitmq,redis,auth,msgpack] #安裝celery對應的依賴

celery其他的依賴包如下:
序列化:
celery[auth]:使用auth序列化。
celery[msgpack]:使用msgpack序列化。
celery[yaml]:使用yaml序列化。
併發:
celery[eventlet]:使用eventlet池。
celery[gevent]:使用gevent池。
celery[threads]:使用執行緒池。
傳輸和後端:
celery[librabbitmq]:使用librabbitmq的C庫.
celery[redis]:使用Redis作為訊息傳輸方式或結果後端。
celery[mongodb]:使用MongoDB作為訊息傳輸方式(實驗性),或是結果後端(已支援)。
celery[sqs]:使用AmazonSQS作為訊息傳輸方式(實驗性)。
celery[memcache]:使用memcache作為結果後端。
celery[cassandra]:使用ApacheCassandra作為結果後端。
celery[couchdb]:使用CouchDB作為訊息傳輸方式(實驗性)。
celery[couchbase]:使用CouchBase作為結果後端。
celery[beanstalk]:使用Beanstalk作為訊息傳輸方式(實驗性)。
celery[zookeeper]:使用Zookeeper作為訊息傳輸方式。
celery[zeromq]:使用ZeroMQ作為訊息傳輸方式(實驗性)。
celery[sqlalchemy]:使用SQLAlchemy作為訊息傳輸方式(實驗性),或作為結果後端(已支援)。
celery[pyro]:使用Pyro4訊息傳輸方式(實驗性)。
celery[slmq]:使用SoftLayerMessageQueue傳輸(實驗性)。

2. 安裝 Redis,以 ubuntu 作業系統為例(如果使用 RabbitMQ,自己裝一下就可以)。

通過原始碼安裝:

$ wget http://download.redis.io/releases/redis-4.0.11.tar.gz
$ tar xzf redis-4.0.11.tar.gz
$ cd redis-4.0.11
$ make

修改 redis 配置檔案 redis.conf,修改bind = 127.0.0.0.1為bind = 0.0.0.0,意思是允許遠端訪問redis資料庫。

啟動 redis-server

$ cd src
$ ./redis-server ../redis.conf

3. 第一個 celery 應用程式。

功能:模擬一個耗時操作,並列印 worker 所在機器的 IP 地址,中間人和結果儲存都使用 redis 資料庫。

#encoding=utf-8
#filename my_first_celery.py
from celery import Celery
import time
import socket

app = Celery(''tasks'', broker='redis://127.0.0.1:6379/0',backend ='redis://127.0.0.1:6379/0' )

def get_host_ip():
    """
    查詢本機ip地址
    :return: ip
    """
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(('8.8.8.8', 80))
        ip = s.getsockname()[0]
    finally:
        s.close()
    return ip


@app.task
def add(x, y):
    time.sleep(3) # 模擬耗時操作
    s = x + y
    print("主機IP {}: x + y = {}".format(get_host_ip(),s))
    return s

啟動這個 worker:

celery -A my_first_celery worker -l info

這裡,-A 表示我們的程式的模組名稱,worker 表示啟動一個執行單元,-l 是批 -level,表示列印的日誌級別。可以使用 celery –help 命令來檢視celery命令的幫助文件。執行命令後,worker介面展示資訊如下:

aaron@ubuntu:~/project$ celery -A my_first_celery worker -l info 
 
 -------------- celery@ubuntu v4.2.1 (windowlicker)
---- **** ----- 
--- * ***  * -- Linux-4.10.0-37-generic-x86_64-with-Ubuntu-16.04-xenial 2018-08-27 22:46:00
-- * - **** --- 
- ** ---------- [config]
- ** ---------- .> app:         tasks:0x7f1ce0747080
- ** ---------- .> transport:   redis://127.0.0.1:6379/0
- ** ---------- .> results:     redis://127.0.0.1:6379/0
- *** --- * --- .> concurrency: 1 (prefork)
-- ******* ---- .> task events: OFF (enable -E to monitor tasks in this worker)
--- ***** ----- 
 -------------- [queues]
                .> celery           exchange=celery(direct) key=celery
                

[tasks]
  . my_first_celery.add

[2018-08-27 22:46:00,726: INFO/MainProcess] Connected to redis://127.0.0.1:6379/0
[2018-08-27 22:46:00,780: INFO/MainProcess] mingle: searching for neighbors
[2018-08-27 22:46:02,075: INFO/MainProcess] mingle: all alone
[2018-08-27 22:46:02,125: INFO/MainProcess] celery@ubuntu ready.

已經相當清晰了。 如果你不想使用 celery 命令來啟動 worker,可直接使用檔案來驅動,修改 my_first_celery.py (增加入口函式main)

if __name__ == '__main__':
    app.start()

再執行

python my_first_celery.py worker

即可。

4. 呼叫任務

在 my_first_celery.py 的同級目錄下編寫如下指令碼 start_task.py如下。

from my_first_celery import add #匯入我們的任務函式add
import time
result = add.delay(12,12) #非同步呼叫,這一步不會阻塞,程式會立即往下執行

while not result.ready():# 迴圈檢查任務是否執行完畢
    print(time.strftime("%H:%M:%S"))
    time.sleep(1)

print(result.get()) #獲取任務的返回結果
print(result.successful()) #判斷任務是否成功執行

執行

python start_task.py

結果如下所示:

22:50:59
22:51:00
22:51:01
24
True

發現等待了大約3秒鐘後,任務返回了結果24,並且是成功完成,此時worker介面增加的資訊如下:

[2018-08-27 22:50:58,840: INFO/MainProcess] Received task: my_first_celery.add[a0c4bb6b-17af-474c-9eab-407d593a7807]  
[2018-08-27 22:51:01,898: WARNING/ForkPoolWorker-1] 主機IP 192.168.195.128: x + y = 24
[2018-08-27 22:51:01,915: INFO/ForkPoolWorker-1] Task my_first_celery.add[a0c4bb6b-17af-474c-9eab-407d593a7807] succeeded in 3.067237992000173s: 24

這裡的資訊非常詳細,其中a0c4bb6b-17af-474c-9eab-407d593a7807是taskid,只要指定了 backend,根據這個 taskid 可以隨時去 backend 去查詢執行結果,使用方法如下:

>>> from my_first_celery import add
>>> taskid= 'a0c4bb6b-17af-474c-9eab-407d593a7807'
>>> add.AsyncResult(taskid).get()
24
>>>#或者
>>> from celery.result import AsyncResult
>>> AsyncResult(taskid).get()
24

重要說明:如果想遠端執行 worker 機器上的作業,請將 my_first_celery.py 和 start_tasks.py 複製到遠端主機上(需要安裝 celery),修改 my_first_celery.py 指向同一個中間人和結果儲存,再執行 start_tasks.py 即可遠端執行 worker 機器上的作業。my_first_celery.add函式的程式碼不是必須的,你也要以這樣呼叫任務:

from my_first_celery import app
app.send_task("my_first_celery.add",args=(1,3))

5. 第一個 celery 專案

在生產環境中往往有大量的任務需要排程,單獨一個檔案是不方便的,celery 當然支援模組化的結構,我這裡寫了一個用於學習的 Celery 小型工程專案,含有佇列操作,任務排程等實用操作,目錄樹如下所示:

celeryproj.png

其中 init.py是空檔案,目的是告訴 Python myCeleryProj 是一個可匯入的包.
app.py

from celery import Celery

app = Celery("myCeleryProj", include=["myCeleryProj.tasks"])

app.config_from_object("myCeleryProj.settings")

if __name__ == "__main__":
    app.start()

settings.py

from kombu import Queue
import re
from datetime import timedelta
from celery.schedules import crontab


CELERY_QUEUES = (  # 定義任務佇列
    Queue("default", routing_key="task.#"),  # 路由鍵以“task.”開頭的訊息都進default佇列
    Queue("tasks_A", routing_key="A.#"),  # 路由鍵以“A.”開頭的訊息都進tasks_A佇列
    Queue("tasks_B", routing_key="B.#"),  # 路由鍵以“B.”開頭的訊息都進tasks_B佇列
)

CELERY_TASK_DEFAULT_QUEUE = "default"  # 設定預設佇列名為 default
CELERY_TASK_DEFAULT_EXCHANGE = "tasks"
CELERY_TASK_DEFAULT_EXCHANGE_TYPE = "topic"
CELERY_TASK_DEFAULT_ROUTING_KEY = "task.default"

CELERY_ROUTES = (
    [
        (
            re.compile(r"myCeleryProj\.tasks\.(taskA|taskB)"),
            {"queue": "tasks_A", "routing_key": "A.import"},
        ),  # 將tasks模組中的taskA,taskB分配至佇列 tasks_A ,支援正規表示式
        (
            "myCeleryProj.tasks.add",
            {"queue": "default", "routing_key": "task.default"},
        ),  # 將tasks模組中的add任務分配至佇列 default
    ],
)


# CELERY_ROUTES = (
#    [
#        ("myCeleryProj.tasks.*", {"queue": "default"}), # 將tasks模組中的所有任務分配至佇列 default
#    ],
# )

# CELERY_ROUTES = (
#    [
#        ("myCeleryProj.tasks.add", {"queue": "default"}), # 將add任務分配至佇列 default
#        ("myCeleryProj.tasks.taskA", {"queue": "tasks_A"}),# 將taskA任務分配至佇列 tasks_A
#        ("myCeleryProj.tasks.taskB", {"queue": "tasks_B"}),# 將taskB任務分配至佇列 tasks_B
#    ],
# )

BROKER_URL = "redis://127.0.0.1:6379/0"  # 使用redis 作為訊息代理

CELERY_RESULT_BACKEND = "redis://127.0.0.1:6379/0"  # 任務結果存在Redis

CELERY_RESULT_SERIALIZER = "json"  # 讀取任務結果一般效能要求不高,所以使用了可讀性更好的JSON

CELERY_TASK_RESULT_EXPIRES = 60 * 60 * 24  # 任務過期時間,不建議直接寫86400,應該讓這樣的magic數字表述更明顯


CELERYBEAT_SCHEDULE = {
    "add": {
        "task": "myCeleryProj.tasks.add",
        "schedule": timedelta(seconds=10),
        "args": (10, 16),
    },
    "taskA": {
        "task": "myCeleryProj.tasks.taskA",
        "schedule": crontab(hour=21, minute=10),
    },
    "taskB": {
        "task": "myCeleryProj.tasks.taskB",
        "schedule": crontab(hour=21, minute=12),
    },
}

tasks.py

import os
from myCeleryProj.app import app
import time
import socket


def get_host_ip():
    """
    查詢本機ip地址
    :return: ip
    """
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
    finally:
        s.close()
    return ip


@app.task
def add(x, y):
    s = x + y
    time.sleep(3)  # 模擬耗時操作
    print("主機IP {}: x + y = {}".format(get_host_ip(), s))
    return s


@app.task
def taskA():
    print("taskA begin...")
    print(f"主機IP {get_host_ip()}")
    time.sleep(3)
    print("taskA done.")


@app.task
def taskB():
    print("taskB begin...")
    print(f"主機IP {get_host_ip()}")
    time.sleep(3)
    print("taskB done.")

readme.txt

#啟動 worker 
#分別在三個終端視窗啟動三個佇列的worker,執行命令如下所示:
celery -A myCeleryProj.app worker -Q default -l info
celery -A myCeleryProj.app worker -Q tasks_A -l info
celery -A myCeleryProj.app worker -Q tasks_B -l info
#當然也可以一次啟動多個佇列,如下則表示一次啟動兩個佇列tasks_A,tasks_B。
celery -A myCeleryProj.app worker -Q tasks_A,tasks_B -l info
#則表示一次啟動兩個佇列tasks_A,tasks_B。
#最後我們再開啟一個視窗來呼叫task: 注意觀察worker介面的輸出
>>> from myCeleryProj.tasks import *
>>> add.delay(4,5);taskA.delay();taskB.delay() #同時發起三個任務
<AsyncResult: 21408d7b-750d-4c88-9929-fee36b2f4474>
<AsyncResult: 737b9502-77b7-47a6-8182-8e91defb46e6>
<AsyncResult: 69b07d94-be8b-453d-9200-12b37a1ca5ab>
#也可以使用下面的方法呼叫task
>>> from myCeleryProj.app import app
>>> app.send_task(myCeleryProj.tasks.add,args=(4,5)
>>> app.send_task(myCeleryProj.tasks.taskA)
>>> app.send_task(myCeleryProj.tasks.taskB)

(完)

相關文章