跳到內容

DO Alarms:內建的排程與去抖動

查證日期
驗證環境wrangler@4.114.0·workerd@1.20260722.1·compatibility_date: 2026-07-24

第 16 篇的結論之一是「週期性工作一律用 alarm,不要用 setInterval」。這篇處理 alarm 本身。

Alarm 是每個 Durable Object 自帶的一個排程器。它取代了大部分「我需要一個 cron」的直覺 —— 因為 cron 是全域的,而 alarm 是每個實體各自一份

三件必須先講清楚的事:

  1. 它是 at-least-once,會重試。 實測失敗的 alarm 總共執行了 7 次(retryCount 0 到 6)。你的 handler 必須 idempotent。
  2. 重試用完之後,alarm 就被靜默丟棄了。 沒有 dead letter、沒有通知。這是本篇最重要的運維知識。
  3. deleteAll() 現在會連 alarm 一起刪。 2026-02-24 之後的 compat date 預設如此,這會讓經典的「自毀」模式靜默改變行為。

this.ctx.storage.setAlarm(Date.now() + 30_000); // ms since epoch
const at = await this.ctx.storage.getAlarm(); // number | null
await this.ctx.storage.deleteAlarm();

處理器是類別上的方法:

async alarm(alarmInfo?: { retryCount: number; isRetry: boolean }): Promise<void> { }

一個物件同時只能有一個 alarmsetAlarm() 會覆蓋既有的。實測:

Terminal window
$ curl -s localhost:8787/armtwice
{
"first": 1785557688457,
"afterFirst": 1785557688457,
"second": 1785557718457,
"afterSecond": 1785557718457,
"afterDelete": null,
"overwritten": true
}

這個「只有一個」的限制不是缺陷,是去抖動的基礎(見下面)。需要多個獨立排程時,把時間表存進 storage、只用一個 alarm 指向最近的那個。

第 8 篇說過 KV 每個 key 每秒只能寫 1 次,第 15 篇說過 DO 的 row write 要錢。所以高頻寫入必須先在記憶體/本地聚合,再批次落地。

Alarm 讓這件事變得很簡單:

async hit(): Promise<void> {
this.ctx.storage.sql.exec("UPDATE pending SET n = n + 1 WHERE id = 1");
// Arm ONLY if nothing is armed. A thousand hits still produce one flush.
if (await this.ctx.storage.getAlarm() === null) {
this.ctx.storage.setAlarm(Date.now() + 2000);
}
}
async alarm(): Promise<void> {
const { n } = this.ctx.storage.sql.exec<{ n: number }>("SELECT n FROM pending WHERE id = 1").one();
if (n > 0) {
await this.flushTo(this.env.DB, n);
this.ctx.storage.sql.exec("UPDATE pending SET n = 0 WHERE id = 1");
}
}

實測五次 hit()

Terminal window
$ curl -s "localhost:8787/hit?n=5"
{ "results": [
{ "armedNew": true, "alarmAt": 1785557630671 },
{ "armedNew": false, "alarmAt": 1785557630671 },
{ "armedNew": false, "alarmAt": 1785557630671 },
{ "armedNew": false, "alarmAt": 1785557630671 },
{ "armedNew": false, "alarmAt": 1785557630671 }
]}
# 4 秒後
$ curl -s localhost:8787/state
{
"alarm": null,
"pending": 0,
"events": [
{ "kind": "alarm", "note": "retryCount=0 isRetry=false mode=ok" },
{ "kind": "flush", "note": "n=5" }
]
}

五次寫入,一次 flush。 而且 alarm 觸發後自動清空(alarm: null)。

getAlarm() === null 這個檢查是整個模式的核心 —— 沒有它,每次 hit() 都會把 alarm 往後推,變成「只要一直有流量就永遠不 flush」。

🔴 重試:7 次嘗試,然後靜默消失

Section titled “🔴 重試:7 次嘗試,然後靜默消失”

官方說法:

「Alarms have guaranteed at-least-once execution and are retried automatically when the alarm() handler throws.」

「Retries are performed using exponential backoff starting at a 2 second delay from the first failure with up to 6 retries allowed.」

我讓一個 alarm handler 永遠 throw,觀察 200 秒:

t+20s: attempts=4 alarmPending=True [retryCount=0, 1, 2, 3]
t+40s: attempts=5 alarmPending=True [retryCount=0..4]
t+80s: attempts=6 alarmPending=True [retryCount=0..5]
t+140s: attempts=7 alarmPending=False [retryCount=0..6]
t+200s: attempts=7 alarmPending=False (不再變化)

總共 7 次執行:1 次原始 + 6 次重試。 累積延遲 2+4+8+16+32+64 = 126 秒,和文件的指數退避一致。

然後 —— alarm 就消失了。 alarmPending: false,沒有任何錯誤事件、沒有 dead letter queue、沒有通知。

這是本篇最重要的運維結論:

重試用完之後,那個排程工作就永遠不會發生了,而且沒有人會告訴你。

實務上必須自己補上可觀測性:

async alarm(alarmInfo?: { retryCount: number; isRetry: boolean }): Promise<void> {
try {
await this.doWork();
} catch (err) {
console.log(JSON.stringify({
event: "alarm_failed",
do: this.ctx.id.name,
retryCount: alarmInfo?.retryCount ?? 0,
lastAttempt: (alarmInfo?.retryCount ?? 0) >= 6, // <- alert on this
error: String(err),
}));
throw err; // rethrow so the retry actually happens
}
}

retryCount >= 6 的那一筆 log 就是「這個工作即將永久消失」的訊號。 第 39 篇會把它變成告警規則。

Terminal window
retryCount=0 isRetry=false 第一次
retryCount=1 isRetry=true 第一次重試
retryCount=2 isRetry=true 第二次重試(成功)

因為是 at-least-once,你的 handler 可能重複執行同一份工作。三種處理方式:

① 讓工作本身 idempotent(最好)

// Upsert keyed on the bucket — running twice is harmless.
env.DB.prepare(
`INSERT INTO click_rollup (link_id, bucket, count) VALUES (?, ?, ?)
ON CONFLICT(link_id, bucket) DO UPDATE SET count = excluded.count`
).bind(linkId, bucket, n);

② 先標記再執行

const claimId = crypto.randomUUID();
this.ctx.storage.sql.exec("UPDATE pending SET claim = ? WHERE id = 1 AND claim IS NULL", claimId);

③ 用 isRetry 分支(只在重試時做額外的檢查)

if (alarmInfo?.isRetry) {
const already = await this.alreadyFlushed(bucket);
if (already) return;
}

⚠️ 注意 alarmInfo 的型別是 optional 的。用 alarmInfo?.retryCount ?? 0 而不是直接存取。

Terminal window
$ curl -s localhost:8787/deleteall
{ "alarmBefore": 1785557748545, "alarmAfter": null, "cleared": true }

Compat flag delete_all_deletes_alarm2026-02-24 之後的 compat date 預設開啟(要關掉用 delete_all_preserves_alarm)。官方原文:

「With the delete_all_deletes_alarm flag set, calling deleteAll() on a Durable Object’s storage will delete any active alarm in addition to all stored data.」

這會讓一個經典模式靜默改變行為。 「自毀」的寫法通常是:

async alarm() {
await this.ctx.storage.deleteAll(); // clean up
// ...and rely on a previously-set alarm to fire once more later
}

在舊的 compat date 下,那個後續 alarm 還在;2026-02-24 之後它被一併刪掉了。升級 compat date 時要特別檢查所有 deleteAll() 的呼叫點。

(順帶複習第 15 篇:deleteAll() 還會把 SQL 表整個 DROP 掉。所以它比多數人以為的更徹底。)

第 16 篇說過 setInterval 會阻止 hibernation,而 alarm 不會 —— 這是兩者最實際的差別。

官方對 alarm 的描述是「Events such as alarms, incoming requests, and scheduled callbacks prevent hibernation」,意思是 alarm 觸發時會讓物件醒著。但在兩次 alarm 之間,物件可以被回收。

⚠️ 一個文件沒有明說的行為:一個已經被回收的物件,pending 的 alarm 到期時會不會被喚醒?從實作上這是必然的(alarm 存在 storage 裡,觸發時重建物件並先跑 constructor 再跑 alarm()),而且我在本機觀察到的行為與此一致。但官方文件沒有一句話明確這樣寫,所以本文把它描述為觀察到的行為而非引用。

