[源码解析] 并行分布式框架 Celery 之 容错机制

[源码解析] 并行分布式框架 Celery 之 容错机制

0x00 摘要

Celery是一个简单、灵活且可靠的,处理大量消息的分布式系统,专注于实时处理的异步任务队列,同时也支持任务调度。本文介绍 Celery 的故障转移容错机制。

0x01 概述

1.1 错误种类

Celery 之中,错误(以及应对策略)主要有 3 种:

  • 用户代码错误:错误可以直接返回应用,因为Celery无法知道如何处理;
  • Broker错误:Celery可以根据负载平衡策略尝试下一个节点;
  • 网络超时错误:Celery可以重试该请求;

1.2 失败维度

从系统角度出发,几个最可能的失败维度如下(本文可能进程,线程两个单词混用,请大家谅解):

  1. Broker失败;

  2. Worker ---> Broker 这个链路会失败;

  3. Worker 节点会失败;

  4. Worker 中的多进程中,某一个进程本身失效;

  5. Worker 的某一个进程中,内部处理任务失败;

大致如图(图上数字分别对应上述序号):

                         +-----------------+
                         |Worker           |
                         |                 |
                         |     Producer    |
                         |                 |
                         |                 |
                         +--------+--------+
                                  |
                                  |  2
                                  v
                     +------------+------------+
                     |          Broker         |  1
                     +------------+------------+
                                  |
                                  |
                                  |
                   +--------------+-------------+------------+
                   |                            |            |
                   v                            v            v
+------------------+------------------------+ +-+-------+ +--+--------+
| worker     3                              | | Worker  | |  Worker   |
|                                           | |         | |           |
| +-------------------+  +----------------+ | |         | |           |
| |  Process  4       |  | Process        | | |         | |           |
| | +--------------+  |  | +------------+ | | |         | |           |
| | |  User task 5 |  |  | | User task  | | | |         | |           |
| | +--------------+  |  | +------------+ | | |         | |           |
| +-------------------+  +----------------+ | |         | |           |
+-------------------------------------------+ +---------+ +-----------+

从实际处理看,broker可以使用 RabbitMQ,可以做 集群和故障转移;这是涉及到整体系统设计的维度,这里暂时不考虑。所以我们重点看后面几项。

1.3 应对手段

依据错误级别,错误处理 分别有 重试 与 fallback 选择 两种。我们在后续会一一讲解。

我们先给出总体图示:

0x02 Worker ---> Broker 通路失效

此维度上主要关心的是:Broker 某一个节点失效 以及 worker 与 Broker 之间网络失效。

在这个维度上,无论是 Celery 还是 Kombu 都做了努力,但是从根本来说,还是 Kombu 的努力。

我们按照 重试 与 fallback 这个种类来看。

2.1 Retry

这里分为几个层次,比如 Retry in Celery,Retry in kombu,Autoretry in kombu。

2.1.1 Retry in Celery

在 Celery 中,对于重试,有 broker_connection_max_retries 配置,就是最大重试次数。

具体是定义了一个 _error_handler,当 调用 ensure_connection 来进行网络连接时候,会配置这个 _error_handler。

当出现网络故障时候,Celery 会根据 broker_connection_max_retries 配置来使用 _error_handler 进行重试。

    def ensure_connected(self, conn):
        # Callback called for each retry while the connection
        # can't be established.
        # 这里就是用来重试
        def _error_handler(exc, interval, next_step=CONNECTION_RETRY_STEP):
            if getattr(conn, 'alt', None) and interval == 0:
                next_step = CONNECTION_FAILOVER
            next_step = next_step.format(
                when=humanize_seconds(interval, 'in', ' '),
                retries=int(interval / 2),
                max_retries=self.app.conf.broker_connection_max_retries)
            error(CONNECTION_ERROR, conn.as_uri(), exc, next_step)

        # remember that the connection is lazy, it won't establish
        # until needed.
        if not self.app.conf.broker_connection_retry:
            # retry disabled, just call connect directly.
            conn.connect()
            return conn

        conn = conn.ensure_connection(
            _error_handler, self.app.conf.broker_connection_max_retries,
            callback=maybe_shutdown,
        )
        return conn

2.1.2 Retry in Kombu

在 Komub 中,同样做了 各种 重试 处理,比如 在 Connection.py 中有如下重试参数:

  • max_retries:最大重试次数;
  • errback (Callable):失败回调策略;
  • callback (Callable):每次重试间隔的回调函数;

注意,这里重连时候,使用了 maybe_switch_next,这就是 fallback,我们在 failover 进行分析。

    def _ensure_connection(
        self, errback=None, max_retries=None,
        interval_start=2, interval_step=2, interval_max=30,
        callback=None, reraise_as_library_errors=True,
        timeout=None
    ):
        """Ensure we have a connection to the server.
        """
        if self.connected:
            return self._connection

        def on_error(exc, intervals, retries, interval=0):
            round = self.completes_cycle(retries)
            if round:
                interval = next(intervals)
            if errback:
                errback(exc, interval)
            self.maybe_switch_next()  # select next host,选择下一个host

            return interval if round else 0

        ctx = self._reraise_as_library_errors
        if not reraise_as_library_errors:
            ctx = self._dummy_context
        with ctx():
            return retry_over_time( #重试
                self._connection_factory, self.recoverable_connection_errors,
                (), {}, on_error, max_retries,
                interval_start, interval_step, interval_max,
                callback, timeout=timeout
            )

retry_over_time 是具体实现如何依据时间进行重连的函数,具体如下:

def retry_over_time(fun, catch, args=None, kwargs=None, errback=None,
                    max_retries=None, interval_start=2, interval_step=2,
                    interval_max=30, callback=None, timeout=None):
    """Retry the function over and over until max retries is exceeded.

    For each retry we sleep a for a while before we try again, this interval
    is increased for every retry until the max seconds is reached.

    Arguments:
        fun (Callable): The function to try
    """
    kwargs = {} if not kwargs else kwargs
    args = [] if not args else args
    interval_range = fxrange(interval_start,
                             interval_max + interval_start,
                             interval_step, repeatlast=True)
    end = time() + timeout if timeout else None
    for retries in count(): # 记录重试次数
        try:
            return fun(*args, **kwargs) #重新运行用户代码
        except catch as exc:
            if max_retries is not None and retries >= max_retries:
                raise
            if end and time() > end:
                raise
            if callback:
                callback()
            tts = float(errback(exc, interval_range, retries) if errback
                        else next(interval_range))
            if tts: # 与时间相关
                for _ in range(int(tts)):
                    if callback:
                        callback()
                    sleep(1.0)
                # sleep remainder after int truncation above.
                sleep(abs(int(tts) - tts))

