DO Alarms 定時器:讓每個物件自己醒來做事 | Cloudflare 完整教學

2026/08/25
DO Alarms 定時器:讓每個物件自己醒來做事 | Cloudflare 完整教學

上一篇《DO SQLite Storage》,我們讓 Durable Object 能建表、查詢、做原子交易,成了一個有結構的有狀態服務。但它還缺一種能力:在未來某個時間點「自己醒來」做事。這一篇,我們深入 CloudflareDO Alarms——每個 Durable Object 私有的定時器。你會學到 ctx.storage.setAlarm / getAlarm / deleteAlarmalarm() 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()handlerAlarm 觸發時平台呼叫的方法,寫在 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 TriggersWorker 全域、固定時刻的排程,與「每實例」的 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 AlarmsCron 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》見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →