Job Scheduler
這是一個滿常見的系統設計題目,這個題目在 Distributed System 的設計上並不會需要有太複雜的 Components,真的比較困難的是有沒有經驗去設計出快速有效的方法去找出即將要執行的 jobs。
找出來之後還有三件事要處理:怎麼讓它準時跑、怎麼撐住流量、以及怎麼確保它真的跑過了。這幾件都放在後面的 Deep Dive。
Functional and Non-Functional Requirements
最主要的是這一條:User 可以選擇立即、排程以及定時的執行一個 Job。
立即沒有什麼太難的,當 Job 送出的時候就完成,但是何時被執行就比較困難了。
非功能的部分有四條,後面每一個 Deep Dive 都是在回應其中一條:
- 準時,job 要在排定時間的 2 秒之內被執行
- 規模,要撐得住每秒一萬個 job
- 至少執行一次,worker 掛掉不能讓 job 就這樣消失
- 不能漏,剛建立而且馬上就要跑的 job 也要抓得到
Data Model
一個 Job Scheduler 最主要有兩個 Entities:Task 是需要執行的內容,Job 則是一個 Task 被定義好要何時被執行,以及執行的狀態。
底下都用同一個情境舉例。user_4471 設定了一個每天早上 09:30 產生前一日對帳報表的定期 Job,另外還送出一個只跑一次的匯出 Job。
最直覺的存法
{
"job_id": "23685bd0-cd19-4129-8cfd-cb46417163c5",
"user_id": "user_4471",
"task_id": "daily_settlement_report",
"scheduled_at": 1773739800,
"parameters": {
"report_date": "2026-03-16",
"format": "pdf"
},
"status": "PENDING"
}
這樣存的問題是,「找出接下來要執行的 Job」會變成一個對 scheduled_at 的範圍查詢,還是得掃過整張表。而且定期執行的 Job 只有一個 scheduled_at 欄位也放不下,因為一個 CRON Job 會產生無限多次執行。所以要把 Job 的定義和某一次的執行拆成兩個表。
Jobs:Job 的定義
{
"job_id": "23685bd0-cd19-4129-8cfd-cb46417163c5", // Partition key,用 job_id 直接查
"user_id": "user_4471",
"task_id": "daily_settlement_report",
"schedule": {
"type": "CRON", // CRON 或 DATE
"expression": "30 9 * * *" // CRON 是週期,DATE 就放指定的時間
},
"parameters": {
"report_date": "2026-03-16",
"format": "pdf"
}
}
一次性的那個 Job 就是 type 為 DATE:
{
"job_id": "d4c156b5-09e4-4900-8096-e79122a95c3e",
"user_id": "user_4471",
"task_id": "export_transactions",
"schedule": {
"type": "DATE",
"expression": "2026-03-17T09:47:15Z"
},
"parameters": {
"range": "2026-Q1"
}
}
Schedules:時間桶
這張表才是回答「接下來要跑什麼」的那張:
{
"time_bucket": 1773738000, // Partition key,execution time 往下取整到整點(2026-03-17 09:00 UTC)
"execution_time": "1773739800#23685bd0-cd19-4129-8cfd-cb46417163c5", // Sort key,精確時間加上 job_id
"job_id": "23685bd0-cd19-4129-8cfd-cb46417163c5",
"user_id": "user_4471",
"status": "PENDING",
"attempt": 0
}
{
"time_bucket": 1773738000, // 同一個桶
"execution_time": "1773740835#d4c156b5-09e4-4900-8096-e79122a95c3e",
"job_id": "d4c156b5-09e4-4900-8096-e79122a95c3e",
"user_id": "user_4471",
"status": "PENDING",
"attempt": 0
}
兩筆都落在 1773738000 這個桶,也就是 09:00 到 10:00 這一格。驗算一下就是把精確時間往下取整,1773739800 // 3600 * 3600 和 1773740835 // 3600 * 3600 都會得到 1773738000。
Sort key 之所以要在精確時間後面接上 job_id,是因為同一個時間點可能有多個 Job 要跑,光用 execution time 沒辦法保證 composite primary key 是唯一的。分隔符我用 # 而不是 -,因為 UUID 本身就有 -,用 - 接會切不乾淨。
有了這張表,「接下來要跑什麼」就變成一次 partition query,拿現在的小時桶去查,再用 sort key 的範圍把時間切到自己要的精度,也就是前面第 2 步和第 3 步在講的事情。
API / Access Pattern
對外的介面很單純:POST /jobs 建立一個 job,body 裡帶 task、參數和排程(一次性的時間或 CRON 運算式);GET /jobs/{id} 查定義;GET /jobs/{id}/executions 查這個 job 每一次執行的結果;DELETE /jobs/{id} 停用。
比較值得講的是內部的存取模式,因為那才是決定資料怎麼擺的東西:
- 用
job_id拿一個 job 的定義 - 用
time_bucket加上時間範圍,拿接下來要執行的那一批 - worker 更新某一次執行的狀態
只有這三條,沒有 ad-hoc 查詢也沒有 join,這件事到最後選資料庫的時候會再回來。
Deep Dive:怎麼找出接下來要執行的 Job
因為我們在設計 Job Scheduler 時,其實可以預期會有非常多的 Job 被送出,我們不可能在一個短時間不斷地針對整個表去做掃描然後找出接下來要執行的 Job,所以要怎麼去設計這個針對時間範圍的搜尋就會變得很重要。
我第一次練習時也沒有自己想到,解法真的是滿精妙的:
- 如果我可以有一個欄位,直接是用 timestamp 作為 index,那我就可以不用整個表去掃描
- 但是我不可能每一秒甚至每一毫秒去 query,可是如果我的 timestamp 是存到小時級好了,那我甚至不用太精準,我就能用 timestamp 的小時級別,一次把該小時所有的資料都找到
- 如果我需要更小的時間,我還是可以用準確的 execution time 的 timestamp 去做快速的 filter
換句話說就是用小時當一個桶去縮小範圍,桶裡面再用精確時間過濾掉還沒到的。
Deep Dive:從 Jobs 怎麼變成 Schedules
兩張表拆開之後,下一個問題是 Schedules 的那些 row 到底是誰、在什麼時候寫進去的。
type 是 DATE 的最單純,建立 Job 的當下就寫一筆 Schedule row,execution_time 就是使用者指定的那個時間,寫完就沒事了,這個 Job 一輩子只會有一筆。
CRON 的麻煩在於它的執行次數是無限的,不可能在建立時就把所有未來都展開,所以有兩種做法。
第一種是展開一個時間窗,用一個背景程序定期把接下來 N 小時之內會發生的 CRON 執行全部算出來寫成 Schedule row。好處是查詢端完全不用知道 CRON 的存在,而且可以直接回答「接下來會跑什麼」這種畫面;壞處是要多維護一個展開程序,而且使用者改了 CRON 或停用 Job 的時候,已經展開的那些未來 row 要記得清掉。
第二種是執行的時候順便排下一次,Schedule 表裡每個 CRON Job 永遠只有一筆「下一次」,當它被取出來執行時就用 CRON expression 算出再下一次的時間,寫進一筆新的 row。這樣儲存最省,改 CRON 也立刻生效,沒有要清掉的殘留。代價是看不到未來的排程,而且這是一條鏈,只要有一次沒有成功寫入下一筆,這個 Job 就永遠停在那裡了。
下一次要在哪一刻寫進去
如果選第二種做法,這是最容易寫錯的地方:不能等到這次執行成功才排下一次。
執行本身可能失敗、可能重試很久、可能整個 worker 就掛掉了。如果下一次的排程綁在「執行成功」上,那一次失敗就會讓整條鏈斷掉。比較正確的做法是在取出的那一刻就把下一次寫進去,把「這一筆標記為處理中」和「寫入下一筆」放在同一個原子操作裡,這樣就算這次執行最後失敗,重試歸重試,下一次的排程已經在表上了。
不管選哪一種做法,都還是要有一個對帳用的 sweeper,定期掃出那些應該要有下一次卻沒有的 CRON Job 再補回去。分散式系統裡沒有不會斷的鏈,差別只在斷掉之後有沒有人發現。
重複寫入
不管是展開程序跑了兩次,還是 dispatch 重試,同一個 job 的同一次執行都有可能被寫入兩次。
這裡剛好可以吃到前面 sort key 的設計。execution_time 已經把精確時間和 job_id 接在一起了,所以同一次執行的 key 是完全固定的,用 conditional write(key 不存在才寫)就能擋掉重複,不需要另外做一張去重表。
時區
CRON 的「每天早上 09:30」是使用者所在時區的 09:30,不是 UTC 的。算下一次執行時間必須帶著時區算,而且要處理日光節約時間,因為換日光節約的那一天,本地時間的 09:30 對應到的 UTC 時刻會整個平移一小時。所以 Jobs 表裡除了 CRON expression 之外,還要存使用者的時區。
Deep Dive:怎麼讓 Job 在排定時間的 2 秒內執行
前面的設計有一個很明顯的上限:我們是每隔幾分鐘去資料庫撈一次接下來要跑的 job,那個 cron 的頻率就是精準度的天花板。cron 每兩分鐘跑一次,job 就可能早兩分鐘或晚兩分鐘執行。
為什麼不能只是把 cron 跑更密
直覺的反應是把 cron 跑快一點,但要達到 2 秒的精準度,就得每 2 秒查一次,這條路走不通。
以每秒一萬個 job 來算,每次查詢要撈出接下來 2 秒內到期的,也就是兩萬筆。就算索引做得好,撈兩萬筆也要好幾百毫秒,再加上網路延遲和序列化,光是把資料拿到手可能就花掉 500 毫秒。拿到之後還要初始化、分派給 worker、開始執行,這些又吃掉剩下的預算。而且每 2 秒對資料庫打一次這種規模的查詢,會壓到資料庫本身,連帶影響其他操作。
所以問題不在查得夠不夠快,而在查詢這件事本身不該站在執行路徑上。
兩層:資料庫負責可靠,佇列負責即時
拆成兩段就好解了。
第一層還是查資料庫,但改成每 5 分鐘查一次,撈出接下來 5 分鐘內要執行的 job(留一點緩衝給網路延遲)。第二層把撈出來的 job 依照執行時間丟進訊息佇列,worker 從佇列裡拿了就執行。
這樣資料庫的負擔從每 2 秒一次大查詢降到每 5 分鐘一次,而精準度改由佇列決定。佇列的吞吐量很高,worker 可以在訊息一出現就拿走,原本那個「cron 頻率等於精準度上限」的限制就消失了。
但 5 分鐘內才建立的 job 怎麼辦
如果使用者現在建立一個 30 秒後要跑的 job,它會被寫進資料庫,可是 cron 剛剛才跑過,要等 5 分鐘後才會看到它,那就整個錯過了。
直覺是直接把它丟進佇列。但如果用的是 Kafka 這種以 log 為基礎的佇列,同一個 partition 裡是照順序消費的,這個新 job 會排到隊尾,卡在前面那一大堆還沒到時間的 job 後面,反而更慢。
所以我們需要的不是普通佇列,是支援延遲投遞的佇列,讓訊息在接近執行時間之前對 worker 不可見。
選項一:Redis Sorted Set
用執行時間當 score 做成優先佇列,worker 去撈 score 小於現在時間的項目。延遲是次毫秒級,操作也是原子的。問題是重試邏輯、失敗處理、複製與故障轉移全部要自己寫,中等規模可以跑,但要在正式環境穩定運行需要不少維運經驗和自訂程式碼。
選項二:RabbitMQ 的 TTL 加死信交換機
訊息發到一個佇列並設定 per-message TTL,過期之後由 dead-letter exchange 轉到真正的處理佇列,用這個組合模擬出延遲投遞。它的持久化和 publisher confirm 都是現成的。問題是高可用要靠 quorum queue,只做叢集是不夠的,叢集只複製拓撲不複製佇列內容,節點掛掉那個佇列的訊息就拿不到了。而且用 TTL 加 DLX 去模擬延遲,本來就比原生支援複雜。
選項三:SQS 的 DelaySeconds
送訊息的時候直接指定延遲幾秒,SQS 會讓訊息在那之前保持不可見,時間到才投遞。worker 掛掉有 visibility timeout 接住,失敗的訊息有死信佇列可以撈,跨可用區的高可用和自動擴展都是託管的。限制是 DelaySeconds 最多 15 分鐘,不過我們的窗口只有 5 分鐘,完全放得下。
選哪一個
這題我會選 SQS,因為延遲投遞是原生支援、worker 失敗有現成的機制、擴展性也夠。要注意 DelaySeconds 是「最少延遲這麼久」而不是精準保證,但因為 worker 是持續在輪詢的,多出來的那點延遲通常可以忽略。
不過很多面試官或公司不希望你用託管服務,如果是那種情況就自己用 Redis 做優先佇列。這件事值得先問一句。
整理成流程
- 使用者建立 job,寫進資料庫
- cron 每 5 分鐘查一次,撈出接下來 5 分鐘要執行的
- 把這些 job 送進 SQS,帶上對應的延遲秒數
- worker 持續輪詢 SQS,訊息一變成可見就處理
- 如果新建立的 job 在 5 分鐘之內就要執行,建立當下就直接送進 SQS
Deep Dive:怎麼撐到每秒一萬個 job
談擴展的時候,我的建議是從左到右一站一站找瓶頸,找到一個處理一個。
Job 建立
如果建立和執行的速率相同,那就是每秒一萬個 job 被建立。這不太可能,因為我們支援定期執行,一個 CRON job 建立一次卻會執行無數次,所以一萬只是上限。這裡值得問面試官一句:定期和一次性的比例大概多少?如果絕大多數是一次性的,建立這一端才有可能變成瓶頸。
有一種常見的作法是在 API 和建立服務之間放一個 Kafka 或 RabbitMQ 當緩衝,流量尖峰時先堆在佇列裡,讓建立服務用自己的節奏消化,順便靠增加 consumer 來水平擴展。
但這在這題其實是過度設計。 資料庫本身就吃得下這個寫入量,而且 API 前面本來就會有一層可以水平擴展的服務。除非建立服務裡有很貴的商業邏輯需要隔離,不然多一個元件只是多一個要維護的東西。面試時先把資料庫和服務層擴好,再考慮加東西。
Jobs 和 Executions 兩張表
前面選了 DynamoDB,這對擴展是有利的,因為它一個 partition 可以撐到每秒 1000 個寫入單位,只要 key 設計得好,資料自然會分散到很多 partition。
Jobs 表用 job_id 當 partition key,每個 job 的 id 都不一樣,寫入自然均勻。
Executions 表用 time_bucket 當 partition key,這裡就要小心了,因為當前這一小時的所有寫入都會落在同一個 partition 上。這是 DynamoDB 的經典反模式,面試官幾乎一定會追問。
解法是把桶再切開,在 partition key 後面加一個 shard 編號,變成 1773738000#0 到 1773738000#15 這樣,寫入時隨機挑一個 shard,查詢時對 16 個 shard 各查一次再合併。代價是查詢從一次變成 N 次,N 要多大取決於單一小時的 Job 量。同一件事其實也決定了桶要多大,桶越小單一 partition 越不熱,但要查的桶數就越多。
另外,job 執行完之後還是要留著給使用者查,但放個一年之後就可以搬到 S3 這種便宜的儲存。
佇列容量
算一下。每 5 分鐘的窗口要處理三百萬個 job(每秒一萬乘以 300 秒)。SQS 的訊息上限是 1 MB,但我們的訊息很小,大概 200 bytes,裡面只有 job id、執行時間和一點 metadata,所以一個窗口大約是 600 MB。
SQS 的 Standard queue 吞吐量基本上沒有上限,每秒一萬則訊息完全在能力範圍內,也不用自己分片或分區。我們還是可能會開多個佇列,但那是為了功能上的區隔(不同優先級或不同類型的 job),不是為了擴展。
如果選的是 Redis 優先佇列,三百萬個 job 也塞得進記憶體,容量不是問題,要擔心的是 Redis 掛掉時的容錯。
Worker 要用容器還是 Lambda
容器(ECS 或 Kubernetes)對穩定的工作負載比較省錢,也比較適合跑比較久的 job,因為它在多次執行之間可以保留狀態。代價是維運成本比較高,彈性擴展也沒有 serverless 那麼即時。
Lambda 幾乎沒有維運負擔,適合 15 分鐘以內的短任務,而且可以瞬間擴展。缺點是冷啟動會直接吃掉我們 2 秒的精準度預算,而且對我們這種穩定的高流量來說反而比較貴。
我們的情境是每秒一萬個、要 2 秒精準度、而且流量穩定可預期,所以我會選容器加上自動擴展群組,不過這題沒有標準答案。可以再用 spot instance 壓成本、依照佇列深度來設定擴展條件、預熱一批容器撐住基準流量。
Deep Dive:怎麼保證至少執行一次
重點在失敗怎麼處理。worker 因為任何原因沒跑完,我們希望這個 job 被重試合理的次數(假設三次)之後才放棄。
失敗分成兩種,處理方式完全不同:看得見的失敗是任務程式碼有 bug 或參數錯誤,它自己會拋錯;看不見的失敗是 worker 整台掛掉,沒有人會告訴你。
看得見的失敗
把任務程式碼包在 try/catch 裡,出錯就記錄下來,在 Executions 表把狀態更新成 RETRYING 並記下目前試了幾次。
然後把 job 重新丟回 SQS,每次重試的延遲拉長,例如第一次等 5 秒、第二次 25 秒、第三次 125 秒,也就是用 attempt 次數去算延遲再設進 DelaySeconds。試滿三次還是失敗就標成 FAILED,不再重試。
SQS 把需要的零件都給好了:visibility timeout 讓失敗的訊息會重新出現、ApproximateReceiveCount 可以拿到重試次數、死信佇列會接住超過上限的訊息。我們只要在 worker 裡實作退避的時間計算。
看不見的失敗
worker 掛掉或沒有回應的時候,要怎麼發現並重試?這裡有三條路。
方法一:健康檢查
每個 worker 開一個 GET /health,中央的監控服務每幾秒輪詢一次,連續幾次沒回應就標記成失效,把它手上的 job 轉給健康的 worker。
問題是這在幾千個 worker 的規模下根本不能用,監控服務要不停地輪詢每一台。監控服務和 worker 之間的網路問題會造成誤判,明明還活著卻被判死,job 就被重複執行了。而且你要額外建一套監控基礎設施,還要處理它和排程器之間的競態條件。最後還有一個很尷尬的問題:監控服務自己掛了怎麼辦。
方法二:Job 租約
用資料庫做分散式鎖。worker 要處理一個 job 之前先去搶租約,把自己的 worker id 和一個到期時間寫進那筆記錄,例如「Job 123 租給 Worker A 到 10:30:15」。處理期間要定期延長到期時間,還在跑就把租約續到 10:30:30。worker 掛了就續不了約,到期之後別人就可以接手。
問題是這個模式的細節很難處理好。續約的寫入量很可觀,假設每個 job 跑 25 秒,那同時在跑的就有 25 萬個,租約每 5 秒續一次的話就是每秒 5 萬次寫入。時鐘同步也變得重要,如果 Worker A 的時鐘說 10:30:00 而 Worker B 的說 10:30:20,B 就會在 A 還在跑的時候把 job 搶走。網路分區更麻煩:A 連不到資料庫所以續不了約,但它其實還在跑,另一個 worker 接手就變成同一個 job 跑了兩次。
方法三:SQS 的 visibility timeout
worker 收到訊息時 SQS 自動讓它對其他 worker 隱形一段時間,處理完就刪除訊息;如果 worker 掛了或在時限內沒處理完,訊息自動變回可見讓別人接手。
為了讓失敗恢復得快,可以把 visibility timeout 設短一點(例如 30 秒),然後讓 worker 定期呼叫 ChangeMessageVisibility 續一次,跑五分鐘的 job 就每 15 秒續一次。這樣 worker 一掛掉,30 秒內就有人接手,而不用等一個很長的逾時。
這條路不需要任何額外的基礎設施,也不需要複雜的協調,失敗的訊息超過重試上限會自動進死信佇列。它其實就是租約,只是租約的維護交給了佇列服務,我們只要負責心跳。
Deep Dive:冪等,至少一次的代價
保證至少執行一次,就等於接受可能執行不只一次,所以任務程式碼必須是冪等的。有三種處理方式。
做法一:收到就執行
最簡單,但只要重試發生就會出事:轉帳的 job 可能轉兩次、計數器的 job 可能加太多次、寄信的 job 會寄出重複的信。這對大部分真實應用都不能接受。
做法二:去重表
執行之前先查一張去重表,看這一次執行是不是已經處理過了,key 用 job id 加上執行時間。缺點是每次執行都多一次資料庫操作,還要多維護一張表並定期清理,而且檢查和寫入之間有一個很小的競態窗口。
做法三:讓任務天生就冪等
把操作設計成重複執行也不會變的形式:不要寫「計數器加一」而是寫「把計數器設成 X」。
拿寄歡迎信當例子。最直覺的寫法是先讀再判斷再寫:
SELECT welcome_email_sent_at FROM users WHERE id = ?;
-- 應用程式判斷是不是 NULL,是的話就寄信
UPDATE users SET welcome_email_sent_at = now() WHERE id = ?;
這樣還是會重複寄。因為讀和寫之間有一段空檔,兩個 worker 可以同時讀到 NULL,然後兩個都認為該寄。
把判斷條件搬進 UPDATE 裡就解決了:
UPDATE users
SET welcome_email_sent_at = now()
WHERE id = ? AND welcome_email_sent_at IS NULL;
這一行是原子的,兩個 worker 同時跑也只有一個會影響到一列,另一個會影響 0 列。所以規則變成:影響 1 列才去寄信,影響 0 列就代表別人已經寄過了,直接當作成功結束。
影響 0 列不是錯誤,這點在寫 worker 的時候很容易搞錯。它的意思是「這件事已經完成了」,正是我們要的結果。
如果任務做的是呼叫外部服務而不是寫自己的資料庫,那就把唯一識別碼傳下去,讓對方去重。付款類的 API 大多支援這件事,例如 Stripe 的 Idempotency-Key header,同一個 key 重送只會扣一次款。識別碼用 job id 加上這一次的執行時間就行,重試的時候要沿用同一個而不是重新產生,不然去重就失效了。
這是最穩的作法,本質上是把冪等的責任推給任務的實作者或下游服務,而不是在排程器裡多養一張去重表。
Deep Dive:為什麼選 DynamoDB 而不是關聯式資料庫
整個系統的查詢其實只有兩種,一種是用 job_id 拿 Job 的定義,另一種是用 time_bucket 加上時間範圍拿接下來要跑的。沒有 ad-hoc 查詢、沒有跨表 join、沒有報表式的聚合。這正好是 DynamoDB 的前提,它要求你先知道存取模式再設計 key,而這題的存取模式從一開始就是固定的兩條。反過來說,關聯式資料庫最值錢的東西是任意條件的查詢、join 和彈性的 index,這題幾乎都用不到,等於付了成本卻沒有拿到好處。
另一個理由是寫入量。每個 Job 的每一次執行都是一筆寫入,狀態變化又是幾次更新,Job 數量一大這張表就是一個寫入密集的表。DynamoDB 的寫入量是靠 partition 水平攤開的,加機器就加吞吐;關聯式資料庫的寫入通常都要經過單一個 primary,要再往上就得自己分片,那等於在應用層重做一次 DynamoDB 已經做好的事。
熱門 partition 是這個選擇最大的代價,處理方式寫在上面的擴展那一節。
什麼時候關聯式反而比較好
這題並不是 NoSQL 一定贏。如果量級沒有那麼大,一張表加上 execution_time 的索引,配合 SELECT ... WHERE execution_time <= now AND status = 'PENDING' ORDER BY execution_time LIMIT n FOR UPDATE SKIP LOCKED,是非常成熟而且簡單很多的做法,SKIP LOCKED 讓多個 worker 可以安全地搶工作,不需要自己處理租約,而且交易和唯一性約束都是現成的。
它會撞牆的地方是所有新資料都插在時間索引的同一端造成尾端熱點、寫入受限於單一 primary、以及表變得很大之後刪除和清理的維護成本。
所以比較誠實的答案是,選 DynamoDB 是因為存取模式單一、寫入量需要水平擴展、而且不需要 join 和複雜交易。講得出「什麼規模以下我會直接用關聯式資料庫」,比背「NoSQL 比較可擴展」有價值得多。