簡體   English   中英

Celery Beat:一次限制為單個任務實例

[英]Celery Beat: Limit to single task instance at a time

我有 celery beat 和 celery(四個工人)批量做一些處理步驟。 其中一項任務大致是“對於每個沒有創建 Y 的 X,創建一個 Y”。

該任務以半快速(10 秒)的速度定期運行。 任務完成得非常快。 還有其他任務正在進行中。

我多次遇到過這個問題,其中節拍任務顯然積壓了,因此同時執行相同的任務(來自不同的節拍時間),導致錯誤地重復工作。 任務似乎也是亂序執行的。

  1. 是否可以限制 celery beat 以確保一次僅完成一個任務的出色實例? 在任務上設置rate_limit=5類的東西是“正確”的方法嗎?

  2. 是否可以確保按順序執行 beat 任務,例如,不是調度任務,而是將它添加到任務鏈中?

  3. 除了使這些任務本身以原子方式執行並且可以安全地同時執行之外,處理此問題的最佳方法是什么? 這不是我期望完成任務的限制......

任務本身是天真地定義的:

@periodic_task(run_every=timedelta(seconds=10))
def add_y_to_xs():
    # Do things in a database
    return

這是一個實際的(清理過的)日志:

  • [00:00.000] foocorp.tasks.add_y_to_xs 已發送。 id->#1
  • [00:00.001]收到的任務:foocorp.tasks.add_y_to_xs[#1]
  • [00:10.009] foocorp.tasks.add_y_to_xs 已發送。 id->#2
  • [00:20.024] foocorp.tasks.add_y_to_xs 已發送。 id->#3
  • [00:26.747]收到的任務:foocorp.tasks.add_y_to_xs[#2]
  • [00:26.748]任務池:應用 #2
  • [00:26.752]收到的任務:foocorp.tasks.add_y_to_xs[#3]
  • [00:26.769]接受任務:foocorp.tasks.add_y_to_xs[#2] pid:26528
  • [00:26.775]任務 foocorp.tasks.add_y_to_xs[#2] 在 0.0197986490093s 中成功:無
  • [00:26.806]任務池:應用 #1
  • [00:26.836]任務池:應用 #3
  • [01:30.020]接受任務:foocorp.tasks.add_y_to_xs[#1] pid:26526
  • [01:30.053]接受任務:foocorp.tasks.add_y_to_xs[#3] pid:26529
  • [01:30.055] foocorp.tasks.add_y_to_xs[#1]: 為 X id 添加 Y #9725
  • [01:30.070] foocorp.tasks.add_y_to_xs[#3]: 為 X id 添加 Y #9725
  • [01:30.074]任務 foocorp.tasks.add_y_to_xs[#1] 在 0.0594762689434s 中成功:無
  • [01:30.087]任務 foocorp.tasks.add_y_to_xs[#3] 在 0.0352867960464s 中成功:無

我們目前使用 Celery 3.1.4 和 RabbitMQ 作為傳輸。

編輯丹,這是我想出的:

丹,這是我最終使用的:

from sqlalchemy import func
from sqlalchemy.exc import DBAPIError
from contextlib import contextmanager


def _psql_advisory_lock_blocking(conn, lock_id, shared, timeout):
    lock_fn = (func.pg_advisory_xact_lock_shared
               if shared else
               func.pg_advisory_xact_lock)
    if timeout:
        conn.execute(text('SET statement_timeout TO :timeout'),
                     timeout=timeout)
    try:
        conn.execute(select([lock_fn(lock_id)]))
    except DBAPIError:
        return False
    return True


def _psql_advisory_lock_nonblocking(conn, lock_id, shared):
    lock_fn = (func.pg_try_advisory_xact_lock_shared
               if shared else
               func.pg_try_advisory_xact_lock)
    return conn.execute(select([lock_fn(lock_id)])).scalar()


class DatabaseLockFailed(Exception):
    pass


@contextmanager
def db_lock(engine, name, shared=False, block=True, timeout=None):
    """
    Context manager which acquires a PSQL advisory transaction lock with a
    specified name.
    """
    lock_id = hash(name)

    with engine.begin() as conn, conn.begin():
        if block:
            locked = _psql_advisory_lock_blocking(conn, lock_id, shared,
                                                  timeout)
        else:
            locked = _psql_advisory_lock_nonblocking(conn, lock_id, shared)
        if not locked:
            raise DatabaseLockFailed()
        yield

芹菜任務裝飾器(僅用於周期性任務):

from functools import wraps
from preo.extensions import db


def locked(name=None, block=True, timeout='1s'):
    """
    Using a PostgreSQL advisory transaction lock, only runs this task if the
    lock is available. Otherwise logs a message and returns `None`.
    """
    def with_task(fn):
        lock_id = name or 'celery:{}.{}'.format(fn.__module__, fn.__name__)

        @wraps(fn)
        def f(*args, **kwargs):
            try:
                with db_lock(db.engine, name=lock_id, block=block,
                             timeout=timeout):
                    return fn(*args, **kwargs)
            except DatabaseLockFailed:
                logger.error('Failed to get lock.')
                return None
        return f
    return with_task
from functools import wraps
from celery import shared_task


def skip_if_running(f):
    task_name = f'{f.__module__}.{f.__name__}'

    @wraps(f)
    def wrapped(self, *args, **kwargs):
        workers = self.app.control.inspect().active()

        for worker, tasks in workers.items():
            for task in tasks:
                if (task_name == task['name'] and
                        tuple(args) == tuple(task['args']) and
                        kwargs == task['kwargs'] and
                        self.request.id != task['id']):
                    print(f'task {task_name} ({args}, {kwargs}) is running on {worker}, skipping')

                    return None

        return f(self, *args, **kwargs)

    return wrapped


@shared_task(bind=True)
@skip_if_running
def test_single_task(self):
    pass


test_single_task.delay()

做到這一點的唯一方法是自己實施鎖定策略

閱讀此處的部分以獲取參考。

與 cron 一樣,如果第一個任務在下一個任務之前沒有完成,任務可能會重疊。 如果這是一個問題,您應該使用鎖定策略來確保一次只能運行一個實例(參見例如確保一次只執行一個任務)。

我使用celery-once解決了這個問題,我將其擴展到celery-one

兩者都為您的問題服務。 它使用 Redis 來鎖定正在運行的任務。 celery-one還將跟蹤正在鎖定的任務。

下面是 celery beat 的一個非常簡單的使用示例。 在下面的代碼中, slow_task每 1 秒安排一次,但它的完成時間是 5 秒。 普通 celery 會每秒安排一次任務,即使它已經在運行。 celery-one可以防止這種情況。

celery = Celery('test')
celery.conf.ONE_REDIS_URL = REDIS_URL
celery.conf.ONE_DEFAULT_TIMEOUT = 60 * 60
celery.conf.BROKER_URL = REDIS_URL
celery.conf.CELERY_RESULT_BACKEND = REDIS_URL

from datetime import timedelta

celery.conf.CELERYBEAT_SCHEDULE = {
    'add-every-30-seconds': {
        'task': 'tasks.slow_task',
        'schedule': timedelta(seconds=1),
        'args': (1,)
    },
}

celery.conf.CELERY_TIMEZONE = 'UTC'


@celery.task(base=QueueOne, one_options={'fail': False})
def slow_task(a):
    print("Running")
    sleep(5)
    return "Done " + str(a)

我嘗試編寫一個裝飾器來使用Postgres 咨詢鎖定,類似於 erydo 在他的評論中提到的。

它不是很漂亮,但似乎工作正常。 這是在 Python 2.7 下的 SQLAlchemy 0.9.7。

from functools import wraps
from sqlalchemy import select, func

from my_db_module import Session # SQLAlchemy ORM scoped_session

def pg_locked(key):
    def decorator(f):
        @wraps(f)
        def wrapped(*args, **kw):
            session = db.Session()
            try:
                acquired, = session.execute(select([func.pg_try_advisory_lock(key)])).fetchone()
                if acquired:
                    return f(*args, **kw)
            finally:
                if acquired:
                    session.execute(select([func.pg_advisory_unlock(key)]))
        return wrapped
    return decorator

@app.task
@pg_locked(0xdeadbeef)
def singleton_task():
    # only 1x this task can run at a time
    pass

(歡迎對改進方法提出任何意見!)

需要分布式鎖定系統,因為那些 Celery beat 實例本質上是不同的進程,可能跨不同的主機。

ZooKeeper 和 etcd 等中心坐標系適用於分布式鎖系統的實現。

我推薦使用 etcd,它是輕量級和快速的。 鎖定在 etcd 上有幾種實現,例如:

python-etcd-lock

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM