跳到內容

LinkForge 總回顧與 production checklist

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

四十二章,四十二個範例,全部在本機跑過。這一章不是摘要,是把散在各章的東西收成兩份可以直接用的清單:一份是「這些坑我們踩過了」,一份是「上線前照著檢查」。


43.1 LinkForge 的最終架構,以及每個決策的理由

Section titled “43.1 LinkForge 的最終架構,以及每個決策的理由”
┌──────────────────────────────────────┐
使用者請求 ─────▶ │ Worker(Hono) │
│ ├ 靜態資源(run_worker_first 例外) │
│ └ 路由 │
└───────┬──────────────────────────────┘
┌───────────────────┼────────────────────┬─────────────────┐
▼ ▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌───────────┐ ┌────────────┐
│ KV │ │ D1 │ │ Queue │ │ R2 │
│ slug快取 │ │ 事實來源 │ │ 點擊事件 │ │ 匯出檔案 │
└─────────┘ └──────────┘ └─────┬─────┘ └────────────┘
┌───────────────┐
│ DO: LinkCounter│
│ per-slug 計數 │
│ + alarm 落盤 │
└───────────────┘
決策選了什麼為什麼不是別的
slug → URL 查詢KV(前面再擋一層 Cache API)D1 每次查詢都要跑 SQL,KV 的讀是 $0.50/M 且全球複製。代價是 60 秒的最終一致(第 8 章)
事實來源D1KV 沒有查詢能力;要「這個租戶的所有連結按時間排序」就必須有 SQL(第 9、13 章)
點擊計數DOKV 每個 key 每秒 1 次寫入的限制直接判死;需要原子遞增(第 14 章)
計數落盤DO alarm不要每次點擊都寫 D1。alarm 批次落盤把 rows written 降兩個數量級(第 17、42 章)
點擊事件傳遞Queue讓 redirect 路徑不等待。但每則訊息 3 次 operation,在第 42 章是第二大開銷
匯出 / 大檔R2egress 免費是決定性的(第 12、42 章)
分析查詢Analytics Engine高基數維度免費,且目前完全不計費(第 22、42 章)

三個「當初想錯了」的地方,值得寫下來:

  1. 一開始想把計數直接寫進 D1。 第 42 章的實測說明為什麼不行:rows written 比 rows read 貴 1000 倍,而且有 index 的欄位每次寫兩筆。
  2. 一開始想用 Cache API 取代 KV。 第 6 章實測 Cache API 在本機是 stub、match() 永遠 miss,而且它是 per-colo 的 —— 不是全球共享。它是 KV 前面的一層,不是替代品。
  3. 一開始想每個租戶一個 DO 當作安全邊界。 第 41 章的結論是:官方從未把 DO 描述成安全隔離邊界。它給的是狀態隔離。租戶邊界還是得靠「tenantId 只能來自驗證過的 session」。

43.2 全系列的六個反覆出現的教訓

Section titled “43.2 全系列的六個反覆出現的教訓”

這是本系列真正的產出。不是「Cloudflare 有什麼功能」,而是「這個平台會用什麼方式騙你」。

教訓 1:typeof x === "function" 在 Workers 上什麼都證明不了

Section titled “教訓 1:typeof x === "function" 在 Workers 上什麼都證明不了”

七次遇到「API 存在但一呼叫就 throw」:

東西症狀
6Cache API本機是 stub,match() 永遠 miss
23Pipelines bindingRPC proxy,本機呼叫即 throw
25Astro.locals.runtime存在但沒有內容
27WebAssembly.compile存在但 EvalError
30Browser Run quickActionRPC proxy
32Email send是 RPC proxy 但本機真的能用 —— 反例
37getMcpAuthContext()乾淨地回 undefined不是 throwing stub —— 反例

規則:不要 feature detect,去讀 node_modules 裡的 .d.ts 與實作。而且**「是 RPC proxy」不等於「不能用」**(第 32、37 章是反例)。

三次:第 18 章(service binding 的 class 名稱只出現在註解裡)、第 26 章(monorepo 跨套件的 ambient type)、第 29 章(getContainer() 拿到的是 binding name 不是 class name)。

規則wrangler types 產生的是設定的型別,不是程式碼的型別。跨 Worker、跨套件的地方一律自己手寫一層結構型別。

第 40 章補了一個正面用法wrangler types 對所有 environment 取聯集,任何 environment 缺 binding 就標成 optional(?)。那個問號是免費的 CI 警報。

教訓 3:真相有三個版本,而且互相矛盾

Section titled “教訓 3:真相有三個版本,而且互相矛盾”
誰比誰寬
15型別比 runtime
21文件比型別
23JSON schema 比 validator
37文件說「移除」,型別說 @deprecated(38 章同款)
38JSDoc 指向一個已經不存在的 package 子路徑
39schema 有 logs.head_sampling_rate,文件一頁都沒提
39文件說「不能手動結束 span」,但 API 三天前就上了
40--strict 在同一頁有兩種說法
41Vectorize namespace 上限兩頁差 50 倍
42Analytics Engine 有價目表但「目前不收費」

規則排序是「實測 > 型別 > schema > 文件」。 而且要標註你用的是哪一層。

教訓 4:本機比 production 寬鬆 —— 除了那次不是

Section titled “教訓 4:本機比 production 寬鬆 —— 除了那次不是”

大部分時候本機比較寬鬆(第 27 章 PBKDF2 迭代上限本機不套用、第 33 章 cpuMs 不強制、第 38 章 new Function 本機能跑)。

🔴 第 37 章是唯一方向相反的一次:Miniflare 把每個 DO class 包一層 Wrapper extends UserClass,所以 hasOwnProperty(Object.getPrototypeOf(this), …) 在本機永遠看不到你的方法 —— 函式庫自己寫的 runtime assertion 在本機被關掉了。錯誤設定會順利通過 wrangler dev,然後在 deploy 之後才 throw。

規則:「本機綠燈」永遠只是必要條件。CI 一定要有一個真的把 Worker 起起來打一發請求的 smoke step(第 40 章)。

教訓 5:模組層變數是跨租戶洩漏

Section titled “教訓 5:模組層變數是跨租戶洩漏”

第 34 章(AI client)、第 37 章(McpServer singleton 洩漏 MCP session)、第 41 章(官方 best practice:“Do not store request-scoped state in global scope”)。

規則任何在模組層被賦值、而值來自請求的東西,都是資料外洩。 isolate 會跨請求重用,這不是可能,是預設。

教訓 6:便宜的東西一定要量,不能假設

Section titled “教訓 6:便宜的東西一定要量,不能假設”

第 42 章:ORDER BY … LIMIT 10 在沒有 index 時讀了兩倍表大小head() 不免費。KV 的 miss 是計費讀取。Queues 的 batch 不省錢

規則meta.rows_read 在本機就看得到。 計費單位大多可以在本機數出來 —— 數它,不要猜它。


43.3 備份與復原:三種資料,三種機制

Section titled “43.3 備份與復原:三種資料,三種機制”

這是最容易被跳過的一節,也是出事時唯一有用的一節。

Terminal window
wrangler d1 time-travel info <database> --timestamp 2026-07-30T12:00:00Z
wrangler d1 time-travel restore <database> --bookmark <bookmark>
wrangler d1 time-travel restore <database> --timestamp 2026-07-30T12:00:00Z

--timestamp 的說明文字就寫著範圍:「within the last 30 days」

兩件要知道的:

  • restore 是就地覆蓋。 沒有「還原到另一個資料庫」的選項。演練一次,不要第一次用是在出事的時候。
  • 第 40 章那條規則在這裡收成:回滾程式碼不會回滾資料。 Time Travel 是分開的動作,而且它會把整個資料庫拉回過去,包含你想留下的那些寫入。

DO 的 PITR 不是 CLI 指令,是 runtime API(第 15 章):

const bookmark = await this.ctx.storage.getCurrentBookmark();
const past = await this.ctx.storage.getBookmarkForTime(Date.now() - 3600_000);
await this.ctx.storage.onNextSessionRestoreBookmark(past);
// 然後 abort 這個 DO,下一次啟動就會從那個 bookmark 開始

注意 onNextSessionRestoreBookmark 的名字:它排程的是「下一次 session 啟動時還原」,不是立刻還原。你還要主動終止當前 session。

實務建議:在每次重要的批次寫入之前 getCurrentBookmark() 並把它記進 log(結構化的,第 39 章)。出事時你需要的是一個具體的 bookmark 字串,不是一個大概的時間。

wrangler 的 r2 bucket 子指令實測有:lifecyclelockcorsnotificationcatalogsippydomaindev-urllocal-uploads

沒有 versioning R2 提供的是 object lock rules(防刪改)與 lifecycle,不是版本歷史。

所以 R2 的「備份」要自己設計:寫入時就用帶版本的 key(docs/<id>/<timestamp>.pdf),加 lifecycle 規則清理舊的。覆寫式的 key 一旦被覆蓋就沒了。

wrangler r2 bucket lock add 可以擋掉誤刪,是防呆的第一道。