2.1.3 Autoretry in Kombu

自动重试是 kombu 的另外一种重试途径,比如在 kombu\connection.py 就有 autoretry,其基本套路是:

  • 在调用fun时候,可以使用 autoretry 这个mapper 做包装。并且可以传入上次调用成功的 channel。
  • 如果调用fun过程中失败,kombu 会自动进行try。

具体如下:

    def autoretry(self, fun, channel=None, **ensure_options):
        """Decorator for functions supporting a ``channel`` keyword argument.

        The resulting callable will retry calling the function if
        it raises connection or channel related errors.
        The return value will be a tuple of ``(retval, last_created_channel)``.

        If a ``channel`` is not provided, then one will be automatically
        acquired (remember to close it afterwards).

        Example:
            >>> channel = connection.channel()
            >>> try:
            ...    ret, channel = connection.autoretry(
            ...         publish_messages, channel)
            ... finally:
            ...    channel.close()
        """
        channels = [channel]

        class Revival:

            def __init__(self, connection):
                self.connection = connection

            def revive(self, channel):
                channels[0] = channel

            def __call__(self, *args, **kwargs):
                if channels[0] is None:
                    self.revive(self.connection.default_channel)
                return fun(*args, channel=channels[0], **kwargs), channels[0]

        revive = Revival(self)
        return self.ensure(revive, revive, **ensure_options)

我们拓展逻辑图如下:

                  +---------------------------------+
                  |  Worker                Producer |
                  |                                 |
                  |                                 |
                  |      Retry in Celery / Kombu    |
                  |                                 |
                  |      Autoretry in Kombu         |
                  +---------------+-----------------+
                                  |
                                  |
                                  |
                                  v

                     +-------------------------+
                     |          Broker         |
                     +------------+------------+
                                  |
                                  |
                                  |
                   +--------------+-------------+------------+
                   |                            |            |
                   v                            v            v
+------------------+------------------------+ +-+-------+ +--+--------+
| worker                                    | | Worker  | |  Worker   |
|                                           | |         | |           |
| +-------------------+  +----------------+ | |         | |           |
| |  Process          |  | Process        | | |         | |           |
| | +--------------+  |  | +------------+ | | |         | |           |
| | |  User task   |  |  | | User task  | | | |         | |           |
| | +--------------+  |  | +------------+ | | |         | |           |
| +-------------------+  +----------------+ | |         | |           |
+-------------------------------------------+ +---------+ +-----------+

2.2 Failover

如果重试不解决问题,则会使用 fallback

2.2.1 Failover in Celery

broker_failover_strategy 是针对 broker Connection 来设置的策略。会自动映射到 kombu.connection.failover_strategies

所以我们还是需要看 Kombu。

2.2.2 Failover in Kombu

简要的说,就是配置多个broker url,failover 时候,会在多个 broker 之间使用 rr 或者 shuffle 策略进行重试

2.2.2.1 配置多broker

在配置 Connection的时候,可以设置多个 broker url,在连接 broker 的时候,kombu 自动会选取最健康的 broker 节点进行连接。

比如下文中就配置了两个url:

conn = Connection(
     'amqp://guest:guest@broken.example.com;guest:guest@healthy.example.com'
)
conn.connect()

也可以设置 failover 策略,比如配置之后,就使用 RR 策略来进行连接。

Connection(
     'amqp://broker1.example.com;amqp://broker2.example.com',
     failover_strategy='round-robin'
)
2.2.2.2 failover strategies

在源码中,具体对 fail over 的使用如下:

        # fallback hosts
        self.alt = alt
        # keep text representation for .info
        # only temporary solution as this won't work when
        # passing a custom object (Issue celery/celery#3320).
        self._failover_strategy = failover_strategy or 'round-robin'
        self.failover_strategy = self.failover_strategies.get(
            self._failover_strategy) or self._failover_strategy
        if self.alt:
            self.cycle = self.failover_strategy(self.alt)
            next(self.cycle)  # skip first entry

具体配置是:

failover_strategies = {
    'round-robin': roundrobin_failover,
    'shuffle': shufflecycle,
}

就是选用 round robin或者 suffle 来挑选下一个

suffle 的实现具体如下。

def shufflecycle(it):
    it = list(it)  # don't modify callers list
    shuffle = random.shuffle
    for _ in repeat(None):
        shuffle(it)
        yield it[0]
2.2.2.3 Failover with Retry

在前面 _ensure_connection 中,重试时候会结合 failover 来挑选 下一个 host,具体使用了 maybe_switch_next 函数实现如下:

    def switch(self, conn_str):
        """Switch connection parameters to use a new URL or hostname.
        Arguments:
            conn_str (str): either a hostname or URL.
        """
        self.close()
        self.declared_entities.clear()
        self._closed = False
        conn_params = (
            parse_url(conn_str) if "://" in conn_str else {"hostname": conn_str}  # noqa
        )
        self._init_params(**dict(self._initial_params, **conn_params))

    def maybe_switch_next(self):
        """Switch to next URL given by the current failover strategy."""
        if self.cycle:
            self.switch(next(self.cycle))

扩展逻辑如下:

                  +---------------------------------+
                  |  Worker                Producer |
                  |                                 |
                  |                                 |
                  |      Retry in Celery / Kombu    |
                  |                                 |
                  |      Autoretry in Kombu         |
                  +---------------+-----------------+
                                  |
                                  | round robin / shuffle
                                  |
                  +-----------------------------------+
                  |               |                   |
                  v               v                   v
      +-----------------------------------------------------------+
      | +-----------------+ +----------------+ +----------------+ |
      | | Broker 1        | | Broker 2       | | Broker 3       | |
      | |                 | |                | |                | |
      | |          url 1  | |         url 2  | |          url 3 | |
      | +-----------------+ +----------------+ +----------------+ |
      +-----------------------------------------------------------+
                                  |
                                  |
                                  |
                   +--------------+----------------------+------------+
                   |                                     |            |
                   v                                     v            v
