AI & Data Dreamers練功房
寶箱片場規則
正片・課程手冊

第 4 章:任務失敗、Worker 掛掉怎麼辦 — retry、requeue 與訊息的命運

這是「會用」和「懂原理」差最多的一章。前面都在讓任務成功,這一章你要故意讓它失敗、故意把 worker 殺掉,觀察訊息在 RabbitMQ 裡到底發生什麼事。


做完這一章,你會做到

  1. 驗證 config.py 的「環境變數覆蓋」,搞懂同一份程式為什麼本機和 Docker 都能跑。
  2. 分得清 self.retry()(重試)和 Reject(requeue=True)(放回佇列)本質差在哪。
  3. 懂 acks_late=True 的意義:做完才確認,沒做完就回佇列。
  4. 會用 RabbitMQ 管理介面觀察 Ready / Unacked 的變化。
  5. 建立「分散式系統要為失敗而設計」的心態。


先搞懂:ack 是什麼

Worker 從 RabbitMQ 拿到一則訊息、成功處理後,要回報一個 ack(確認),RabbitMQ 收到 ack 才會把這則訊息刪掉。反過來說:

  • 沒 ack 的訊息不會消失,RabbitMQ 會認為「可能還沒處理完」而保留它。
  • acks_late=True 表示「做完才 ack」。所以如果 worker 做到一半掛掉、還沒 ack,這則訊息會回到佇列給別人重做。(預設 acks_late=False 是「一拿到就先 ack」,worker 掛掉任務就沒了。)

這一章用一個獨立的 Celery app(worker_demo.py)來示範,不影響你前面用的 worker.py。


這一章會用到的檔案

檔案 角色 說明
crawler/worker_demo.py 獨立的 app task_demo,全域開啟 acks_late 與 task_reject_on_worker_lost
crawler/tasks_demo_fail.py 任務定義 四個模擬失敗的任務
crawler/producer_demo_fail.py 生產者 發送 demo 任務
crawler/DEMO_FAIL_README.md 索引 內容已完整整合進本章,該檔只留指標

worker_demo.py 的關鍵設定:

app = Celery("task_demo", include=["crawler.tasks_demo_fail"], broker=...)
app.conf.task_acks_late = True                 # 做完才確認
app.conf.task_reject_on_worker_lost = True     # worker 掛掉時,任務重新排隊

四個情境,一行一行讀懂

先看全景——worker 拿到一則訊息之後,它有四種命運:

                    worker 從佇列拿到訊息(Unacked = 1)
                                 │
        ┌───────────┬────────────┴───────────┬──────────────────┐
        │           │                        │                  │
      成功        失敗 + retry            失敗 + requeue      worker 被殺
        │           │                        │              (acks_late)
        ▼           ▼                        ▼                  │
       ack      發「新任務」回佇列        「原訊息」放回佇列          ▼
     訊息消失    (max_retries=3 有上限)  (沒有上限 ♻️)      沒 ack → 訊息回佇列
     SUCCESS         情境 1                 情境 2            回 Ready 給人重做
                                                                情境 4

              (失敗 + requeue=False → 訊息直接丟棄 🗑️ → 情境 3)

