ChatGPT Tasks 排程任務 (Design ChatGPT Tasks)
設計一個 排程任務系統:使用者透過自然語言跟 ChatBot 說「每天早上八點幫我總結財經新聞」,系統就把它變成一個 一次性或週期性的 job,在 正確時間點 觸發執行(呼叫 connector / 產生摘要 / 發通知)。本質是 分散式 cron + 可靠任務執行,規模 10K 排程請求/秒,硬需求是 至少一次執行保證(at-least-once)。
招牌取捨:把 「查詢到期 job」(watcher)與「執行 job」(worker)用 queue 解耦,並接受 at-least-once(而非 exactly-once)——放棄「精準一次」的語意簡單,換取 突發流量吸收、執行失敗隔離、worker 獨立擴展、crash 後不靜默丟失;代價是 job 必須 idempotent。其餘決勝點:time_bucket partition key(避免掃全表 broadcast read)、SQS Visibility Timeout 當 heartbeat(偵測 worker crash)、append-only RunEvent outbox 驅動 recurring(對 immutable event 反應而非輪詢 mutable state)、MCP server 把自然語言意圖橋接到 Job Scheduler API。
配速(35 min 預算):~5 min 釐清需求 → ~4 min 容量估算 → ~3 min API + ~5 min 資料模型(time_bucket 是重點)→ ~6 min 高階架構(含 MCP)→ ~9 min deep dive(watcher/worker 解耦 / at-least-once / MCP 可靠性)→ ~3 min 擴展瓶頸 → ~2 min 收尾。開場先一句話複述題目、宣告配速、把 MCP 與 at-least-once 標記為「待 deep dive」——先把端到端 scheduling 串起來,再回頭挖兩個硬骨頭。
1. 釐清需求與範圍 (Clarify & Scope ~5 min)
排程系統的魔鬼在「準時」與「不丟」兩個字。開場先把這兩件事問清楚,別急著畫 queue。
| 釐清問題 | 為何要問 / 影響到哪個決策 | 本題假設(依來源) |
|---|---|---|
| Job 有哪幾種觸發型態? | 決定資料模型與 recurring 的處理方式 | immediate / one-time / recurring(cron 格式) |
| 排程請求量多大? | 直接驅動 DB 選型(SQL vs NoSQL) | 最高 10K jobs/sec 排程寫入 |
| 執行保證等級?exactly-once 還是 at-least? | 這是整題的命脈,決定整個可靠性架構 | at-least-once(至少一次) |
| 準時度要求多嚴格?秒級還是分鐘級? | 決定 watcher 掃描頻率與時間窗 | 接近準時即可(來源用 5 分鐘掃描窗) |
| 使用者怎麼下單? | 決定要不要 MCP / 自然語言層 | 自然語言 → ChatBot(需 MCP 橋接) |
| 使用者要看 job 狀態嗎?怎麼排序? | 決定 viewing 路徑的 index / GSI 設計 | 要,依 建立時間 排序列出 |
| 可用性? | 決定容錯 / 多 worker / DLQ | High Availability |
「at-least-once 具體怎麼實作、以及 ChatGPT 怎麼把自然語言變成一筆 job 我先標記,等 deep dive 再展開——現在先假設有個 POST /v1/jobs 的 API 會收到結構化的 job spec,我們先把 schedule → store → trigger → execute 這條主幹串起來。」把 MCP 與可靠性細節推遲,保住 scheduling 主敘事的連貫。
2. 功能性與非功能性需求 (FR / NFR 與設計北極星)
-
功能性需求 (FR)
- 使用者可預約 現在或未來 的 jobs,型態為 one-time 或 recurring。
- 使用者可 檢視已預約 jobs 的狀態(scheduled / running / completed / failed)。
- 使用者可透過 自然語言與 ChatBot 互動 來預約 jobs(例:「每天早上八點幫我總結財經新聞」)。
-
非功能性需求 (NFR)
- High Availability。
- Scalability:支援最高 10K jobs/sec 的排程請求。
- At-least-once execution guarantee:每個 job 至少被執行一次。
設計北極星(NFR 直接導出後續所有選型的判準):
| NFR | 導出的設計北極星 | 後續對映選型 |
|---|---|---|
| 10K writes/sec 排程 | 寫吞吐要高、可水平擴展、可估算分區 | NoSQL(DynamoDB / Cassandra)+ time_bucket partition |
| at-least-once 執行 | 失敗必須可觀察、不可靜默丟失、可安全重試 | queue + Visibility Timeout heartbeat + DLQ + idempotent |
| 準時觸發 | 查詢到期 job 不可被「執行慢」拖累 → 查詢與執行解耦 | watcher / worker 分離、queue 當 buffer |
| recurring 不重複 / 不漏 | 下一次執行由 immutable event 集中產生 | append-only RunEvent outbox + RecurringJobWatcher |
| 自然語言下單 | 意圖 → 結構化 API 呼叫 的可靠橋接 | MCP server + 嚴謹 JSON Schema + action-verb 命名 |
| 高可用 | 任一 worker / watcher 掛掉系統不停 | 無狀態多 worker、queue 重新投遞 |
「這是一個 準時、不丟、可重試 的排程系統」——準時靠 watcher/worker 解耦,不丟靠 queue + heartbeat,可重試靠 idempotent + at-least-once。後面每個選型都能從這三個詞推回去。
3. 容量估算 (Back-of-Envelope ~4 min)
估算目的是 驅動 DB 選型與分區策略,不是算到小數點。回連 02-Distributed-Systems/05-Numbers-to-Know。
寫流量(schedule QPS)
排程寫入峰值 = 10,000 jobs/sec(來源給定的 NFR 上限)
→ 單一關聯式 write node 通常難穩定吃 10K writes/sec
→ 指向「水平可擴展的 NoSQL」(DynamoDB / Cassandra)
JobRun 放大效應(recurring 的隱藏成本)
recurring job 一次預約 → 對應「多筆」未來 job runs(每次執行一筆 run)
若大量使用者排「每天早上八點」→ 同一 time_bucket 內 job runs 密集
→ 讀路徑風險不是「總量」,而是「同一時間點扎堆」(hot bucket)
讀流量(watcher 掃描)
watcher 每 ~5 分鐘掃一次「接下來到期」的 PENDING job runs
若無分區策略 → 必須 broadcast 掃所有 partition(致命)
→ 用 time_bucket 當 partition key,把「掃描」變成「定位單一 / 少數 partition」
下表數字除 10K/sec 為來源給定外,其餘為 推導值(由 recurring 放大與掃描模式推出,來源未給總 job 數)。
| 維度 | 數字 / 結論 | 對後續決策的意義 |
|---|---|---|
| Schedule 寫入峰值 | 10K writes/sec(來源給定) | 超出單機 SQL 舒適區 → 選 NoSQL |
| 讀掃描模式 | 時間區間查詢(推導值) | 必須避免 broadcast read → time_bucket partition key |
| Hot bucket 風險 | 同一時刻扎堆(推導值,如「每天八點」) | partition key 加 shard suffix 分散 |
| 執行突發 | 平時 1000/5min → 峰值暴增(來源舉例) | 需 queue buffer 吸收,watcher 不直接打 worker |
「10K writes/sec」一個數字就否決了單機 SQL、指向 NoSQL;而「同一時間點扎堆」這個 讀模式 否決了 naive 全表掃描、逼出 time_bucket partition key。這兩個推論是本題從估算到選型的關鍵 hand-off。
4. 用到的積木 / Building Blocks Used
架構即目錄:下表每個決策對映既有 concept note(無 alias 連結);本表即「本案例練到哪些積木」的單一事實來源。
| 設計決策 | 積木 | 在本題的作用(≤1 行) |
|---|---|---|
| 容量估算驅動選型 | 02-Distributed-Systems/05-Numbers-to-Know | 10K writes/sec → NoSQL;時間區間讀 → time_bucket 分區 |
| 高寫吞吐 KV / 寬列儲存 | 06-Database-Tech/03-DynamoDB | partition key + sort key 模型,吃 10K writes/sec |
| partition / sort key 設計 | 05-Database-Advanced/02-Sharding | time_bucket 當 partition、(scheduled_at, job_id) 當 sort |
| 排程屬於長時間 / 非同步任務 | 10-Design-Patterns/03-Long-Running-Tasks | 預約 → 之後才執行,天生 async;watcher/worker 模式 |
| queue 當 buffer 解耦 | 09-Messaging-Coord/02-Queue | 隔離執行失敗、吸收突發、worker 獨立擴展 |
| at-least-once 可靠投遞 | 12-Ops-and-Reliability/03-Reliable-Delivery | Visibility Timeout heartbeat + DLQ + idempotent |
| 重試 / 失敗處理 | 01-Networking/07-Fault-Handling | RETRYING → FAILED → DLQ 的重試與降級鏈 |
| 突發執行高峰防護 | 12-Ops-and-Reliability/02-Overload-Protection | queue 削峰填谷,worker 依容量消費,不被瞬時尖峰打垮 |
| 狀態更新與 outbox 一致性 | 05-Database-Advanced/01-Transactions | 同 transaction 更新狀態 + append RunEvent,避免 race |
| recurring 用 LLM / connector | 08-Search-and-AI/02-RAG | job action 常是呼叫 LLM 摘要(如財經新聞);MCP 為其入口 |
| 無狀態 worker 水平擴展 | 02-Distributed-Systems/03-Scalability | worker / watcher 無狀態,加機器即擴容 |
| API endpoint 設計 | 03-API-Design/02-REST | POST /v1/jobs、GET /jobs?status=... 資源風格 |
| viewing 排序 / GSI | 04-Database-Indexing/01-Indexing-Overview | user_id 當 PK、time-sortable id 當 SK;必要時建 GSI |
5. API 設計 (API Design ~3 min)
由 FR 驅動,採 REST 資源風格(理據見 03-API-Design/02-REST)。對外是 REST,對 ChatGPT 則由 MCP server 把 tool call 轉成這些呼叫。
| Method | Path | 用途 | 重點 |
|---|---|---|---|
POST |
v1/jobs |
排程一個 job | body 帶 action、job_params:{type, time, schedule} |
GET |
jobs?status=&start_time=&end_time=&pageSize=&page= |
查詢 job 狀態 | 依狀態 / 時間範圍 / 分頁列出,回傳 Job[] |
POST /v1/jobs 的 body 結構(來源原樣)
{
"action": "呼叫某 connector / 產生摘要 / 發通知",
"job_params": {
"type": "immediate | one-time | recurring",
"time": "<ISO 時間>", // 僅 one-time 需要:指定未來執行時間
"schedule": "* 10 * * *", // 僅 recurring 需要:cron 格式
},
}
type 把三種觸發型態收斂成一個欄位:immediate 不需 time/schedule、one-time 用 time、recurring 用 cron 的 schedule。這讓 同一張表、同一條寫入路徑 處理全部型態,差異只在「下一次 run 怎麼算」。
6. 資料模型與儲存選型 (Data Model & Storage Choice ~5 min)
核心是 兩張表 + 一個 outbox:Jobs(job 定義)、JobRun(每一次實際執行實體)、RunEvent(append-only 執行事件)。JobRun 獨立成表是 recurring 的關鍵——一個 recurring job 對應「多筆」未來 runs。
Jobs ← job 的「定義」
--------------------------------------------------
job_id STRING PK
user_id STRING
action JSON 要執行什麼
type ENUM immediate | one-time | recurring
schedule STRING cron(recurring)
created_at TIMESTAMP ← viewing 依此排序
JobRun ← 每一次「實際執行實體」(核心表)
--------------------------------------------------
time_bucket STRING PARTITION KEY ← scheduled_at truncate 到「最近一小時」
scheduled_at TIMESTAMP ┐ SORT KEY ← 同 partition 內依執行時間排序
job_id STRING ┘ SORT KEY ← (scheduled_at, job_id) 複合
status ENUM PENDING|RUNNING|SUCCEEDED|RETRYING|FAILED
retry_count INT
--------------------------------------------------
partition key 範例(防 hot shard):YYYYMMDDHH#S{00..31} ← 後綴 shard suffix
RunEvent ← append-only outbox(驅動 recurring)
--------------------------------------------------
event_id STRING
job_id STRING
status ENUM terminal status(SUCCEEDED / FAILED)
emitted_at TIMESTAMP
(immutable,只 append 不 update)
為什麼 time_bucket 是整題的資料模型核心:watcher 要找「接下來 5 分鐘到期的 PENDING runs」。若以 job_id 為 partition key,這個時間區間查詢得 掃所有 partition(broadcast read)——10K/sec 規模下直接爆炸。把 scheduled_at truncate 到最近一小時當 partition key,時間區間查詢就退化成「定位 1~2 個 partition + 在 partition 內二分」,複雜度逼近 O(log n)。
儲存選型:
| 選項 | 適配本題嗎 | 理由 |
|---|---|---|
| NoSQL(DynamoDB / Cassandra) | ✅ 主選 | 10K writes/sec 超出單機 SQL;partition + sort key 天生契合時間區間查詢 |
| 關聯式 DB | △ 也行 | viewing 排序 / 交易直覺,但 10K 寫入需 sharding,複雜度更高 |
| append-only 表(RunEvent) | ✅ 輔助 | recurring 對 immutable event 反應而非輪詢 mutable state,降低 race |
→ 積木:NoSQL 選型見 06-Database-Tech/03-DynamoDB;partition / sort key 設計見 05-Database-Advanced/02-Sharding;index / GSI 見 04-Database-Indexing/01-Indexing-Overview。
Viewing 流程的資料模型:使用者要「列出自己所有 jobs、依建立時間排序」。
- partition key =
user_id,sort key =job_id(前提是 job_id 為 time-sortable,如 ULID / KSUID)。 - 若
job_id無法保證時間排序 → 建 GSI(PK:user_id、SK:created_at)。
time_bucket 解決的是 執行路徑(按時間掃描);user_id PK 解決的是 viewing 路徑(按使用者列出)。同一份資料、兩種存取模式 → 兩套 key 設計,這是 NoSQL 單表 / GSI 思維的典型體現。別想用一個 partition key 兼顧兩者。
7. 高階架構 (High-Level Design ~6 min)
先看 naive scheduling,再看 MCP 整合,最後是 deep dive 後的健壯版(第 8 段才引入 queue)。
(A) Naive scheduling flow(deep dive 前,executor 同時查 + 執行)
使用者 (自然語言) ─► ChatBot
│ POST v1/jobs { action, job_params }
▼
Job Scheduling Service
│ 1. 寫 Jobs table(job 定義)
│ 2. 寫 JobRun(status=PENDING, time_bucket, scheduled_at)
▼
┌──────────── NoSQL (Jobs / JobRun) ────────────┐
└───────────────────┬───────────────────────────┘
│ 每 ~5 min 掃描
▼
Executor(查詢 + 執行 都它做) ◄─ 第8.1 指出此為脆弱點
│ 找出 time_bucket 內、接下來5分鐘、PENDING 的 runs
│ 在正確時間點執行 → status=SUCCEEDED
│ recurring → 算下一次、再寫一筆 JobRun
│ 失敗 → RETRYING → 耗盡 retries → FAILED
▼
執行 action(connector / LLM 摘要 / 通知)
(B) MCP 整合 flow(自然語言 → tool call → Job Scheduler API)
使用者「每天早上八點總結財經新聞」
▼
ChatGPT (model) ──(thread 開始時) Discovery:呼叫 tools/list ──► MCP Server
│ └─ 回傳 tool metadata(名稱/描述/JSON Schema),快取在 thread
│ 每輪 model 看到:system instr + user msg + 快取的 tool list
│ Tool selection:依名稱/描述/schema 選 tool(如 task.create@v1)
│ Argument filling:依 schema 組 JSON args → 產生 mcp_tool_call
▼
MCP Server ──轉發──► Job Scheduler API (POST v1/jobs) ──► 乾淨 JSON 結果
▲ │
└───────────────── mcp_tool_result ◄──────────────────────┘
▼
model 把 JSON 整合成自然語言回覆給使用者
(C) Scaled 架構(deep dive 後,watcher / worker 用 queue 解耦)
使用者(NL) ─► ChatGPT ─► MCP Server ─► Job Scheduling Service
│ 寫 Jobs + JobRun(PENDING)
▼
┌─────── NoSQL (Jobs / JobRun / RunEvent) ───────┐
└───┬───────────────────────────────────▲───────┘
│ 每 ~5min 查 PENDING │ 同 txn:
▼ (time_bucket 定位,非 broadcast) │ 更新狀態
Watcher Process │ + append RunEvent
│ 把到期 run 發佈到 queue │
▼ │
┌──────────────┐ 消費 ┌────────────────┴───┐
│ Message Queue │ ───────► │ Workers (N, 無狀態) │
│ (SQS) │ ◄──crash │ 執行 action │
└──────┬───────┘ 重新可見 │ 成功→刪訊息 │
│ retries 耗盡 │ 失敗→RETRYING/重試 │
▼ └─────────┬──────────┘
DLQ(人工檢查) │ recurring 時
▼
RecurringJobWatcher 輪詢 RunEvent
(terminal status) → 算下次 → 插新 JobRun
關鍵 hand-off:Scheduling Service 只負責「寫入定義 + 建 PENDING run」;Watcher 只負責「查到期 → 丟 queue」;Worker 只負責「消費 → 執行 → 更新狀態 + append event」;RecurringJobWatcher 只負責「看 immutable event → 排下一次」。四個角色職責單一、各自獨立擴展。
8. 深入探討 (Deep Dives ~9 min)
通用機制(queue 語意、replication、index 原理)只在 concept note 解釋;這裡只講「本題怎麼用、選哪個、為什麼」。本題無 home note 的新材料(Visibility Timeout heartbeat、time_bucket partition key、MCP protocol、append-only RunEvent outbox)才在此 inline 教學並登記到 new_concepts_introduced。
8.1 如何確保 job 準時執行?(watcher / worker 解耦)
問題:怎麼讓 job 在「正確時間點」被執行,且系統放大後不變脆弱?
為何是瓶頸:naive 版的 executor 同時負責查詢與執行,兩件事互相拖累:
- 執行慢拖垮查詢:executor 每 5 分鐘要查下一批,但某個 job 本身執行 > 5 分鐘 → 下一批查不到、後續全 遲到。
- 執行高峰打爆 executor:平時 5 分鐘 ~1000 runs,但「Black Friday 提醒」之類情境瞬間湧入大量同時段 job → executor 無法在時間窗內跑完。
| 方案 | 做法 | 優點 | 缺點 |
|---|---|---|---|
| A. 單一 executor(查 + 執行) | 一個 process 掃 DB 又跑 job | 簡單、組件少 | 執行慢拖垮查詢;尖峰打爆;失敗難隔離 |
| B. watcher / worker + queue 解耦 | watcher 查到期 → 丟 queue;worker 群消費執行 | 隔離失敗、吸收突發、worker 獨立擴展 | 多一個 queue 組件;引入 at-least-once 複雜 |
選擇與理由:採 B。queue 作為 buffer 達成三件事:(1) 隔離 job execution failure,執行失敗不影響查詢;(2) worker 可獨立於 watcher 水平擴展;(3) queue 提供 durability——僅在成功執行後才移除訊息(如 SQS)。watcher 變得很輕(只查 + 丟),worker 多寡按負載彈性增減。
→ 積木:10-Design-Patterns/03-Long-Running-Tasks、09-Messaging-Coord/02-Queue、12-Ops-and-Reliability/02-Overload-Protection
若 worker 執行完直接算下一次並插 JobRun,會把「執行」與「排程」耦在一起,crash / retry 時容易 重複插入或漏插(race condition)。改用 append-only RunEvent outbox:worker 在 同一個 transaction 內「更新狀態 + append 一筆 immutable RunEvent」;RecurringJobWatcher 定期輪詢 RunEvent 中達 terminal status 的事件,集中計算並 idempotent 插入 下一次 JobRun。對 immutable event 反應 而非輪詢 mutable state,搭配 transactional claim-and-mark + idempotent insert,能在 crash / retry 下避免重複排程。見 05-Database-Advanced/01-Transactions。
watcher 的查詢是「接下來 N 分鐘、status=PENDING 的 runs」——一個 時間區間查詢。若 partition key 不含時間,DB 得 broadcast 掃所有 partition。解法:partition key = scheduled_at truncate 到最近一小時(如 2026062208),sort key = (scheduled_at, job_id)。如此查某時段只需定位 1~2 個 partition 並在內部二分,複雜度 ≈ O(log n)。Hot shard 防護:若大量 job 排在同一小時(「每天八點」),在 partition key 後加 shard suffix(YYYYMMDDHH#S{00..31}),把讀寫散到 32 個 sub-partition。
8.2 如何確保至少一次執行(at-least-once)?
問題:worker 取了訊息後 crash 並離線,這個 job 可能「靜默消失(silent failure)」——既沒成功、也沒回到 queue。怎麼保證它最終被執行?
為何是瓶頸:retry policy + DLQ 的前提是「失敗能被觀察到」。worker 拿了訊息就 crash,系統根本不知道它沒做完。
需要一種 heartbeat 機制:worker 接手後若逾時沒做完,系統判定它掛了、把 job 交給別的 worker 重試。Amazon SQS Visibility Timeout 直接提供:
- worker 從 queue 取訊息後,SQS 讓該訊息在一段時間內 對其他 worker 不可見。
- worker 需 定期延長 visibility timeout(這就是 heartbeat:我還活著、還在做)。
- worker 成功完成 → 刪除訊息(訊息消失 = 確認完成)。
- worker crash 或逾時未完成 → SQS 自動讓訊息重新可見 → 別的 worker 接手。
這套機制根除 worker crash 的 silent failure,是達成 at-least-once 的核心。
選擇與理由:採 SQS Visibility Timeout heartbeat + retry + DLQ 組合。worker 重試耗盡後把 job 送 DLQ(Dead Letter Queue) 供人工檢查,不阻塞主流程。
→ 積木:12-Ops-and-Reliability/03-Reliable-Delivery、09-Messaging-Coord/02-Queue、01-Networking/07-Fault-Handling
訊息重新可見 → 同一 job 可能被執行 多次。所以 job 本身必須 idempotent:重複執行多次的結果必須等同只執行一次(例如「發摘要 email」要用去重鍵避免發兩封)。這就是本題的 core_tradeoff——選 at-least-once 換取「不丟」,代價是把 idempotency 的責任推給 job 本身。若要 exactly-once,複雜度與成本會高出一個量級。
8.3 如何讓 ChatGPT 與 MCP Server 的整合更可靠?
問題:model 是機率性的,怎麼讓「自然語言 → 正確的 tool call → 正確的 API 呼叫」這條鏈穩定?
為何重要:tool 選錯 / 參數填錯 → job 排錯時間 / 排錯動作,使用者完全無感地踩雷。
MCP 是開源標準,讓 AI 應用(ChatGPT / Claude)連外部系統(資料來源、工具、prompts)。整合流程:(1) Discovery(一個 thread 只做一次):ChatGPT 呼叫 tools/list,tool metadata 快取在 thread;(2) 每輪 model 看到 system instructions + user message + 快取 tool list;(3) Tool selection:依名稱/描述/schema 選 tool;(4) Argument filling:依 schema 組 JSON args → mcp_tool_call;(5) MCP server 轉發 至實際 Job Scheduler API;(6) model 把回傳 JSON 整合成自然語言回覆。
| 可靠性原則 | 做法 | 為什麼有效 |
|---|---|---|
| action-verb 命名 | task.create@v1,附「何時用 / 何時不用」一句描述 |
model 把使用者動詞對應 imperative 命名更可靠 |
| version suffix | 一律加 @v1;schema 改動發 @v2,舊版續存 |
chat 快取 tool list,改同名 schema 會破壞長對話 |
| 嚴謹 JSON Schema | required(僅標 model 無法推斷的)、enums、defaults、additionalProperties:false |
降錯誤率、減 retries、讓 auto-repair 更易 |
| 極簡 system instruction | 第一則 message 說明何時用哪 tool、缺輸入時的預設(~150 tokens) | 一次性低成本地消歧義 |
| 結構化、可修正錯誤 | 回 {ok:false, error:{code, message, field, expected}} |
model 能自我修正後重試,而非卡死 |
選擇與理由:這五條讓「機率性 model」與「確定性 API」之間的橋接 可預測、可自癒。job action 常是呼叫 LLM(如「總結財經新聞」),MCP 正是這類 LLM-as-action 的標準入口。
→ 積木:08-Search-and-AI/02-RAG(job action 常為 LLM 摘要 / 檢索)、03-API-Design/02-REST(MCP 轉發的底層 API)
9. 擴展與瓶頸 (Scaling & Bottlenecks ~3 min)
主動找出系統會先在哪裂開並補強。
| 瓶頸 / 風險 | 補強手段 | 積木 |
|---|---|---|
| 10K writes/sec 壓垮單機 DB | NoSQL + partition key 分散寫入 | 06-Database-Tech/03-DynamoDB、05-Database-Advanced/02-Sharding |
| 同一時段扎堆(hot partition) | partition key 加 shard suffix(YYYYMMDDHH#S{00..31})散開讀寫 |
05-Database-Advanced/02-Sharding |
| 執行突發尖峰打爆 worker | queue 當 buffer 削峰,worker 依容量消費;必要時 autoscale worker | 09-Messaging-Coord/02-Queue、12-Ops-and-Reliability/02-Overload-Protection |
| worker crash → 靜默丟 job | Visibility Timeout heartbeat 逾時重投,DLQ 收尾 | 12-Ops-and-Reliability/03-Reliable-Delivery |
| recurring 重複 / 漏排 | append-only RunEvent + idempotent insert,對 immutable event 反應 | 05-Database-Advanced/01-Transactions |
| watcher 自己掛了 | 多 watcher 無狀態、按 time_bucket 分工;掛掉重啟接續掃描 | 02-Distributed-Systems/03-Scalability |
| broadcast read 拖慢查詢 | time_bucket partition key 把區間查詢變 O(log n),不掃全表 | 04-Database-Indexing/01-Indexing-Overview |
「系統會 先在『執行突發 + worker crash』兩處裂開:突發靠 queue buffer 削峰、worker 獨立擴展;crash 靠 Visibility Timeout heartbeat 重投 + DLQ。寫入 10K/sec 與時間區間讀則靠 NoSQL + time_bucket(加 shard suffix)解決。」先點出最脆弱處再補,比逐項羅列有說服力。
10. 取捨總表與收尾 (Trade-off Recap & Wrap-up ~2 min)
| 決策點 | 選擇 | 放棄了什麼 | 為什麼值得 |
|---|---|---|---|
| 執行語意 | at-least-once + idempotent job | exactly-once 的語意簡單 | 換取「不靜默丟失」+ 可安全重試(招牌取捨) |
| 查詢 vs 執行 | watcher / worker 用 queue 解耦 | 單一 executor 的組件簡單 | 隔離失敗、吸收突發、worker 獨立擴展 |
| crash 偵測 | SQS Visibility Timeout heartbeat | 自建 heartbeat / lease 機制 | 直接複用託管能力,逾時自動重投 |
| DB 選型 | NoSQL(DynamoDB / Cassandra) | SQL 的交易 / 排序直覺 | 吃 10K writes/sec、partition+sort 契合時間查詢 |
| partition key | time_bucket(+ shard suffix) | 用 job_id 的直覺 | 避免 broadcast read、防 hot shard |
| recurring 排下一次 | append-only RunEvent outbox + 集中 watcher | worker 直接插入的簡單 | 對 immutable event 反應,避免重複 / race |
| NL 下單 | MCP server + 嚴謹 schema + version suffix | 直接讓 model 拼 API | 機率性 model → 確定性 API 的可預測 / 自癒橋接 |
「整題的單一錨點是 at-least-once:我寧可讓一個 job 偶爾跑兩次(要求它 idempotent),也不能讓它 靜默消失一次——這就決定了整條可靠性鏈:queue 解耦 watcher/worker、Visibility Timeout 當 heartbeat 抓 crash、DLQ 收尾、recurring 走 append-only outbox。寫入 10K/sec 與時間區間讀則靠 NoSQL + time_bucket partition(加 shard suffix)。最上層用 MCP server 把自然語言意圖可靠地翻成 Job Scheduler API。」引用既有決策用連結,不重新推導。
後續追問演練 (Interviewer Follow-ups — "What if…")
單一 executor 同時查 + 執行時,執行慢會拖垮查詢(job 跑超過 5 分鐘 → 下一批查不到 → 全部遲到),執行尖峰會打爆 executor(同時段大量 job)。分開後:watcher 只查到期 run 並丟 queue,worker 群消費執行;queue 當 buffer 吸收突發、隔離執行失敗、worker 可獨立水平擴展。見 10-Design-Patterns/03-Long-Running-Tasks、09-Messaging-Coord/02-Queue。
watcher 的查詢是「接下來 N 分鐘到期的 PENDING runs」——時間區間查詢。若用 job_id 當 partition key,得 broadcast 掃所有 partition。把 scheduled_at truncate 到最近一小時當 partition key、(scheduled_at, job_id) 當 sort key,時間區間查詢就退化成定位少數 partition + 內部二分,複雜度 ≈ O(log n)。見 05-Database-Advanced/02-Sharding、04-Database-Indexing/01-Indexing-Overview。
因為 crash 重試 / Visibility Timeout 重投會讓 同一 job 被執行多次。at-least-once 只保證「不少於一次」,不保證「恰好一次」。所以 job 本身必須 idempotent:多次執行結果等同一次(如用去重鍵避免重複發 email)。見 12-Ops-and-Reliability/03-Reliable-Delivery。
寫入 partition 先裂。依序:(1) NoSQL 本就可水平擴 partition,但同時段扎堆會造 hot partition → 加大 shard suffix 範圍(如 S{00..255});(2) Scheduling Service 無狀態,加機器 即擴;(3) watcher 按 time_bucket / shard 分工,多 watcher 並行掃描;(4) worker 群依 queue 深度 autoscale。app 層無狀態,見 02-Distributed-Systems/03-Scalability、05-Database-Advanced/02-Sharding。
經典 hot partition。手段:(1) partition key 加 shard suffix(YYYYMMDDHH#S{00..31} 甚至更多),把同一小時散到 N 個 sub-partition,watcher 並行掃 N 個;(2) 在 bucket 內加 jitter(把「八點整」微抖到 07:58~08:02)攤平瞬時尖峰;(3) worker 端靠 queue buffer 削峰、autoscale 消費。見 05-Database-Advanced/02-Sharding、12-Ops-and-Reliability/02-Overload-Protection。
不會靜默消失。worker 取訊息後訊息對其他 worker 不可見,worker 需定期 延長 visibility timeout(heartbeat)。crash 後它停止 heartbeat → 逾時 → SQS 自動讓訊息重新可見 → 別的 worker 接手重試。重試耗盡才進 DLQ 人工檢查。前提是 job idempotent。見 12-Ops-and-Reliability/03-Reliable-Delivery、01-Networking/07-Fault-Handling。
worker 直接插會把「執行」與「排程下一次」耦合,crash / retry 下易 重複插或漏插(race)。改成 worker 在 同一 transaction 更新狀態 + append 一筆 immutable RunEvent;獨立的 RecurringJobWatcher 輪詢達 terminal status 的 event,集中且 idempotent 地插入下一次。對 immutable event 反應而非輪詢 mutable state,是降低 race 的關鍵。見 05-Database-Advanced/01-Transactions。
因為 ChatGPT 在 thread 開始時做一次 Discovery(tools/list)並把 tool metadata 快取在整個 thread。若你在 同名 tool 下偷改 JSON Schema,正在進行的長對話拿的是舊快取 → schema 不一致 → 破壞對話 / 參數填錯。正確做法:schema 變動發 新版本 @v2、舊 @v1 續存,讓既有 thread 不受影響。
以下為一般推論,本來源(5-11)只設計排程系統。 判準是同一條:單機是否扛得住寫入吞吐。QR Code 寫只有 ~58 QPS、200GB → 單機綽綽有餘,硬上 sharding 是炫技;本題排程寫入峰值 10K/sec,遠超單機 SQL 舒適區 → 必須 走可水平擴展的 NoSQL + partition key。同一準則、不同輸入、相反結論——能說清「為什麼這題需要、那題不需要」才是成熟度。對照 05-Database-Advanced/02-Sharding 的觸發門檻。
主動回想:白板重建 (Whiteboard from Scratch)
不看上文,照下面提示把整個設計重建一次。每格都該能 30 秒內口述。
常見陷阱 (Exam Traps for This Problem)
What:一個 process 既掃 DB 找到期 job、又直接執行它。
Why:執行慢會拖到下一輪查詢(job 跑 >5 分鐘 → 後續全遲到);執行尖峰會打爆它(同時段大量 job)。
正確做法:watcher 查 + 丟 queue / worker 群消費執行 解耦,queue 當 buffer。見 10-Design-Patterns/03-Long-Running-Tasks、09-Messaging-Coord/02-Queue。
What:JobRun 表用 job_id 當 partition key。
Why:watcher 要查「接下來 N 分鐘到期」是 時間區間查詢,job_id 分區下得 掃所有 partition,10K/sec 規模直接爆。
正確做法:time_bucket(scheduled_at truncate 到小時)當 partition key、(scheduled_at, job_id) 當 sort key;hot 時加 shard suffix。見 05-Database-Advanced/02-Sharding。
What:口頭保證「每個 job 只跑一次」,但 worker crash / 重投時 job 會被執行多次。
Why:分散式下 exactly-once 極難且昂貴;at-least-once + 重投是務實解,但重投必然造成重複執行。
正確做法:明說 at-least-once,並要求 job idempotent(去重鍵 / upsert)。見 12-Ops-and-Reliability/03-Reliable-Delivery。
What:worker 取了訊息就 crash,沒有任何機制把它送回重試。
Why:retry / DLQ 的前提是「失敗可被觀察」,crash 後系統根本不知道它沒做完 → silent failure。
正確做法:用 SQS Visibility Timeout 當 heartbeat,worker 逾時未延長 → 訊息自動重新可見 → 他人接手。見 12-Ops-and-Reliability/03-Reliable-Delivery。
What:worker 跑完當下就算下次時間並 INSERT 新 JobRun。
Why:執行與排程耦合,crash / retry 下會 重複插或漏插(race condition)。
正確做法:worker 同 txn 更新狀態 + append immutable RunEvent;獨立 RecurringJobWatcher 對 event 反應、idempotent 插下一次。見 05-Database-Advanced/01-Transactions。
What:直接在 task.create 上改 JSON Schema。
Why:ChatGPT 把 tool list 快取在整個 thread;改同名 schema 會讓進行中的長對話拿到不一致 metadata → 參數填錯。
正確做法:一律加 version suffix,schema 變動發 @v2、舊 @v1 續存。
Related Notes
- 02-Distributed-Systems/05-Numbers-to-Know
- 02-Distributed-Systems/03-Scalability
- 03-API-Design/02-REST
- 04-Database-Indexing/01-Indexing-Overview
- 05-Database-Advanced/01-Transactions
- 05-Database-Advanced/02-Sharding
- 06-Database-Tech/03-DynamoDB
- 08-Search-and-AI/02-RAG
- 09-Messaging-Coord/02-Queue
- 10-Design-Patterns/03-Long-Running-Tasks
- 12-Ops-and-Reliability/02-Overload-Protection
- 12-Ops-and-Reliability/03-Reliable-Delivery
- 01-Networking/07-Fault-Handling