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

第 3 章:多佇列分流 — 把任務送到專屬的 Worker

Celery 預設所有任務擠在同一條佇列。這一章你會學會依「任務類型」把任務分流到不同佇列(twse / tpex),並讓專門的 worker 各自去處理,互不搶資源。最後再用 --scale 一行指令把同一種 worker 開成多份。


做完這一章,你會做到

  1. 用 .s() + apply_async(queue=...) 把不同股票送到不同佇列。
  2. 啟動兩個「只吃指定佇列」的 worker(-Q twse、-Q tpex)。
  3. 確認 twse 的任務只有 twse worker 拿得到,tpex 反之。
  4. 做一個對照實驗:故意不開某個 worker,看到那條佇列的訊息一直堆積沒人處理。
  5. 用 --scale 把 twse worker 開成 3 份,看一批任務被 3 個實例分擔。
  6. 講得出「為什麼 worker 可以隨便加減份數」的三個原因。

先搞懂:為什麼要分流

打個比方:一間餐廳只有一台出單機時,熱炒、飲料、甜點的單全混在一起排隊。生意一忙,做甜點的師傅可能被前面一大排熱炒單卡住,明明他很閒卻拿不到自己的單。解法是把出單分成幾條線,各自有專責師傅。

在這個專案裡,你會把股票分成兩類、兩條佇列:

  • twse 佇列:上市股票(例如台積電 2330)
  • tpex 佇列:上櫃 / 其他標的(例如美債 ETF 00679B)

分流之後有三個好處:

  1. 互不干擾:某一類暴量,不會卡住另一類。
  2. 可獨立擴充:哪條線忙,就只加開那條線的 worker。
  3. 可差異化設定:不同佇列的 worker 可以各自調併發數、甚至用不同的 pool。

這是第 1 章「多開 worker」的精緻版:不只是「加人」,而是「讓對的人做對的事」。


這一章會用到的檔案

檔案 角色 說明
crawler/producer_multi_queue_print.py 生產者(print 版) 分流 + 只印出,方便你觀察
crawler/producer_multi_queue.py 生產者(正式版) 分流 + 呼叫會寫 DB 的任務
crawler/worker.py 不動 沿用
docker-compose-local.yml 部署 定義了 worker_twse / worker_tpex 兩個專屬 worker
compose-advanced/ 部署(進階) 拆開的網路版 compose,--scale 實驗用
Dockerfile 容器化 worker 容器的 image 定義(--scale 時 build)

一行一行讀懂 producer(print 版)

from crawler.tasks_crawler_finmind import crawler_finmind_print

# .s() = signature,把「task + 參數」綁成一個可派送物件
task_2330 = crawler_finmind_print.s(stock_id="2330")
# apply_async(queue="twse"):指定這個任務送到 twse 佇列
task_2330.apply_async(queue="twse")
print("send task_2330 task")

task_00679b = crawler_finmind_print.s(stock_id="00679B")   # 美債 ETF
task_00679b.apply_async(queue="tpex")                      # 送到 tpex 佇列
print("send task_00679b task")

有三個關鍵你要懂:

  • .s(stock_id="2330") 是什麼? .s 是 signature(簽章)的縮寫。它把「要執行哪個 task」加上「帶什麼參數」打包成一個物件,但還沒送出。好處是這個物件可以再附加 queue、重試策略等進階設定。你可以想成「填好、但還沒投遞的包裹」。
  • .apply_async(queue="twse") 才是真正投遞,而且指定投到名為 twse 的佇列。
  • 為什麼不能用 .delay()? 因為 .delay() 是簡化版,不能指定佇列。要分流就一定要用完整的 apply_async。這就是第 1 章埋的伏筆「想指定佇列就得用 apply_async」在這裡兌現。

正式版 producer_multi_queue.py 幾乎一樣,只是把 crawler_finmind_print 換成會寫資料庫的 crawler_finmind。再一次示範「分流邏輯不變,只換任務落地方式」。


Worker 端:用 -Q 只吃指定佇列

專案的 docker-compose-local.yml 裡,兩個 worker 是這樣啟動的(你在本機也可以直接下這兩行):

# twse 專屬 worker:只消費 twse 佇列
uv run python -m celery -A crawler.worker worker --loglevel=info --hostname=twse@%h -Q twse

# tpex 專屬 worker:只消費 tpex 佇列
uv run python -m celery -A crawler.worker worker --loglevel=info --hostname=tpex@%h -Q tpex

兩個新參數:

  • -Q twse:這個 worker 只處理 twse 佇列的任務,完全看不到 tpex 的。這是分流能成立的關鍵——沒加 -Q 的 worker 只吃預設的 celery 佇列。
  • --hostname=twse@%h:幫 worker 取個好認的名字(%h 會自動換成主機名),這樣你在 Flower / RabbitMQ 上一眼就能分辨誰是誰。

