為什麼是 Workers:isolate 不是「更快的 Lambda」
這篇要解決的問題
Section titled “這篇要解決的問題”如果你寫過 AWS Lambda、Google Cloud Run 或 Vercel Functions,你對 serverless 已經有一套心智模型:上傳一包程式碼 → 平台幫你開容器 → 跑完關掉 → 按執行時間計費。這套模型在 Workers 上有一半是錯的,而且錯的那一半剛好是會讓你寫出昂貴、或直接跑不起來的程式碼的那一半。
最常見的三個誤判:
- 「Workers 就是跑在更多節點上的 Lambda。」—— 隔離機制完全不同,導致計費模型、記憶體限制、能用的函式庫全都不同。
- 「開了
nodejs_compat就等於 Node.js。」—— 不等於。node:fs存在,但你不會有一顆磁碟。 - 「免費方案 10ms 太少,什麼都做不了。」—— 那是 CPU time,不是 wall time。等待資料庫回應的 300ms 不算在裡面。
這篇不寫太多程式碼。目標是讓你在寫第一行 Worker 之前,先把這三件事分清楚,因為後面 42 篇的每一個技術決策都建立在這個模型上。
隔離單位:從 VM 到 container 到 isolate
Section titled “隔離單位:從 VM 到 container 到 isolate”Serverless 平台的核心工程問題是:如何在同一台實體機器上安全地跑多個租戶的程式碼。三個世代給了三種答案。
| VM(EC2 時代) | microVM / container(Lambda、Cloud Run) | isolate(Workers) | |
|---|---|---|---|
| 隔離單位 | 整個 OS | 每個 function 一個 microVM/container | V8 isolate(同一個 process 內的 heap 隔離) |
| 啟動成本 | 數十秒 | 100ms – 數秒 | 約 5ms |
| 每個實例的記憶體開銷 | GB 級 | 數十至數百 MB | 數 MB |
| 單機密度 | 個位數 | 數十 | 數千 |
| 能跑什麼 | 任何東西 | 任何 Linux binary | 只有 JS / WASM |
| 誰負責隔離 | Hypervisor | Hypervisor + kernel namespace | V8 + Cloudflare 的多層防護 |
關鍵在最後兩列。isolate 之所以能做到 5ms 啟動和數千的密度,是因為它放棄了跑任意 binary 的能力。你的 Worker 和其他幾百個租戶的 Worker 跑在同一個 OS process 裡,靠 V8 的 heap 隔離分開。這是一個非常明確的取捨:
用「只能跑 JS/WASM」換「幾乎為零的啟動成本與極高的部署密度」。
理解這個取捨,後面所有限制都會變得可預測。沒有檔案系統?因為你不是一個 container。不能 eval()?因為動態產生的程式碼會破壞 V8 的安全假設。記憶體只有 128MB 而且是跨並行請求共用?因為那是一個 isolate 的 heap,不是一台機器。
「幾乎沒有 cold start」的正確理解
Section titled “「幾乎沒有 cold start」的正確理解”網路上常見「Workers 沒有 cold start」的說法。準確一點的講法是:
- V8 isolate 的建立本身約 5ms,而且可以在 TLS handshake 還在進行的時候就先做掉。等到 HTTP request 真的到達時,isolate 已經準備好了。所以使用者感受到的冷啟動延遲通常趨近於零。
- 但你的模組頂層程式碼仍然要執行一次。這一段有獨立的限制:startup CPU time 上限 1 秒。如果你在頂層做了大量計算、或 import 了一包巨大的依賴,你會撞到這道牆,而且是在部署時就被拒絕。
- Worker bundle 有大小上限:Free 3MB / Paid 10MB(壓縮後),壓縮前一律 64MB。這個限制比 Lambda 的 250MB 嚴格得多,會直接影響你的依賴選擇。
所以正確的表述是:冷啟動延遲被壓到可以忽略,但「啟動」這件事仍然存在,而且有它自己的預算。
實務含義:把重量級的初始化放進 handler 內部並快取在模組作用域,而不是在頂層直接執行。
// ❌ Runs at startup, counts against the 1s startup CPU budget.const parsedRules = parseHugeRuleSet(RAW_RULES);
// ✅ Lazily initialised on first use, then reused by the same isolate.let parsedRules: RuleSet | undefined;function getRules(): RuleSet { parsedRules ??= parseHugeRuleSet(RAW_RULES); return parsedRules;}順帶一提,第二種寫法還揭露了另一件事:模組作用域的變數會在同一個 isolate 的多次請求之間存活。這是效能工具,但也是安全陷阱 —— 詳見下面的「常見陷阱」。
CPU time ≠ wall time(這是計費的核心)
Section titled “CPU time ≠ wall time(這是計費的核心)”這是從其他 serverless 平台過來的人最容易誤判的一點。
Lambda 計費看的是 wall clock:function 從開始到結束的真實時間,包含等 RDS 回應、等 S3 回應、等第三方 API 回應的每一毫秒。所以在 Lambda 上,「等待」是要付錢的,這也是為什麼大家會做 connection pooling、會怕 N+1 查詢慢。
Workers 計費看的是 CPU time:只算 CPU 真的在執行你的程式碼的時間。官方文件的說法是:
“CPU time measures how long the CPU spends executing your Worker code. Waiting on network requests… does not count toward CPU time.”
這個差異的實際影響很大:
export default { async fetch(request: Request, env: Env): Promise<Response> { // Wall time: ~300ms. CPU time: well under 1ms. const upstream = await fetch("https://slow-api.example.com/data"); const data = await upstream.json();
// Wall time: ~0ms. CPU time: this is what you actually pay for. const summary = data.items.map(normalise).filter(isRelevant);
return Response.json(summary); },} satisfies ExportedHandler<Env>;這支 Worker 的 wall time 是 300ms,但 CPU time 可能只有 2ms。在 Free 方案的 10ms CPU 限制下,它跑得非常輕鬆 —— 儘管「10ms」這個數字看起來完全不可能撐住一次 API 呼叫。
反過來的推論同樣重要:Workers 對 I/O 密集的工作負載極度友善,但對 CPU 密集的工作負載反而比 Lambda 嚴格。一次影像處理、一次 bcrypt、一次大型 JSON 的深度轉換,都是實打實地花 CPU。Free 方案的 10ms 很容易就爆掉 —— 這也是第 27 篇會談到的「密碼雜湊在 Free plan 上根本跑不完」的根源。
用一句話總結選型判準:
等待很便宜,計算很貴。 這和你在 Lambda 上養成的直覺剛好相反。
這個模型換來的限制
Section titled “這個模型換來的限制”以下每一條都是前面那個取捨的直接後果,不是 Cloudflare 隨手加的規則:
| 限制 | 為什麼 |
|---|---|
| 沒有真正的檔案系統 | 你不是一個 container。node:fs 有實作,但那是虛擬的,不是持久化磁碟 |
| 沒有長駐 process、沒有背景 thread | isolate 隨時可能被驅逐;沒有多執行緒與共享記憶體(也是 Spectre 防護的一環) |
eval() 與從 bytes 動態編譯 WASM 不可用 | 動態程式碼會破壞 V8 的隔離假設 |
| 128MB 記憶體,跨並行請求共用 | 那是一個 isolate 的 heap 上限,不是一台機器的記憶體 |
| 同時只能有 6 條連線在等待 response header | 防止單一 Worker 佔滿 colo 的連線資源 |
Date.now() 在執行期間不會前進 | 高精度計時器會被拿來做 Spectre 側通道攻擊 |
最後那一條特別反直覺,值得單獨拉出來。官方文件寫得很直接:
“the time value returned is not the current time.
Date.now()returns the time of the last I/O. It does not advance during code execution.”
意思是你沒辦法在 Worker 內部量測一段純計算的耗時 —— 前後兩次 Date.now() 會回傳完全相同的值,除非中間發生了 I/O。這對「我想在程式裡加個 timer 看看哪裡慢」的直覺是致命的。要量測效能,你需要的是第 39 篇的 Workers Traces,不是 Date.now()。
nodejs_compat 的邊界
Section titled “nodejs_compat 的邊界”Workers 的 runtime 是 workerd,不是 Node.js。nodejs_compat flag(需要 compatibility_date >= 2024-09-23)會補上一大批 Node API,但補上的方式分三個等級:
- 🟢 完整支援(20+ 個):
assert、AsyncLocalStorage、Buffer、crypto、dns、EventEmitter、http/https、net、path、process、stream、tls、url、util、zlib等。 - 🟡 部分支援 / 空殼:
child_process、cluster、http2、os、perf_hooks、readline、repl、dgram、v8、vm等。這些可以 import 讓 bundle 不炸,但呼叫進去多半會拿到[unenv] <method> is not implemented yet!。 - ⚪ 不支援:
node:sqlite、Node 內建 test runner。
實務上的判斷方式很簡單:能不能跑,取決於這個套件有沒有真的需要作業系統。 一個純粹做字串處理的套件通常沒問題;一個需要 spawn process、讀寫磁碟、或載入 native addon(.node 檔)的套件必然不行。這也是為什麼 bcrypt、@node-rs/argon2 這類套件在 Workers 上完全沒有出路(第 27 篇會處理)。
另外,2026 年有一個容易被忽略的細節:nodejs_compat_v2 已經被摺進 nodejs_compat,不需要也不應該再手動加。 網路上 2024 年的教學會叫你兩個都寫,現在那是多餘的。
還有一件事會讓從 2024 年文件抄設定的人困惑:compatibility_date 本身會改變 runtime 行為。2026 年的日期預設開啟的 Node API 遠比 2024 年的日期多(enable_nodejs_http_modules、enable_nodejs_fs_module、child_process、worker_threads 等一系列 flag 在 2025–2026 陸續轉為預設開啟)。同一份程式碼,改個日期就可能從「跑不起來」變成「跑得起來」。這個機制我們在第 2 篇會完整拆解。
動手做:讓 isolate 模型現出原形
Section titled “動手做:讓 isolate 模型現出原形”我們不做「Workers vs Lambda 壓測」——那需要你有 AWS 帳號,而且結果會被網路條件汙染。更有價值的做法是寫一支 Worker,直接把 isolate 模型的四個特徵印出來。
完整程式碼:
examples/ch01-isolate-model/
mkdir ch01-isolate-model && cd ch01-isolate-modelnpm init -ynpm i -D wrangler@4 typescript @cloudflare/workers-typeswrangler.jsonc:
{ "$schema": "./node_modules/wrangler/config-schema.json", "name": "ch01-isolate-model", "main": "src/index.ts", "compatibility_date": "2026-07-24", "observability": { "enabled": true }}src/index.ts
Section titled “src/index.ts”⚠️ 這段程式碼的第一版是壞的,而且壞得很有教育意義。 我原本寫的是
const ISOLATE_ID = crypto.randomUUID()放在模組頂層。啟動直接失敗:Uncaught Error: Disallowed operation called within global scope.Asynchronous I/O (ex: fetch() or connect()), setting a timeout, andgenerating random values are not allowed within global scope.注意這條規則比多數人以為的更廣:不只是 I/O,連
setTimeout和「產生隨機值」都被禁止。原因同樣回到隔離模型 —— 模組頂層的執行結果可能被跨請求重用,一個在頂層產生的「隨機」值會變成整個 isolate 共用的常數,而那幾乎一定不是你要的語意。所以下面改成 lazy 初始化。
// ---------------------------------------------------------------------------// Module scope runs ONCE per isolate, not once per request, and counts// against the 1 second startup CPU budget.//// NOTE: async I/O, timers AND random value generation are all forbidden here.// So the identity is initialised lazily, on the first request this isolate// happens to serve. That laziness is itself the lesson.// ---------------------------------------------------------------------------let isolateId: string | undefined;let isolateFirstSeenAt: number | undefined;let requestsServedByThisIsolate = 0;
function identify() { isolateId ??= crypto.randomUUID(); isolateFirstSeenAt ??= Date.now(); return { isolateId, isolateFirstSeenAt };}
export default { async fetch(request: Request): Promise<Response> { requestsServedByThisIsolate++; const url = new URL(request.url);
switch (url.pathname) { case "/isolate": return handleIsolate(); case "/clock": return handleClock(); case "/cpu": return handleCpu(Number(url.searchParams.get("n") ?? 1_000_000)); case "/wait": return handleWait(Number(url.searchParams.get("ms") ?? 500)); default: return new Response( "Try /isolate, /clock, /cpu?n=10000000, /wait?ms=2000\n", { status: 404 }, ); } },} satisfies ExportedHandler;
// --- 1. Isolate reuse ------------------------------------------------------// Refresh this repeatedly. The isolate id stays the same while the same// isolate serves you, then changes when you land on a different one.function handleIsolate(): Response { const { isolateId, isolateFirstSeenAt } = identify(); return Response.json({ isolateId, isolateFirstSeenAt: new Date(isolateFirstSeenAt).toISOString(), requestsServedByThisIsolate, });}
// --- 2. The clock does not advance during execution ------------------------// `before` and `after` are identical: no I/O happened in between.// `afterIo` differs, because awaiting a fetch is an I/O boundary.async function handleClock(): Promise<Response> { const before = Date.now(); let sink = 0; for (let i = 0; i < 5_000_000; i++) sink += i; const after = Date.now();
await fetch("https://cloudflare.com/cdn-cgi/trace"); const afterIo = Date.now();
return Response.json({ sink, before, after, deltaWithoutIo: after - before, // always 0 afterIo, deltaAfterIo: afterIo - before, // non-zero });}
// --- 3. CPU-bound work is what you actually pay for ------------------------// On the Free plan, a large enough `n` will exceed the 10ms CPU limit// and the request is terminated.function handleCpu(iterations: number): Response { let sink = 0; for (let i = 0; i < iterations; i++) sink += Math.sqrt(i); return Response.json({ iterations, sink });}
// --- 4. Waiting is nearly free --------------------------------------------// This blocks for `ms` of wall time but consumes almost no CPU time.// Compare the "CPU Time" chart in the dashboard against wall duration.async function handleWait(ms: number): Promise<Response> { await scheduler.wait(ms); return Response.json({ waitedMs: ms });}跑起來看四件事
Section titled “跑起來看四件事”npx wrangler dev① isolate 會被重複使用
for i in {1..5}; do curl -s localhost:8787/isolate | jq -c; done實測輸出:
{"isolateId":"3805b103-...","isolateFirstSeenAt":"2026-07-27T08:52:55.202Z","requestsServedByThisIsolate":1}{"isolateId":"3805b103-...","isolateFirstSeenAt":"2026-07-27T08:52:55.202Z","requestsServedByThisIsolate":2}{"isolateId":"3805b103-...","isolateFirstSeenAt":"2026-07-27T08:52:55.202Z","requestsServedByThisIsolate":3}同一個 isolateId,而 requestsServedByThisIsolate 持續遞增。這證明模組作用域的狀態在請求之間存活。部署到 production 之後再跑一次,你會看到 id 偶爾跳動 —— 那是你被路由到不同的 isolate(或不同的 colo)。
② 時鐘在執行期間是凍結的 —— 但只在 production
curl -s localhost:8787/clock | jq這裡有一個本機與 production 的行為分歧,值得單獨記住。官方文件明確說 Date.now() 回傳的是「上一次 I/O 的時間、在程式執行期間不前進」,所以 deltaWithoutIo 應該是 0。但在本機 wrangler dev 實測:
{ "deltaWithoutIo": 9, "deltaAfterIo": 102 }本機是 9,不是 0。 獨立執行的 workerd 沒有套用這層 Spectre 緩解,只有 Cloudflare 的 production 環境才有。要驗證凍結行為,你必須 wrangler deploy 之後打線上的 endpoint。
這件事本身比那個數字更重要:workerd 在本機與在 Cloudflare 上跑,行為並不完全一致。 這不是唯一一個案例 —— 第 27 篇會看到 PBKDF2 的迭代次數限制也是本機沒有、production 才有(而且是 600k 迭代在本機過、部署後直接炸)。
規則:任何跟安全邊界或資源限制有關的行為,一律以部署後的實測為準,不要相信本機跑得過。 這也是本系列的 examples repo 要用 CI 做真實部署驗證的原因。
③ CPU 是真的會用完的
部署到 Free 方案後:
curl -s "https://<your-worker>.workers.dev/cpu?n=1000000" # OKcurl -s "https://<your-worker>.workers.dev/cpu?n=200000000" # Error 1102Error 1102: Worker exceeded CPU time limit 是你會在 production 遇到的真實錯誤。
④ 等待幾乎免費
$ time curl -s "localhost:8787/wait?ms=1500"{"waitedMs":1500}real 0m1.515sWall time 一秒半,但 dashboard 上的 CPU Time 幾乎是 0。在 Free 方案的 10ms CPU 限制下,這個請求完全合法。 如果你還帶著 Lambda 的直覺,這一點會非常反直覺 —— 而它正是 Workers 在 API gateway、BFF、edge middleware 這類 I/O 密集場景上便宜得離譜的原因。
選作:如果你手上剛好有 Lambda 或 Cloud Run 環境,把
/wait這條路徑原封不動搬過去,比較兩邊的帳單。這是理解計費模型差異最快的方式。
接進 LinkForge
Section titled “接進 LinkForge”本系列的貫穿專案是 LinkForge:一個多租戶短網址 + 即時分析平台。之所以選它,是因為短網址是極端讀多寫少、天然多租戶、天然事件流的工作負載,會逐一逼出 Cloudflare 每一個 primitive 的存在理由。
從這一篇的心智模型出發,我們可以先劃出三條界線:
放在 edge 的(因為 I/O 密集、延遲敏感)
- Redirect 熱路徑:查一次 key,回一個 302。CPU 用量趨近於零,但每次請求都要求全球低延遲。這是 Workers 的完美形狀。
- API 與 dashboard SSR:大量等待資料庫,計算很少。
- 點擊事件的接收與轉發:只做 enqueue,不做處理。
放在 edge 但需要小心的(因為 CPU 密集)
- 密碼雜湊 —— 一定要 Paid 方案(第 27 篇)。
- 圖片處理 —— 交給 Images binding 而不是自己在 JS 裡算(第 31 篇)。
- 報表聚合 —— 交給 Workflows 拆步驟,或交給 Analytics Engine 用 SQL 算(第 21、22 篇)。
故意不放在 edge 的
- 大批次的離線資料處理。如果哪天需要,那是 Containers 的工作(第 29 篇),而不是硬塞進 isolate。
本篇的交付物:還沒有程式碼。只有一份寫進 repo 的 ARCHITECTURE.md,記錄上面這三條界線,以及每一條的判斷理由。這份文件在第 43 篇會被拿出來逐條驗收。
以下數字查證於 2026-07-27。計費條款會變,正式估算請以官方 pricing 頁為準。
| 項目 | Free | Paid |
|---|---|---|
| CPU time / 次呼叫 | 10 ms | 預設 30 s,最高 5 min |
| Wall time(HTTP) | 客戶端保持連線即不限 | 同左 |
| Wall time(Cron / Queues / DO alarm) | 15 min | 15 min |
waitUntil() 延長 | 回應後最多 30 s | 同左 |
| Startup CPU time | 1 s | 1 s |
| Script 大小(壓縮後) | 3 MB | 10 MB |
| Script 大小(壓縮前) | 64 MB | 64 MB |
| 記憶體 | 128 MB / isolate | 128 MB / isolate |
| Subrequest / 次呼叫 | 50 | 10,000(可提高至 10M) |
| 同時等待 response header 的連線 | 6 | 6 |
| 環境變數數量 | 64 | 128(單筆最大 5 KB) |
| Worker 數量 / 帳號 | 100 | 500 |
| Free | Paid | |
|---|---|---|
| 月費 | $0 | $5 起 |
| 請求 | 100,000 / 日 | 含 1,000 萬 / 月,超出 $0.30 / 百萬 |
| CPU time | 不另計 | 含 3,000 萬 CPU-ms / 月,超出 $0.02 / 百萬 CPU-ms |
一個直覺換算:3,000 萬 CPU-ms 相當於 300 萬次「每次 10ms CPU」的請求。 如果你的 Worker 平均只花 1ms CPU(I/O 密集的 API 很常見),那就是 3,000 萬次請求 —— CPU 幾乎不會是你的成本瓶頸,請求數才是。反過來,如果你在 Worker 裡做重運算,CPU 會先於請求數把帳單推高。第 42 篇會把這件事量化。
① 把模組作用域當快取,結果洩漏了租戶資料
模組作用域的變數在同一個 isolate 的請求之間共用,而 isolate 會服務不同使用者、不同租戶的請求。
// ❌ Leaks across requests and across tenants.let currentUser: User | null = null;
export default { async fetch(request: Request, env: Env) { currentUser = await authenticate(request, env); // request A sets it return handle(currentUser); // request B may read A's value },} satisfies ExportedHandler<Env>;規則:模組作用域只能放與請求無關的東西(設定、編譯好的 regex、解析過的規則表)。任何跟使用者有關的狀態,一律留在 handler 的區域變數裡。
② 在模組作用域做 I/O、設 timer、或產生隨機值
// ❌ All three fail with "Disallowed operation called within global scope."const config = await env.KV.get("config");const id = crypto.randomUUID();setTimeout(tick, 1000);import { env } from "cloudflare:workers" 讓你可以在頂層讀 vars 和 secrets,但這三類操作一律禁止。需要初始化就用前面示範的 lazy 模式。注意「隨機值」也在禁止之列 —— 這一條最常被忽略,而它會讓 Worker 在部署時就失敗,不是 runtime 才炸。
③ 忘記 ctx.waitUntil(),背景工作被砍掉
回應一送出,執行環境就可能被回收。任何「回應之後才做完」的工作都必須交給 waitUntil()(第 3 篇詳談)。
④ 用 Date.now() 做效能量測
它不會動。要量測請用 Workers Traces(第 39 篇)。
⑤ 以為 10ms CPU 限制代表 Free 方案沒用
它限制的是計算,不是等待。大部分 API 型的工作負載在 Free 方案上跑得非常舒服。真正會撞牆的是密碼雜湊、影像處理、大型 JSON 轉換這幾類。
⑥ 從 2024 年的教學抄設定
以下這些在 2026 年都已經是錯的,後面幾篇會一一說明:
- Service Worker 語法
addEventListener('fetch', ...)—— 已棄用,binding 只存在於 module 語法的env上 wrangler publish—— v4 已移除,用wrangler deploynode_compat、usage_model—— 已移除- 手動加
nodejs_compat_v2—— 已摺進nodejs_compat
本篇要記住的三句話
Section titled “本篇要記住的三句話”- **isolate 用「只能跑 JS/WASM」換來「幾乎零冷啟動 + 極高密度」。**後面所有限制都是這個取捨的後果。
- **等待很便宜,計算很貴。**這和 Lambda 的計費直覺相反,也是 Workers 選型判斷的核心。
- **模組作用域在請求之間共用。**它是效能工具,也是資料洩漏的第一大來源;而且它禁止 I/O、timer 與隨機值。
加碼一句,寫這篇時實測撞到的:本機 workerd 與 production 的行為不完全一致。 凡是牽涉安全邊界或資源限制的行為(Date.now() 凍結、PBKDF2 迭代上限、CPU 限制),一律以部署後實測為準。
- Cloud Computing without Containers —— Cloudflare 對 isolate 模型的原始論述
- Workers Security Model —— Spectre 防護與
Date.now()凍結的完整說明 - Workers Limits
- Workers Pricing
- Node.js compatibility
- workerd 原始碼 —— 想確認某個行為到底怎麼實作的時候,這裡比文件可靠
下一篇:02. 工具鏈:Wrangler v4、wrangler.jsonc 與本機開發 —— 建立 2026 年標準的專案結構,並搞懂 compatibility_date 為什麼會改變你的 runtime 行為。