+------------------+------------------------+          +-+-------+ +--+--------+
| worker                                    |          | Worker  | |  Worker   |
|                                           |          |         | |           |
| +-------------------+  +----------------+ |          |         | |           |
| |  Process          |  | Process        | |          |         | |           |
| | +--------------+  |  | +------------+ | |          |         | |           |
| | |  User task   |  |  | | User task  | | |          |         | |           |
| | +--------------+  |  | +------------+ | |          |         | |           |
| +-------------------+  +----------------+ |          |         | |           |
+-------------------------------------------+          +---------+ +-----------+

0x03 Worker 任务失效

当用户代码失效时候,Celery 也会进行相应处理,也有 Retry 和 fallback 两种途径。

这里是需要用户主动显式设置。因为 worker 不知道如何处理失败,只能用户主动设置。

3.2 Retry in Task

在任务执行的过程中,总会由于偶尔的网络抖动或者其他原因造成网络请求超时或者抛出其他未可知的异常,任务中不能保证所有的异常都被及时重试处理,celery 提供了很方便的重试机制,可以配置重试次数,和重试时间间隔。

如果想要任务重试,则可以在任务中手动配置。其是在 Worker 内部完成的,即 worker 会重新进行任务分发。

3.2.1 示例

具体示例如下,如果配置了 retry,则在失败时候会进行调用。

from imaginary_twitter_lib import Twitter
from proj.celery import app

@app.task(bind=True)
def tweet(self, auth, message):
     twitter = Twitter(oauth=auth)
     try:
         twitter.post_status_update(message)
     except twitter.FailWhale as exc:
         # Retry in 5 minutes.
         self.retry(countdown=60 * 5, exc=exc)

3.2.2 配置

retry的参数可以有:

  • exc:指定抛出的异常;
  • throw:重试时是否通知worker是重试任务;
  • eta:指定重试的时间/日期;
  • countdown:在多久之后重试(每多少秒重试一次);
  • max_retries:最大重试次数;

3.2.3 实现

可以看出来,如果遇到了异常,则会重新进行任务分发,放入 task queue。

    def retry(self, args=None, kwargs=None, exc=None, throw=True,
              eta=None, countdown=None, max_retries=None, **options):
        """Retry the task, adding it to the back of the queue."""
        request = self.request
        retries = request.retries + 1
        max_retries = self.max_retries if max_retries is None else max_retries

        is_eager = request.is_eager
        S = self.signature_from_request(
            request, args, kwargs,
            countdown=countdown, eta=eta, retries=retries,
            **options
        )

        if max_retries is not None and retries > max_retries:
            if exc:
                raise_with_context(exc)
            raise self.MaxRetriesExceededError(
                "Can't retry {}[{}] args:{} kwargs:{}".format(
                    self.name, request.id, S.args, S.kwargs
                ), task_args=S.args, task_kwargs=S.kwargs
            )

        ret = Retry(exc=exc, when=eta or countdown, is_eager=is_eager, sig=S)

        try:
            S.apply_async() # 重新进行任务分发,放入 task queue。
        except Exception as exc:
            raise Reject(exc, requeue=False)
        if throw:
            raise ret
        return ret

3.3 Autoretry in Task

在 Celery 之中,也有 autoretry。

Autoretry in Task 机制,是在 Worker 内部完成的,最终调用 retry,即 worker 会自动重新进行任务分发。

3.3.1 示例

具体实例如下,在定义task时候,如果使用了 autoretry_for 注解,则在注册 task 时候会做相关处理。

    from twitter.exceptions import FailWhaleError

    @app.task(autoretry_for=(FailWhaleError,))
    def refresh_timeline(user):
        return twitter.refresh_timeline(user)

3.3.2 使用

具体在 task 注册过程中,会调用 add_autoretry_behaviour 进行处理。

class TaskRegistry(dict):
    """Map of registered tasks."""

    def register(self, task):
        """Register a task in the task registry.

        The task will be automatically instantiated if not already an
        instance. Name must be configured prior to registration.
        """
        task = inspect.isclass(task) and task() or task
        add_autoretry_behaviour(task)
        self[task.name] = task

3.3.3 实现

add_autoretry_behaviour 的具体实现在 celery\app\autoretry.py。

可以看出其思路:

  • 首先提取 task 的配置,看看是否有 autoretry 相关配置,如果设置,就把原始用户函数设定为 _orig_run,生成了 run 这个自动重试机制;
  • 返回 task._orig_run, task.run;
  • 在真实调用中,会首先调用 用户代码 task._orig_run(*args, **kwargs),如果遇到异常,则会调用 task . retry 进行重试。

具体代码如下:

def add_autoretry_behaviour(task, **options):
    """Wrap task's `run` method with auto-retry functionality."""

    if autoretry_for and not hasattr(task, '_orig_run'):

        @wraps(task.run)
        def run(*args, **kwargs):
            try:
                return task._orig_run(*args, **kwargs)
            except Ignore:
                # If Ignore signal occures task shouldn't be retried,
                # even if it suits autoretry_for list
                raise
            except Retry:
                raise
            except autoretry_for as exc:
                if retry_backoff:
                    retry_kwargs['countdown'] = \
                        get_exponential_backoff_interval(
                            factor=retry_backoff,
                            retries=task.request.retries,
                            maximum=retry_backoff_max,
                            full_jitter=retry_jitter)
                # Override max_retries
                if hasattr(task, 'override_max_retries'):
                    retry_kwargs['max_retries'] = getattr(task,
                                                          'override_max_retries',
                                                          task.max_retries)
                ret = task.retry(exc=exc, **retry_kwargs)
                # Stop propagation
                if hasattr(task, 'override_max_retries'):
                    delattr(task, 'override_max_retries')
                raise ret

        task._orig_run, task.run = task.run, run

扩展逻辑如下,可以看到,在 task 之中会使用 retry,autoretry 来进行重试:

                         +---------------------------------+
                         |  Worker                Producer |
                         |                                 |
                         |                                 |
                         |      Retry in Celery / Kombu    |
                         |                                 |
                         |      Autoretry in Kombu         |
                         +---------------+-----------------+
                                         |
                                         | round robin / shuffle
                                         |
                         +-----------------------------------+
                         |               |                   |
                         v               v                   v
             +-----------------------------------------------------------+
             | +-----------------+ +----------------+ +----------------+ |
             | | Broker 1        | | Broker 2       | | Broker 3       | |
             | |                 | |                | |                | |
             | |          url 1  | |         url 2  | |          url 3 | |
             | +-----------------+ +----------------+ +----------------+ |
             +-----------------------------------------------------------+
                                         |
                                         |
                                         |
                          +--------------+-----------------------------------+
                          |                                                  |
                          |                                                  |
                          |                                                  |
                          v                                                  v
+-------------------------+----------------------------------------+   +-----+---------+
|        worker                                                    |   | Worker        |
|                                                                  |   |               |
| +-------------------------------------------------------------+  |   | +-----------+ |
| |         Process                                             |  |   | |           | |
| | +--------------------------------------------------------+  |  |   | | Process   | |
| | |         User task                                      |  |  |   | |           | |
| | |                                                        |  |  |   | |           | |
| | |                        +-----------------------------+ |  |  |   | +-----------+ |
| | |                        | retry                       | |  |  |   | +-----------+ |
| | |   autoretry +--------> |                             | |  |  |   | |           | |
| | |                        |                             | |  |  |   | | Process   | |
| | |                        |     User business logic     | |  |  |   | |           | |
| | |  max_retries  +------> |                             | |  |  |   | |           | |
| | |                        |                             | |  |  |   | |           | |
| | |                        +-----------------------------+ |  |  |   | +-----------+ |
| | |                                                        |  |  |   |               |
| | +--------------------------------------------------------+  |  |   |               |
| +-------------------------------------------------------------+  |   |               |
+------------------------------------------------------------------+   +---------------+

0x04 QoS in Kombu

因为后续会使用到 Kombu 的 QoS 功能,所以我们需要先介绍。

具体可以分为三个方向来介绍。

4.1 Prefetch

目前 Kombu QoS 只是支持 prefetch_count。

设置 prefetch_count 的目的是:

  • Prefetch指的是一个Celery Worker节点,能够提前获取一些还还未被其他节点执行的任务,这样可以提高Worker节点的运行效率。
  • 同时也可以通过设置Qos的prefetch count来控制consumer的流量,防止消费者从队列中一下拉取所有消息,从而导致击穿服务,导致服务崩溃或异常。

Kombu qos prefetch_count 是一个整数值N,表示的意思就是一个消费者最多只能一次拉取N条消息,一旦N条消息没有处理完,就不会从队列中获取新的消息,直到有消息被ack。

Kombu 中,会记录 prefetch_count的值,同时记录的还有该channel dirty (acked/rejected) 的消息个数。

4.2 acknowledge

Acknowledged则是一个任务执行完后,只有确认返回发送了Acknowledged确认信息后,该任务才算完成。

消费者在开启 acknowledge 的情况下,对接收到的消息可以根据业务的需要异步对消息进行确认。

QoS是从 Kombu Channel 角度来说的,所以这个 ack 是 amqp 角度的 ack。

4.3 消费

当 celery要将队列中的一条消息投递给消费者时,会:

  • 遍历该队列上的消费者列表,选一个合适的消费者,然后将消息投递出去。
  • 其中挑选消费者的一个依据就是:看消费者对应的 channel 上未ack的消息数是否达到设置的prefetch_count个数,如果未ack的消息数达到了prefetch_count的个数,则不符合要求。
  • 当挑选到合适的消费者后,中断后续的遍历。

所以,判断是否可以消费的代码 如下,就是看看有没有达到 prefetch count:

    def can_consume(self):
        """Return true if the channel can be consumed from.

        Used to ensure the client adhers to currently active
        prefetch limits.
        """
        pcount = self.prefetch_count
        return not pcount or len(self._delivered) - len(self._dirty) < pcount

0x05 Worker节点失败

如果 Worker节点失败,则会导致 该节点的 job 都失败,我们需要一个机制来处理这些失败的job。

这部分我们可以和 Quartz 做比较

Quartz是:

  • 在数据库中集中记录了各个节点的状态;
  • 每个节点会定期在数据库中修改自己的状态,可以认为是心跳;
  • 所以如果某一个节点出错,其他节点就会在这个数据库表中发现有节点出错了;
  • 于是得到控制权的这个节点会修改出错节点的job,重新给他们一个新的调度机会;

所以我们可以看出来,Quartz 是依赖于心跳和节点状态来处理失败节点的job。

作为对比,我们看看 Celery 的运作方式:

  • Celery 也有心跳,具体是每个节点用广播方式给其他所有节点都发送心跳;
  • 每个节点都知道其他节点的状态;
  • 但是每个节点并不用节点状态来决定 "如何控制其他节点的job";
  • 具体如何处理 失败节点的 job?是通过直接去 redis 查看 job 状态来判别。或者说,Celery 不在乎其他节点的状态(感觉用节点状态只是来监控而已),而只关心 unacked job 的状态;

因此,Celery 的运作方式是:虽然有心跳但是没有利用心跳,也忽略节点状态,而是单纯依赖 unacked job 的状态来处理失败 job

具体我们剖析如下:

5.1 默认Acknowledged行为

Acknowledged机制是设计用来确定一个任务已经被执行/已完成的。

Celery默认的ACK行为是,当一个任务被执行后,立刻发送Acknowledged信息,标记该任务已被执行,不管是否完成了任务,同时从你的代理队列中将它们删除。

比如一个任务被节点执行后,节点发送Acknowledged信号标记该任务已被执行。结果执行过程中该节点出现由断电、运行中被结束等异常行为,那么该任务则不会被重新分发到其他节点,因为该任务已经被标记为Acknowledged了。

如果你的任务不是幂等的(可重复而不会出问题),这种行为是很好的。但它不适用于处理随机错误,比如你的数据库连接随机断开。在这种情况下,你的工作就会丢失,因为Celery在尝试它之前就把它从队列中删除了。

5.2 延迟确认

以上是 Celery 的默认Acknowledged机制。

而我们有时候需要一个任务确实被一个节点执行完成后才发送Acknowledged消息。这就是 “延迟确认”,即只在任务成功完成后进行确认。这是其他许多队列系统(如SQS)所推荐的行为。

Celery在它的FAQ : “我应该使用重试还是acks_late?” 中对这一点进行了介绍。这是一个微妙的问题,但确实默认的“提前确认”行为是违反直觉的。

针对延迟确认,Celery 有一个配置task_acks_late

当我们设置一个节点为task_acks_late=True之后,那么这个节点上正在执行的任务若是遇到断电,运行中被结束等情况,这些任务会被重新分发到其他节点进行重试

注意:要求被重试的任务是幂等的,即多次运行不会改变结果

同时,Celery还提供了一个Task级别的acks_late设置,可以单独控制某一个任务是否是采用Acknowledged Late模式的。

建议在Celery配置中将 acks_late = True设置为默认值,并充分考虑哪种模式适合每个任务。你也可以通过将acks_late传递给@shared_task装饰器来在每个任务函数上重新配置它。

5.3 acks_late in Celery

现在我们知道了,在 Celery 中,acks_late 可以完成对失败 Worker 节点任务的处理。

具体在 celery\worker\request.py 可以看到,如果不是延迟ack,就会立刻 acknowledge。

    def on_accepted(self, pid, time_accepted):
        """Handler called when task is accepted by worker pool."""
    
        task_accepted(self)
        if not self.task.acks_late:
            self.acknowledge()
            
 	    ......

如果是延迟 ack,则在以下几种情况下才会确认,比如 retry,failure.....:

    def on_timeout(self, soft, timeout):
        """Handler called if the task times out."""
        if soft:
			...
        else:
            task_ready(self)

            if self.task.acks_late and self.task.acks_on_failure_or_timeout:
                self.acknowledge()
            ...

    def on_success(self, failed__retval__runtime, **kwargs):
        """Handler called if the task was successfully processed."""
        task_ready(self)

        if self.task.acks_late:
            self.acknowledge()

        self.send_event('task-succeeded', result=retval, runtime=runtime)

    def on_retry(self, exc_info):
        """Handler called if the task should be retried."""
        if self.task.acks_late:
            self.acknowledge()

        self.send_event('task-retried',
                        exception=safe_repr(exc_info.exception.exc),
                        traceback=safe_str(exc_info.traceback))

    def on_failure(self, exc_info, send_failed_event=True, return_ok=False):
        """Handler called if the task raised an exception."""
        task_ready(self)

		.....

        # (acks_late) acknowledge after result stored.
        requeue = False
        if self.task.acks_late:
            reject = (
                self.task.reject_on_worker_lost and
                isinstance(exc, WorkerLostError)
            )
            ack = self.task.acks_on_failure_or_timeout
            if reject:
                requeue = True
                self.reject(requeue=requeue)
                send_failed_event = False
            elif ack:
                self.acknowledge()
            else:
                # supporting the behaviour where a task failed and
                # need to be removed from prefetched local queue
                self.reject(requeue=False)

		......

5.4 具体实现

下面我们看看 Celey 是具体如何实现 “处理 失败节点的 job” 的。具体 Celery 就是调用 Kombu 的 QoS 来实现。

5.4.1 消费消息

Celery 在从redis获取到消息之后,会调用到 qos 把 消息放入 unack 队列。

    def basic_consume(self, queue, no_ack, callback, consumer_tag, **kwargs):
        """Consume from `queue`."""
        self._tag_to_queue[consumer_tag] = queue
        self._active_queues.append(queue)

        def _callback(raw_message):
            message = self.Message(raw_message, channel=self)
            if not no_ack:
                self.qos.append(message, message.delivery_tag) # 插入 unack 队列
            return callback(message)

我们大致可以猜想到,是以 message.delivery_tag 为标识,把 message 插入到 unack 队列。

此时具体变量为:

message.delivery_tag = {str} 'b6e6ec93-8993-442e-821e-31afeec7fa07'

self.qos = {QoS} <kombu.transport.redis.QoS object at 0x0000024F6A045F48>

callback = {method} <bound method Consumer._receive_callback of <Consumer: [<Queue celery -> <Exchange celery(direct) bound to chan:1> -> celery bound to chan:1>]>>
    
message = {Message} <Message object at 0x24f6a181e58 with details {'state': 'RECEIVED', 'content_type': 'application/json', 'delivery_tag': 'b6e6ec93-8993-442e-821e-31afeec7fa07', 'body_length': 81, 'properties': {'correlation_id': '8c1923ae-e330-4ca8-b81f-432e2959de5e'}, 'delivery_info': {'exchange': '', 'routing_key': 'celery'}}>

raw_message = {dict: 5}
 'body' = {str} 'W1syLCA4XSwge30sIHsiY2FsbGJhY2tzIjogbnVsbCwgImVycmJhY2tzIjogbnVsbCwgImNoYWluIjogbnVsbCwgImNob3JkIjogbnVsbH1d'
 'content-encoding' = {str} 'utf-8'
 'content-type' = {str} 'application/json'
 'headers' = {dict: 15} {'lang': 'py', 'task': 'myTest.add', 'id': '8c1923ae-e330-4ca8-b81f-432e2959de5e', 'shadow': None, 'eta': None, 'expires': None, 'group': None, 'group_index': None, 'retries': 0, 'timelimit': [None, None], 'root_id': '8c1923ae-e330-4ca8-b81f-432e2959de5e', 'parent_id': None, 'argsrepr': '(2, 8)', 'kwargsrepr': '{}', 'origin': 'gen7576@DESKTOP-0GO3RPO'}
 'properties' = {dict: 7} {'correlation_id': '8c1923ae-e330-4ca8-b81f-432e2959de5e', 'reply_to': '7d97167c-099f-3446-867c-54a782371e6f', 'delivery_mode': 2, 'delivery_info': {'exchange': '', 'routing_key': 'celery'}, 'priority': 0, 'body_encoding': 'base64', 'delivery_tag': 'b6e6ec93-8993-442e-821e-31afeec7fa07'}


self = {Channel} <kombu.transport.redis.Channel object at 0x0000024F6896CAC8>

具体堆栈为:

_callback, base.py:629
_deliver, base.py:980
_brpop_read, redis.py:748
on_readable, redis.py:358
handle_event, redis.py:362
get, redis.py:380
drain_events, base.py:960
drain_events, connection.py:318
synloop, loops.py:111
start, consumer.py:592
start, bootsteps.py:116
start, consumer.py:311
start, bootsteps.py:365
start, bootsteps.py:116
start, worker.py:204
worker, worker.py:327

5.4.2 插入 unack 队列

在 QoS之中,会把消息放入 Redis,具体是:

以时间戳作为score,把 delivery_tag 作为key,插入到一个 zset 中。delivery_tag 就是 message 的标识。

把 message (delivery_tag 作为key,message body 作为 value)插入到 hash。

    def append(self, message, delivery_tag):
        delivery = message.delivery_info
        EX, RK = delivery['exchange'], delivery['routing_key']
        # TODO: Remove this once we soley on Redis-py 3.0.0+
        if redis.VERSION[0] >= 3:
            # Redis-py changed the format of zadd args in v3.0.0
            zadd_args = [{delivery_tag: time()}]
        else:
            zadd_args = [time(), delivery_tag]

        with self.pipe_or_acquire() as pipe:
            pipe.zadd(self.unacked_index_key, *zadd_args) \
                .hset(self.unacked_key, delivery_tag,
                      dumps([message._raw, EX, RK])) \
                .execute()
            super().append(message, delivery_tag)

此时变量为:

delivery = {dict: 2} {'exchange': '', 'routing_key': 'celery'}

delivery_tag = {str} 'b6e6ec93-8993-442e-821e-31afeec7fa07'

message = {Message} <Message object at 0x24f6a181e58 with details {'state': 'RECEIVED', 'content_type': 'application/json', 'delivery_tag': 'b6e6ec93-8993-442e-821e-31afeec7fa07', 'body_length': 81, 'properties': {'correlation_id': '8c1923ae-e330-4ca8-b81f-432e2959de5e'}, 'delivery_info': {'exchange': '', 'routing_key': 'celery'}}>

pipe = {Pipeline: 0} Pipeline<ConnectionPool<Connection<host=localhost,port=6379,db=0>>>

self = {QoS} <kombu.transport.redis.QoS object at 0x0000024F6A045F48>

zadd_args = {list: 1} [{'b6e6ec93-8993-442e-821e-31afeec7fa07': 1612016412.6437156}]

redis具体如下:

127.0.0.1:6379> keys *unacked*
1) "unacked"
2) "unacked_index"

127.0.0.1:6379> zrange unacked_index 0 -1
1) "a548ebb8-c6bc-4fab-83f9-01a630a04a9b"

127.0.0.1:6379> hgetall unacked
1) "a548ebb8-c6bc-4fab-83f9-01a630a04a9b"
2) "[{\"body\": \"W1syLCA4XSwge30sIHsiY2FsbGJhY2tzIjogbnVsbCwgImVycmJhY2tzIjogbnVsbCwgImNoYWluIjogbnVsbCwgImNob3JkIjogbnVsbH1d\", \"content-encoding\": \"utf-8\", \"content-type\": \"application/json\", \"headers\": {\"lang\": \"py\", \"task\": \"myTest.add\", \"id\": \"04c05d81-d182-4458-acbf-066b4924bd4c\", \"shadow\": null, \"eta\": null, \"expires\": null, \"group\": null, \"group_index\": null, \"retries\": 0, \"timelimit\": [null, null], \"root_id\": \"04c05d81-d182-4458-acbf-066b4924bd4c\", \"parent_id\": null, \"argsrepr\": \"(2, 8)\", \"kwargsrepr\": \"{}\", \"origin\": \"gen15572@DESKTOP-0GO3RPO\"}, \"properties\": {\"correlation_id\": \"04c05d81-d182-4458-acbf-066b4924bd4c\", \"reply_to\": \"500b943f-1813-3cfd-95dc-d293921d9129\", \"delivery_mode\": 2, \"delivery_info\": {\"exchange\": \"\", \"routing_key\": \"celery\"}, \"priority\": 0, \"body_encoding\": \"base64\", \"delivery_tag\": \"a548ebb8-c6bc-4fab-83f9-01a630a04a9b\"}}, \"\", \"celery\"]"

5.4.3 确认消息

前面我们知道,在消息处理完之后,会调用 acknowledge 来进行确认消息。

    def acknowledge(self):
        """Acknowledge task."""
        if not self.acknowledged:
            self._on_ack(logger, self._connection_errors)
            self.acknowledged = True

在 Celery 中,ack 函数的设置是在 request.py:

on_ack = {promise} <promise@0x24f6a04f2c8 --> <bound method Consumer.call_soon of <Consumer: celery@DESKTOP-0GO3RPO (running)>>>

self = {Request} <Request: myTest.add[04c05d81-d182-4458-acbf-066b4924bd4c] (2, 8) {}>

这里就会 调用 QoS 来清除 Unack。

5.4.4 delivery_tag

这里要特殊说明下delivery_tag,可以认为这是消息在 redis 之中的唯一标示,是 UUID 格式。

具体举例如下:

"delivery_tag": "fa1bc9c8-3709-4c02-9543-8d0fe3cf4e6c"

是在发送消息之前,对消息做了进一步增强时候,在 Channel 的 _next_delivery_tag 函数中生成的。

def _next_delivery_tag(self):
    return uuid()

所以 QoS 就使用 delivery_tag 为 key 对 redis 来做各种处理。

with self.pipe_or_acquire() as pipe:
    pipe.zadd(self.unacked_index_key, *zadd_args) \
        .hset(self.unacked_key, delivery_tag,
              dumps([message._raw, EX, RK])) \
        .execute()
    super().append(message, delivery_tag)

5.4.5 处理 unack 队列

对于 QoS 内部来说,ack 分为两步。

    def ack(self, delivery_tag):
        self._remove_from_indices(delivery_tag).execute()
        super().ack(delivery_tag)

第一步是 调用 _remove_from_indices 从 redis 的 ack 中删除 zset,hash 中的部分。

    def _remove_from_indices(self, delivery_tag, pipe=None):
        with self.pipe_or_acquire(pipe) as pipe:
            return pipe.zrem(self.unacked_index_key, delivery_tag) \
                       .hdel(self.unacked_key, delivery_tag)

第二步是 基类的 ack 就是在内部数据变量中设置 _dirty,这样以后消费新消息时候,就知道如何处理。

self._dirty = set()
self._quick_ack = self._dirty.add

def ack(self, delivery_tag):
        """Acknowledge message and remove from transactional state."""
        self._quick_ack(delivery_tag) # _quick_ack 在上面已经设置为 _dirty.add

5.4.6 处理 失败job

讲到了现在,我们还是没有看到如何处理失败 job。

我们先总说下:Celery 设置了一个失效时间 visibility_timeout,Celery 认为所有任务都应该在 visibility_timeout 时间内处理完毕,如果没有处理完,就说明 对应的进程或者任务出现了问题,Celery 就会重新运行这个任务。

5.4.6.1 visibility timeout

如果一个任务没有在visibility timeout时间内被确认,就会被重新分发到另一个worker去执行。

所以,Celery 就是通过查看任务时间 与 visibility timeout 的对比,来决定是否重新运行任务

