一個簡單且可被平行處理的資料庫 Job Queue 實作

Roga Lin

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 處理失敗並放棄,不再自動重試

其中,02 雖然都不會再進入待處理佇列,語意並不相同:

正常的狀態轉換可以簡化成:

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 能被認領。

執行後必須檢查受影響的資料筆數:

概念上的虛擬碼如下:

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_countnext_attempt_atlast_error 等欄位。單純把狀態改回 NULL,並不算完整的重試機制。

單一 process 太慢時,怎麼增加處理速度?

一個 worker 一次只處理一筆工作。若工作需要等待網路、檔案或外部服務,單一 process 很容易把大量時間花在 I/O 等待上。此時可以同時啟動多個 worker,讓不同工作並行處理。

常見方案有以下幾種。

1. 在啟動 script 裡面建立固定數量的 worker

最簡單的方法,是由一個 script 啟動數個獨立 worker process。例如:依序啟動五個 worker,彼此間隔幾秒,避免所有 process 在完全相同的時間查詢資料庫。

優點是修改少、容易導入;缺點是 worker 意外結束後,啟動 script 本身通常不會自動補回,也較難管理各 process 的生命週期。

2. 使用 process supervisor

可以交由作業系統服務管理工具或 process supervisor 維持指定的 worker 數量。worker 異常結束時由 supervisor 重新啟動,也能集中處理啟動、停止、日誌與自動重啟。

對長時間執行的 worker 而言,這通常比單純的背景啟動 scirpt 更可靠,而且不需要改變 queue 的資料庫設計。

3. 由 launcher 建立多個子 process

也可以新增一個 launcher,由父 process 建立固定數量的子 process,每個子 process 各自執行 worker loop。父 process 可以等待、回收或重啟子 process。

這種方式能把 worker 數量與生命週期管理放在應用程式內,但實作複雜度較高。採用 fork 類機制時,資料庫連線與網路連線通常應由子 process 在建立後各自初始化,不應直接共用父 process 已開啟的連線;此外,也要處理 signal、子 process 回收與正常關閉。

4. 由排程器或容器平台啟動多個 worker

既有的排程系統、容器平台或批次工作平台,也可以定期或按需求建立多個 worker instance。這種方式適合基礎設施已經具備相關能力的環境,但必須避免上一批 worker 尚未結束,下一批又無限制地疊加。

5. 成長後改用專用 Queue

當系統開始需要大量吞吐、延遲重試、優先級、dead-letter queue、可觀測性或跨服務傳遞工作時,就應評估專用的訊息佇列或工作佇列。資料庫 queue 適合的是需求單純、規模有限,而且團隊希望降低營運複雜度的場景。

不論選擇哪種多 process 方案,安全性的核心都相同:worker 必須透過帶有 status IS NULL 條件的 UPDATE 認領工作,並檢查受影響筆數。單純增加 process,卻沒有原子認領,只會讓重複處理變得更頻繁。

worker 數量也不是越多越好。增加並行度時應觀察:

實務上可以先從少量 worker 開始,再依監控結果逐步增加。

這個設計的限制

加入條件式認領與 abort 狀態後,這套設計已能處理多 worker 競爭與永久失敗,但它仍然是一個刻意保持簡單的 queue。

最需要注意的是:如果 worker 在把狀態設成 9 之後,被強制終止或主機當機,例外處理沒有機會執行,工作就可能永久停留在處理中。只有一個 status 欄位時,系統無法判斷它是真的還在執行,還是已經成為孤兒工作。

資料量或可靠性要求提高後,可以循序加入:

另外,ORDER BY date DESC 可能讓舊工作發生 starvation。如果產品未來要求每筆工作最終都要被處理,就需要加入老化機制、分批處理策略,或在新資料壓力降低時安排 backlog worker。

結語

用資料庫與一個 status 欄位,就能做出一套小而實用的 Job Queue,而且只要顧到狀態轉換背後的規則就沒有太大的問題:

  1. SELECT 只找候選工作,條件式 UPDATE 才是真正的認領。
  2. 只有成功影響一筆資料的 worker 可以開始處理。
  3. 成功、abuse 與執行失敗必須有不同且明確的終止狀態。
  4. 沒有次數與退避策略的「設回 NULL」會讓永久失敗變成無限重試。
  5. 多 process 能提高吞吐量,但每個 worker 仍必須遵守相同的原子認領協議。

對小型系統而言,先把競爭條件和 poison job 處理正確,通常比一開始就導入完整的 queue infrastructure 更有價值。等需求真的超過這套模型,再逐步補上 lease、重試、監控,或遷移到專用 Queue,會是一條相對務實的演進路線。