2026-08-25 22:40:32 +08:00
當系統規模還不大,又不想立刻導入專用訊息佇列時,可以直接利用既有資料庫做一個簡單的 Job Queue。
這種做法的優點是容易理解、部署成本低,也不需要多維護一套基礎設施。不過,只要開始同時執行多個 worker,或遇到永遠不會成功的工作,就必須仔細處理「工作認領」與「失敗狀態」,否則很容易發生重複執行或無限重試。
以下簡單介紹如何只依靠 status 欄位實作的 Job
Queue,以及它從單純可用,逐步修正到能夠安全並行、遇到永久失敗也能繼續運作的過程
(a.k.a. 我的踩雷過程)。
status
表示工作的生命週期這個簡易的 Job Queue 實作方法是直接在待處理資料上加入
status 欄位。
status |
意義 |
|---|---|
NULL |
尚未處理,等待 worker 認領 |
9 |
處理中,其他 worker 不應再取得這筆工作 |
1 |
處理完成 |
2 |
已被業務規則標記為 abuse |
0 |
處理失敗並放棄,不再自動重試 |
其中,0 和 2
雖然都不會再進入待處理佇列,語意並不相同:
0
代表執行工作時失敗,例如外部服務無法連線、資料格式不合法,最後決定放棄。2 代表工作或資料本身被業務規則判定為 abuse。正常的狀態轉換可以簡化成:
NULL(等待處理)──認領──> 9(處理中)──成功──> 1(完成)
└──失敗──> 0(放棄)
因為業務規則而被判定為 abuse ───────────────> 2 (直接略過)
worker 每次先選出一筆 status IS NULL 的資料:
SELECT *
FROM `table_a`
WHERE `status` IS NULL
ORDER BY `date` DESC
LIMIT 1;這裡刻意使用 date DESC,也就是優先處理最新資料。
它的目的不是保證先進先出,而是避免系統累積大量過舊資料時,worker 長時間都在消化歷史 backlog,導致剛產生的新資料一直得不到處理。代價是當新資料持續湧入時,舊資料可能等待更久;不過這是我在工作的新鮮度與公平性之間做出的選擇,最後還是應該依實際業務需求來決定。
SELECT
之後,仍然需要安全地認領工作如果只有一個 worker,查出資料後直接處理,看起來沒有問題。但當兩個
worker 同時執行時,它們可能在非常接近的時間執行同一個
SELECT,因此取得相同的資料。
所以,SELECT
只是在找候選工作,不能視為已經取得工作。真正的認領動作是接下來這個有條件的
UPDATE:
UPDATE `table_a`
SET `status` = 9
WHERE `id` = :id
AND `status` IS NULL;這句 SQL 不只把某個等待處理的 job 的 status 設為
9,而且也在 where 條件也加入了
AND status IS NULL。這個條件讓資料庫幫我們做一次原子的「確認並修改」:只有狀態仍為待處理
(null) 的 job 能被認領。
執行後必須檢查受影響的資料筆數:
1:認領成功,這個 worker 可以開始處理。0:工作已被其他 worker
搶先認領,放棄這筆候選資料並重新查詢。概念上的虛擬碼如下:
loop:
job = 找出最新的一筆待處理工作
if job 不存在:
等待一段時間
continue
affected_rows = 嘗試把 job.status 從 NULL 改為 9
if affected_rows != 1:
# 已被其他 worker 認領
continue
處理 job
這種設計不會阻止多個 worker 同時查到同一筆候選資料,但能確保只有一個 worker 取得實際處理權。它也讓增加 worker 數量成為可能,而不必先導入額外的分散式鎖。
工作成功時,把狀態從處理中改為完成:
UPDATE `table_a`
SET `status` = 1
WHERE `id` = :id
AND `status` = 9;同樣保留 status = 9
的條件,可以避免意外覆蓋已被其他流程改變的狀態。若工作還會產生結果資料,也可以在同一個
UPDATE 中一併寫入;需要多筆寫入時,則應評估是否以
transaction 保持一致性。
早期的設計在工作失敗時,會把狀態從 9 改回
NULL:
UPDATE `table_a`
SET `status` = NULL
WHERE `id` = :id
AND `status` = 9;這看似提供了自動重試,但它沒有重試次數、延遲時間,也沒有下一次重試時間。只要失敗原因不會自行消失,例如:
worker 回到迴圈後,這筆資料會立刻再次符合
status IS NULL,而且在「最新資料優先」的排序下,很可能又是第一筆。結果就是同一筆工作被無限執行,不只浪費
CPU、網路與資料庫資源,也可能阻塞後續工作。
這類永遠無法成功、卻會持續被重試的工作,通常稱為 poison job。
0:明確表示失敗並放棄簡單而有效的修正,是增加
status = 0,表示這筆工作確實已被處理,但執行失敗,系統決定不再自動重試:
UPDATE `table_a`
SET `status` = 0
WHERE `id` = :id
AND `status` = 9;由於待處理查詢只選取 status IS NULL,被設為
0 的工作會離開 queue。worker
可以繼續處理下一筆資料,不會再被同一筆永久失敗的工作卡住。
完整流程可以寫成:
loop:
job = 找出最新的一筆 status 為 NULL 的資料
if job 不存在:
等待一段時間
continue
if 無法把 job.status 從 NULL 改為 9:
continue
try:
result = 執行工作(job)
寫入 result,並把 status 從 9 改為 1
catch error:
記錄錯誤
把 status 從 9 改為 0
這次修改選擇的是「穩定性優先」:先終止無限重試,不在同一次變更裡加入重試次數、退避演算法或新的排程欄位。這讓修正範圍保持很小,也不需要變更整套系統。
如果未來確實需要自動重試,則應區分暫時性錯誤與永久性錯誤,並另外加入
attempt_count、next_attempt_at、last_error
等欄位。單純把狀態改回 NULL,並不算完整的重試機制。
一個 worker 一次只處理一筆工作。若工作需要等待網路、檔案或外部服務,單一 process 很容易把大量時間花在 I/O 等待上。此時可以同時啟動多個 worker,讓不同工作並行處理。
常見方案有以下幾種。
最簡單的方法,是由一個 script 啟動數個獨立 worker process。例如:依序啟動五個 worker,彼此間隔幾秒,避免所有 process 在完全相同的時間查詢資料庫。
優點是修改少、容易導入;缺點是 worker 意外結束後,啟動 script 本身通常不會自動補回,也較難管理各 process 的生命週期。
可以交由作業系統服務管理工具或 process supervisor 維持指定的 worker 數量。worker 異常結束時由 supervisor 重新啟動,也能集中處理啟動、停止、日誌與自動重啟。
對長時間執行的 worker 而言,這通常比單純的背景啟動 scirpt 更可靠,而且不需要改變 queue 的資料庫設計。
也可以新增一個 launcher,由父 process 建立固定數量的子 process,每個子 process 各自執行 worker loop。父 process 可以等待、回收或重啟子 process。
這種方式能把 worker 數量與生命週期管理放在應用程式內,但實作複雜度較高。採用 fork 類機制時,資料庫連線與網路連線通常應由子 process 在建立後各自初始化,不應直接共用父 process 已開啟的連線;此外,也要處理 signal、子 process 回收與正常關閉。
既有的排程系統、容器平台或批次工作平台,也可以定期或按需求建立多個 worker instance。這種方式適合基礎設施已經具備相關能力的環境,但必須避免上一批 worker 尚未結束,下一批又無限制地疊加。
當系統開始需要大量吞吐、延遲重試、優先級、dead-letter queue、可觀測性或跨服務傳遞工作時,就應評估專用的訊息佇列或工作佇列。資料庫 queue 適合的是需求單純、規模有限,而且團隊希望降低營運複雜度的場景。
不論選擇哪種多 process 方案,安全性的核心都相同:worker 必須透過帶有
status IS NULL 條件的 UPDATE
認領工作,並檢查受影響筆數。單純增加
process,卻沒有原子認領,只會讓重複處理變得更頻繁。
worker 數量也不是越多越好。增加並行度時應觀察:
實務上可以先從少量 worker 開始,再依監控結果逐步增加。
加入條件式認領與 abort 狀態後,這套設計已能處理多 worker 競爭與永久失敗,但它仍然是一個刻意保持簡單的 queue。
最需要注意的是:如果 worker 在把狀態設成 9
之後,被強制終止或主機當機,例外處理沒有機會執行,工作就可能永久停留在處理中。只有一個
status
欄位時,系統無法判斷它是真的還在執行,還是已經成為孤兒工作。
資料量或可靠性要求提高後,可以循序加入:
claimed_at:記錄何時被認領,讓系統能找出逾時工作;worker_id:記錄由哪個 worker 處理;attempt_count:限制重試次數;next_attempt_at:實作延遲與 exponential backoff;last_error:保留失敗原因,方便排查與人工重送;status = 9 工作。另外,ORDER BY date DESC 可能讓舊工作發生
starvation。如果產品未來要求每筆工作最終都要被處理,就需要加入老化機制、分批處理策略,或在新資料壓力降低時安排
backlog worker。
用資料庫與一個 status 欄位,就能做出一套小而實用的 Job
Queue,而且只要顧到狀態轉換背後的規則就沒有太大的問題:
SELECT 只找候選工作,條件式 UPDATE
才是真正的認領。NULL」會讓永久失敗變成無限重試。對小型系統而言,先把競爭條件和 poison job 處理正確,通常比一開始就導入完整的 queue infrastructure 更有價值。等需求真的超過這套模型,再逐步補上 lease、重試、監控,或遷移到專用 Queue,會是一條相對務實的演進路線。