巧用DO绕过Cloudflare Worker免费计划的10ms CPU时间限制
qaz741wsd856 Lv2

巧用DO绕过Cloudflare Worker免费计划的10ms CPU时间限制

前言

一句话总结:将重计算从Worker卸载到DO中。

我见网络上有零星的讨论提到可以将CPU任务卸载到DO中,但没找到一篇详细介绍的教程(也可能是我太火星了),官方文档里也没有类似的建议,能查到的主流用法都是维持ws长连接。我又问了一圈AI,也没找到什么有价值的线索,它们给出的回答也不一致(甚至前后不一致),故来水一帖。

为什么 Duration Object 可以缓解CPU时间限制

什么是 Duration Object

根据官方文档 What are Durable Objects?,原文如下:

A Durable Object is a special kind of Cloudflare Worker which uniquely combines compute with storage. Like a Worker, a Durable Object is automatically provisioned geographically close to where it is first requested, starts up quickly when needed, and shuts down when idle. You can have millions of them around the world. However, unlike regular Workers:

  • Each Durable Object has a globally-unique name, which allows you to send requests to a specific object from anywhere in the world. Thus, a Durable Object can be used to coordinate between multiple clients who need to work together.
  • Each Durable Object has some durable storage attached. Since this storage lives together with the object, it is strongly consistent yet fast to access.

Therefore, Durable Objects enable stateful serverless applications.

我的英语水平太过于塑料,就不翻译了,简单画一下重点:

  • Worker能跑的代码DO基本也能跑
  • DO是根据名称(id)来区分实例的,可以单例串行(多Worker共享)也可以多个实例并行
  • DO是直接有存储、有状态的

DO是有状态的这一核心特征我们用不上,我们接着往下看文档。

计费与限制

|Duration Object 的计费主要围绕计算和存储展开,计算部分的额度与计费规则如下:|||

Free plan Paid plan
Requests 100,000 / day 1 million, + $0.15/million
Includes HTTP requests, RPC sessions, WebSocket messages, and alarm invocations
Duration 13,000 GB-s / day 400,000 GB-s, + $12.50/million GB-s

其中有两条脚注需要特别注意:

4 Duration is billed in wall-clock time as long as the Object is active, but is shared across all requests active on an Object at once. Calling accept()​ on a WebSocket in an Object will incur duration charges for the entire time the WebSocket is connected. It is recommended to use the WebSocket Hibernation API to avoid incurring duration charges once all event handlers finish running. For a complete explanation, refer to When does a Durable Object incur duration charges?.

5 Duration billing charges for the 128 MB of memory your Durable Object is allocated, regardless of actual usage. If your account creates many instances of a single Durable Object class, Durable Objects may run in the same isolate on the same physical machine and share the 128 MB of memory. These Durable Objects are still billed as if they are allocated a full 128 MB of memory.

|这也就是DO是按照挂钟时间×128MB内存来计费的,貌似没提CPU时间的事啊,不急,我们再来看看Limits怎么说:||

Feature Limit
Number of Objects Unlimited (within an account or of a given class)
Maximum Durable Object classes 500 (Workers Paid) / 100 (Free)
Storage per account Unlimited (Workers Paid) / 5GB (Free)
Storage per class Unlimited
Storage per Durable Object 10 GB
Key size Key and value combined cannot exceed 2 MB
Value size Key and value combined cannot exceed 2 MB
WebSocket message size 32 MiB (only for received messages)
CPU per request 30 seconds (default) / configurable to 5 minutes of active CPU time

这里的active CPU time就是指的CPU时间,居然没有单独限制免费计划?!

|我们再来跟Worker的限制对比一下:|||

Feature Workers Free Workers Paid
Request 100,000 requests/day
1000 requests/min
No limit
Worker memory 128 MB 128 MB
CPU time 10 ms 5 min HTTP request
15 min Cron Trigger
Duration No limit No limit for Workers.
15 min duration limit for Cron Triggers, Durable Object Alarms and Queue Consumers

看起来,虽然Worker对免费计划有单独的10ms限制,但DO却没有,那理论上所有请求都用DO处理请求的话,平均CPU时间可以到1.04s?这…这对吗?

对不对一试便知

剧透一下,对的,从这里开始就是教程了

从一个最简单的Demo开始

我们来实现一个最简单的处理HTTP请求的DO试试。

定义 Durable Object 类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/index.ts
import { DurableObject } from 'cloudflare:workers'

// 1. 定义 DO 类,必须 export
export class MyDurableObject extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
}

// 2. 必须实现 fetch 方法,处理发给这个 DO 的请求
async fetch(request: Request): Promise<Response> {
// 在这里执行 CPU 密集型操作
// 可以使用 this.env 获取环境变量绑定
// 避免使用全局变量,使用 DO 类的成员变量
const result = await this.heavyComputation();
return Response.json({ result });
}

private async heavyComputation(): Promise<string> {
// 你的 CPU 密集型逻辑
return "done";
}
}

在 wrangler.toml 中绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
name = "do-cpu-test"
main = "src/index.ts"
compatibility_date = "2024-01-01"

# 声明 Durable Object 绑定
[durable_objects]
bindings = [
{ name = "MY_DO", class_name = "MyDurableObject" }
]

# 声明 DO 类的迁移(首次部署必须,免费计划只能使用 SQLite)
[[migrations]]
tag = "v1"
new_sqlite_classes = ["MyDurableObject"]

定义 Env 类型

1
2
3
4
interface Env {
MY_DO: DurableObjectNamespace;
// 其他绑定...
}

从 Worker 调用 DO

1
2
3
4
5
6
7
8
9
10
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// 获取 DO 实例的 stub
const id = env.MY_DO.idFromName("singleton");
const stub = env.MY_DO.get(id);