👀 這些在哪裡看得到? 圖裡「任務執行」的部分(執行、失敗、retry、reject、succeeded、進度)全部會印在 worker 的終端機;「訊息在佇列的狀態」(Ready / Unacked 的變化、訊息被放回)只在 RabbitMQ UI(http://localhost:15672 → Queues → celery)看得到。所以做這章的實驗,開兩個視窗並排:左邊 worker log、右邊 RabbitMQ UI,一動一靜對著看。

先搞懂:失敗是怎麼「設計」出來的

這四個任務都不是真的爬蟲——完全沒有呼叫 FinMind。失敗全是刻意注入的,這樣它才可控、可重現:真實 API 的失敗看緣分,教學實驗的失敗要一按就有。四種注入方式剛好對應真實世界的四種災難:

情境 失敗怎麼注入 程式碼開關 模擬真實世界的什麼
1 task_might_fail 擲硬幣:50% 機率故意拋例外 if random.random() < 0.5: 時好時壞的暫時性故障——API timeout、HTTP 429 限流,「再試一次就好」的那種
2 task_requeue 無條件失敗:一進來就拋,沒有任何 if raise Reject(requeue=True) 永遠處理不了的訊息——資料格式壞掉、怎麼 parse 都爆的「毒訊息」
3 task_reject_no_requeue 無條件失敗,但策略改為丟棄 raise Reject(requeue=False) 同上,但選擇「認賠丟掉」而不是無限重來
4 task_slow 程式本身不會失敗——失敗從外部來:你動手殺 worker 沒有失敗程式碼,只有 time.sleep 機器層級的災難——容器被 OOM 殺掉、主機斷電、部署時被重啟

那 2330 是什麼? 只是個標籤,跟失敗與否完全無關——stock_id 在四個任務裡都只拿來 print,沒有任何邏輯判斷它。情境 1 沿用台積電代號是為了連貫感(「就是平常那個爬蟲任務,只是會隨機失敗」);情境 2/3/4 用 REQUEUE_TEST、REJECT_TEST、SLOW_TEST,名字本身就說明用途,在 log 和 RabbitMQ UI 裡一眼認出。就算把 2330 改成 HELLO,行為一模一樣——失敗的開關在 random.random(),不在股票代號。

四個情境其實在教「失敗的三個層次」:

  1. 任務內、暫時性(情境 1)→ 解法是 retry,等一下再試就好
  2. 任務內、永久性(情境 2、3)→ retry 沒用,要做決策:放回(會無限循環)還是丟棄(正式系統用 DLQ 冷處理)
  3. 任務外、行程死亡(情境 4)→ 程式碼寫得再完美也擋不住,只能靠 acks_late 這種協定層的保險

情境 1:task_might_fail — 失敗自動重試

@app.task(bind=True, acks_late=True, max_retries=3, default_retry_delay=5)
def task_might_fail(self, stock_id):
    print(f"開始處理 {stock_id}...(第 {self.request.retries + 1} 次嘗試)")
    if random.random() < 0.5:        # ← 失敗開關在這裡:擲硬幣,50% 機率走進失敗分支(跟 stock_id 無關)
        print(f"❌ {stock_id} 失敗!5 秒後重試(剩餘 {self.max_retries - self.request.retries} 次)")
        raise self.retry(exc=Exception(f"{stock_id} 模擬錯誤"))   # ← 拋 Retry:發「新任務」回佇列,5 秒後再試
    print(f"✅ {stock_id} 處理成功!")   # ← 另外 50% 什麼都沒發生:正常成功 → ack → 訊息消失
    return f"{stock_id} done"
  • bind=True:讓第一個參數變成 self(指向這個任務物件),才能用 self.retry() 和 self.request.retries(第 1 章的深入段落提過)。
  • max_retries=3, default_retry_delay=5:最多重試 3 次,每次間隔 5 秒。
  • self.retry() 的本質:它發一則新任務回佇列去重試,有次數上限,超過就標記為 FAILURE 並 ack(訊息消失)。

訊息流程:

Producer 發任務 → RabbitMQ → Worker 拿出來
  → 成功 → ack → 訊息消失 ✅
  → 失敗(50%)→ self.retry() → 發「新任務」回佇列 → 5 秒後再試
  → 連續失敗滿 3 次 → 標記 FAILURE → ack → 訊息消失

決策分支圖(迴圈有次數上限,是這張圖的重點):

Producer ──.delay()──▶ [RabbitMQ 佇列]
                             │
                             ▼  worker 取出(Unacked)
                       ┌───────────┐
                       │  執行任務  │◀──────────────── 等 5 秒 ──┐
                       └─────┬─────┘                          │
                   50% ┌─────┴─────┐ 50%                       │
                       ▼           ▼                          │
                  ✅ 成功      ❌ 失敗 → self.retry()            │
                       │           │(發「新任務」回佇列)         │
                       ▼           ▼                          │
                  ack、訊息消失   已重試滿 3 次?──否──────────────┘
                   SUCCESS         │
                                  是
                                   ▼
                          標記 FAILURE → ack → 訊息消失

每一步去哪看(🖥️ = worker 終端機、🐰 = RabbitMQ UI、🌸 = Flower):

圖中步驟 去哪看 你會看到
worker 取出訊息 🖥️ Task task_might_fail[...] received
執行 → 失敗 🖥️ ❌ 2330 失敗!5 秒後重試(剩餘 3 次)
retry 發新任務回佇列 🖥️ + 🐰 🖥️ Retry in 5s;🐰 這 5 秒訊息掛在 Unacked(worker 先收下、時間到才執行)
5 秒後重試 🖥️ 開始處理 2330...(第 2 次嘗試)
成功 ack 🖥️ + 🌸 🖥️ ✅ 處理成功 / succeeded;🌸 Tasks 頁狀態 SUCCESS(重試過會先看到 RETRY)
訊息消失 🐰 Ready 0 / Unacked 0

實際跑起來 worker log 長這樣:

開始處理 2330...(第 1 次嘗試)
❌ 2330 失敗!5 秒後重試(剩餘 3 次)
Task crawler.tasks_demo_fail.task_might_fail[f5923bcb-…] retry: Retry in 5s: Exception('2330 模擬錯誤')
(5 秒後)開始處理 2330...(第 2 次嘗試)
✅ 2330 處理成功!

情境 2:task_requeue — 訊息放回佇列(重點)

@app.task(bind=True, acks_late=True)
def task_requeue(self, stock_id):
    print(f"開始處理 {stock_id}...")
    print(f"❌ {stock_id} 處理失敗!訊息放回 queue")
    raise Reject(reason=f"{stock_id} 處理失敗", requeue=True)
    # ↑ 注意:上面「沒有任何 if」——無條件拋 Reject,100% 必定失敗(這就是毒訊息的設計)
    #   requeue=True = 把「原訊息」放回佇列 → 下一秒又被拿出來 → 又失敗 → 無限循環
  • Reject(requeue=True) 把原本那則訊息丟回佇列。
  • 它沒有次數限制 → worker 又拿出來、又失敗、又放回……無限循環,訊息永遠不消失,直到你停掉 worker。
  • 這是 RabbitMQ 管理介面上最好看的示範:Ready ↔ Unacked 一直跳。

訊息流程:

Producer 發任務 → RabbitMQ → Worker 拿出來
  → 失敗 → Reject(requeue=True) → 「原訊息」放回佇列
  → Worker 又拿出來 → 又失敗 → 又放回 → ♻️ 無限循環
  → 訊息永遠不消失,直到你停掉 worker

決策分支圖(注意那條回頭的箭頭——它沒有出口):

Producer ──.delay()──▶ [RabbitMQ 佇列]Ready = 1 ◀────────────────┐
                             │                                   │
                             ▼  worker 取出                       │
                       Ready 0 / Unacked 1                       │
                             │                                   │
                        執行 → ❌ 一定失敗                          │
                             │                                   │
                    Reject(requeue=True)                         │
                    「原訊息」放回佇列 ────────♻️ 毫秒級、無次數上限────┘

              唯一出口:你 Ctrl+C 停掉 worker → 訊息仍留在 Ready(從沒被 ack 過)

每一步去哪看:

圖中步驟 去哪看 你會看到
發任務(先不開 worker) 🐰 Ready = 1,訊息在等
worker 取出 🖥️ received——注意 task id 每次都同一個
失敗 + 放回 🖥️ ❌ 處理失敗!+reject requeue=True,毫秒級洗版
循環中的佇列 🐰 Ready ↔ Unacked 在 0/1 之間狂跳(重新整理頁面看)
Ctrl+C 停掉 worker 🐰 訊息仍在 Ready = 1——它從沒被 ack 過

實際 log,注意兩件事:

Task task_requeue[9e1344bc-…] received
❌ REQUEUE_TEST 處理失敗!訊息放回 queue
Task task_requeue[9e1344bc-…] reject requeue=True
Task task_requeue[9e1344bc-…] received        ← 同一個 task id 又來了!
❌ REQUEUE_TEST 處理失敗!訊息放回 queue
Task task_requeue[9e1344bc-…] reject requeue=True
...(毫秒級速度無限洗版)
  1. task id(UUID)從頭到尾都一樣——證明是「同一則訊息」被放回再拿出;對照情境 1 的 retry,每次重試 log 是同一個 id 但那是「新發的任務」帶著原 id 重排程,且有次數上限。
  2. 循環快到毫秒級——中間沒有任何延遲,worker 被它完全佔住、什麼別的事都做不了。這就是毒訊息的危險之處。

情境 3:task_reject_no_requeue — 訊息丟棄

@app.task(bind=True, acks_late=True)
def task_reject_no_requeue(self, stock_id):
    print(f"開始處理 {stock_id}...")
    print(f"❌ {stock_id} 處理失敗!訊息丟棄(不放回 queue)")
    raise Reject(reason=f"{stock_id} 處理失敗", requeue=False)
    # ↑ 跟情境 2 一樣是「無條件必定失敗」——唯一差別是 requeue=False:
    #   訊息直接丟棄、不放回,所以不會循環,Ready 直接歸 0

失敗就把訊息扔掉,效果等同 acks_late=False 失敗時的行為——訊息直接消失。

訊息流程:

Producer 發任務 → RabbitMQ → Worker 拿出來
  → 失敗 → Reject(requeue=False) → 訊息直接丟棄 🗑️ → Ready 歸 0

決策分支圖(一路到底,沒有回頭路):

Producer ──.delay()──▶ [RabbitMQ 佇列]Ready = 1
                             │
                             ▼  worker 取出
                        執行 → ❌ 失敗
                             │
                    Reject(requeue=False)
                             │
                             ▼
                     🗑️ 訊息直接丟棄
              Ready 歸 0 ─ 不重試、不放回、就此消失

每一步去哪看:

圖中步驟 去哪看 你會看到
發任務 🐰 Ready = 1
worker 取出執行 🖥️ 開始處理 REJECT_TEST...+❌ 訊息丟棄(不放回 queue)
丟棄之後 🐰 Ready 歸 0——跟情境 2 對比:這次不會再回來

情境 4:task_slow — 做到一半殺掉 worker

@app.task(acks_late=True)
def task_slow(stock_id, seconds=30):
    print(f"開始處理 {stock_id},需要 {seconds} 秒...")
    for i in range(seconds):           # ← 這個任務「不會失敗」:只是慢慢跑 30 秒
        time.sleep(1)                  #    失敗是從「外部」注入的——由你在第 10 秒動手殺 worker
        print(f"  {stock_id} 進度 {i+1}/{seconds}")
    print(f"✅ {stock_id} 處理完成!")   # ← 只有「沒被殺」才會走到這行,然後才 ack
    return f"{stock_id} done"
  • 這任務要跑 30 秒;你在第 10 秒 Ctrl+C 殺掉 worker。
  • 因為 acks_late=True、還沒 ack,訊息回到佇列;你重開 worker,任務會從頭再跑一次。

訊息流程:

Producer 發任務 → RabbitMQ → Worker 拿出來(Unacked = 1)
  → 跑到進度 10/30 → 你 Ctrl+C 殺掉 worker
  → 還沒 ack → 訊息回到佇列(Ready = 1)
  → 重開 worker → 從進度 1/30 從頭跑 → 30/30 → ack → 訊息消失

決策分支圖(acks_late 的生死線——被殺不等於遺失):

Producer ──.delay()──▶ [RabbitMQ 佇列]Ready = 1
                             │
                             ▼  worker 取出(acks_late:先不 ack)
                       Ready 0 / Unacked 1
                             │
                       跑到進度 10/30
                             │
                    ✂️ 你 Ctrl+C 殺掉 worker
                             │
              訊息從未被 ack → 連線斷,RabbitMQ 把它放回佇列
                             │
                             ▼
                       Ready = 1(任務沒有遺失)
                             │  重開 worker
                             ▼
              從「進度 1/30」從頭跑 ──▶ 30/30 ──▶ ack → 訊息消失 ✅
                             (做了兩次 → 這就是第 6 章要教冪等的原因)

每一步去哪看:

圖中步驟 去哪看 你會看到
發任務 🐰 Ready = 1
worker 執行中 🖥️ + 🐰 🖥️ 進度 1/30、2/30…;🐰 Ready 0 / Unacked 1(借走了、還沒確認)
按 Ctrl+C 🖥️ worker: Warm shutdown (MainProcess);沒退出就再按一次
worker 死後 🐰 訊息回到 Ready = 1——任務沒有遺失
重開 worker 🖥️ 進度 1/30 從頭跑 → 30/30 → succeeded in 30.2s
完成之後 🐰 Ready 0 / Unacked 0——這才真正消失

retry vs requeue 對照(背下這張表)

self.retry() Reject(requeue=True)
訊息行為 發新任務到 queue 原訊息放回 queue
有次數限制? 有(max_retries) 沒有
最終結果 成功、或 FAILURE 永遠留在 queue
RabbitMQ 看到 一則新訊息 同一則訊息

一步一步跟著做

Step 1:只需要 RabbitMQ + Flower

docker compose -f docker-compose-local.yml up -d rabbitmq flower
curl -o /dev/null -s -w "RabbitMQ: %{http_code}\n" http://localhost:15672

✅ curl 回 200。

Step 2:先「不開 worker」就發任務

uv run crawler/producer_demo_fail.py

producer_demo_fail.py 預設發**情境 1(2330)和情境 2(REQUEUE_TEST)**兩個任務。

✅ 預期:印出 sent 2330、sent REQUEUE_TEST、發送完畢的提示。

Step 3:去 RabbitMQ UI 看「還沒被處理」的訊息

打開 http://localhost:15672(worker/worker)→ Queues → celery,看 Messages Ready。

✅ 因為還沒開 worker,情境 1、2 的兩則任務會停在 Ready = 2,沒人處理。這就證明了:訊息送進佇列後會等,不會因為沒人處理就消失。

Step 4:開 worker(併發設 1,才看得清楚)

uv run python -m celery -A crawler.worker_demo worker --loglevel=info --concurrency=1

注意這裡指向的是 crawler.worker_demo,不是平常的 crawler.worker——失敗情境的任務註冊在這個獨立的 app 裡。

Step 5:邊看 worker log、邊看 RabbitMQ UI

  • task_might_fail(2330):可能一次就成功,或「❌ 失敗 → 5 秒後重試」幾次後成功 / 3 次後放棄(FAILURE)。
  • task_requeue(REQUEUE_TEST):一直重複消費、一直放回,你會看到 log 不停印「處理失敗」,RabbitMQ UI 的 Ready ↔ Unacked 不停跳。

✅ 看到 REQUEUE_TEST 無限循環,就代表你抓到 requeue 的行為了。

再做一個關鍵觀察:Ctrl+C 停掉 worker,回 RabbitMQ UI 看 celery 佇列——REQUEUE_TEST 的訊息還躺在 Ready 裡。它從來沒被成功 ack 過,所以永遠留在佇列。

Step 6:測情境 3 — 訊息丟棄

先做兩件清場動作(不做的話 Ready 永遠不會歸 0,看不到本情境的重點):

  1. 清掉還躺在佇列裡的 REQUEUE_TEST:RabbitMQ UI → Queues → celery → 頁面最下方 Purge Messages。
  2. 把 producer 裡情境 1、2 那幾行註解掉——不然重發時 REQUEUE_TEST 又會進佇列、又開始無限循環。

然後編輯 crawler/producer_demo_fail.py,取消情境 3 的註解:

# task_might_fail.delay(stock_id="2330")            # ← 註解掉
# task_requeue.delay(stock_id="REQUEUE_TEST")       # ← 註解掉
task_reject_no_requeue.delay(stock_id="REJECT_TEST")  # ← 打開

重新發送(uv run crawler/producer_demo_fail.py)、開 worker。

✅ 預期:worker 印出:

開始處理 REJECT_TEST...
❌ REJECT_TEST 處理失敗!訊息丟棄(不放回 queue)

然後 RabbitMQ 的 Ready 歸 0——訊息真的消失了,不像情境 2 那樣循環。這就是 requeue=False 和 requeue=True 的差別。

Step 7:測情境 4 — 中途殺 worker,驗證 acks_late

跟 Step 6 一樣先清場:情境 1、2、3 全部註解掉,只打開情境 4:

task_slow.delay(stock_id="SLOW_TEST", seconds=30)

操作順序(每步都附執行結果):

  1. 發任務 → 開 worker,看到 進度 1/30、2/30...
    • 此時 RabbitMQ UI:Ready 0 / Unacked 1——訊息「借給」worker 處理中、還沒確認
  2. 大約第 10 秒按 Ctrl+C 殺掉 worker
    • 你會先看到 worker: Warm shutdown (MainProcess)——第一次 Ctrl+C 是「溫和關機」;若 worker 遲遲不退出,再按一次 Ctrl+C 強制冷關機
  3. 等 worker 退出後回 RabbitMQ UI → ✅ SLOW_TEST 的訊息回到 Ready(Ready 1 / Unacked 0)
  4. 重開 worker → ✅ 任務從頭再跑。log:
  SLOW_TEST 進度 1/30      ← 從頭開始,不是接著 10/30 跑
  ...
  SLOW_TEST 進度 30/30
Task crawler.tasks_demo_fail.task_slow[2bf4dfa2-…] succeeded in 30.2s: 'SLOW_TEST done'

跑完後 Ready 0 / Unacked 0——訊息這才真正消失。

關鍵:acks_late=True 代表「做完才確認」。worker 中途掛掉 → 沒 ack → 訊息不遺失、重新排隊。這是保證任務不丟的核心設定。也請注意「從頭再跑」——同一件事可能被做兩次,這就是第 6 章要教冪等的原因。


檢查你是不是真的做到了

# 你應該看到 它證明了什麼
1 環境變數實驗:預設值 vs 覆蓋 同一份程式靠環境變數跑不同環境
2 開 worker 前,celery 佇列 Ready = 2 訊息會在佇列等待
3 REQUEUE_TEST 無限重複消費 Reject(requeue=True) 沒有次數上限
4 停 worker 後 REQUEUE_TEST 還在 Ready 它從沒被成功處理過
5 REJECT_TEST 後 Ready 歸 0 requeue=False 直接丟棄訊息
6 殺 worker 後 SLOW_TEST 從頭再跑 acks_late 讓被殺的任務不遺失

收工

docker compose -f docker-compose-local.yml down     # 保留資料

想再深入一點

  • 為什麼要用一個獨立的 worker_demo app? 因為這一章要全域開啟 acks_late 和 task_reject_on_worker_lost 這些「危險」設定來做實驗,作者不想污染你平常在用的 worker.py。這也示範了一個好習慣:做破壞性實驗時,開一個獨立環境。
  • Reject 的效果依賴 acks_late。 Reject(requeue=True) 之所以能把訊息放回佇列,前提是 acks_late=True(訊息還沒被確認)。如果用預設的 worker.py(沒開 acks_late),你看不到一樣的效果。
  • 情境 2 是一個「毒訊息(poison message)」的教學陷阱。 一則永遠處理失敗、又一直被放回的訊息,會卡住 worker、佔用資源。正式系統要防這個:設定「重試上限」或「死信佇列(DLQ)」——超過幾次就把它移到另一條佇列冷處理,而不是無限循環。
  • acks_late 是可靠性的雙面刃。 它保證「worker 掛掉不遺失任務」,但代價是任務可能被執行兩次(做到一半掛掉、重跑一次)。所以搭配 acks_late 的任務最好是冪等的——重跑也不會出問題。什麼是冪等、怎麼做到,第 6 章會完整教。

想一想(確認你懂了)

Q1:self.retry() 和 Reject(requeue=True) 最大的差別是什麼?哪個會無限循環?

self.retry() 是「發一則新任務」去重試,有 max_retries 上限,超過就放棄(FAILURE)。Reject(requeue=True) 是把「原訊息」放回佇列,沒有次數上限,所以會無限循環,直到你停掉 worker 或訊息被成功處理。會無限循環的是後者。

Q2:acks_late=True 為什麼能讓「被殺掉的 worker」不遺失任務?

因為 ack 是「做完才回報」。worker 被殺時任務還沒做完、還沒 ack,RabbitMQ 就認為這則訊息還沒被處理完,於是把它放回佇列給別的 worker(或重開後的 worker)重做。所以任務不會因為 worker 中途掛掉而消失。

Q3:情境 2 那種「毒訊息」在正式系統會造成什麼災難?該怎麼防?

一則永遠失敗又一直被放回的訊息,會讓 worker 不停地拿它、失敗、放回,白白吃掉處理能力,甚至卡住整條佇列讓正常任務也慢下來。防法:設「重試上限」,或設「死信佇列(DLQ)」,超過次數就把它移去別的地方冷處理、發警報,而不是讓它無限循環。

Q4:同一份程式碼,在本機連 127.0.0.1、在容器裡連 rabbitmq,是怎麼做到的?

靠 config.py 的 os.environ.get(key, default):本機沒設環境變數就用預設值 127.0.0.1;compose 裡用 environment 設了 RABBITMQ_HOST=rabbitmq,容器裡的程式讀到的就是服務名。程式碼一行不用改——這是熱身實驗驗證過的。


換你試試看

練習 1:讓重試「看得見」

只發情境 1(task_might_fail),開 worker(--concurrency=1),連續觀察好幾輪。你會看到 log 印出「第 N 次嘗試」,有時第一次就成功,有時重試兩三次才成功、或最後放棄。把「重試次數」和「最後結果」記幾筆下來,你就理解 max_retries 和 default_retry_delay 實際怎麼運作。

練習 2:製造「毒訊息」再看 RabbitMQ

只發情境 2(REQUEUE_TEST),開一個 worker,打開 RabbitMQ UI 的 celery 佇列頁面,盯著 Ready 和 Unacked 這兩個數字。你會看到它們一直在 0↔1 之間跳——訊息被拿走(Unacked=1)、失敗放回(Ready=1)、又被拿走……用自己的話描述這個循環,你就懂 requeue 了。看完把 worker 停掉,確認那則訊息還在 Ready 裡。

練習 3:驗證 acks_late 保住任務

發情境 4(task_slow,把 producer 對應註解打開),開 worker,等它印到「進度 10/30」左右時 Ctrl+C 殺掉。到 RabbitMQ UI 確認那則訊息回到 Ready,然後重開 worker——你會看到它從進度 1 重新開始跑。這證明了「acks_late 讓被中斷的任務不遺失」,也讓你體會為什麼這種任務最好是冪等的。


卡住了?常見錯誤這樣排

你遇到的狀況 原因 怎麼解
Reject 好像沒效果 用了平常的 worker.py(沒開 acks_late) 這章要用 -A crawler.worker_demo,它有開 acks_late
看不清訊息命運 併發太高,多個任務交錯 加 --concurrency=1
worker 一直被 REQUEUE_TEST 佔住 這是情境 2 的預期行為 看夠了就 Ctrl+C;正式環境要設重試上限 / DLQ
指向錯的 app 用了 -A crawler.worker 這章要 -A crawler.worker_demo
情境 3/4 沒反應 producer 裡的註解沒打開 編輯 producer_demo_fail.py 取消對應註解再發
Step 6 的 Ready 一直不歸 0 情境 1、2 沒註解掉,REQUEUE_TEST 又進了佇列 註解掉情境 1、2,UI 上 Purge 佇列後重發
按 Ctrl+C worker 沒馬上停 第一次 Ctrl+C 是 warm shutdown(先收工再退) 再按一次 Ctrl+C 冷關機;訊息一樣會回到 Ready

這一章你學到了

  • 環境變數覆蓋讓同一份程式在本機(127.0.0.1)和 Docker(服務名)都能跑。
  • ack 是訊息生命的開關;acks_late 決定「何時確認」。
  • retry 是發新訊息、有上限;requeue 是放回原訊息、無上限。
  • acks_late 保證不遺失任務,但可能重跑,所以任務最好冪等(第 6 章會教)。
  • 分散式系統要為失敗而設計。

下一章要做什麼

訊息的可靠性有了保障,但資料到目前都只是印出來、看過就忘。下一章你會正式把資料存下來——拿掉 _print,改用會寫進 MySQL 的 crawler_finmind,讓爬回來的股價真正落地。