另外要記住:alarm() 觸發時 constructor 會先跑,所以 schema 建立、auto-response 設定都會重新執行。這也是第 15 篇建議把 schema 抽成 ensureSchema() 的另一個理由。

ctx.id.namealarm() 裡是可用的 —— 這對 log 很重要,因為你需要知道是哪個物件的 alarm 失敗了。

項目
每個物件的 alarm 數1setAlarm 覆蓋)
併發「Only one instance of alarm() will ever run at a given time per Durable Object instance」
Wall time15 分鐘(HTTP/RPC 是「呼叫端連著就不限」)
重試最多 6 次,指數退避從 2 秒起
計費每次 alarm 觸發算一個 request
排程精度未文件化 —— 不要假設任何精確度

那個 15 分鐘的 wall time 是設計上的關鍵約束:長時間的工作必須自己重新排程。

async alarm(): Promise<void> {
const batch = this.nextBatch(500);
await this.process(batch);
if (this.hasMore()) {
this.ctx.storage.setAlarm(Date.now() + 1000); // continue in a new alarm
}
}
AlarmCron Trigger(第 20 篇)Workflows(第 21 篇)
粒度每個實體各自一份全域,每個 Worker每個 instance
排程任意時間點cron 運算式step.sleep / sleepUntil
重試6 次,然後靜默丟棄controller.noRetry()每個 step 可設定
狀態DO storagedurable,跨 step
上限15 分鐘15 分鐘可睡數月
適合去抖動、per-entity TTL、批次 flush全域清理、報表觸發多步驟長流程

判準

  • 每個 X 各自要在某個時間做某件事」→ alarm。不需要掃全表找誰到期了。
  • 每天凌晨做一次全域的事」→ cron。
  • 這個流程有五個步驟、中間要等人審核」→ Workflows。

第 20 篇會再對照一次。


完整程式碼:examples/ch17-do-alarms/

Terminal window
cd examples/ch17-do-alarms && npm install && npm run dev
Terminal window
B=localhost:8787
curl -s "$B/reset"
curl -s "$B/armtwice" # 一個物件一個 alarm
curl -s "$B/deleteall?o=da" # deleteAll 清掉 alarm
curl -s "$B/hit?n=5" # 五次 hit
sleep 4 && curl -s "$B/state" # 一次 flush,n=5

重現重試(會跑約 2 分鐘):

Terminal window
curl -s "$B/reset?o=fail"
curl -s "$B/mode?o=fail&m=throw"
curl -s "$B/arm?o=fail&ms=500"
# 每 20 秒查一次
watch -n 20 "curl -s '$B/state?o=fail' | jq '[.alarm, (.events|length)]'"

你會看到 attempts 從 1 爬到 7,然後 alarm 變成 null 而 attempts 不再增加 —— 那個工作永久消失了


Alarm 在 LinkForge 有三個用途。

第 15 篇建好了 schema,這裡接上去:

async record(country: string): Promise<void> {
const bucket = Math.floor(Date.now() / 60_000) * 60_000;
this.ctx.storage.sql.exec(
`INSERT INTO clicks (bucket, n) VALUES (?, 1) ON CONFLICT(bucket) DO UPDATE SET n = n + 1`,
bucket,
);
// Debounce: arm only if nothing is armed.
if (await this.ctx.storage.getAlarm() === null) {
this.ctx.storage.setAlarm(Date.now() + 30_000);
}
}
async alarm(alarmInfo?: { retryCount: number; isRetry: boolean }): Promise<void> {
const rows = this.ctx.storage.sql
.exec<{ bucket: number; n: number }>("SELECT bucket, n FROM clicks WHERE flushed = 0")
.toArray(); // consume before any await (ch15)
if (rows.length === 0) return;
try {
// Idempotent: the rollup row is keyed on (link_id, bucket) and we write an
// absolute value, so a retry produces the same result.
await this.env.DB.batch(
rows.map((r) =>
this.env.DB.prepare(
`INSERT INTO click_rollup (link_id, bucket, count) VALUES (?, ?, ?)
ON CONFLICT(link_id, bucket) DO UPDATE SET count = excluded.count`,
).bind(this.ctx.id.name, r.bucket, r.n),
),
);
this.ctx.storage.sql.exec("UPDATE clicks SET flushed = 1 WHERE flushed = 0");
await this.notifyDashboard(rows);
} catch (err) {
console.log(JSON.stringify({
event: "alarm_failed", do: this.ctx.id.name,
retryCount: alarmInfo?.retryCount ?? 0,
lastAttempt: (alarmInfo?.retryCount ?? 0) >= 6,
error: String(err),
}));
throw err;
}
}

