【问题标题】:Celery: Rate limit on tasks with the same parametersCelery:具有相同参数的任务的速率限制
【发布时间】:2023-03-05 10:39:01
【问题描述】:

我正在寻找一种方法来限制函数何时被调用,但仅限于输入参数不同时,即:

@app.task(rate_limit="60/s")
def api_call(user):
   do_the_api_call()

for i in range(0,100):
  api_call("antoine")
  api_call("oscar")

所以我希望api_call("antoine") 被调用每秒60次api_call("oscar")每秒60次

有什么帮助吗?

--编辑 27/04/2015 我曾尝试在任务中使用 rate_limit 调用子任务,但它也不起作用:rate_limit 始终应用于所有实例化的子任务或任务(这是合乎逻辑的)。

@app.task(rate_limit="60/s")
def sub_api_call(user):
   do_the_api_call()

@app.task
def api_call(user):
  sub_api_call(user)

for i in range(0,100):
  api_call("antoine")
  api_call("oscar")

最好的!

【问题讨论】:

  • 不能只在方法上使用@app.task(rate_limit=60) 装饰器吗?
  • 好吧,我不这么认为,因为它会限制 api_call("antoine") 和 api_call("Oscar") @30/s,我希望每个参数都应用限制,而不是每个函数。
  • 更正,我希望限制不仅适用于每个函数,还适用于每个参数。
  • 基于参数的值是多少?是否可以将 celery 任务包装在另一个装饰器中,并以某种方式将 rate_limit 添加到实际的任务调用中?
  • 对不起,我不明白,你能说得更具体点吗?

标签: python celery rate-limiting ratelimit


【解决方案1】:

更新

请参阅 cmets 部分以获取更好方法的链接,该方法包含此处的大部分内容,但修复了此处版本存在的乒乓问题。这里的版本天真地重试任务。也就是说,它只是稍后再试一次,有一些抖动。如果您有 1,000 个任务都在排队,这会造成混乱,因为它们都在争夺下一个可用位置。他们都只是在任务工作者中进进出出,在最终获得运行机会之前被尝试了数百次。

我没有采用那种幼稚的方法,接下来我尝试的是指数退避,每次限制任务时,它都会比之前的时间回退一点。这个概念可以工作,但它要求您存储每个任务的重试次数,这很烦人并且必须集中,而且它也不是最佳的,因为您在等待预定的时间时可能会出现长时间没有活动的延迟要运行的任务。 (想象一个任务被限制了第 50 次并且必须等待一个小时,而一个限制计时器在它被重新安排这么久之后几秒钟到期。在这种情况下,工作人员将在等待的过程中空闲一个小时要运行的任务。)

尝试此操作的更好方法是使用调度程序,而不是简单的重试或指数退避。 cmets 部分中链接的更新版本维护了一个基本调度程序,该调度程序知道何时重试任务。它跟踪任务被限制的顺序,并知道任务运行的下一个窗口何时发生。因此,想象一下 1 个任务分钟的限制,时间线如下:

00:00:00 - Task 1 is attempted and begins running
00:00:01 - Task 2 is attempted. Oh no! It gets throttled. The current
           throttle expires at 00:01:00, so it is rescheduled then.
00:00:02 - Task 3 is attempted. Oh no! It gets throttled. The current
           throttle expires at 00:01:00, but something is already  
           scheduled then, so it is rescheduled for 00:02:00.
00:01:00 - Task 2 attempts to run again. All clear! It runs.
00:02:00 - Task 3 attempts to run again. All clear! It runs.

换句话说,根据积压的长度,它会在当前限制到期并且所有其他重新计划的、限制的任务都有机会运行后重新调度任务。 (这需要数周时间才能弄清楚。)


原答案

我今天花了一些时间来解决这个问题,并提出了一个不错的解决方案。对此的所有其他解决方案都存在以下问题之一:

  • 它们要求任务进行无限重试,从而使 celery 的重试机制无用。
  • 它们不会根据参数进行节流
  • 它因多个工作人员或队列而失败
  • 它们很笨重,等等。

基本上,您可以像这样包装您的任务:

@app.task(bind=True, max_retries=10)
@throttle_task("2/s", key="domain", jitter=(2, 15))
def scrape_domain(self, domain):
    do_stuff()

结果是将任务限制为每个域参数每秒运行 2 次,随机重试抖动介于 2-15 秒之间。 key 参数是可选的,但对应于任务中的参数。如果没有给出关键参数,它只会将任务限制到给定的速率。如果提供,则限制将应用于(任务,密钥)二元组。

另一种看待这个的方式是没有装饰器。这提供了更多的灵活性,但需要您自己重试。除了上面的,你可以这样做:

@app.task(bind=True, max_retries=10)
def scrape_domain(self, domain):
    proceed = is_rate_okay(self, "2/s", key=domain)
    if proceed:
        do_stuff()
    else:
        self.request.retries = task.request.retries - 1  # Don't count this as against max_retries.
        return task.retry(countdown=random.uniform(2, 15))

我认为这与第一个示例相同。更长一点,更复杂,但更清楚地显示它是如何工作的。我希望自己总是使用装饰器。

这一切都是通过在 redis 中保存一个计数来实现的。实现非常简单。您在 redis 中为任务创建一个密钥(以及密钥参数,如果给定),并根据提供的计划使 redis 密钥过期。如果用户将速率设置为 10/m,则您创建一个 60 秒的 redis 键,并且每次尝试使用正确名称的任务时递增它。如果您的增量器太高,请重试该任务。否则,运行它。

def parse_rate(rate: str) -> Tuple[int, int]:
    """

    Given the request rate string, return a two tuple of:
    <allowed number of requests>, <period of time in seconds>

    (Stolen from Django Rest Framework.)
    """
    num, period = rate.split("/")
    num_requests = int(num)
    if len(period) > 1:
        # It takes the form of a 5d, or 10s, or whatever
        duration_multiplier = int(period[0:-1])
        duration_unit = period[-1]
    else:
        duration_multiplier = 1
        duration_unit = period[-1]
    duration_base = {"s": 1, "m": 60, "h": 3600, "d": 86400}[duration_unit]
    duration = duration_base * duration_multiplier
    return num_requests, duration


def throttle_task(
    rate: str,
    jitter: Tuple[float, float] = (1, 10),
    key: Any = None,
) -> Callable:
    """A decorator for throttling tasks to a given rate.

    :param rate: The maximum rate that you want your task to run. Takes the
    form of '1/m', or '10/2h' or similar.
    :param jitter: A tuple of the range of backoff times you want for throttled
    tasks. If the task is throttled, it will wait a random amount of time
    between these values before being tried again.
    :param key: An argument name whose value should be used as part of the
    throttle key in redis. This allows you to create per-argument throttles by
    simply passing the name of the argument you wish to key on.
    :return: The decorated function
    """

    def decorator_func(func: Callable) -> Callable:
        @functools.wraps(func)
        def wrapper(*args, **kwargs) -> Any:
            # Inspect the decorated function's parameters to get the task
            # itself and the value of the parameter referenced by key.
            sig = inspect.signature(func)
            bound_args = sig.bind(*args, **kwargs)
            task = bound_args.arguments["self"]
            key_value = None
            if key:
                try:
                    key_value = bound_args.arguments[key]
                except KeyError:
                    raise KeyError(
                        f"Unknown parameter '{key}' in throttle_task "
                        f"decorator of function {task.name}. "
                        f"`key` parameter must match a parameter "
                        f"name from function signature: '{sig}'"
                    )
            proceed = is_rate_okay(task, rate, key=key_value)
            if not proceed:
                logger.info(
                    "Throttling task %s (%s) via decorator.",
                    task.name,
                    task.request.id,
                )
                # Decrement the number of times the task has retried. If you
                # fail to do this, it gets auto-incremented, and you'll expend
                # retries during the backoff.
                task.request.retries = task.request.retries - 1
                return task.retry(countdown=random.uniform(*jitter))
            else:
                # All set. Run the task.
                return func(*args, **kwargs)

        return wrapper

    return decorator_func


