題幹與適用情境
請設計一個多租戶分散式任務排程器。它支援單次任務和帶 IANA 時區的 Cron 任務,保存 2,000 萬筆有效排程,每天產生 2 億次執行實例,整點最多有 50 萬次觸發。嚴格任務在正常尖峰下,從排定時間到任務進入持久佇列的 p99 不超過 5 秒;不要求精確時刻的任務可在 5 分鐘彈性視窗內分散。
系統提供建立、暫停、恢復、更新和取消排程的 API,投遞語意為至少一次。排程器只負責在正確時間產生執行實例並交給目標佇列,不負責容器資源配置、DAG 相依或任意工作流程編排。任務真正完成所需時間不計入 5 秒排程 SLO。
這道題適合中高階後端、平台、基礎設施與系統設計職位。2026 年公開的系統設計面試資料仍把 Job Scheduler 當成獨立題目;AWS 與 Kubernetes 的現行文件也直接揭露彈性時間視窗、重複或漏建、錯過觸發、時區及重試等真實邊界。核心考點不是撰寫 Cron 表達式解析器,而是把「某個排定時間」可靠地實體化為一次可追蹤、可重試、可冪等的執行。
面試官考察重點
第一個訊號是先區分 Schedule、Occurrence 與 Attempt。排程描述未來何時執行;執行實例代表某個確定的排定時間;嘗試代表執行實例的一次投遞或處理。只用一張帶 nextrunat 和 status 的資料表,會把重試、歷史紀錄、更新與下一次觸發混在一起。
第二個訊號是沒有輕率承諾 exactly-once。排程節點可能在訊息已入列、狀態尚未寫入時崩潰,工作節點也可能完成副作用後遺失確認。Kubernetes 明確提示 CronJob 在某些情況下可能建立兩個 Job 或一個都沒建立,任務應具備冪等性;標準佇列的可見性租約也無法絕對阻止重複投遞。強回答會選擇至少一次投遞,並用穩定執行實例 ID 讓業務效果具備冪等性。
第三個訊號是能處理整點相關尖峰。每日平均吞吐量只有約 2,315 次/秒,但 50 萬個嚴格觸發若要在 5 秒內入列,排程資料面要承受約 10 萬次/秒。依平均值擴充會在每小時零分失效;把所有任務放進單一按時間排序的全域 heap,也會形成單點與熱門分區。
最後看時間與故障語意是否寫成產品契約:Cron 是當地牆上時間還是固定間隔、日光節約時間中不存在或重複的時刻如何處理、排程器停機十分鐘後要補跑還是跳過、上一輪沒結束時是否允許重疊、更新與取消已入列實例有什麼邊界。
回答前需要釐清的問題
- 5 秒是指什麼? 本題定義為
enqueuedat - scheduledfor,只測排程與入列,不包含下游排隊和執行時間;若要求任務完成,容量模型會完全改變。 - 單次、Cron 與固定頻率是否都要支援? 本題支援單次和 Cron。固定每 24 小時與每天當地時間 09:00 的日光節約時間語意不同,不能用同一種加法取代。
- 允許重複還是允許漏跑? 本題選擇至少一次投遞,重複由冪等處理;對不可重複的外部副作用,目標必須支援冪等鍵或接受殘餘風險。
- 停機期間錯過的實例怎麼辦? 每個排程需要
misfire_policy、最大延遲時間和補跑上限;否則復原時可能突然產生數百萬個過期實例。 - 同一排程可以重疊執行嗎? 預設
ALLOW;另支援SKIPIFRUNNING。強制取代正在執行的任務會遇到無法撤銷的副作用,不作為通用預設。 - 更新和取消保證到哪裡? 尚未入列的舊版本實例應失效;已被工作節點領取或完成的實例無法靠刪除排程追回,API 必須回傳實際狀態。
- 目標是什麼? 本題把執行實例寫入持久佇列,由註冊處理器消費;不接受任意使用者腳本,避免把運算沙箱與資源排程混入題目。
- 多租戶需要怎樣隔離? 需要排程數量、觸發速率、執行並行數與補跑速率四類配額,不能讓一個整點批次任務占滿所有分片和佇列。
30 秒回答框架
「我會把排程、執行實例與執行嘗試分開。排程表保存表達式、IANA 時區、版本和下一觸發時間;排程節點依時間桶和排程 ID 雜湊分片,只實體化有限時間視窗內的實例。執行實例 ID 由排程 ID、版本及原定時間決定,唯一約束避免故障切換時重複產生。實例、下一觸發時間與 outbox 在同一交易提交,再由 relay 至少一次寫入持久佇列。工作節點用可續租的可見性逾時處理,業務端依實例 ID 冪等。嚴格任務依尖峰擴充,可彈性任務用確定性抖動分散;停機補跑、重疊、時區與取消都用明確策略定義。最後透過整點 50 萬觸發、各崩潰點、日光節約時間與取消競態,驗證排程延遲、漏跑及重複副作用。」
分步深入解析
先計算資料面,而不是先畫元件:
average = 200,000,000 / 86,400 ≈ 2,315 occurrences/s
strict_peak = 500,000 / 5 = 100,000 dispatches/s每日平均值與嚴格尖峰相差 43 倍以上。假設一筆有效排程和一筆執行實例的邏輯紀錄各約 1 KB,排程中繼資料約 20 GB,執行實例每天約新增 200 GB;索引、副本、佇列訊息和歷史保留會繼續放大。數值用於決定分區與冷熱分層,真實容量必須用實際欄位與索引壓測。
控制面負責驗證表達式、鑑權、配額、冪等建立與版本更新。一個簡化模型如下:
Schedule(schedule_id, tenant_id, expression, time_zone, version,
next_run_at, state, misfire_policy, max_lateness,
overlap_policy, flexible_window)
Occurrence(occurrence_id, schedule_id, schedule_version,
scheduled_for, available_at, state, attempt_count)
Attempt(attempt_id, occurrence_id, lease_token, started_at,
finished_at, result)
UNIQUE(schedule_id, schedule_version, scheduled_for)建立 API 帶呼叫方冪等鍵。服務驗證 Cron 與時區,並預覽未來若干次觸發,避免使用者保存語法合法但意義錯誤的表達式。nextrunat 統一存 UTC,原始表達式和 IANA 時區也要保留;每天當地時間執行的任務應從時區規則計算下一次,不能在上次 UTC 時間固定加 24 小時。
時區邊界必須形成穩定契約。本題規定:春季跳時導致不存在的當地時間就跳過,秋季回撥產生兩次相同牆上時間時只執行一次;更新時區只影響新版本的未來實例。AWS Scheduler 公開了相同的跳過與單次處理範例,但這是本文選定的產品語意,不應假裝所有排程器都相同。固定頻率任務若日後加入,則依經過時長計算,不隨日光節約時間改變。
排程儲存建立 (timebucket, shard, nextrunat) 或同等索引,shard = hash(scheduleid) mod N。一組排程節點以短租約擁有多個邏輯分片,掃描有限規劃視窗,例如未來幾分鐘。全域只選一個 leader 會限制擴充與故障復原;分片租約減少重複掃描,資料庫唯一約束和條件更新承擔最終正確性。
對每筆到期排程,排程節點在同一交易內完成三件事:插入確定性的 Occurrence、依目前排程版本計算並條件更新 nextrunat、寫入帶 availableat 的 dispatch outbox。若兩個節點因租約切換同時處理,唯一約束讓同一 (scheduleid, version, scheduled_for) 只留下一個實例;條件更新防止舊節點把下一觸發時間倒退。
不能一次預先產生無限 Cron 歷史。規劃視窗太短會把儲存抖動直接傳給 5 秒 SLO,太長則讓更新和取消面對大量舊版本實例。視窗長度應覆蓋排程故障切換與佇列寫入預算,再由指標調整。單次排程完成後進入終態;週期排程只保留下一觸發游標,並依保留策略封存歷史實例。
接近 availableat 時,dispatch relay 把 outbox 事件寫入依租戶與優先級分區的持久佇列。寫入佇列成功後若 relay 在確認資料庫前崩潰,它會再次傳送同一 occurrenceid;這是刻意接受的重複。直接「先改成已傳送,再發訊息」會在第二步失敗時漏跑,先發訊息再改狀態則會重複,transactional outbox 把選擇明確定為可冪等重試的至少一次。
整點 50 萬個任務不能只靠增加輪詢執行緒。時間桶還要依排程 ID 雜湊拆成足夠多的邏輯分片。若壓測得到一個分片在包含交易、索引和佇列寫入時的安全吞吐為 Q,嚴格資料面至少需要 ceil(100,000 / Q) 個同時可用分片,再為節點故障保留餘裕。佇列端依租戶做加權公平排程與速率配額,避免一個租戶占滿嚴格通道。
允許 5 分鐘視窗的任務使用確定性抖動:availableat = scheduledfor + hash(occurrence_id) mod 300s。同一實例在重試和故障切換後得到相同時間,便於重現,也能把相關觸發攤開。嚴格任務不移動原定時間,獨立保留容量。Amazon Builders' Library 也把對週期性背景任務加入抖動列為減少相關故障的方法。
工作節點從佇列領取執行實例後取得有期限的可見性租約,長任務透過 heartbeat 續租。租約過期會讓訊息重新可見,即使有效期內也不能絕對排除重複,因此狀態機不能把「租約存在」當成 exactly-once:
SCHEDULED -> ENQUEUED -> RUNNING -> SUCCEEDED
| |
| +-> RETRY_WAIT -> ENQUEUED
+------> DEAD
Side states: MISSED, CANCELED, SKIPPED_OVERLAP每次領取產生新的 leasetoken 或遞增 attempt 代次。完成更新必須符合目前 token,防止逾時的舊工作節點在新嘗試完成後把狀態改回去。這種 fencing 只保護排程狀態,不能撤銷舊節點已呼叫的外部系統。真正的業務冪等要由目標使用 occurrenceid:在同一資料庫交易以唯一約束記錄效果,或把它當作外部 API 的冪等鍵。目標不支援冪等且副作用無法查詢時,只能承認重複風險。
重試依錯誤類型處理。明確的參數錯誤進入 DEAD;網路錯誤、限流和暫時 5xx 使用指數退避與全抖動,並受最大嘗試次數、實例截止時間及租戶預算約束。佇列 DLQ 保存耗盡重試的實例。重放沿用同一 occurrence_id,不能產生新 ID 繞過去重。
控制面故障復原需要明確的 misfire 策略:SKIP 把超過最大延遲時間的實例標成 MISSED;FIREONCE 只補最近一次;CATCHUP 最多補 K 次,並經過租戶補跑速率限制。Kubernetes 的 startingDeadlineSeconds 與錯過排程限制說明了同一問題:復原策略若沒有邊界,要麼漏掉業務需要的任務,要麼形成復原尖峰。
SKIPIFRUNNING 透過查詢同一排程是否有目前 RUNNING 實例,決定是否標記 SKIPPED_OVERLAP。這項檢查與狀態轉換要原子化,否則兩個實例會同時認為沒有前一個任務。取消或更新會遞增排程版本,並使尚未領取的舊版本實例失效;工作節點在副作用前複查版本與取消標記。已開始的任務只能協作取消,已完成的外部副作用不能被排程器回復。
可觀測性圍繞可證明的邊界:schedulelag = enqueuedat - scheduled_for 的 p50/p95/p99、各時間桶待處理數、實體化延遲、唯一衝突、outbox 積壓、佇列年齡、重複領取率、重試與 DLQ、MISSED 與重疊跳過數、租戶限流量、時鐘偏差,以及執行實例各狀態停留時間。API 可用率不能取代「該執行的實例是否按時入列」。
驗收先在整點同時注入 50 萬個嚴格實例,確認 p99 入列不超過 5 秒且租戶公平;再讓排程節點分別在插入實例前、交易提交後和佇列傳送後崩潰,斷言沒有靜默漏跑且同一實例 ID 可去重。還要覆蓋工作節點完成後遺失確認、租約過期、十分鐘控制面停機後的三種 misfire 策略、春秋日光節約時間、排程更新與取消競態、長任務重疊及單一租戶補跑尖峰。
高品質示範回答
「我先把 SLO 定義為原定時間到持久佇列接收的延遲,不包含任務執行。每天 2 億次觸發平均約 2,315 次/秒,但整點 50 萬次要在 5 秒內入列,所以嚴格通道要依 10 萬次/秒設計,可彈性任務則在 5 分鐘內用確定性抖動攤開。
資料模型分成排程、執行實例和嘗試。排程保存 Cron、IANA 時區、版本和 nextrunat;實例 ID 由排程 ID、版本與 scheduled_for 決定,並有唯一約束。排程節點依時間桶和排程 ID 雜湊分片,短租約負責掃描。每次在一個交易裡插入實例、推進下一觸發時間並寫 dispatch outbox,所以節點切換不會靜默漏掉實例,重複 relay 也只會傳送相同實例 ID。
佇列和工作節點採用至少一次語意。工作節點使用可續租的可見性逾時,完成狀態必須符合目前 lease token;業務處理器則以實例 ID 當資料庫唯一鍵或下游冪等鍵。這樣租約逾時最多增加嘗試,不會重複業務效果;沒有冪等能力的外部目標必須明確說明殘餘風險。
我會把錯過觸發、重疊、時區和取消定義成 API 策略。停機後可選擇跳過、只補一次或最多補 K 次;Cron 的不存在時間跳過、重複牆上時間只執行一次;更新遞增版本,工作節點在副作用前複查。最後用整點尖峰、每個崩潰視窗、重複訊息、日光節約時間與取消競態驗收,並監控 schedule lag、due backlog、重複領取、missed、DLQ 及租戶限流。」
常見錯誤
- 把排程、執行與嘗試放在一列 → 重試會覆蓋歷史,更新也無法區分舊實例 → 拆成
Schedule、Occurrence與Attempt。 - 依每天平均 2,315 次/秒擴充 → 整點 50 萬觸發會讓排程延遲爆炸 → 依嚴格尖峰壓測分片,並把可彈性任務確定性分散。
- 一個全域 leader 加記憶體最小 heap → leader 成為容量與復原單點,重啟還要重建所有未來任務 → 使用持久時間索引、邏輯分片與有限規劃視窗。
- 先標記已傳送再寫入佇列 → 佇列寫入失敗會造成靜默漏跑 → 在交易中寫 outbox,由 relay 至少一次傳送。
- 宣稱鎖和可見性逾時保證 exactly-once → 逾時與確認遺失仍會產生重疊嘗試 → 穩定實例 ID、fencing token 與業務冪等共同處理。
- 復原後補跑所有歷史實例 → 數百萬個過期任務會形成第二次故障 → 設定最大延遲時間、補跑上限與租戶速率。
- 用 UTC 加 24 小時實作當地每日 Cron → 日光節約時間後牆上時間會漂移 → 保留 IANA 時區並依當地規則計算下一次。
- 暫停或取消就刪除排程列 → 已實體化或入列的實例仍可能執行,也失去稽核紀錄 → 版本化失效並在副作用前複查狀態。
- 所有租戶共用一個 FIFO → 大租戶整點批次會阻塞其他租戶 → 依租戶和優先級公平排程,分別限制觸發與執行並行數。
- 只看 API 成功率 → 建立排程成功也可能從未按時觸發 → 監控 schedule lag、due backlog、missed 與實例狀態對帳。
追問與應對
追問一:排程節點在佇列傳送成功後、更新 outbox 前崩潰怎麼辦?
relay 會重送同一 occurrence_id。佇列消費者和業務目標依該 ID 冪等,outbox 狀態更新也使用條件寫入。選擇重複而非漏跑,因為跨資料庫與佇列沒有單一交易;若使用支援交易消費的同一日誌系統,可以縮小視窗,但外部副作用仍需冪等。
追問二:如何支援多地域主動—主動?
先為每個排程分配唯一 home region 與遞增 generation,只有持有目前 generation 的地域可以實體化實例;執行佇列可依目標地域分發。故障切換先提升 generation,再由新地域從持久游標接管。兩個地域同時實體化時,全域唯一實例鍵或匯聚層去重仍是最後邊界。若業務不需要跨地域寫入,單主控制面加異地接管更簡單。
追問三:日光節約時間回撥時,當地 01:30 出現兩次,應該執行幾次?
沒有通用答案,必須成為排程契約。本題選擇一次,並以當地日期、牆上時間與時區折疊兩個 UTC 候選;金融對帳若要求兩個真實時間點都執行,就改成兩次並產生不同 scheduled_for。API 應預覽未來觸發,讓使用者在保存前看到選擇。
追問四:上一輪任務執行兩小時,下一次每小時到達怎麼辦?
ALLOW 會並行;SKIPIFRUNNING 原子檢查並把新實例記為 SKIPPEDOVERLAP。若業務要求排隊等待,應增加 QUEUEONE 語意並限制最多保留一個待執行實例。直接停止上一輪可能已經產生副作用,只能在處理器支援協作取消時提供。
追問五:排程器停機一天,如何復原而不壓垮下游?
逐排程套用 SKIP、FIREONCE 或有上限的 CATCHUP,再依租戶與目標容量限速。復原掃描從持久 nextrunat 開始,不能依目前時間直接覆蓋游標。監控最舊未處理時間與預估清空時長,嚴格任務與補跑任務使用獨立容量。
追問六:如何證明沒有靜默漏跑?
離線對帳應從排程版本與時間規則獨立重算預期實例集合,再與 Occurrence 唯一鍵集合比較,分類為成功、失敗、取消、錯過或缺失。線上指標發現延遲,離線對帳發現缺口;兩者搭配才能涵蓋「系統沒有報錯,但實例從未產生」的情況。