跳到內容

為什麼是 Workers:isolate 不是「更快的 Lambda」

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

如果你寫過 AWS Lambda、Google Cloud Run 或 Vercel Functions,你對 serverless 已經有一套心智模型:上傳一包程式碼 → 平台幫你開容器 → 跑完關掉 → 按執行時間計費。這套模型在 Workers 上有一半是錯的,而且錯的那一半剛好是會讓你寫出昂貴、或直接跑不起來的程式碼的那一半。

最常見的三個誤判:

  1. 「Workers 就是跑在更多節點上的 Lambda。」—— 隔離機制完全不同,導致計費模型、記憶體限制、能用的函式庫全都不同。
  2. 「開了 nodejs_compat 就等於 Node.js。」—— 不等於。node:fs 存在,但你不會有一顆磁碟。
  3. 「免費方案 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/containerV8 isolate(同一個 process 內的 heap 隔離)
啟動成本數十秒100ms – 數秒約 5ms
每個實例的記憶體開銷GB 級數十至數百 MB數 MB
單機密度個位數數十數千
能跑什麼任何東西任何 Linux binary只有 JS / WASM
誰負責隔離HypervisorHypervisor + kernel namespaceV8 + 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 上養成的直覺剛好相反。

以下每一條都是前面那個取捨的直接後果,不是 Cloudflare 隨手加的規則:

限制為什麼
沒有真正的檔案系統你不是一個 container。node:fs 有實作,但那是虛擬的,不是持久化磁碟
沒有長駐 process、沒有背景 threadisolate 隨時可能被驅逐;沒有多執行緒與共享記憶體(也是 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()

Workers 的 runtime 是 workerd,不是 Node.js。nodejs_compat flag(需要 compatibility_date >= 2024-09-23)會補上一大批 Node API,但補上的方式分三個等級:

  • 🟢 完整支援(20+ 個):assertAsyncLocalStorageBuffercryptodnsEventEmitterhttp/httpsnetpathprocessstreamtlsurlutilzlib 等。
  • 🟡 部分支援 / 空殼child_processclusterhttp2osperf_hooksreadlinerepldgramv8vm 等。這些可以 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_modulesenable_nodejs_fs_modulechild_processworker_threads 等一系列 flag 在 2025–2026 陸續轉為預設開啟)。同一份程式碼,改個日期就可能從「跑不起來」變成「跑得起來」。這個機制我們在第 2 篇會完整拆解。


動手做:讓 isolate 模型現出原形

Section titled “動手做:讓 isolate 模型現出原形”

我們不做「Workers vs Lambda 壓測」——那需要你有 AWS 帳號,而且結果會被網路條件汙染。更有價值的做法是寫一支 Worker,直接把 isolate 模型的四個特徵印出來

完整程式碼:examples/ch01-isolate-model/

Terminal window
mkdir ch01-isolate-model && cd ch01-isolate-model
npm init -y
npm i -D wrangler@4 typescript @cloudflare/workers-types

wrangler.jsonc

{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "ch01-isolate-model",
"main": "src/index.ts",
"compatibility_date": "2026-07-24",
"observability": { "enabled": true }
}

⚠️ 這段程式碼的第一版是壞的,而且壞得很有教育意義。 我原本寫的是 const ISOLATE_ID = crypto.randomUUID() 放在模組頂層。啟動直接失敗:

Uncaught Error: Disallowed operation called within global scope.
Asynchronous I/O (ex: fetch() or connect()), setting a timeout, and
generating 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 });
}
Terminal window
npx wrangler dev

① isolate 會被重複使用

Terminal window
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

Terminal window
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 方案後:

Terminal window
curl -s "https://<your-worker>.workers.dev/cpu?n=1000000" # OK
curl -s "https://<your-worker>.workers.dev/cpu?n=200000000" # Error 1102

Error 1102: Worker exceeded CPU time limit 是你會在 production 遇到的真實錯誤。

④ 等待幾乎免費

Terminal window
$ time curl -s "localhost:8787/wait?ms=1500"
{"waitedMs":1500}
real 0m1.515s

Wall time 一秒半,但 dashboard 上的 CPU Time 幾乎是 0。在 Free 方案的 10ms CPU 限制下,這個請求完全合法。 如果你還帶著 Lambda 的直覺,這一點會非常反直覺 —— 而它正是 Workers 在 API gateway、BFF、edge middleware 這類 I/O 密集場景上便宜得離譜的原因。

選作:如果你手上剛好有 Lambda 或 Cloud Run 環境,把 /wait 這條路徑原封不動搬過去,比較兩邊的帳單。這是理解計費模型差異最快的方式。


本系列的貫穿專案是 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 頁為準。

項目FreePaid
CPU time / 次呼叫10 ms預設 30 s,最高 5 min
Wall time(HTTP)客戶端保持連線即不限同左
Wall time(Cron / Queues / DO alarm)15 min15 min
waitUntil() 延長回應後最多 30 s同左
Startup CPU time1 s1 s
Script 大小(壓縮後)3 MB10 MB
Script 大小(壓縮前)64 MB64 MB
記憶體128 MB / isolate128 MB / isolate
Subrequest / 次呼叫5010,000(可提高至 10M)
同時等待 response header 的連線66
環境變數數量64128(單筆最大 5 KB)
Worker 數量 / 帳號100500
FreePaid
月費$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 deploy
  • node_compatusage_model —— 已移除
  • 手動加 nodejs_compat_v2 —— 已摺進 nodejs_compat

  1. **isolate 用「只能跑 JS/WASM」換來「幾乎零冷啟動 + 極高密度」。**後面所有限制都是這個取捨的後果。
  2. **等待很便宜,計算很貴。**這和 Lambda 的計費直覺相反,也是 Workers 選型判斷的核心。
  3. **模組作用域在請求之間共用。**它是效能工具,也是資料洩漏的第一大來源;而且它禁止 I/O、timer 與隨機值。

加碼一句,寫這篇時實測撞到的:本機 workerd 與 production 的行為不完全一致。 凡是牽涉安全邊界或資源限制的行為(Date.now() 凍結、PBKDF2 迭代上限、CPU 限制),一律以部署後實測為準。



下一篇02. 工具鏈:Wrangler v4、wrangler.jsonc 與本機開發 —— 建立 2026 年標準的專案結構,並搞懂 compatibility_date 為什麼會改變你的 runtime 行為。