建一個 Job Scheduler 之前要釐清的 11 個取捨點
任務調度系統(Task Scheduler / Job Scheduler)這種東西,看起來每家公司都有,用起來都差不多,但只要你認真去看一次原始碼,就會發現每個團隊的取捨都不一樣。
這篇文章不是要告訴你「任務調度是什麼」。網路上的科普文已經太多了。我要講的是:當我們真的坐下來要畫一張架構圖,每一個節點為什麼要這樣選。參考的影片是 s09g 的《system design — 任務調度系統》(約 29 分鐘),我把裡面 12 個章節的取捨整理成一條線,並加上我自己做後端這些年的看法。
我們的場景先講清楚:假設要替一個內部系統(10k 工程師規模)做一個任務調度平台。
Table of Contents
一、第一步永遠不是畫圖:是先問三個問題
系統設計面試有一個很常見的盲點:很多人一坐下來就開始畫 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:先把紅線劃出來
確認問題之後,可以來看「系統要做什麼」。
功能面:兩條就夠
- 使用者能提交任務(tasks 可以是短任務或 long-running service)
- 使用者能在 dashboard 看任務結果
就這樣。其他都是進階功能。
進階功能先列出來放旁邊,不要做
- 未來排程:可以排程未來的時間執行
- 定時任務(Cron Service):每 30 分鐘收信、每天 12 點備份資料庫
- DAG Job Dependency:第一個任務跑完才能跑二、三號,這是有向無環圖
這三個列著,等你基礎功能確認能跑穩了再回頭看。系統設計最常見的錯誤就是把時間花在進階功能上,結果基礎的是個空殼。
非功能面:兩個 latency、兩個性質
- 任務提交到執行的 latency:10 秒內。這代表使用者按下去,要不了十秒就能看到任務動起來。
- dashboard 同步:60 秒內。我不在乎你 1 秒內更新,使用者也不在乎。
- scalability:未來要能擴展
- reliability:服務掛了重新拉起時,任務不能掉
10 秒 vs 60 秒,這兩個數字看似不起眼,後面會在 QPS 估算時發揮關鍵作用。
三、資料模型與狀態機:Job 怎麼寫、狀態怎麼流
Job Data Model:兩塊拼起來
一個可執行的任務,本質上是兩部分:
- 可執行的二進位檔:放在獨立的 repo(可能是 AWS S3 之類的 bucket),裡面是 code 或 config,反正就是要能被執行
- 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 的失效策略寫進程式碼。
雙寫是個陷阱
資料庫存一次、訊息佇列再存一次,看似很正常,但你會立刻遇到兩個問題:
- 誰是 source of truth?這兩個系統如果不一致,誰說了算?
- 要解就要靠分散式鎖或分散式交易:但這代表寫入 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 任務更複雜,因為有依賴關係。第一個任務跑完才能跑第二個。常見做法:
- 把 DAG 整個丟給 DAG Service
- 在記憶體裡做拓樸排序(topological sort)
- 按順序把任務丟給底層的 Task Client
- 收到 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 在影片最後說的:這題沒有標準答案,只有針對你場景的最佳解。
參考資料
- 影片來源:s09g — system design 任務調度系統
- 延伸閱讀:K8s Scheduler 的 priority & preemption、AWS Firecracker 架構、Spanner Queue 設計