四個決策:

① 寫入 D1 的是絕對值不是增量。 ON CONFLICT ... SET count = excluded.count 讓重試無害。如果寫成 count = count + ?,一次重試就會讓數字翻倍 —— 而 at-least-once 保證了重試會發生。

② 用 flushed 旗標而不是刪除。 保留原始資料到 D1 確認寫入之後,重試才有東西可用。

retryCount >= 6 的 log 是告警來源。 那代表這 30 秒的點擊資料即將永久遺失。

④ 30 秒而不是 5 秒。 這是「儀表板即時性」和「D1 寫入成本」的取捨。第 15 篇的分桶是每分鐘,所以 30 秒的 flush 頻率保證每個桶最多被寫兩次。

第 16 篇留下的:

async alarm(): Promise<void> {
const closed = await this.pruneStale();
// Re-arm only if there is still anyone connected — otherwise let the object
// hibernate with no alarm at all.
if (this.ctx.getWebSockets().length > 0) {
this.ctx.storage.setAlarm(Date.now() + 60_000);
}
}

沒有連線時就不重新排程。 這樣一個沒人看的儀表板物件完全不會被喚醒,符合第 16 篇的成本模型。

// When a link with expires_at is created:
this.ctx.storage.setAlarm(expiresAt);

這就是 alarm 相對 cron 的核心優勢。 用 cron 的話你得每分鐘掃一次 links 表找出到期的(第 9 篇:rows_read 就是帳單)。用 alarm 的話,每個連結自己知道什麼時候到期。

用途週期重試失敗的後果告警
Click flush30 s該時段統計永久遺失retryCount >= 6
Stale prune60 s(有連線時)幽靈連線累積低優先
Link expiry一次性過期連結仍可用retryCount >= 6

本篇交付物LinkCounter.alarm() 完整實作(含 idempotent 寫入與告警 log)、LiveDashboard 的條件式重新排程、連結過期 alarm、以及上面那張 ADR 表。


① handler 不 idempotent

at-least-once 保證了重試會發生。用 upsert + 絕對值。

② 沒有針對重試耗盡告警

7 次之後 alarm 靜默消失。retryCount >= 6 就是最後一次機會。

③ 每次事件都 setAlarm()

會把 flush 無限往後推。先檢查 getAlarm() === null

④ 以為可以排多個 alarm

一個物件一個。setAlarm 覆蓋。

⑤ 升級 compat date 沒檢查 deleteAll()

2026-02-24 起它會連 alarm 一起刪。

⑥ 忘記 deleteAll() 也會 DROP 表

第 15 篇。之後所有 SQL 都會炸。

⑦ alarm 裡跑超過 15 分鐘

wall time 上限。長工作要自己重新排程。

⑧ 直接存取 alarmInfo.retryCount

參數是 optional 的。用 ?? 0

⑨ 用 setInterval 代替 alarm

第 16 篇:會毀掉 hibernation。

⑩ 假設排程精度

官方沒有文件化任何精度保證。


  1. **重試 6 次之後 alarm 靜默消失。**沒有 dead letter。retryCount >= 6 的 log 是你唯一的訊號。
  2. getAlarm() === null 才 arm —— 這一行就是去抖動的全部。
  3. **寫絕對值不寫增量。**at-least-once 代表重試一定會發生,增量會讓數字翻倍。


下一篇18. Workers RPC 與 Service Bindings —— Part 3 收尾。把單體拆成多個 Worker 而不付出網路代價,以及 promise pipelining 怎麼把多趟往返壓成一趟。