Hero Image
- Mark

建一個 Job Scheduler 之前要釐清的 11 個取捨點

建一個 Job Scheduler 之前要釐清的 11 個取捨點

任務調度系統(Task Scheduler / Job Scheduler)這種東西,看起來每家公司都有,用起來都差不多,但只要你認真去看一次原始碼,就會發現每個團隊的取捨都不一樣。

這篇文章不是要告訴你「任務調度是什麼」。網路上的科普文已經太多了。我要講的是:當我們真的坐下來要畫一張架構圖,每一個節點為什麼要這樣選。參考的影片是 s09g 的《system design — 任務調度系統》(約 29 分鐘),我把裡面 12 個章節的取捨整理成一條線,並加上我自己做後端這些年的看法。

我們的場景先講清楚:假設要替一個內部系統(10k 工程師規模)做一個任務調度平台。


一、第一步永遠不是畫圖:是先問三個問題

系統設計面試有一個很常見的盲點:很多人一坐下來就開始畫 client、API、database。但是影片的作者 s09g 在影片的前 15 秒就提醒過,現實世界的系統永遠是在幾個方向上做取捨,所以第一件事永遠是澄清問題,否則你畫了十分鐘發現走錯方向。

1. 這是內部系統還是對外系統?

這影響你接下來的每一個決定。對外的公開系統動輒數百萬用戶,你寫資料庫前就要先想 sharding、cache、CDN。內部系統 10k 工程師、每天提交幾批任務,整體流量可能就 10 QPS。

為什麼這重要? 因為 QPS 差異決定了你要不要為了「一致性」付出額外複雜度。我們後面會看到,這個決定直接影響到 datastore 的選擇。

2. 我們要做哪種 Scheduler?

Kubernetes 有 Scheduler、Spark 有 Scheduler(還是兩層架構的)、Linux 的 Cron 也是廣義的 Scheduler。這三種使用情境完全不同:

  • Kubernetes scheduler:排 Pod 怎麼落在哪個 Node
  • Spark scheduler:排 DAG 任務怎麼切到 Executors
  • Cron:定時跑一個 shell script

時間不到一小時、要做 system design,永遠先解決最基礎的事情。否則你做再 fancy 的功能也沒用,基礎不穩上面會全塌。

所以這篇文章鎖定的範圍是:MVP(Minimum Viable Product)層級的最基礎 scheduler

3. 任務類型是哪幾種?

這會影響你的狀態機設計。常駐的 long-running service 和跑一次就丟的 temp script 在 scheduler 心裡是兩種完全不同的東西:

  • 短任務:30 秒跑完、沒人看、半夜失敗沒關係
  • 長服務:要在線上長期駐守、不能用 timeout 判斷死亡

後面在設計 retry 機制的時候,這個區分會再次出現。

二、Functional 與 Non-Functional Requirement:先把紅線劃出來

確認問題之後,可以來看「系統要做什麼」。

功能面:兩條就夠

  1. 使用者能提交任務(tasks 可以是短任務或 long-running service)
  2. 使用者能在 dashboard 看任務結果

就這樣。其他都是進階功能。

進階功能先列出來放旁邊,不要做

  • 未來排程:可以排程未來的時間執行
  • 定時任務(Cron Service):每 30 分鐘收信、每天 12 點備份資料庫
  • DAG Job Dependency:第一個任務跑完才能跑二、三號,這是有向無環圖

這三個列著,等你基礎功能確認能跑穩了再回頭看。系統設計最常見的錯誤就是把時間花在進階功能上,結果基礎的是個空殼。

非功能面:兩個 latency、兩個性質

  • 任務提交到執行的 latency:10 秒內。這代表使用者按下去,要不了十秒就能看到任務動起來。
  • dashboard 同步:60 秒內。我不在乎你 1 秒內更新,使用者也不在乎。
  • scalability:未來要能擴展
  • reliability:服務掛了重新拉起時,任務不能掉

10 秒 vs 60 秒,這兩個數字看似不起眼,後面會在 QPS 估算時發揮關鍵作用。


三、資料模型與狀態機:Job 怎麼寫、狀態怎麼流

Job Data Model:兩塊拼起來

一個可執行的任務,本質上是兩部分

  1. 可執行的二進位檔:放在獨立的 repo(可能是 AWS S3 之類的 bucket),裡面是 code 或 config,反正就是要能被執行
  2. metadata 元資料:job id、owner id、二進位檔的 URL、input/output 路徑、created_time、number of retry
job:
  id: string
  owner_id: string
  binary_url: string  # 指向 S3 上的執行檔
  input_path: string
  output_path: string
  created_time: timestamp
  retry_count: int

設計的重點是把可執行的東西跟描述它的 metadata 分開,這樣後面要切分任務到不同機器跑的時候,metadata 才好序列化、好轉移。

狀態機:簡單五個狀態

ready → waiting → running → success
                    ↓
                  retry (回到 waiting) 或 final_failed

設計哲學:任務從 ready 開始、提交後進 waiting、排到機器就進入 running。running 結束有兩種結果:成功就結束;失敗就看 retry 數量判斷是回到 waiting 重試、還是進 final_failed。

Retry 機制:為什麼是 3 次?

3 次不是魔術數字,這個閾值的設定有兩個考量:

  • 少於 3 次:你抓不到偶發的網路錯誤或暫時性資源爭搶。例如 Redis 快取剛好被清的瞬間。
  • 多於 3 次:任務其實已經死透了,重試只是浪費資源、佔用 worker、延遲最終失敗的回報時間。

實務上,retry 數量也可以根據任務類型調整:重要的批次作業可以設定 5 次,I/O bound 的 API 呼叫可能設 5 次都不夠。3 次是通用預設值,不是規則。


四、Queue 與 Datastore:兩個系統還是一個系統?

高層架構長這樣:

Client → Submission → Datastore ← Dashboard
                  ↓
                Queue → Worker

提交服務把 job 寫進 datastore,再丟到 queue 讓 worker 拉走。

QPS 估算:先不要做 cache

內部系統 10k 用戶、每人每天 100 個 job:

  • 寫:10,000 × 100 ÷ 86,400 ≈ 12 QPS(峰值約 50)
  • 讀(dashboard 看結果):再 10 倍也只是 ~100 QPS,峰值 500 QPS

這個數量級下,加 cache 是不必要的複雜度。未來真的需要再加也不遲,但 MVP 不要一開始就把 cache 的失效策略寫進程式碼。

雙寫是個陷阱

資料庫存一次、訊息佇列再存一次,看似很正常,但你會立刻遇到兩個問題:

  1. 誰是 source of truth?這兩個系統如果不一致,誰說了算?
  2. 要解就要靠分散式鎖或分散式交易:但這代表寫入 latency 會上升、queue 掛掉的時候連 datastore 都不能寫——這跟你「用 queue 解耦」的初衷直接衝突。

兩個極端方案

s09g 在影片裡提到兩個有意思的極端:

  • Message Queue as Datastore:Kafka as Database。技術上可行,因為 Kafka 是 streaming system、延遲低、可以分區。但這叫「流表一體」,工程界接受度還不算高,後續維運成本可能是個坑。
  • Database as Queue:直接在 SQL 裡面實作 queue 的語義。Google Spanner 上有 Spanner Queue。一般團隊沒這麼強的 database,所以單機性能可能差,但 QPS 不高的場景下 sharding 就夠了。
SELECT * FROM job_table
WHERE status = 'WAITING' AND retry_count < 3
ORDER BY id LIMIT 100

就這麼簡單。基本上你是在 datastore 上「模擬」出一個 queue。

我的選擇:Database as Queue + 獨立 Informer

與其讓雙寫找麻煩,不如讓 datastore 成為唯一的 source of truth,然後加一個叫 Informer(或者 Publisher)的輕量級服務,它每秒跑一次上面那段 SQL、把 waiting 任務推送出去。

這個架構的好處:只有一個資料系統就沒有 consistency 問題,retry、failure、status 全部在同一張表裡追蹤。


五、Worker 與 Informer 三種溝通模式

把任務從 datastore 拉出來之後,下一個問題是:任務怎麼實際交到 Worker 手上? 影片裡列出三種模式,這是分散式系統裡經典的 pull / push 取捨。

Pull Model:Worker 自己來拉

Informer 把任務丟到 queue 裡,Worker 自己發 RPC 來 pull,執行完再寫回 datastore。

優點:Informer 不用管太多事情,fire-and-forget(丟了不負責)。

缺點:

  • 95% 的時間 worker 拉到「空」回應,造成空轉
  • Worker 發起 RPC 代表它有大權限,安全模型不乾淨
  • Worker 可能掛掉,狀態無法更新,必須靠 heartbeat 或 timeout

但 timeout 對 long-running service 不適用,因為你不知道它什麼時候該停。

Push Model:Informer 推任務過來

Informer 主動發 RPC 給 Worker,把任務送過去,並負責追蹤狀態。

優點:只在有任務時才啟動 Worker、不需要 Worker 發起 RPC

缺點:Informer 要長期追蹤每個 Worker、要建立長連線、要記住每個 task 對應到哪個 Worker

這在大規模下會變成 Informer 的負擔瓶頸。

Hybrid Model:Sidecar + Heartbeat(推薦)

這個我覺得是實務上最乾淨的設計。Informer 推送任務給 Worker,Worker 旁邊跑一個 sidecar(邊車),sidecar 負責每 60 秒發一次 heartbeat。

SELECT * FROM job_table
WHERE status = 'RUNNING'
  AND last_heartbeat < NOW() - 180

連續 180 秒沒收到 heartbeat(漏三次)就視為任務失敗,讓它回到 retry 邏輯。

為什麼這個模式最好?

  • 安全:Worker 沒有「主動拉」的權限
  • 簡單:Informer 不用維護 task-to-worker 的 mapping
  • 可靠:sidecar 把心跳跟任務本體解耦

壞處是 sidecar 增加了每個 Worker 的開銷,但這是值得的。


六、資源分配:Worker 忙不過來怎麼辦?

任務跑得多了,總會遇到資源不夠。

第一反應:加機器

但影片特別提到:「老闆要求你降本增效」——這句話太真實了。所以我們需要其他手段。

短期止痛:Exponential Backoff

如果資源緊張只是暫時的,可以拉長一點等待時間:

第 1 次失敗等 1 秒再重試
第 2 次等 2 秒
第 3 次等 4 秒

任務接收 latency 上限是 10 秒,所以你有 1 + 2 + 4 = 7 秒可以浪費。第四次再失敗就進 final_failed。前提是資源緊張是暫時的、不是系統性的。

系統性的資源壓力:Runtime 優化

如果整個系統都吃不下了,就要從 runtime 層下手。執行任務通常要 sandbox 隔離,有幾條輕量化路線:

方案 體積 啟動時間 隔離強度
完整 VM GB 級 分鐘級 最強
容器(Docker) MB 級 秒級 中等(共享 Kernel)
gVisor + 容器 MB 級 秒級 較強(gVisor 攔 syscall)
Firecracker microVM < 5 MB < 500 ms 接近 VM

Google 內部用的 no-vm 跟 AWS Firecracker 都是這種「裁切過的 VM」,啟動一瞬間、開完即丟,這就是 serverless 排程器的底層基礎設施

Workload Isolation vs Hybrid Deployment

資源調度還可以做得更細。任務可以分成 IO 密集型、CPU 密集型。兩種做法:

  • Workload Isolation:把同類型任務塞到同一台機器。IO 密集型打滿頻寬,CPU 密集型配高效能 CPU。簡單但浪費資源(IO 任務用不到 CPU)。
  • Hybrid Deployment:在同一台機器上混合部署 IO 任務和 CPU 任務。利用率最高,但需要更複雜的排程演算法(這就是 Kubernetes scheduler 的核心問題)。

Preemptive 任務:可壓縮 vs 不可壓縮資源

當資源真的吃緊時,有些任務可以讓位

  • 可壓縮資源(CPU):CPU 被壓縮只是執行慢一點,不會掛
  • 不可壓縮資源(記憶體):記憶體被壓縮可能直接 OOM 死掉

生產環境常駐服務屬於高優先,必須保住;但每天凌晨的資料庫備份、定時清理任務,晚個十幾分鐘跑根本沒差。

這就是 K8s PriorityClass + Preemption 機制背後的核心想法。


七、進階功能:Cron 與 DAG 怎麼掛上去?

基礎跑穩後,可以開始想進階功能。

封裝成 Library,而不是開新微服務

有一個重要的設計決策:不要為每個進階功能開新微服務。把 Client 端封裝成 Library,讓上層服務去用它。

這樣進階功能可以各自獨立實現,底下還是同一個任務調度平台。

Cron Service:Priority Queue

Cron 服務本質上是一個 Priority Queue,每個 cron job 帶著 next_run_time 排進去。服務每秒鐘看 Queue 頂端的任務,時間到了就拿出來、丟給底層 scheduler 的 Client。

        +----------------+
User →  | Cron Service   |
        |  PriorityQueue |
        +-------+--------+
                ↓ (到期時間)
        +-------+--------+
        |  Task Client   |
        |  (Library)     |
        +----------------+

執行完馬上把 next_run_time 重新排進 Queue,循環下去。

DAG Service:拓樸排序

DAG 任務更複雜,因為有依賴關係。第一個任務跑完才能跑第二個。常見做法:

  1. 把 DAG 整個丟給 DAG Service
  2. 在記憶體裡做拓樸排序(topological sort)
  3. 按順序把任務丟給底層的 Task Client
  4. 收到 success 回應才送下一個

進退兩難

  • 把 Priority Queue 和拓樸排序放到 Informer 裡 → 不用多開 Service,但 Informer 變太肥
  • 抽出來當獨立 Service → 多一個部署,但擴展性、可讀性都好

我個人傾向抽出來。理由是 Informer 已經負責「拉任務、推任務、追 heartbeat」,再加 cron queue、topological sort 它會變成 mega-component,不好維護。


八、最後的反思:為什麼這題值得做

寫完才發現,這個所謂的「任務調度系統」看起來小,其實把分散式系統最關鍵的取捨都包含進去了

  • 資料一致性:database + queue 雙寫
  • 通訊模式:pull vs push vs hybrid
  • 失敗處理:retry、heartbeat、timeout
  • 資源管理:背壓、優先級、輕量化 runtime

面試的時候把這些節點盤清楚,是夠格的系統設計答案。但在實務工作上,更重要的是知道什麼時候該停在簡單的設計,什麼時候才值得付複雜度的代價。

就像 s09g 在影片最後說的:這題沒有標準答案,只有針對你場景的最佳解。


參考資料

Other Related Posts:

Log System 系統設計:面試官逐點拆解的五個關鍵取捨

Log System 系統設計:面試官逐點拆解的五個關鍵取捨

從 sidecar 模式、Ingestion 架構到倒排索引,一次搞懂集中式日誌系統設計的面試重點


最近看了一支 s09g 的系統設計 Mock Interview,受面者設計「集中式日誌系統」,面試官在 45 分鐘內從 Clarify 一路 DeepDive 到查詢層,把每一層的設計決定都翻過一遍。這類「面試官逐點 feedback」的影片,對準備系統設計面試的人特別有價值——比起「如何設計一個 log system」的教學文,它揭露的是真正的 trade-off 與面試官在意什麼

這篇整理影片裡面試官挑出的五個關鍵設計決定,包含他自己的「我會怎麼問」與「為什麼這樣...

17th Aug 2026 - Mark