既然知道如何判断,我们就来看看何时重新运行。

5.4.6.2 何时重新运行

我们仅仅以 Redis Transport 为例,因为 Redis 不是专用消息队列,所以 kombu 自己被迫做了不少实现。

像 rabbitmq 自己有心跳机制,kombu 不需要特殊实现,只要把几个 worker 都注册为 rabbitmq 的 consumer 就行。这样一个 worker 失败了,rabbitmq 会自动选择一个新 worker 进行发布消息。

这里宣传一个同学的分布式函数调度框架,https://github.com/ydf0509/distributed_framework。非常优秀的实现。大家可以作为 Celery 的替代品。

作为竞品开发者,作者对 Celery 的理解也非常深入,我从他那里也学到了很多

回到 Redis,Redis 有两种重新运行的可能:

  • 在 Transport 之中,当注册loop时候,会在loop中定期调用 maybe_restore_messages,于是就在这里,会定期检查是否有未确认的消息。
  • 在 Transport 之中,在读取消息时候,如果没有新消息,也会使用 maybe_restore_messages 检查是否有未确认的消息。

从代码上来看,是每一个(未失败)的worker 都会做定期检查(或者 get 时候检查),哪一个先拿到 redis 的消息,哪一个就先处理

class Transport(virtual.Transport):
    """Redis Transport."""

    def register_with_event_loop(self, connection, loop):
        cycle = self.cycle
        cycle.on_poll_init(loop.poller)

        def on_poll_start():
            cycle_poll_start()
            [add_reader(fd, on_readable, fd) for fd in cycle.fds]
            
        loop.on_tick.add(on_poll_start)
        loop.call_repeatedly(10, cycle.maybe_restore_messages) # 定期查看unack队列
        ......

    def get(self, callback, timeout=None):
        self._in_protected_read = True
        try:
            for channel in self._channels:
                if channel.active_queues:           # BRPOP mode?
                    if channel.qos.can_consume():
                        self._register_BRPOP(channel)
                if channel.active_fanout_queues:    # LISTEN mode?
                    self._register_LISTEN(channel)

            events = self.poller.poll(timeout)
            if events:
                for fileno, event in events:
                    ret = self.handle_event(fileno, event)
                    if ret:
                        return
            # - no new data, so try to restore messages.
            # - reset active redis commands.
            self.maybe_restore_messages()  # 这里也会查看 unack 队列        

具体代码还是调用了 QoS 完成。

    def maybe_restore_messages(self):
        for channel in self._channels:
            if channel.active_queues:
                # only need to do this once, as they are not local to channel.
                return channel.qos.restore_visible(
                    num=channel.unacked_restore_limit,
                )

在 QoS之中,会检查一个可以配置的时间 interval,就是我们之前提到的 visibility timeout

使用 zrevrangebyscore 来获取在 time() - self.visibility_timeout 这期间已经过期的任务

如果发现有,就从 zset,hash 中删除任务。

    def restore_visible(self, start=0, num=10, interval=10):
        self._vrestore_count += 1
        if (self._vrestore_count - 1) % interval:
            return
        
        with self.channel.conn_or_acquire() as client:
            ceil = time() - self.visibility_timeout
            try:
                with Mutex(client, self.unacked_mutex_key,
                           self.unacked_mutex_expire):
                    env = _detect_environment()
                    if env == 'gevent':
                        ceil = time()
                    visible = client.zrevrangebyscore( # 这里从unack队列提出失败的job
                        self.unacked_index_key, ceil, 0,
                        start=num and start, num=num, withscores=True)
                    for tag, score in visible or []:
                        self.restore_by_tag(tag, client)
            except MutexHeld:
                pass

    def restore_by_tag(self, tag, client=None, leftmost=False):
        with self.channel.conn_or_acquire(client) as client:
            with client.pipeline() as pipe:
                p, _, _ = self._remove_from_indices(
                    tag, pipe.hget(self.unacked_key, tag)).execute()
            if p:
                M, EX, RK = loads(bytes_to_str(p))  # json is unicode
                self.channel._do_restore_message(M, EX, RK, client, leftmost)

5.4.6.3 恢复到正常工作队列

具体把 unack 的任务恢复到 正常工作队列,是由 Channel 完成,使用 lpush 来继续插入。

    def _do_restore_message(self, payload, exchange, routing_key,
                            client=None, leftmost=False):
        with self.conn_or_acquire(client) as client:
            try:
                try:
                    payload['headers']['redelivered'] = True
                except KeyError:
                    pass
                for queue in self._lookup(exchange, routing_key):
                    (client.lpush if leftmost else client.rpush)(
                        queue, dumps(payload),
                    )
            except Exception:
                crit('Could not restore message: %r', payload, exc_info=True)

5.4.7 visibility timeout 的潜在问题

5.4.7.1 问题

visibility timeout 的潜在问题就是会重复运行job。

之前提到:当我们设置一个ETA时间比visibility_timeout长的任务时,每过一次 visibility_timeout 时间,celery就会认为这个任务没被worker执行成功,重新分配给其它worker再执行。

这会让采用了etc/countdown/retry这些特性并且超时没有确认的任务出问题,具体就是任务被重复地执行。

比如:

因为redis作为broker时,visibility timeout的默认值是一小时,所以延时任务被重复执行的问题就发生了。即 每个小时未被确认的任务被重新分发到新的worker里去执行;这样到了预定的时间,就会有很多个待执行任务;通过把visibility timeout减少到很短的时间,可以复现问题;

5.4.7.2 解决办法

而解决方法也就是把 visibility timeout 这个配置的值调到足够得大。所以,你必须增加visibility timeout的配置值来覆盖你打算使用的最长eta延时时间。

你可以这样配置这个值:

app.conf.broker_transport_options = {‘visibility_timeout’: 43200}  # 12h

配置值必须是整数,表示总的秒数;

网上也有更精细的方案:

最后我的解决方法是在每次定时任务执行完就在redis中写入一个唯一的key对应一个时间戳,当下次任务执行前去获取redis中的这个key对应的value值,和当前的时间做比较,当满足我们的定时频率要求时才执行,这样保证了同一个任务在规定的时间内只会执行一次。

或者

在出现重复提交的任务中加锁.
1 使用唯一标识为key(如task+操作对象object_id),配合redis的原子操作SETNX(SET IF NOT EXIST)执行前判断是否在cache中存在,已存在则tasks直接返回,不执行业务逻辑.
2 在Django-redis中使用方法为**cache.set(key, value, timeout, nx=True)**.
3 若不存在,上述操作完成key:value的写入并返回**True**, 说明tasks第一次执行.
大致代码如下:

或者

任务可能会因为各种各样的原因而崩溃,而其中的许多任务是你无法控制的。例如,如果你的数据库服务器崩溃了,Celery可能就无法执行任务,并且会引发一个“连接失败”错误。

解决这个问题最简单的方法是使用第二个定期的“清理器任务”,它将扫描并重复/重新入列漏掉的任务。

5.4.8 总体图示

我们给出一个 “处理失败 job” 总体逻辑图,针对此图,再做一下具体步骤解析:

首先,我们假定图中的 Worker 2(右边的)失败了,左面的 worker 1 是正常工作的。

其次,左边的 redis 其实就是 broker 中的一个,这里只是拿出来详细说明。

第三,具体流程如下(具体序号与图上对应):

  1. 调用 basic_consume 来进行消费,在从redis获取到消息之后,会调用到 qos 把 消息放入 unack 队列。
  2. 在 QoS之中,会调用 append 把消息放入 Redis 中的 unack 队列之中,具体是:以时间戳作为score,把 delivery_tag 作为key,插入到一个 zset 中。delivery_tag 就是 message 的标识。把 message (delivery_tag 作为key,message body 作为 value)插入到 hash。
  3. 在消息处理完之后,会调用 acknowledge 来进行确认消息。这里就会 调用 QoS 来清除 Unack。
    1. 第一步是 调用 _remove_from_indices 从 redis 的 ack 中删除 zset,hash 中的部分。
    2. 第二步是 基类的 ack 就是在内部数据变量中设置 _dirty,这样以后消费新消息时候,就知道如何处理。
  4. 在两种情况下,会进行失败 job 处理。
    1. 在 Transport 之中,当注册loop时候,会在loop中定期调用 maybe_restore_messages,于是就在这里,会定期检查是否有未确认的消息。
    2. 在 Transport 之中,在读取消息时候,如果没有新消息,也会使用 maybe_restore_messages 检查是否有未确认的消息。
  5. 调用 qos.restore_visible 完成处理。
  6. 使用 zrevrangebyscore 来获取在 time() - self.visibility_timeout 这期间已经过期的任务。
  7. 如果发现有,就从 zset,hash 中删除任务。
                                                                                   +---------------------------------+
                                                                                   |  Worker                Producer |
                                                                                   |                                 |
                                                                                   |                                 |
                                                                                   |      Retry in Celery / Kombu    |
                                                                                   |                                 |
+------------------------------------+                                             |      Autoretry in Kombu         |
| Redis                              |         5 restore_visible                   +---------------+-----------------+
|  +---------------------------+     |                                                             |
|  | unack queue               +<-------------------------------+                                  | round robin / shuffle
|  |                           |     |                          |                                  |
|  |  worker 2 failed jobs     +<----------------------------+  |                  +-----------------------------------+
|  |                           |     |             3 ack     |  |                  |               |                   |
|  |                           +<----------+                 |  |                  v               v                   v
|  +----+----------------------+     |     |                 |  |  +---------------------------------------------------------------+
|       |                            |     |                 |  |  |     +-----------------+ +----------------+ +----------------+ |
|       |                            |     |                 |  |  +     | Broker 1        | | Broker 2       | | Broker 3       | |
|       | 6 zrevrangebyscore         |     |                 |  |        |                 | |                | |                | |
|       |                            |     |                 |  |  +     |          url 1  | |         url 2  | |          url 3 | |
|       | 7 lpush                    |     |                 |  |  |     +-----------------+ +----------------+ +----------------+ |
|       |                            |     |                 |  |  +---------------------------------------------------------------+
|       v                            |     |                 |  |                                  |
|                                    |     |                 |  |                                  |
|  +---------------------------+     |     |                 |  |                                  |
|  | task queue                |     |     |  2 append       |  |                   +--------------+-----------------------------------+
|  |                           |     |     |                 |  |                   |                                                  |
|  +-----+---------------------+     |     |                 |  |                   |                                                  |
|        |                           |     |                 |  |                   |                                                  |
+------------------------------------+     |                 |  |                   v                                                  v
         |                                 |      +---------------------------------+----------------------------------------+   +-----+--------------+
         |                                 |      | worker 1 |  |                                                  running   |   | Worker 2           |
         |                                 |      |          |  |                                                            |   |                    |
         |                                 |      |   +------+--+-+  +----------------------------------------------------+  |   |        FAILED      |
         |                                 |      |   | QoS       |  |         Process                                    |  |   |                    |
         |                                 +----------+           |  | +------------------------------------------------+ |  |   | +----------------+ |
         |                                        |   |           |  | |         User task                              | |  |   | |                | |
         |                                        |   +-----------+  | |                                                | |  |   | | Process        | |
         |     1 basic_consume                    |                  | |                        +---------------------+ | |  |   | |                | |
         |                                        |     ^            | |                        | retry               | | |  |   | |                | |
         +--------------------------------------> |     | 4 restore  | |   autoretry +--------> |                     | | |  |   | +----------------+ |
                                                  |     |            | |                        |                     | | |  |   | +----------------+ |
                                                  |     |            | |                        | User business logic | | |  |   | |                | |
                                                  |   +-+---------+  | | max_retries +--------> |                     | | |  |   | | Process        | |
                                                  |   | Transport |  | |                        +---------------------+ | |  |   | |                | |
                                                  |   +-----------+  | +------------------------------------------------+ |  |   | |                | |
                                                  |                  +----------------------------------------------------+  |   | +----------------+ |
                                                  +--------------------------------------------------------------------------+   +--------------------+

手机如下:

0xEE 个人信息

★★★★★★关于生活和技术的思考★★★★★★

微信公众账号:罗西的思考

如果您想及时得到个人撰写文章的消息推送,或者想看看个人推荐的技术资料,敬请关注。

在这里插入图片描述

0xFF 参考

RabbitMQ(六)流量控制 -- basic.qos,prefetch_count

Celery:Prefetch与Acknowledged相关配置

使用Celery的7个常见问题

使用Celery踩过的坑

Celery ETA任务重复提交的问题解决

celery延时任务踩坑 · Yab Zhang’s Blog

posted @ 2021-05-17 20:22  罗西的思考  阅读(2036)  评论(0编辑  收藏  举报