一步一步跟著做

方式 A:本機開兩個 worker(推薦,看得最清楚)

你需要四個終端機:twse worker、tpex worker、producer、瀏覽器。

T1 — 啟動 twse worker:

uv run python -m celery -A crawler.worker worker -l info --hostname=twse@%h -Q twse

✅ banner 裡看到 .> queues: twse、worker 名字是 twse@...,就過關。

T2 — 啟動 tpex worker:

uv run python -m celery -A crawler.worker worker -l info --hostname=tpex@%h -Q tpex

✅ 這個 worker 的 queue 是 tpex。

T3 — 派送分流任務:

uv run crawler/producer_multi_queue_print.py

你會看到:

send task_2330 task
send task_00679b task

方式 B:用 docker compose 一次起兩個 worker

docker compose -f docker-compose-local.yml up -d --build \
  rabbitmq flower mysql phpmyadmin worker_twse worker_tpex

第一次要 build image(下載 base image + uv sync),需要幾分鐘。

確認兩個 worker ready:

docker compose -f docker-compose-local.yml logs worker_twse | grep ready
docker compose -f docker-compose-local.yml logs worker_tpex | grep ready

✅ 預期:

twse@xxxx ready.
tpex@xxxx ready.

💡 名稱小提醒:這個 compose 的服務名是 worker_twse / worker_tpex(docker compose logs 用它),但 container_name 設成 crawler_twse / crawler_tpex(docker ps、docker logs 看到的是它)。同一個容器、兩個名字,別以為是兩個東西。

然後用容器裡的 producer 服務派送(或照方式 A 在本機跑 producer):

docker compose -f docker-compose-local.yml up producer

觀察與驗證

觀察點 1:任務只被對應的 worker 拿到

  • T1(twse worker) 只會印出 2330 的股價表。
  • T2(tpex worker) 只會印出 00679B 的股價表。
  • 它們不會拿到對方佇列的任務。

✅ 這是本章最重要的驗證:2330 永遠不會出現在 tpex worker,反過來也一樣。

用 Docker 跑的話,用 log 驗證:

docker compose -f docker-compose-local.yml logs worker_twse | grep succeeded
docker compose -f docker-compose-local.yml logs worker_tpex | grep succeeded

觀察點 2:RabbitMQ UI 看到兩條佇列

打開 http://localhost:15672 → Queues and Streams,你會看到 twse、tpex 兩條佇列各自有流量進出。

觀察點 3:Flower 看分工

打開 http://localhost:5555 :

頁籤 觀察
Dashboard 兩個 worker:twse@... 和 tpex@...,都 Online
Workers 點 twse@...,它的 Queues 只有 twse
Tasks 2330 那筆的 worker 欄是 twse,00679B 的 worker 欄是 tpex

✅ Tasks 頁能清楚看到「哪個任務被哪個 worker 處理」,這是分流生效最直接的證據。

觀察點 4(一定要做的對照實驗):故意不開某個 worker

  1. 只開 twse worker,不要開 tpex worker。
  2. 跑一次 producer_multi_queue_print.py。
  3. 到 RabbitMQ UI 看 tpex 佇列 → Ready 數會是 1,而且一直不動(沒人領)。
  4. 這時你再把 tpex worker 開起來 → 那則訊息立刻被消化、Ready 歸零。

✅ 這個實驗直接證明了「worker 只吃自己被 -Q 指定的佇列」,也順便預告了第 4 章「訊息會在佇列裡等待」的行為。

對照總表

# 你應該看到 它證明了什麼
1 twse worker 只印 2330 -Q 讓 worker 只吃指定佇列
2 tpex worker 只印 00679B 佇列彼此隔離
3 RabbitMQ 有 twse / tpex 兩條佇列 apply_async(queue=) 真的建立了分流
4 Flower Tasks 頁 worker 欄對得上 分流路徑可以被監控
5 不開 tpex worker → tpex 佇列堆積 訊息在佇列等待,不會消失

進階:用 --scale 把同一種 worker 開成多份

分流解決了「讓對的人做對的事」。但如果 twse 這條線就是忙不過來呢?加人。 Docker Compose 的 --scale 讓你一行指令把同一個 worker 服務開成 N 份,連程式碼都不用碰。

這一段改用 compose-advanced/ 的網路版 compose(服務拆開、共用一個外部網路),因為 --scale 需要 worker 服務沒有寫死 container_name——名字寫死就沒辦法開第二份。

⚠️ 先把上面整合版停掉,避免 port 衝突:

docker compose -f docker-compose-local.yml down

Step 1:建立共用網路

compose-advanced/ 的檔案都掛在外部網路 my_network 上,要先建:

docker network create my_network

Step 2:啟動 RabbitMQ + Flower、MySQL + phpMyAdmin(網路版)

