第 4 章:任務失敗、Worker 掛掉怎麼辦 — retry、requeue 與訊息的命運
這是「會用」和「懂原理」差最多的一章。前面都在讓任務成功,這一章你要故意讓它失敗、故意把 worker 殺掉,觀察訊息在 RabbitMQ 裡到底發生什麼事。
做完這一章,你會做到
- 驗證 config.py 的「環境變數覆蓋」,搞懂同一份程式為什麼本機和 Docker 都能跑。
- 分得清
self.retry()(重試)和Reject(requeue=True)(放回佇列)本質差在哪。 - 懂
acks_late=True的意義:做完才確認,沒做完就回佇列。 - 會用 RabbitMQ 管理介面觀察 Ready / Unacked 的變化。
- 建立「分散式系統要為失敗而設計」的心態。
先搞懂: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)→ 解法是 retry,等一下再試就好
- 任務內、永久性(情境 2、3)→ retry 沒用,要做決策:放回(會無限循環)還是丟棄(正式系統用 DLQ 冷處理)
- 任務外、行程死亡(情境 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
...(毫秒級速度無限洗版)
- task id(UUID)從頭到尾都一樣——證明是「同一則訊息」被放回再拿出;對照情境 1 的 retry,每次重試 log 是同一個 id 但那是「新發的任務」帶著原 id 重排程,且有次數上限。
- 循環快到毫秒級——中間沒有任何延遲,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,看不到本情境的重點):
- 清掉還躺在佇列裡的 REQUEUE_TEST:RabbitMQ UI → Queues →
celery→ 頁面最下方 Purge Messages。 - 把 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)
操作順序(每步都附執行結果):
- 發任務 → 開 worker,看到
進度 1/30、2/30...- 此時 RabbitMQ UI:Ready 0 / Unacked 1——訊息「借給」worker 處理中、還沒確認
- 大約第 10 秒按
Ctrl+C殺掉 worker- 你會先看到
worker: Warm shutdown (MainProcess)——第一次 Ctrl+C 是「溫和關機」;若 worker 遲遲不退出,再按一次 Ctrl+C 強制冷關機
- 你會先看到
- 等 worker 退出後回 RabbitMQ UI → ✅ SLOW_TEST 的訊息回到 Ready(Ready 1 / Unacked 0)
- 重開 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_demoapp? 因為這一章要全域開啟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,讓爬回來的股價真正落地。

送件鴿飛出去了