def is_rate_okay(task: Task, rate: str = "1/s", key=None) -> bool:
    """Keep a global throttle for tasks

    Can be used via the `throttle_task` decorator above.

    This implements the timestamp-based algorithm detailed here:

        https://www.figma.com/blog/an-alternative-approach-to-rate-limiting/

    Basically, you keep track of the number of requests and use the key
    expiration as a reset of the counter.

    So you have a rate of 5/m, and your first task comes in. You create a key:

        celery_throttle:task_name = 1
        celery_throttle:task_name.expires = 60

    Another task comes in a few seconds later:

        celery_throttle:task_name = 2
        Do not update the ttl, it now has 58s remaining

    And so forth, until:

        celery_throttle:task_name = 6
        (10s remaining)

    We're over the threshold. Re-queue the task for later. 10s later:

        Key expires b/c no more ttl.

    Another task comes in:

        celery_throttle:task_name = 1
        celery_throttle:task_name.expires = 60

    And so forth.

    :param task: The task that is being checked
    :param rate: How many times the task can be run during the time period.
    Something like, 1/s, 2/h or similar.
    :param key: If given, add this to the key placed in Redis for the item.
    Typically, this will correspond to the value of an argument passed to the
    throttled task.
    :return: Whether the task should be throttled or not.
    """
    key = f"celery_throttle:{task.name}{':' + str(key) if key else ''}"

    r = make_redis_interface("CACHE")

    num_tasks, duration = parse_rate(rate)

    # Check the count in redis
    count = r.get(key)
    if count is None:
        # No key. Set the value to 1 and set the ttl of the key.
        r.set(key, 1)
        r.expire(key, duration)
        return True
    else:
        # Key found. Check it.
        if int(count) <= num_tasks:
            # We're OK, run it.
            r.incr(key, 1)
            return True
        else:
            return False

【讨论】:

  • 很好的解决方案,几个问题。 1. 油门是恒定窗口而不是滑动窗口(在任何给定时间点,过去 K 周期内应该有最多 N 个任务)。这不是很准确的节流。 2. 这个解决方案使用了一个装饰器,如果你有多个任务在不同的服务器和一个分布式队列上运行,它们将如何在工作人员和队列服务器之间同步?
  • 是的,滑动窗口是准确性/效率的权衡。有关详细信息,请参阅 cmets 中提到的博客。它会正确平均,但在每个窗口期间可以关闭 2 倍。 (2) 是的,装饰器会在您的 redis 数据库中创建一个检查油门的中心位置,因此您可以将任务放在任何队列中,只要它们具有相同的名称并使用相同的 Redis 数据库,您将一切顺利。
  • 在过去几个月在生产中使用它后,我发现它有一个乒乓球问题。任务会受到限制,但很快就会重试。如果有 1,000 个受限制的任务,它们都会不断重试。为了解决这个问题,我编写了一个更新版本,其中包括一个用于需要重试的任务的调度程序。它跟踪积压并设置任务以在应该清除时重试。这里的代码中有很多 cmets 和注释:github.com/freelawproject/courtlistener/blob/…
【解决方案2】:

我认为使用 Celery 的内置任务限制器无法实现这一点。

假设您为 API 使用某种缓存,最好的解决方案可能是创建任务名称和参数的散列,并将该键用于基于缓存的节流器。

如果您使用的是 Redis,您可以设置一个 60 秒超时的锁,或者使用增量计数器来计算每分钟的调用次数。

这篇文章可能会给你一些关于使用 Redis 分布式限制 Celery 任务的建议:

https://callhub.io/distributed-rate-limiting-with-redis-and-celery/

【讨论】:

  • 感谢您的回答和建议。到目前为止,我一直在使用 RabbitMQ 和 Celery,但我可能会继续使用我的旧代码!
猜你喜欢
  • 2019-09-25
  • 2013-10-14
  • 2020-10-20
  • 2023-03-25
  • 2017-01-11
  • 2021-01-27
  • 2012-05-26
  • 1970-01-01
  • 2015-10-04
相关资源
最近更新 更多