DO Alarms 定時器:讓每個物件自己醒來做事 | Cloudflare 完整教學
上一篇《DO SQLite Storage》,我們讓 Durable Object 能建表、查詢、做原子交易,成了一個有結構的有狀態服務。但它還缺一種能力:在未來某個時間點「自己醒來」做事。這一篇,我們深入 Cloudflare 的 DO Alarms——每個 Durable Object 私有的定時器。你會學到
ctx.storage.setAlarm/getAlarm/deleteAlarm與alarm()handler、如何用「在 alarm 內重新排程」實作週期性任務、靠平台的自動重試做到可靠執行、用去抖動批次聚合省下大量寫入,並徹底分清 Alarms(每實例)與 Cron Triggers(全域)的差別。
前言
在 Durable Objects(持久物件,簡稱 DO) 裡,Alarm(定時器) 是一個很特別的能力:它讓每一個 DO 實例都能替自己排定一個未來的喚醒時間——時間一到,平台就會把這個 DO(即使它此刻正在休眠、記憶體已被釋放)重新叫醒,並執行你定義的 alarm() 方法。
打個比方。傳統的排程系統(例如 Linux 的 cron)像是辦公室牆上的一座大鬧鐘:它只有一個,到了設定的時刻,對整個辦公室響一次,誰該起身做事得靠這聲鈴自己判斷。而 DO Alarms 則像是發給每一位員工的一支私人手錶:每個人(每個 DO)可以各自設定自己的鬧鈴——「我這張訂單 30 分鐘後若還沒付款就提醒我」「我這個房間閒置 5 分鐘就自己清理」。每支手錶獨立運作,彼此互不干擾,而且手錶會替你記著:就算你打了個盹(DO 休眠),時間一到還是會把你叫醒。
更關鍵的是這支手錶很可靠:萬一你被叫醒後做事做到一半跌倒了(handler 拋出例外),它不會就此作罷,而是過一會兒再叫你一次,直到你把事情做完為止。這種「至少執行一次」的保證,正是 Alarms 相對於「自己在記憶體裡用 setTimeout」最大的價值。
這篇聚焦在「DO 的定時器」這一個主題(DO 的定位、儲存已在前兩篇講過,這裡不重複)。讀完你會掌握:
- Alarm 的三個 API 與
alarm()handler——setAlarm(設定/替換)、getAlarm(查詢)、deleteAlarm(取消),以及觸發時執行的alarm()方法 - 自我排程(self-scheduling)——為何 Alarm 是「一次性」的,以及如何「在 alarm 內重排下一次」來做週期性任務
- 可靠性與冪等——平台的自動重試(指數退避)如何運作,以及為什麼你的 handler 必須冪等
- 批次/去抖動與 Alarms vs Cron Triggers 的選型——用 Alarm 把高頻寫入聚合成一次,以及「每實例定時器」與「全域排程」的分水嶺
核心概念
一個 DO,一個 Alarm
先建立最重要的心智模型:每個 DO 實例同時只能有「一個」待觸發的 Alarm。它不是一組定時器、不是佇列,就是單一一個時間戳(timestamp)。當你呼叫 setAlarm(t),你是在說「請在時間點 t 叫醒我」;如果之前已經設過一個 Alarm,新的呼叫會直接「替換」掉舊的——不會累加成兩個。
這個「單一 Alarm」的模型看似受限,實際上非常好推理。它對應到一個具體的心智圖:
setAlarm(T) ──► [ DO 記下:下次喚醒 = T ] ──► (時間到 T)
│
▼
平台喚醒 DO,呼叫 alarm()
│
┌──────────────────────┴───────────────────────┐
▼ ▼
alarm() 正常結束 alarm() 中途 throw
│ │
Alarm 被消耗(清空) 平台指數退避,稍後重試 alarm()
│ │
(若要週期性,需在此再 setAlarm) (直到成功;handler 需冪等)
看懂這張圖,你就掌握了 Alarms 的全部精髓:觸發即消耗、失敗即重試、週期靠自排。
三個 API + 一個 handler
Alarm 的操作面其實很小,只有三個儲存 API 加一個生命週期方法:
| 名稱 | 型別 | 作用 |
|---|---|---|
ctx.storage.setAlarm(timestamp) | 設定 | 排定在 timestamp(毫秒 epoch)喚醒;已存在則替換 |
ctx.storage.getAlarm() | 查詢 | 回傳目前排定的時間戳,若無則為 null |
ctx.storage.deleteAlarm() | 取消 | 清除目前的 Alarm(若有) |
async alarm() | handler | Alarm 觸發時平台呼叫的方法,寫在 DO 類別裡 |
三個 API 都是非同步的(要 await,因為它們是對持久儲存的操作)。setAlarm 接受的是一個絕對時間戳(毫秒),所以「1 分鐘後」要寫成 Date.now() + 60_000,而不是傳「60000」這個間隔。
alarm() 則是你在 DO 類別裡必須自行實作的方法(名稱固定為 alarm)。它的執行環境和其他 RPC 方法一樣:能存取 this.ctx.storage、DO 的私有 SQLite、以及類別上的所有狀態。它最長可以執行 15 分鐘(比一般請求的 30 秒 CPU 上限寬鬆得多),適合跑較重的批次工作。
「至少執行一次」與自動重試
Alarms 提供的可靠性保證是 at-least-once(至少執行一次)。具體來說:
- 一旦 Alarm 被排定,它就被持久化了。即使 DO 此刻休眠、被驅逐(eviction),甚至底層機器重啟,到了時間點平台仍會把 DO 叫醒執行
alarm()。 - 如果
alarm()執行時拋出未捕捉的例外,平台會以指數退避(exponential backoff)自動重試整個 handler,直到它成功結束為止。你不需要自己寫重試迴圈。
這是 Alarms 最迷人的地方,但「至少一次」有它的另一面:同一個 alarm() 可能被執行不只一次(重試時)。因此你的 handler 必須是冪等(idempotent) 的——重複執行不能造成重複發信、重複扣款、重複計數。這一點後面會用程式碼具體示範。
關鍵術語速查
| 術語 | 一句話定義 |
|---|---|
setAlarm(t) | 排定在絕對時間戳 t(毫秒)喚醒;已存在則替換 |
alarm() | Alarm 觸發時執行的 handler,寫在 DO 類別裡 |
| 自我排程(self-scheduling) | 在 alarm() 內再次 setAlarm,形成週期性循環 |
| at-least-once | 保證至少執行一次;失敗指數退避重試,故 handler 需冪等 |
| 去抖動(debounce) | 用 Alarm 把短時間內大量觸發「合併」成一次批次處理 |
| Cron Triggers | Worker 全域、固定時刻的排程,與「每實例」的 Alarm 不同 |
實作範例
我們用三個由淺入深的例子貫穿本文:(1) 最小的一次性提醒、(2) 自我排程的週期性心跳、(3) 去抖動的批次聚合寫入。三個例子都以「新專案已用 new_sqlite_classes 啟用 SQLite backend」為前提(見上一篇《DO SQLite Storage》)。
1. 最小可執行:一次性提醒
先看最基礎的形態:設定一個 Alarm,時間到執行一次 alarm()。這個 DO 代表「一張訂單」,下單後 15 分鐘若仍未付款就標記為逾期。
// src/index.ts
import { DurableObject } from "cloudflare:workers";
export interface Env {
ORDER: DurableObjectNamespace<Order>;
}
export class Order extends DurableObject<Env> {
private sql = this.ctx.storage.sql;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
// 用一張單列表保存這張訂單的狀態(paid / expired)
this.sql.exec(`
CREATE TABLE IF NOT EXISTS state (
k TEXT PRIMARY KEY,
v TEXT NOT NULL
);
`);
});
}
// 下單:排定 15 分鐘後檢查是否逾期(RPC 方法)
async create(): Promise<void> {
this.sql.exec(
`INSERT INTO state (k, v) VALUES ('status', 'pending')
ON CONFLICT(k) DO UPDATE SET v = 'pending';`
);
// 關鍵:setAlarm 接受「絕對時間戳」,所以是 now + 間隔
await this.ctx.storage.setAlarm(Date.now() + 15 * 60_000);
}
// 使用者付款:清掉那個還沒觸發的 Alarm(RPC 方法)
async pay(): Promise<void> {
this.sql.exec(
`UPDATE state SET v = 'paid' WHERE k = 'status';`
);
// 已付款就不需要逾期檢查了,取消 Alarm
await this.ctx.storage.deleteAlarm();
}
// Alarm 觸發時,平台自動呼叫這個方法
async alarm(): Promise<void> {
const { v } = this.sql
.exec<{ v: string }>(`SELECT v FROM state WHERE k = 'status';`)
.one();
// 冪等:只有仍是 pending 才標記逾期;重複觸發也安全
if (v === "pending") {
this.sql.exec(`UPDATE state SET v = 'expired' WHERE k = 'status';`);
// 這裡可以再做:通知使用者、釋放庫存……
}
}
}
三個重點:(1) setAlarm 傳的是 Date.now() + 間隔,是絕對時間戳;(2) 使用者付款後我們主動 deleteAlarm,避免無謂觸發;(3) alarm() 裡先讀狀態、只有 pending 才動作——這就是冪等,即使因重試被叫醒兩次也不會出錯。
2. 自我排程:週期性心跳
Alarm 是一次性的:alarm() 跑完,這個 Alarm 就被消耗掉了。想做「每 60 秒跑一次」的週期性任務,唯一正解是在 alarm() 的尾端,自己再排下一次——這就是自我排程(self-scheduling)。
// 一個定期把記憶體中累積的指標寫入 SQLite 的 DO
export class Aggregator extends DurableObject<Env> {
private sql = this.ctx.storage.sql;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
this.sql.exec(`
CREATE TABLE IF NOT EXISTS ticks (
at INTEGER PRIMARY KEY,
note TEXT NOT NULL
);
`);
});
}
// 啟動週期性任務:只需排第一次,之後由 alarm() 自我延續(RPC 方法)
async start(): Promise<void> {
// 用 getAlarm 檢查,避免重複啟動又疊加(其實 setAlarm 會替換,但語意更清楚)
const existing = await this.ctx.storage.getAlarm();
if (existing === null) {
await this.ctx.storage.setAlarm(Date.now() + 60_000);
}
}
async alarm(): Promise<void> {
// ── 做這一輪該做的事 ──
this.sql.exec(
`INSERT INTO ticks (at, note) VALUES (?, ?);`,
Date.now(),
"heartbeat"
);
// ── 關鍵:在尾端重排下一次,形成週期循環 ──
// 漏掉這行 => Alarm 只會跑第一次就停
await this.ctx.storage.setAlarm(Date.now() + 60_000);
}
// 停止週期性任務:清掉 Alarm,循環就中斷(RPC 方法)
async stop(): Promise<void> {
await this.ctx.storage.deleteAlarm();
}
}
這裡的因果鏈要記牢:Alarm 一次性 → 週期性需自排 → 重排寫在 alarm() 尾端。少了最後那行 setAlarm,你的「心跳」只會跳一下。start() 只需排「第一次」,之後便由 alarm() 自己接力;stop() 用 deleteAlarm 打斷循環。
一個進階的細節:若你希望固定頻率(每分鐘的第 0 秒觸發,而非「上次跑完 +60 秒」導致慢慢飄移),可以把下一次時間對齊到整分鐘,例如 Math.ceil(Date.now() / 60_000) * 60_000,避免因 handler 執行耗時累積出的時間漂移。
3. 去抖動:批次聚合寫入
Alarms 有一個極實用的模式——去抖動(debounce)批次聚合。假設你的 DO 每秒收到數百次「增加計數」的請求,若每次都立刻寫 SQLite,寫入量會很可觀。更好的做法是:把增量先累積在記憶體,並排定「1 秒後」一個 Alarm;1 秒內無論來多少次請求都只是累加,不重排更晚的時間——時間到了,alarm() 一次把這一秒的總量寫進 SQLite。
// 高頻計數器:用 Alarm 把大量增量「去抖動」成每秒一次批次寫入
export class HotCounter extends DurableObject<Env> {
private sql = this.ctx.storage.sql;
private pending = 0; // 記憶體中累積、尚未落地的增量
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
this.sql.exec(`
CREATE TABLE IF NOT EXISTS counter (
id INTEGER PRIMARY KEY CHECK (id = 1),
total INTEGER NOT NULL
);
`);
this.sql.exec(
`INSERT INTO counter (id, total) VALUES (1, 0)
ON CONFLICT(id) DO NOTHING;`
);
});
}
// 高頻呼叫:只累加記憶體,並「確保有一個」1 秒後的 Alarm(RPC 方法)
async bump(n = 1): Promise<void> {
this.pending += n;
// 去抖動關鍵:只有在「還沒排 Alarm」時才排,
// 已排定的話就沿用同一個到期時間,不往後延
if ((await this.ctx.storage.getAlarm()) === null) {
await this.ctx.storage.setAlarm(Date.now() + 1_000);
}
}
async alarm(): Promise<void> {
const flush = this.pending;
if (flush === 0) return;
// 先把待寫量歸零,再落地(即使重試,pending 已清空也不會重複寫兩份)
this.pending = 0;
this.sql.exec(
`UPDATE counter SET total = total + ? WHERE id = 1;`,
flush
);
// 不重排:沒有新的 bump 就不需要下一次 Alarm(下次 bump 會自己排)
}
async total(): Promise<number> {
// 讀取時把尚未落地的 pending 也算進去,回傳即時值
const { total } = this.sql
.exec<{ total: number }>(`SELECT total FROM counter WHERE id = 1;`)
.one();
return total + this.pending;
}
}
這個模式把「數百次寫入」壓成「每秒一次寫入」,大幅降低寫入成本,同時 total() 讀取時把 pending 加回去,對外看起來仍是即時的。注意 bump 裡的判斷——已有 Alarm 就不重排,才能達到「1 秒內合併」的去抖動效果;若每次 bump 都往後延 1 秒,持續的流量會讓它永遠不觸發(這是常見的去抖動 vs 節流的差異,這裡要的是「固定視窗批次」)。
4. 從 Worker 觸發
上述 RPC 方法從 Worker 端呼叫的方式,與前幾篇完全一致——getByName 取得 stub 後直接呼叫:
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const stub = env.HOT_COUNTER.getByName("page-views:home");
await stub.bump(1); // 觸發去抖動累加,Alarm 由 DO 內部自理
return new Response("ok");
},
} satisfies ExportedHandler<Env>;
Worker 完全不用管 Alarm 的排程與觸發——那是 DO 內部自治的行為。這正是 Alarms 的優雅之處:排程邏輯內聚在「擁有狀態的那個實體」裡。
常見錯誤與最佳實踐
坑一:以為 Alarm 是週期性的,結果只跑一次。
這是頭號誤解。Alarm 是一次性的——alarm() 跑完就被消耗。要週期性,必須在 alarm() 內自己再 setAlarm。
// ❌ 錯誤:期待它每分鐘跑,但只會跑第一次
async alarm() {
await this.doWork();
// 少了重排,循環就斷了
}
// ✅ 正確:在尾端重排下一次,形成自我延續的週期
async alarm() {
await this.doWork();
await this.ctx.storage.setAlarm(Date.now() + 60_000);
}
坑二:handler 不冪等,重試時造成重複副作用。
因為是 at-least-once,alarm() 可能被執行多次(失敗重試時)。若 handler 裡直接「發信/扣款/計數」而不檢查狀態,重試就會重複觸發。正解是先檢查狀態或先標記,讓重複執行無害。
// ❌ 錯誤:重試會重複發信
async alarm() {
await sendEmail(this.userEmail); // 若後面 throw 被重試,信會發第二次
this.sql.exec(`UPDATE sub SET reminded = 1;`);
}
// ✅ 正確:先檢查旗標,冪等
async alarm() {
const { reminded } = this.sql
.exec<{ reminded: number }>(`SELECT reminded FROM sub WHERE id = 1;`)
.one();
if (reminded) return; // 已處理過,直接返回
await sendEmail(this.userEmail);
this.sql.exec(`UPDATE sub SET reminded = 1;`);
}
坑三:誤把「間隔」當「時間戳」傳進 setAlarm。
setAlarm 收的是絕對時間戳(毫秒 epoch),不是「幾秒後」的間隔。傳 60_000 會被解讀成「1970 年 1 月 1 日 00:01」——一個早已過去的時間,結果 Alarm 幾乎立刻就觸發。
// ❌ 錯誤:被當成 1970 年的絕對時間 => 立刻觸發
await this.ctx.storage.setAlarm(60_000);
// ✅ 正確:現在時間 + 間隔
await this.ctx.storage.setAlarm(Date.now() + 60_000);
坑四:忽略時區,用「本地時間」推算排程。
Workers 執行環境的時鐘是 UTC,Date.now() 回傳的是與時區無關的 epoch 毫秒。若你要排「每天台灣時間早上 9 點」這類掛鐘時間,務必自己換算 UTC 偏移(台灣為 UTC+8,即 UTC 的凌晨 1 點),別假設本地時區。單純的相對延遲(「30 分鐘後」)則沒有時區問題,直接 Date.now() + 間隔 即可。
坑五:該用 Cron 的全域工作,硬塞進某個 DO 的 Alarm。
這牽涉到最重要的一個選型判斷:Alarms(每實例)vs Cron Triggers(全域)。
| 判斷維度 | 用 DO Alarms | 用 Cron Triggers |
|---|---|---|
| 排程粒度 | 每個 DO 實例各自獨立的定時器 | Worker 全域、一條 cron 表達式觸發一次 |
| 觸發時有無實體上下文 | 有:人就在該 DO 內,能存取其私有狀態 | 無:全域 handler,拿不到特定實體 |
| 時間點 | 任意、動態(如「這張訂單 15 分後」) | 固定時刻(如每天午夜、每 5 分鐘) |
| 可靠性 | at-least-once,失敗指數退避重試 | 到點觸發(不針對單一實體重試語意) |
| 典型場景 | 訂閱到期提醒、閒置房間清理、去抖動聚合 | 每晚產全站報表、定時清全域快取、定時輪詢 |
一句話選法:「對某個特定實體、在未來某刻做事」用 Alarms;「對全站、在固定時刻做一件事」用 Cron Triggers。 兩者不互斥:大型系統常讓 Cron 負責「全域掃描並喚醒一批 DO」,再由各 DO 用自己的 Alarm 做精細後續。
最佳實踐小結:記牢——setAlarm 傳絕對時間戳(Date.now() + 間隔);週期性務必在 alarm() 尾端重排;handler 一律寫成冪等(因為 at-least-once 會重試);去抖動聚合時「已有 Alarm 就不重排」;掛鐘時間記得換算 UTC;全域固定排程改用 Cron Triggers。守住這幾條,你的每實例定時器就既可靠又正確。
小結
上一篇《DO SQLite Storage》,我們讓每個 DO 擁有一個內建的 SQLite 資料庫,能建表、查詢、做原子交易;這一篇,我們替它裝上了定時器,讓它能在未來某刻自己醒來做事:
- 一個 DO,一個 Alarm——
setAlarm(設定/替換)、getAlarm(查詢)、deleteAlarm(取消)三個 API,加上觸發時執行的async alarm()handler。setAlarm收的是絕對時間戳。 - 一次性 + 自我排程——Alarm 觸發即消耗;要做週期性任務,必須在
alarm()尾端自己再排下一次,形成自我延續的循環。 - at-least-once + 冪等——失敗時平台以指數退避自動重試整個 handler,你不必自寫重試;但同一個 handler 可能執行多次,所以務必寫成冪等。
- 去抖動批次 + Alarms vs Cron——用「已有 Alarm 就不重排」把高頻寫入聚合成每秒一次;而「每實例動態定時」用 Alarms、「全域固定時刻」用 Cron Triggers。
現在你的 DO 不只能存值、查詢、交易,還能替自己排定可靠、可重試的定時任務,成了一個能「自主行動」的有狀態實體。但 DO 最招牌的能力我們還沒碰:長時間持有 WebSocket 連線,並在空閒時休眠以省下計費。下一篇《DO WebSocket Hibernation》,我們就深入 ctx.acceptWebSocket 與休眠 API,看每個 DO 如何在不斷線的前提下優雅地打盹。
想先查閱官方對 Durable Objects Alarms 的完整說明,可以隨時參考 Cloudflare Durable Objects Alarms 官方文件。每實例的可靠定時器已經到手,我們下一篇《DO WebSocket Hibernation》見。