docker compose -f compose-advanced/rabbitmq.yml up -d
docker compose -f compose-advanced/mysql.yml up -d

✅ 驗證:

curl -o /dev/null -s -w "RabbitMQ: %{http_code}\n" http://localhost:15672
curl -o /dev/null -s -w "phpMyAdmin: %{http_code}\n" http://localhost:8080

兩個都回 200。

Step 3:用 --scale 啟動 3 個 twse worker

docker compose -f compose-advanced/docker-compose-worker-network.yml up -d --scale crawler_twse=3

確認起了幾份:

docker compose -f compose-advanced/docker-compose-worker-network.yml ps

✅ 預期:crawler_twse 有 3 個實例(名稱結尾 -1 / -2 / -3),crawler_tpex 有 1 個。

Step 4:發一批 twse 任務,看 3 個實例分擔

uv run python -c "
from crawler.tasks_crawler_finmind import crawler_finmind_print
for sid in ['2330', '2317', '2454', '2412', '1301', '1216']:
    crawler_finmind_print.s(stock_id=sid).apply_async(queue='twse')
    print('sent', sid)
"

然後看 log:

docker compose -f compose-advanced/docker-compose-worker-network.yml logs crawler_twse | tail -40

✅ 預期:6 個任務被 3 個不同的實例分擔(log 前綴的容器名不同),整批完成的時間比單一 worker 快。三個實例都在搶同一條 twse 佇列——誰有空誰拿。

Step 5:縮回 1 份、收工

# 縮回 1 份
docker compose -f compose-advanced/docker-compose-worker-network.yml up -d --scale crawler_twse=1

# 全部收掉
docker compose -f compose-advanced/docker-compose-worker-network.yml down
docker compose -f compose-advanced/rabbitmq.yml down
docker compose -f compose-advanced/mysql.yml down
docker network rm my_network

💡 mysql.yml down 預設保留資料 volume;加 -v 才會連資料一起刪。


為什麼 worker 可以隨便加減份數?

你剛剛從 1 份 scale 到 3 份、再縮回 1 份,全程沒改任何程式。這不是理所當然的——是三個設計特性讓它成立:

特性 說明 帶來的好處
無狀態(Stateless) worker 不在本機保存任何狀態,每個任務都是獨立的 隨便加減 worker 都不會弄亂資料
不開對外 port worker 只「主動連出去」抓 RabbitMQ 的任務,不需要別人連進來 不會 port 衝突,才能開多份
共用 broker 所有 worker 連同一個 RabbitMQ,從同一條佇列搶任務 加 worker = 更多手一起搶,自動負載平衡

對照:為什麼 RabbitMQ / MySQL 不能這樣隨便 scale

Worker RabbitMQ / MySQL
有狀態? 無 有(訊息 / 資料)
開對外 port? 否 是(5672 / 3306)
scale 多份? ✅ 隨意 ❌ 需要 cluster 設定

記住這句:要 scale 的東西必須無狀態、不搶 port、共用同一個資料/訊息來源。 Celery worker 三者都符合,所以是整套系統裡最容易水平擴充的部分。


想再深入一點

  • .delay() vs .s().apply_async():前者簡單但不能指定佇列;後者彈性,能指定 queue、countdown(延遲)、eta(指定時間)、link(串接後續任務)等。分流一定要用後者。
  • 預設佇列叫 celery:沒指定 queue 的任務會進 celery 佇列;沒加 -Q 的 worker 也只吃 celery。這解釋了下面那個最常見的 bug。
  • 一個 worker 也可以吃多條佇列:-Q twse,tpex 就會同時消費兩條。你可以想想:什麼時候要專職、什麼時候要兼職。

卡住了?常見錯誤這樣排

你遇到的狀況 原因 怎麼解
任務送出去卻永遠沒人做 worker 沒加對應的 -Q,只吃預設 celery 佇列 worker 加 -Q twse(或 tpex)
想分流卻用了 .delay() .delay() 不能指定佇列 改用 .s(...).apply_async(queue=...)
producer 送 twse、worker 卻 -Q twes 佇列名稱打錯、對不上 檢查兩邊佇列名稱完全一致
兩個 worker 名字一樣互相衝突 沒設 --hostname 各自加不同的 --hostname=xxx@%h
--scale 報 container name 衝突 compose 裡 worker 寫死了 container_name 用 compose-advanced/ 的網路版(沒寫死名字)
scale 的 worker 連不上 rabbitmq 服務不在同一個 Docker 網路 確認都掛在 my_network,且先 docker network create my_network

想一想(確認你懂了)

先自己想過再看答案。

Q1:如果不分流、全部擠在一條佇列,會有什麼問題?

所有任務混在一起排隊,一種任務暴量時會卡住另一種。例如上市股票的任務突然湧入幾百個,上櫃的任務就得排在後面乾等,即使你其實想讓上櫃優先。而且你沒辦法針對不同類型的任務給不同的資源配置——全部共用同一批 worker、同一個併發設定。

