DO Alarms:內建的排程與去抖動
這篇要解決的問題
Section titled “這篇要解決的問題”第 16 篇的結論之一是「週期性工作一律用 alarm,不要用 setInterval」。這篇處理 alarm 本身。
Alarm 是每個 Durable Object 自帶的一個排程器。它取代了大部分「我需要一個 cron」的直覺 —— 因為 cron 是全域的,而 alarm 是每個實體各自一份。
三件必須先講清楚的事:
- 它是 at-least-once,會重試。 實測失敗的 alarm 總共執行了 7 次(retryCount 0 到 6)。你的 handler 必須 idempotent。
- 重試用完之後,alarm 就被靜默丟棄了。 沒有 dead letter、沒有通知。這是本篇最重要的運維知識。
deleteAll()現在會連 alarm 一起刪。 2026-02-24 之後的 compat date 預設如此,這會讓經典的「自毀」模式靜默改變行為。
每個物件一個 alarm
Section titled “每個物件一個 alarm”this.ctx.storage.setAlarm(Date.now() + 30_000); // ms since epochconst at = await this.ctx.storage.getAlarm(); // number | nullawait this.ctx.storage.deleteAlarm();處理器是類別上的方法:
async alarm(alarmInfo?: { retryCount: number; isRetry: boolean }): Promise<void> { }一個物件同時只能有一個 alarm,setAlarm() 會覆蓋既有的。實測:
$ curl -s localhost:8787/armtwice{ "first": 1785557688457, "afterFirst": 1785557688457, "second": 1785557718457, "afterSecond": 1785557718457, "afterDelete": null, "overwritten": true}這個「只有一個」的限制不是缺陷,是去抖動的基礎(見下面)。需要多個獨立排程時,把時間表存進 storage、只用一個 alarm 指向最近的那個。
🎯 去抖動:alarm 最重要的用途
Section titled “🎯 去抖動: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():
$ 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 篇會把它變成告警規則。
alarmInfo 與 idempotency
Section titled “alarmInfo 與 idempotency”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 而不是直接存取。
🔴 deleteAll() 現在會刪掉 alarm
Section titled “🔴 deleteAll() 現在會刪掉 alarm”$ curl -s localhost:8787/deleteall{ "alarmBefore": 1785557748545, "alarmAfter": null, "cleared": true }Compat flag delete_all_deletes_alarm,2026-02-24 之後的 compat date 預設開啟(要關掉用 delete_all_preserves_alarm)。官方原文:
「With the
delete_all_deletes_alarmflag set, callingdeleteAll()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 掉。所以它比多數人以為的更徹底。)
Alarm 與 hibernation
Section titled “Alarm 與 hibernation”第 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.name 在 alarm() 裡是可用的 —— 這對 log 很重要,因為你需要知道是哪個物件的 alarm 失敗了。
| 項目 | 值 |
|---|---|
| 每個物件的 alarm 數 | 1(setAlarm 覆蓋) |
| 併發 | 「Only one instance of alarm() will ever run at a given time per Durable Object instance」 |
| Wall time | 15 分鐘(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 }}Alarm vs Cron vs Workflows
Section titled “Alarm vs Cron vs Workflows”| Alarm | Cron Trigger(第 20 篇) | Workflows(第 21 篇) | |
|---|---|---|---|
| 粒度 | 每個實體各自一份 | 全域,每個 Worker | 每個 instance |
| 排程 | 任意時間點 | cron 運算式 | step.sleep / sleepUntil |
| 重試 | 6 次,然後靜默丟棄 | controller.noRetry() | 每個 step 可設定 |
| 狀態 | DO storage | 無 | durable,跨 step |
| 上限 | 15 分鐘 | 15 分鐘 | 可睡數月 |
| 適合 | 去抖動、per-entity TTL、批次 flush | 全域清理、報表觸發 | 多步驟長流程 |
判準:
- 「每個 X 各自要在某個時間做某件事」→ alarm。不需要掃全表找誰到期了。
- 「每天凌晨做一次全域的事」→ cron。
- 「這個流程有五個步驟、中間要等人審核」→ Workflows。
第 20 篇會再對照一次。
完整程式碼:
examples/ch17-do-alarms/
cd examples/ch17-do-alarms && npm install && npm run devB=localhost:8787curl -s "$B/reset"curl -s "$B/armtwice" # 一個物件一個 alarmcurl -s "$B/deleteall?o=da" # deleteAll 清掉 alarmcurl -s "$B/hit?n=5" # 五次 hitsleep 4 && curl -s "$B/state" # 一次 flush,n=5重現重試(會跑約 2 分鐘):
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 不再增加 —— 那個工作永久消失了。
接進 LinkForge
Section titled “接進 LinkForge”Alarm 在 LinkForge 有三個用途。
① LinkCounter 的 30 秒 flush
Section titled “① LinkCounter 的 30 秒 flush”第 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 頻率保證每個桶最多被寫兩次。
② LiveDashboard 的 stale 連線清理
Section titled “② LiveDashboard 的 stale 連線清理”第 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 篇的成本模型。
③ 連結過期
Section titled “③ 連結過期”// When a link with expires_at is created:this.ctx.storage.setAlarm(expiresAt);這就是 alarm 相對 cron 的核心優勢。 用 cron 的話你得每分鐘掃一次 links 表找出到期的(第 9 篇:rows_read 就是帳單)。用 alarm 的話,每個連結自己知道什麼時候到期。
一張要寫進 ADR 的表
Section titled “一張要寫進 ADR 的表”| 用途 | 週期 | 重試失敗的後果 | 告警 |
|---|---|---|---|
| Click flush | 30 s | 該時段統計永久遺失 | retryCount >= 6 |
| Stale prune | 60 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。
⑩ 假設排程精度
官方沒有文件化任何精度保證。
本篇要記住的三句話
Section titled “本篇要記住的三句話”- **重試 6 次之後 alarm 靜默消失。**沒有 dead letter。
retryCount >= 6的 log 是你唯一的訊號。 getAlarm() === null才 arm —— 這一行就是去抖動的全部。- **寫絕對值不寫增量。**at-least-once 代表重試一定會發生,增量會讓數字翻倍。
- Alarms API
- Storage API(
deleteAll與 alarm 的互動) - Compatibility flags(
delete_all_deletes_alarm) - 限制
下一篇:18. Workers RPC 與 Service Bindings —— Part 3 收尾。把單體拆成多個 Worker 而不付出網路代價,以及 promise pipelining 怎麼把多趟往返壓成一趟。