[原始碼解析] 並行分散式框架 Celery 之 容錯機制
0x00 摘要
Celery是一個簡單、靈活且可靠的,處理大量訊息的分散式系統,專注於實時處理的非同步任務佇列,同時也支援任務排程。本文介紹 Celery 的故障轉移容錯機制。
0x01 概述
1.1 錯誤種類
Celery 之中,錯誤(以及應對策略)主要有 3 種:
- 使用者程式碼錯誤:錯誤可以直接返回應用,因為Celery無法知道如何處理;
- Broker錯誤:Celery可以根據負載平衡策略嘗試下一個節點;
- 網路超時錯誤:Celery可以重試該請求;
1.2 失敗維度
從系統角度出發,幾個最可能的失敗維度如下(本文可能程式,執行緒兩個單詞混用,請大家諒解):
-
Broker失敗;
-
Worker ---> Broker
這個鏈路會失敗; -
Worker 節點會失敗;
-
Worker 中的多程式中,某一個程式本身失效;
-
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 中的一個,這裡只是拿出來詳細說明。
第三,具體流程如下(具體序號與圖上對應):
- 呼叫 basic_consume 來進行消費,在從redis獲取到訊息之後,會呼叫到 qos 把 訊息放入 unack 佇列。
- 在 QoS之中,會呼叫 append 把訊息放入 Redis 中的 unack 佇列之中,具體是:以時間戳作為score,把 delivery_tag 作為key,插入到一個 zset 中。delivery_tag 就是 message 的標識。把 message (delivery_tag 作為key,message body 作為 value)插入到 hash。
- 在訊息處理完之後,會呼叫 acknowledge 來進行確認訊息。這裡就會 呼叫 QoS 來清除 Unack。
- 第一步是 呼叫 _remove_from_indices 從 redis 的 ack 中刪除 zset,hash 中的部分。
- 第二步是 基類的 ack 就是在內部資料變數中設定 _dirty,這樣以後消費新訊息時候,就知道如何處理。
- 在兩種情況下,會進行失敗 job 處理。
- 在 Transport 之中,當註冊loop時候,會在loop中定期呼叫 maybe_restore_messages,於是就在這裡,會定期檢查是否有未確認的訊息。
- 在 Transport 之中,在讀取訊息時候,如果沒有新訊息,也會使用 maybe_restore_messages 檢查是否有未確認的訊息。
- 呼叫 qos.restore_visible 完成處理。
- 使用 zrevrangebyscore 來獲取在
time() - self.visibility_timeout
這期間已經過期的任務。 - 如果發現有,就從 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