// 直接把请求转发给 DO
return stub.fetch(request);
}
};

串行复用与并行处理

还记得之前说过DO是通过名称(id)来识别实例的么?我们可以通过传递id来选择是否创建新的实例。刚才的代码中名称固定是"singleton"​,这样每次获取的都是同一个实例,一次只能处理一个请求,这在高并发下会排队,不过优势是可在DO内部维护状态(本文用不到)。

如果想并行处理:

每次请求都创建一个新的实例

可以直接使用newUniqueId()​创建新id。

1
2
3
4
5
6
7
8
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// newUniqueId() 每次返回全新的实例
const id = env.MY_DO.newUniqueId();
const stub = env.MY_DO.get(id);
return stub.fetch(request);
}
};

按需区分(如请求参数、用户id)

1
2
3
4
5
6
7
8
9
10
11
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// 用 URL 参数作为实例名称
const instanceName = url.searchParams.get("name") || "default";

const id = env.MY_DO.idFromName(instanceName);
const stub = env.MY_DO.get(id);
return stub.fetch(request);
}
};

完整的示例

这里我实现了两个端点,分别在Worker和DO中执行PBKDF2迭代,来模拟实际CPU密集任务。
用法如下:

1
2
3
4
5
# Worker 内执行(很快会撞 CPU 限制)
curl "https://your-worker.workers.dev/worker/pbkdf2?iters=100000&reps=100"

# DO 内执行(可以随便跑,reps拉倒1000都行)
curl "https://your-worker.workers.dev/do/pbkdf2?iters=100000&reps=100&name=bench"

wrangler.toml

1
2
3
4
5
6
7
8
9
10
11
12
name = "do-cpu-test"
main = "src/index.ts"
compatibility_date = "2025-12-25"

[durable_objects]
bindings = [
{ name = "CPU_DO", class_name = "CpuDo" }
]

[[migrations]]
tag = "v1"
new_sqlite_classes = ["CpuDo"]

src/index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
/// <reference types="@cloudflare/workers-types" />
import { DurableObject } from 'cloudflare:workers'

export interface Env {
CPU_DO: DurableObjectNamespace;
}

// ============ 工具函数:PBKDF2 烧 CPU ============
async function burnCpu(iters: number, reps: number) {
const password = "benchmark-password";
const salt = crypto.getRandomValues(new Uint8Array(16));
const enc = new TextEncoder();

const keyMaterial = await crypto.subtle.importKey(
"raw",
enc.encode(password),
"PBKDF2",
false,
["deriveBits"]
);

const t0 = performance.now();
let lastHash = "";

for (let r = 0; r < reps; r++) {
const bits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt,
iterations: iters,
hash: "SHA-256",
},
keyMaterial,
256
);
lastHash = [...new Uint8Array(bits)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}

return {
params: { iters, reps, totalIterations: iters * reps },
timing: { wallMs: Math.round(performance.now() - t0) },
output: lastHash,
};
}

// ============ Durable Object 类 ============
export class CpuDO extends DurableObject<Env> {
constructor(
ctx: DurableObjectState,
env: Env
) {
super(ctx, env);
}

async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const iters = Math.min(parseInt(url.searchParams.get("iters") || "100000"), 100000);
const reps = Math.min(parseInt(url.searchParams.get("reps") || "1"), 1000);

const result = await burnCpu(iters, reps);
return Response.json({
executor: "DurableObject",
instanceId: this.ctx.id.toString(),
...result,
});
}
}

// ============ Worker 入口 ============
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;

// 路由:Worker 内执行
if (path === "/worker/pbkdf2") {
const iters = Math.min(parseInt(url.searchParams.get("iters") || "100000"), 100000);
const reps = Math.min(parseInt(url.searchParams.get("reps") || "1"), 1000);
const result = await burnCpu(iters, reps);
return Response.json({ executor: "Worker", ...result });
}

// 路由:DO 内执行
if (path === "/do/pbkdf2") {
const name = url.searchParams.get("name") || "default";
const id = env.CPU_DO.idFromName(name);
const stub = env.CPU_DO.get(id);
return stub.fetch(request);
}

return Response.json({
endpoints: {
"/worker/pbkdf2?iters=N&reps=M": "在 Worker 内执行(受 10ms CPU 限制)",
"/do/pbkdf2?iters=N&reps=M&name=X": "在 DO 内执行(按 Duration 计费)",
},
});
},
};

package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"name": "do-cpu-test",
"scripts": {
"dev": "wrangler dev",
"dev:remote": "wrangler dev --remote",
"deploy": "wrangler deploy"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20251201.0",
"typescript": "^5.7.3",
"wrangler": "^4.34.0"
}
}

测试结果

我直接把reps拉到1000,除了玄学1101外没遇到别的限制。
image
这个CPU时间是free plan的Worker跑到冒烟都跑不出来的。

结论

现在可以回答开头的问题了:这对,DO真的是30s CPU时间,只是DO还可能会受一些别的限制,比如释放不及时、同一台物理机上的DO会共用128M内存、创建DO和往返需要额外挂钟时间等。

现在我们来通俗的总结一下免费计划中Worker和DO的区别,这就像是占着茅
还是文雅的总结一下好了,这就像是在一家会员制餐厅里:

  • Worker是点餐的:点了多少才能吃多少(CPU时间限制),但是这个座位想占多久占多久(挂钟时间不限制)
  • Durable Object是吃自助餐的:按座位数×时间算钱,但占着座位的时候可以一直猛猛吃。

无脑DO不可取,但适当利用可以极大拓展免费版Worker的可能,比如可以采用自实现的高迭代次数的密码hash、解析大体积JSON……

不知各位在用Worker构建项目时是否也遇到了CPU时间限制的困扰呢?也许DO就是你项目的最后一块拼图。