Workers Cron Triggers 排程:scheduled handler 與 cron 表達式實戰 | Cloudflare 完整教學
前幾篇我們的 Worker 都是「被動」的——有人送請求上門,
fetchhandler 才醒來做事。但真實世界有一大類任務是主動、定時的:每天凌晨清掉過期資料、每小時同步一次匯率、每五分鐘寄一批待發的通知。這種「不靠人觸發、時間到就自己跑」的能力,靠的就是 Cloudflare Workers 的 Cron Triggers。這一篇我們深入排程:在wrangler.jsonc的triggers.crons設定 cron 表達式、匯出scheduled(event, env, ctx)handler、用event.cron與scheduledTime做多排程分流、以wrangler dev --test-scheduled在本地觸發測試,並講清楚 cron 為何一律吃 UTC、為何一定要用ctx.waitUntil、以及長任務何時該交棒給 Queues 或 Workflows。
前言
上一篇《WebSockets》,我們讓 Worker 學會「講電話」——透過 WebSocketPair 與 101 升級握手,做出雙方隨時互相說話的即時通訊。但無論是 fetch 還是 WebSocket,它們都有一個共同點:都得有人先來敲門。使用者送出 HTTP 請求、瀏覽器發起 WebSocket 連線,Worker 才會被喚醒。它是一個盡責的「門房」,但門房只會在有人來的時候動作。
問題是,很多重要的工作沒有人會來觸發。資料庫裡的過期 session 需要每天清理一次;外部匯率 API 需要每小時同步一次;累積在佇列裡的電子報需要每五分鐘寄出一批。這些任務的共同特徵是:它們該在「特定時間」自己發生,而不是等某個使用者剛好上門時才順便做。這就是 Cron Triggers(排程觸發器) 的舞台。
先給一句話定義:Cron Triggers 是 Cloudflare Workers 提供的「定時觸發」機制——你用 cron 表達式描述「什麼時間該執行」,Cloudflare 就會在那些時間點自動喚醒你的 Worker,呼叫它匯出的 scheduled handler,完全不需要任何 HTTP 請求。 換句話說,它讓你的 Worker 從「被動的門房」升級成「會看時鐘做事的管家」。
打個比方:如果 fetch handler 像一間「隨時待命、有客人按鈴才開門」的接待櫃檯,那 scheduled handler 就像一個「上了鬧鐘的管家」——鬧鐘不響時它安靜休息、不佔資源;時間一到鬧鐘響起,它就自動起身,把該做的家事(倒垃圾、澆花、記帳)做完,然後繼續睡。你不需要在旁邊盯著、也不需要有人來喊它,鬧鐘(cron 表達式)才是它行動的唯一依據。 而且你可以上好幾個鬧鐘,每個對應不同的家事。
讀完本篇,你會掌握:
scheduledhandler 與 cron 表達式——scheduled(event, env, ctx)的三個參數、cron 五欄位語法怎麼讀,兩者如何對應wrangler.jsonc的triggers.crons設定——如何宣告一到多個排程,讓 Cloudflare 知道「幾點該叫醒你」event.cron/scheduledTime多排程分流——一個 Worker 掛多個 cron 時,如何判斷「這次是哪個排程觸發的」- 本地測試與正確架構——
wrangler dev --test-scheduled手動觸發、UTC 時區陷阱、ctx.waitUntil的必要性,以及長任務何時該交給 Queues / Workflows
核心概念
scheduled handler:排程專屬的進入點
在前幾篇,我們的 Worker 都匯出一個 fetch handler 來處理 HTTP 請求。Cron Triggers 則對應另一個進入點——scheduled handler。 當排程時間到,Cloudflare 不會呼叫 fetch,而是呼叫 scheduled。兩者可以並存於同一個 Worker(既服務 HTTP、又跑排程),互不干擾。
scheduled handler 的簽章是 scheduled(event, env, ctx),三個參數與 fetch 相似但第一個不同:
| 參數 | 型別 | 說明 |
|---|---|---|
event | ScheduledController | 本次排程事件的資訊,含 event.cron(觸發的 cron 字串)與 event.scheduledTime(預定執行時間,毫秒 Unix timestamp) |
env | Env | 所有 Bindings 與環境變數(KV、D1、R2、Queues 等),與 fetch 拿到的完全一樣 |
ctx | ExecutionContext | 生命週期控制,最重要的是 ctx.waitUntil(promise)——把非同步工作納入本次執行、等它完成再回收 |
最小骨架長這樣:
// src/index.ts —— 最小的 scheduled handler
export default {
async scheduled(event: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
// event.cron:觸發本次的 cron 表達式字串,例如 "0 0 * * *"
// event.scheduledTime:預定執行時間(毫秒 Unix timestamp)
console.log(`排程觸發!cron=${event.cron}, time=${new Date(event.scheduledTime).toISOString()}`);
// 把真正的工作包成 Promise,交給 waitUntil 確保它跑完才回收
ctx.waitUntil(doScheduledWork(env));
},
} satisfies ExportedHandler<Env>;
async function doScheduledWork(env: Env): Promise<void> {
// 這裡放你的排程邏輯:清理、同步、發報表……
}
注意:scheduled 沒有回傳「Response」的概念——它不是在回應誰,而是在「做一件事」。它的回傳型別是 Promise<void>。這也是為什麼 ctx.waitUntil 在這裡如此關鍵(下一節詳談):沒有 Response 幫你「撐住」執行,你得靠 waitUntil 明確告訴 Runtime 別太早收工。
cron 表達式:用五個欄位描述「什麼時間」
scheduled 負責「做什麼」,而「什麼時間做」由 cron 表達式決定。cron 表達式是一串由五個欄位組成、以空格分隔的字串,由左到右分別是:
┌───────────── 分鐘 (0 - 59)
│ ┌───────────── 小時 (0 - 23)
│ │ ┌───────────── 日 (1 - 31)
│ │ │ ┌───────────── 月 (1 - 12)
│ │ │ │ ┌───────────── 星期 (0 - 6,0 = 星期日)
│ │ │ │ │
* * * * *
每個欄位可以用四種寫法:
| 符號 | 意義 | 範例 |
|---|---|---|
* | 任意值(每一個) | 分鐘欄位寫 * = 每分鐘 |
, | 列舉多個值 | 小時欄位寫 9,17 = 9 點和 17 點 |
- | 範圍 | 星期欄位寫 1-5 = 週一到週五 |
/ | 間隔步進 | 分鐘欄位寫 */15 = 每 15 分鐘 |
搭配幾個常見範例,你就能秒讀 cron 了:
| cron 表達式 | 含義(UTC 時間) |
|---|---|
* * * * * | 每分鐘 |
*/5 * * * * | 每 5 分鐘 |
0 * * * * | 每小時整點(每小時的第 0 分) |
0 0 * * * | 每天 00:00(UTC 午夜) |
0 3 * * * | 每天 03:00(UTC) |
30 8 * * 1 | 每週一 08:30(UTC) |
0 0 1 * * | 每月 1 號 00:00(UTC) |
0 16 * * * | 每天 UTC 16:00 = 台灣時間隔天 00:00 |
最後一列是重點中的重點:Cloudflare 的 cron 一律以 UTC 解讀,沒有時區設定。你寫的每一個時刻都是 UTC。台灣是 UTC+8,所以「台灣時間每天午夜」要寫成 0 16 * * *(UTC 16:00)。這個時區陷阱後面「常見錯誤」還會再強調,因為它是最多人踩雷的地方。
wrangler.jsonc:把排程宣告給 Cloudflare
光有 scheduled handler 還不夠——Cloudflare 得知道「這個 Worker 想在哪些時間被叫醒」。這就要在 wrangler.jsonc 的 triggers.crons 陣列裡宣告你的 cron 表達式:
// wrangler.jsonc
{
"name": "my-scheduled-worker",
"main": "src/index.ts",
"compatibility_date": "2026-08-01",
"triggers": {
// crons 是一個陣列,可以同時掛多個排程
"crons": [
"0 16 * * *", // 每天 UTC 16:00(= 台灣 00:00),做每日清理
"*/15 * * * *" // 每 15 分鐘,做一次同步
]
}
}
triggers.crons 是陣列,代表一個 Worker 可以同時掛多個排程——上例掛了兩個。部署(wrangler deploy)之後,Cloudflare 就會依這些表達式定時觸發你的 scheduled handler。當你掛多個排程時,自然會遇到一個問題:scheduled 被呼叫時,怎麼知道這次是哪個 cron 觸發的? 這就要靠下一個工具——event.cron。
event.cron 與 scheduledTime:多排程如何分流
當一個 Worker 掛了多個 cron,所有觸發都會進到同一個 scheduled handler。要區分「這次是哪個排程」,就讀 event.cron——它是觸發本次的那條 cron 表達式字串,和你在 wrangler.jsonc 裡寫的一字不差。搭配 switch 就能把不同排程導向不同邏輯:
async scheduled(event: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
switch (event.cron) {
case "0 16 * * *":
// 每天午夜(台灣)的每日清理
ctx.waitUntil(dailyCleanup(env));
break;
case "*/15 * * * *":
// 每 15 分鐘的同步
ctx.waitUntil(syncData(env));
break;
default:
console.warn(`未知的 cron 觸發:${event.cron}`);
}
}
另一個參數 event.scheduledTime 則是本次預定執行的時間,以毫秒 Unix timestamp 表示。它很適合拿來做「以觸發時間為基準」的計算——例如「刪除比觸發時間早 7 天以上的資料」,用 event.scheduledTime 當基準會比 Date.now() 更精確、更可預期(因為它是排程「應該」執行的時刻,不受實際觸發的些微延遲影響)。
實作範例
概念齊了,我們把它串成幾個完整可執行的排程 Worker,涵蓋:每日清理、多 cron 分流、以及本地測試。
範例一:每日清理過期資料(單一 cron)
最經典的排程場景:每天定時清掉 KV 裡過期的 session。先看設定:
// wrangler.jsonc
{
"name": "cleanup-worker",
"main": "src/index.ts",
"compatibility_date": "2026-08-01",
"kv_namespaces": [
{ "binding": "SESSIONS", "id": "<你的 KV namespace id>" }
],
"triggers": {
"crons": ["0 16 * * *"] // UTC 16:00 = 台灣時間每天 00:00
}
}
再看 Worker 本體:
// src/index.ts —— 每日清理過期 session
interface Env {
SESSIONS: KVNamespace;
}
export default {
async scheduled(event: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
// 用 scheduledTime 當「現在」的基準,比 Date.now() 更可預期
const now = event.scheduledTime;
console.log(`開始每日清理,基準時間:${new Date(now).toISOString()}`);
// 把清理工作交給 waitUntil,確保它跑完才回收 Worker
ctx.waitUntil(cleanupExpiredSessions(env, now));
},
} satisfies ExportedHandler<Env>;
async function cleanupExpiredSessions(env: Env, now: number): Promise<void> {
// 列出所有 session key(實務上大量資料要處理分頁 cursor)
const list = await env.SESSIONS.list({ prefix: "session:" });
let deleted = 0;
for (const key of list.keys) {
// 假設每筆 session 的 metadata 存了 expiresAt(毫秒 timestamp)
const expiresAt = (key.metadata as { expiresAt?: number } | undefined)?.expiresAt;
if (expiresAt && expiresAt < now) {
await env.SESSIONS.delete(key.name);
deleted++;
}
}
console.log(`清理完成,刪除了 ${deleted} 筆過期 session`);
}
重點回顧:cron 0 16 * * * 是 UTC 16:00(台灣午夜);event.scheduledTime 拿來當清理基準;真正的清理邏輯包在 cleanupExpiredSessions,再由 ctx.waitUntil 接手,確保迴圈裡的每個 delete 都有機會跑完。
範例二:一個 Worker,多個排程分流
實務上一個 Worker 常常身兼數職:每日發報表、每小時同步、每 5 分鐘做健康檢查。用 event.cron 分流即可:
// wrangler.jsonc(片段)
{
"triggers": {
"crons": [
"0 0 * * *", // 每天 UTC 00:00:發送每日報表
"0 * * * *", // 每小時整點:同步外部資料
"*/5 * * * *" // 每 5 分鐘:健康檢查
]
}
}
// src/index.ts —— 多排程分流
export default {
async scheduled(event: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
console.log(`觸發排程:${event.cron}`);
switch (event.cron) {
case "0 0 * * *":
ctx.waitUntil(sendDailyReport(env, event.scheduledTime));
break;
case "0 * * * *":
ctx.waitUntil(syncExternalData(env));
break;
case "*/5 * * * *":
ctx.waitUntil(healthCheck(env));
break;
default:
// 掛了沒對應處理的 cron,記下來以便排查
console.warn(`收到未處理的 cron:${event.cron}`);
}
},
} satisfies ExportedHandler<Env>;
async function sendDailyReport(env: Env, scheduledTime: number): Promise<void> {
const date = new Date(scheduledTime).toISOString().slice(0, 10);
console.log(`產生 ${date} 的每日報表……`);
// 例:查 D1 統計 → 組報表 → 呼叫外部 email API 寄出
}
async function syncExternalData(env: Env): Promise<void> {
const res = await fetch("https://api.example.com/rates");
const data = await res.json();
console.log("同步完成", data);
// 例:寫入 KV / D1 供前端讀取
}
async function healthCheck(env: Env): Promise<void> {
console.log("執行健康檢查……");
// 例:ping 幾個關鍵依賴,異常就發告警
}
event.cron 的字串必須與 wrangler.jsonc 裡寫的完全一致(包含空格),switch 才比對得到。建議把 cron 字串抽成具名常數(例如 const CRON_DAILY_REPORT = "0 0 * * *"),避免手誤與重複。
範例三:本地測試——wrangler dev --test-scheduled
排程最惱人的地方是「總不能等到半夜真的觸發才知道對不對」。好在 Wrangler 提供了本地手動觸發的機制。用 --test-scheduled 旗標啟動開發伺服器:
# 啟動本地開發伺服器,並開啟排程測試端點
npx wrangler dev --test-scheduled
這會在本地額外開一個 /cdn-cgi/handler/scheduled 端點,讓你用 HTTP 請求「假裝」觸發排程:
# 觸發預設(第一個)cron 的 scheduled handler
curl "http://localhost:8787/cdn-cgi/handler/scheduled"
# 指定要模擬「哪一條 cron」觸發——event.cron 會收到這個值
curl "http://localhost:8787/cdn-cgi/handler/scheduled?cron=*/5+*+*+*+*"
用 ?cron= 帶入你想模擬的表達式(空格用 + 或 %20 編碼),event.cron 就會收到對應值,你就能在本地逐一驗證每條排程的分流邏輯,再配合 console.log 觀察輸出。這是排程開發的必備手段——先在本地把每條 cron 都手動觸發、確認邏輯無誤,再部署上線。
常見錯誤與最佳實踐
坑一:忘記 cron 是 UTC,排程總在「奇怪的時間」跑。
這是排程最常見、最讓人抓狂的坑。你想「每天台灣午夜清理」,直覺寫下 0 0 * * *,結果它其實在台灣早上 8 點跑(因為 UTC 00:00 = 台灣 08:00)。Cloudflare 的 cron 一律吃 UTC,沒有任何時區設定可調。 正確做法:寫 cron 前,先把你要的當地時間手動換算成 UTC,並在註解裡標清楚原始當地時間。台灣是 UTC+8,所以「台灣 00:00」= UTC 16:00 = 0 16 * * *。務必養成寫註解的習慣:"0 16 * * *" // 台灣時間每天 00:00,日後維護才不會再被自己騙。
坑二:忘記 ctx.waitUntil,非同步工作被中途掐斷。
scheduled handler 一旦同步返回,Runtime 就認定工作結束、開始回收環境——此時任何還在 await 的 fetch、還沒寫完的 KV/D1、還沒送出的 Queue 訊息都可能被掐斷,造成「只做一半」的隱性 bug。正確做法:把主要工作包成 async 函式,用 ctx.waitUntil(doWork(env)) 明確把它的 Promise 交給 Runtime,告訴它「等這個做完再回收我」。同時謹記絕不要解構 ctx——const { waitUntil } = ctx 會拋 “Illegal invocation” 錯誤,一定要保留 ctx.waitUntil(...) 的完整寫法。
坑三:把耗時的長任務整包塞進 scheduled,撞上 CPU 上限。
scheduled 一樣受 Workers 的 CPU 時間限制(觸發間隔 < 1 小時的排程約 30 秒、≥ 1 小時的約 15 分鐘),而且它是「單次觸發、單一執行」,沒有內建重試與斷點續傳。如果你在一個 scheduled 裡同步處理一萬筆資料、或呼叫上千次外部 API,很容易撞上 CPU 或子請求上限而被中斷,且失敗後前功盡棄。正確做法:把 scheduled 當「觸發器」而非「工作者」。 讓 cron 定時醒來,只負責「把待辦拆成訊息丟進 Queues」或「啟動一個 Workflow」——真正耗時的處理交給 Queues 的 consumer(可批次、可自動重試、可控併發)或 Workflows(可持久化、分步驟、單步失敗只重跑該步)。cron 決定「幾點該做」,Queues/Workflows 負責「把事做完」。
// 好架構:cron 只負責「派工」,不負責「幹活」
async scheduled(event: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
// 撈出這批要處理的項目(只做輕量查詢)
const items = await getPendingItems(env);
// 逐一丟進 Queue,耗時處理交給 consumer 慢慢做、可自動重試
ctx.waitUntil(
Promise.all(items.map((item) => env.TASK_QUEUE.send(item)))
);
}
坑四:排程失敗卻毫無所覺,因為沒有「回應」可看。
fetch 出錯,使用者會收到 500、你會在存取紀錄看到;但 scheduled 是無聲運作的,失敗了沒人會告訴你。正確做法:在 scheduled 裡務必用 try/catch 包住主邏輯並 console.error 記錄;開啟 Workers 的 Observability(Logs / Tail),或在 Cloudflare Dashboard 的 Worker 頁面查看 Cron Triggers 的「Past Events」執行紀錄(成功/失敗、耗時);關鍵排程失敗時主動發告警(例如打到 Slack webhook)。無聲的排程最危險,一定要讓它「失敗會出聲」。
最佳實踐小結: 記三條分水嶺——時間一律換算成 UTC 再寫進 cron(並註解當地時間)、非同步工作一律用 ctx.waitUntil 撐住、耗時工作一律交棒給 Queues / Workflows。守住這三條,你的排程就能既準時、又跑得完、還看得見。
小結
上一篇《WebSockets》,我們讓 Worker 學會「講電話」——用 WebSocketPair 與 101 升級握手做出雙向即時通訊。這一篇,我們讓 Worker 學會「看時鐘做事」,深入了 Cron Triggers 排程:
scheduledhandler——排程專屬進入點scheduled(event, env, ctx),回傳Promise<void>,和fetch並存於同一個 Worker、互不干擾。- cron 表達式——分鐘、小時、日、月、星期五個欄位,搭配
*/,/-//描述「什麼時間執行」;*/5 * * * *每 5 分鐘、0 16 * * *每天 UTC 16:00。 triggers.crons設定——在wrangler.jsonc用陣列宣告一到多個排程,部署後 Cloudflare 依表達式自動觸發。event.cron/scheduledTime——多排程掛在同一個 handler 時,用event.cron(觸發的表達式字串)switch分流,scheduledTime(毫秒 timestamp)當時間基準。- 本地測試與正確架構——
wrangler dev --test-scheduled+?cron=手動觸發驗證;守住「cron 吃 UTC」「一律ctx.waitUntil」「長任務交給 Queues / Workflows」三條鐵律。
Cron Triggers 補上了 Worker「主動、定時」的能力,讓你的邊緣程式從被動門房升級成會看時鐘的管家。不過到目前為止,我們用 env 存取 KV、D1、Queue 時,都把它當理所當然——這個把 Cloudflare 服務「注入」進 Worker 的機制,正是平台威力的核心。下一篇《Bindings 與 Node.js 相容》,我們會深入 Bindings 的運作原理、wrangler types 自動生成型別、以及 nodejs_compat 相容性 flag 如何讓你在邊緣用上熟悉的 Node.js 模組。
想深入官方 Cron Triggers 細節,可以隨時參考 Cloudflare 官方 Cron Triggers 文件。準備好讓你的 Worker 學會看時鐘做事了嗎?我們下一篇《Bindings 與 Node.js 相容》見。