Q2:worker_twse 為什麼看不到 00679B 的任務?

因為 worker_twse 啟動時加了 -Q twse,代表它只訂閱 twse 這條佇列。而 00679B 是被 apply_async(queue="tpex") 送到 tpex 佇列的。worker 只會去拿自己訂閱的佇列裡的訊息,不會去別條佇列撈,所以它根本看不到 00679B。

Q3:假設上市股票的量是上櫃的 10 倍,你會怎麼配置這兩種 worker?

給 twse 更多資源。做法有兩種、通常一起用:多開幾個 -Q twse 的 worker(橫向,--scale crawler_twse=N 一行搞定),以及把 twse worker 的 --concurrency 調高(縱向);tpex 這邊量小,保守配置就好。這樣資源就配在真正忙的那條線上。

Q4:為什麼分流一定要用 apply_async、不能用 .delay()?

因為 .delay() 是簡化版,它固定把任務送到預設佇列,沒有讓你指定 queue 的參數。要指定佇列就必須用完整的 apply_async(queue=...)(或先用 .s() 綁好再 apply_async)。這也是為什麼多佇列的 producer 都改用 .s().apply_async() 的寫法。

Q5:--scale crawler_twse=3 之後,3 個實例會不會重複做同一個任務?

不會。3 個實例訂閱的是同一條 twse 佇列,而佇列裡的一則訊息一次只會交給一個消費者(RabbitMQ 負責分派)。所以 6 個任務會被 3 個實例「分著做」,不會重工。這也是「共用 broker」帶來的自動負載平衡——不需要你自己寫任何分配邏輯。


換你試試看

練習 1:新增第三條佇列 etf

在 producer 裡加上把 0050、0056 送到 etf 佇列的程式碼:

crawler_finmind_print.s(stock_id="0050").apply_async(queue="etf")
crawler_finmind_print.s(stock_id="0056").apply_async(queue="etf")

然後啟動一個只吃 etf 佇列的 worker:

uv run python -m celery -A crawler.worker worker -l info --hostname=etf@%h -Q etf

你會看到 0050、0056 只出現在這個 etf worker,twse / tpex worker 都拿不到。這讓你確認:分流的規則完全由「producer 送到哪條佇列」+「worker 訂閱哪條佇列」這兩件事決定。

練習 2:做「不開 worker 就送任務」的對照實驗

只開 twse worker、不開 tpex worker,跑一次 producer,然後去 RabbitMQ UI(http://localhost:15672 → Queues)看 tpex 佇列。

你會看到 tpex 的 Ready 數字停在 1 不動——因為訊息送進佇列了,但沒有任何 worker 訂閱 tpex 去拿它,所以它只能一直等。這時你再把 tpex worker 開起來,那個數字會立刻歸零、任務被消化。Ready 就是「在佇列裡排隊、還沒被拿走」的訊息數,這個實驗讓你看懂佇列的緩衝本質:訊息不會因為沒人處理就消失,它會等。

練習 3:讓一個 worker 兼吃兩條佇列

uv run python -m celery -A crawler.worker worker -l info -n both@%h -Q twse,tpex

用這個 worker(不要開別的),跑一次多佇列 producer。你會看到 twse 和 tpex 的任務都被這一個 worker 做掉。這讓你理解 -Q 可以訂閱多條佇列,也讓你想清楚一個設計問題:什麼時候該用「一個 worker 兼多條」(省資源),什麼時候該用「專職 worker」(互不干擾、好擴充)。

練習 4:把 tpex 也 scale 成 2 份

沿用進階段落的環境,跑:

docker compose -f compose-advanced/docker-compose-worker-network.yml up -d --scale crawler_twse=3 --scale crawler_tpex=2
docker compose -f compose-advanced/docker-compose-worker-network.yml ps

你會看到兩種 worker 各自 scale 到不同份數。這讓你體會分流 + scale 的組合威力:每一條線都可以獨立決定要投多少人力。


這一章你學到了

  • 多佇列 = 依任務類型分流,搭配 -Q 讓 worker 各司其職。
  • .s() + apply_async(queue=...) 是分流的關鍵語法,.delay() 做不到。
  • --scale 一行指令就能把 worker 開成 N 份,靠的是「無狀態、不開 port、共用 broker」三個設計。
  • 分流(做對的事)× scale(加人手)= 分散式系統的資源調度基本功。

下一章要做什麼

到目前為止任務都一帆風順。但真實世界的爬蟲一定會失敗——API 超時、網路斷線、worker 被殺。下一章你會故意讓任務失敗、故意殺掉 worker,觀察訊息在 RabbitMQ 裡的命運,學會 retry、requeue 與 acks_late。