照著這份跑一次。每一條後面標了它從哪一章來。

  • compatibility_date 明確寫死,不用 --latest(第 2 章)
  • observability.enabled: true(第 39 章)
  • upload_source_maps: true —— 上限 15 MB gzip 後,不影響 CPU(第 39 章)
  • preview_urls 明寫在設定檔裡,不要只在 dashboard 切(第 40 章)
  • 每個 named environment 宣告了所有 binding —— 用 wrangler types 產生的 ? 檢查(第 40 章)
  • CI 有 wrangler types --check,而且沒有 pipe(第 40 章)
  • CI 有 deploy --dry-run --outdir 的 bundle 預算(第 40 章)
  • CI 有部署後的 smoke test(第 38、40 章)
  • concurrency 群組防止部署賽跑(第 40 章)
  • rollback 是一個 workflow_dispatch 按鈕,而且回滾後也跑 smoke(第 40 章)
  • 改 DO class 生命週期的變更單獨部署,用 wrangler deploy(第 40 章)
  • 知道 versions upload 不會套用 observability 改動(第 40 章)
  • log 一律傳物件,不要 JSON.stringify(第 39 章)
  • 告警條件是 http.response.status_code >= 500,不是 outcome != "ok"(第 39 章)
  • 篩 Worker 用 $metadata.service,不是 service.name(第 39 章)
  • alert 規則裡沒有寫死 span 名稱(官方說會改)(第 39 章)
  • trace 抽樣率設過了,而且知道 log 抽樣會讓 debug 變難(第 39、42 章)
  • 成本告警:把第 42 章那個模型的輸出設成月度預期,超過就通知
  • 沒有任何模組層變數存放請求相關的狀態(第 41 章)
  • 所有 D1 存取走 tenant-scoped 包裝,env.DB 只在一個檔案出現(第 41 章)
  • tenantId 只從驗證過的 session / JWT claim 讀(第 27、41 章)
  • 每張表的主鍵開頭是 tenant_id(第 41 章)
  • rate limit 的 key 保證是非空字串,否則 fail closed(第 41 章)
  • 精確配額用 DO,不是 rate limit binding(第 41 章)
  • 密鑰比對用 先 SHA-256 再 crypto.subtle.timingSafeEqual(第 41 章)
  • cookie 全部有 __Host- 前綴(第 41 章)
  • SSR 回應的安全 header 在 Worker 裡加,不是 _headers(第 41 章)
  • Access 的 JWT 驗了 audience(第 41 章)
  • 跑使用者程式碼時 globalOutbound: null(第 33、41 章)
  • 會 fetch 使用者提供 URL 的地方開了 global_fetch_strictly_public(第 41 章)
  • CI 有 npm audit,用 lockfile(第 40、41 章)
  • 每一個熱查詢都量過 meta.rows_read,而且 rows_read ≈ 回傳列數(第 42 章)
  • 沒有熱路徑上的 R2.list()(第 42 章)
  • 所有 WebSocket 都用 hibernation API(第 16、42 章)
  • queue 有 retry 上限與 DLQ(第 19、42 章)
  • queue 訊息 小於 64 KB(第 42 章)
  • D1 migration 向後相容(因為回滾不會回滾資料)(第 12、40 章)
  • Time Travel 演練過一次(第 43 章)
  • R2 重要物件用帶版本的 key,加 lock rule(第 43 章)
  • DO 在重要批次前記錄 bookmark(第 15、43 章)
  • compatibility_date 每季往前推一次,看一次 changelog 的 flag 清單(第 2 章)
  • wrangler、@cloudflare/vitest-pool-workers、vitest 釘死版本(第 38 章)
  • 0.x 的套件(agents@cloudflare/containers@cloudflare/codemode釘到 patch(第 29、37 章)
  • 升級 wrangler 之後重跑 wrangler types,看 diff(第 40 章)

實測 wrangler 4.118.0 的頂層指令清單,這些是本系列完全沒碰的:

指令 / 產品狀態大概是什麼
wrangler vpcopen beta連到你自己的私有網路
wrangler tunnelexperimentalCloudflare Tunnel
wrangler flagshipopen betafeature flag
wrangler websearchexperimentalCloudflare Web Search
wrangler artifactsprivate betaArtifacts namespace
wrangler agent-memoryprivate betaAgent 記憶體 namespace(第 37 章的延伸)
wrangler ai-searchopen betaAI Search(第 36 章的託管版)
wrangler cert / mtls-certificateopen beta / GAclient mTLS
wrangler turnstilealphaTurnstile widget 管理(第 41 章用了產品但沒用 CLI)
wrangler previewprivate beta另一種 preview deployment

以及不在 wrangler 裡的:Zaraz(第三方腳本管理)、Stream(影片)、Snippets(輕量版 Workers)、Zero Trust 的完整產品線Terraform provider

⚠️ 注意 private betaalpha 那幾個。 它們出現在 CLI 的 help 裡,但那不代表你申請得到、也不代表 API 穩定。本系列的原則從第 1 章就是「不要宣稱你沒驗證過的東西」 —— 這張表是誠實的邊界,不是待辦清單。


這個系列有 42 個「實測結論彙整」表格,加起來幾百條。如果只能記三條:

  1. 去讀 node_modules 型別檔、bundle、config-schema.json 都在你的硬碟上,而且它們比文件新。本系列最有價值的十幾個發現全部來自這裡。

  2. 本機綠燈只是必要條件。 尤其記得第 37 章那個反方向的例子 —— Miniflare 會關掉函式庫自己的 assertion。CI 一定要有真的打一發請求的 smoke step。

  3. 計費單位可以在本機數出來。 meta.rows_read 那一行就是帳單那一行。一個 index 的差別是 $0 和 $4,975。

其他的都會過期。這三條不會。


全系列完。 四十三章,四十二個可執行的範例,每一個結論都標了它是實測、推論,還是引用。歡迎在你自己的環境重跑一遍 —— 如果有哪一條在你那裡結果不同,那本身就是最有價值的回饋。