Cloudflare Durable Objects 初学者指南
解析 Cloudflare Durable Objects:路由、单实例状态、SQLite 存储,以及 Workers 的 TypeScript 速率限制示例。
Durable Object 是一个 JavaScript 类的单一实例,Cloudflare 在任一时刻只会在一个位置运行它;所有指定该实例的请求,无论发起自世界何处,都会被路由到它,并且该实例自带私有存储。
如果在普通的 Workers 上实现计数器、锁,或者”谁在这个房间里”的列表,两个请求很可能会得出互相矛盾的结果。处理第一个请求的 Worker 和处理第二个请求的 Worker,可能是位于不同城市的不同 isolate,彼此之间没有共享内存。
本指南会先讲解让 Durable Objects 得以运作的路由模型,然后用最精简的 TypeScript 代码来演示它,并以”按 API key 限流”作为贯穿全文的唯一示例。本文假设你已经了解 bindings、wrangler 和 fetch handler;如果你需要先补充这些背景知识,可以从 OpenReplay 的 Cloudflare Workers 初学者指南开始。
关键要点
- Durable Objects 是一种自带私有存储的计算原语,而不是一个供 Worker 读取的存储产品;其存储只能由运行在该对象内部的代码访问。
- 传给
env.BINDING.getByName(name)的字符串就是该对象的身份标识:Cloudflare 网络上传入相同字符串的所有请求,都会抵达同一个正在运行的实例。 - 每个 Durable Object 都拥有一个内嵌的 SQLite 数据库,与其代码运行在同一线程上,因此
this.ctx.storage.sql.exec()会同步返回一个游标,无需await。 - 类字段能在连续的多个请求之间保留,但当对象在大约 10 秒无活动后进入休眠时会被丢弃;任何必须持久保留的数据都应放在
ctx.storage中。 - 当多个位置需要读取相同数据、且可以容忍写入需要一段时间才能在各处可见时,使用 Workers KV;当多个客户端必须在同一时刻对当前值达成一致时,使用 Durable Object。
为什么无状态的 Workers 无法完成协调?
Worker 在请求之间不保留任何内容。两次调用可能落在不同位置的不同 isolate 上,彼此都看不到对方做了什么,因此任何需要连续请求对某个值达成一致的功能都会失效。Cloudflare 自己的 Durable Objects 设计指南正是在无状态 Workers 与有状态协调之间划出了这条界线。
下面是一个看起来正确、实际上并不正确的朴素限流器:
// Broken: this Map exists per isolate. Another isolate has its own copy.
const hits = new Map<string, number>();
export default {
async fetch(request): Promise<Response> {
const key = request.headers.get("x-api-key") ?? "anonymous";
const count = (hits.get(key) ?? 0) + 1;
hits.set(key, count);
return new Response(count > 10 ? "slow down" : "ok", {
status: count > 10 ? 429 : 200,
});
},
} satisfies ExportedHandler;
模块级的 Map 只存在于某一个 isolate 中。一个客户端发送 30 个请求、分别落在三个 isolate 上时,会看到三个各自独立、各自停在 10 的计数器,限流实际上从未生效。把计数器移到外部数据库可以解决共享问题,但会在并发请求之间引入”先读后写”的竞态。问题的本质不在于数据放在哪里,而在于没有任何机制保证检查与更新发生在同一个地方。
核心思想:一个名称对应一个对象,每次都是同一实例
Durable Objects 通过为每个名称提供且仅提供一个正在运行的实例、并把针对该名称的所有请求都路由到它,从而解决协调问题。概念页面阐述了背后的三个特性:每个对象都对应一个全球唯一的名称;它的存储与它同处一地,而不是跨网络访问;并且它一次只做一件事,就像浏览器标签页中的 JavaScript 那样。
由这一模型可以推导出三个特性:
- 身份即名称。 你的 Worker 选定一个字符串(API key、房间 ID、文档 ID),平台会把它映射到唯一一个实例。位于不同大陆的两个 Worker 传入相同的字符串,就会与同一个对象通信。
- 创建是隐式的。 不存在 create 调用。namespace API 参考说明,单独获取一个 ID 并不会创建任何东西,对象只有在真正被访问时才会被构建。实际上,构造函数会在对 stub 的第一次方法调用到达时运行。
- 执行是单线程的。 方法内部的同步代码不会被另一个请求打断。只有当你的代码在等待非存储类 I/O(例如
fetch())时,其他请求才可能运行。
由于每个对象都是单机上的单线程,吞吐量是靠横向扩展而非纵向扩展来提升的:限流器应该是每个 API key 一个对象,绝不能用一个全局对象来承载所有流量。
如何定义并注册一个 Durable Object 类?
Durable Object 是一个导出的类,它继承自 cloudflare:workers 中的 DurableObject,在构造函数中接收 ctx 和 env,并通过 RPC 把其公有方法暴露给 Workers。入门指南规定构造函数签名为 (ctx: DurableObjectState, env: Env),并且必须调用 super(ctx, env)。
import { DurableObject } from "cloudflare:workers";
export class RateLimiter extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.ctx.storage.sql.exec(
"CREATE TABLE IF NOT EXISTS hits (bucket INTEGER PRIMARY KEY, count INTEGER NOT NULL)"
);
}
async increment(limit: number, windowMs: number): Promise<{ allowed: boolean; remaining: number }> {
const bucket = Math.floor(Date.now() / windowMs);
// No await between the read and the write: nothing else can run in between.
const row = this.ctx.storage.sql
.exec<{ count: number }>("SELECT count FROM hits WHERE bucket = ?", bucket)
.toArray()[0];
const count = row ? row.count + 1 : 1;
this.ctx.storage.sql.exec(
"INSERT INTO hits (bucket, count) VALUES (?, ?) ON CONFLICT(bucket) DO UPDATE SET count = ?",
bucket, count, count
);
return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
}
}
SELECT 与 upsert 之间没有任何 await,因此针对这个 API key 的其他请求无法插入执行。这正是无状态版本所无法提供的关键保证。
注册该类需要在 wrangler.jsonc 中添加两处配置。exports 配置项把该类标记为使用 sqlite 存储的 durable-object,它会在首次部署时预置 namespace。durable_objects.bindings 配置项则通过 env 为 Worker 提供一个句柄:
{
"durable_objects": {
"bindings": [
{ "name": "RATE_LIMITER", "class_name": "RateLimiter" }
]
},
"exports": {
"RateLimiter": {
"type": "durable-object",
"storage": "sqlite"
}
}
}
较早的示例是通过带有 new_sqlite_classes 的 migrations 数组来注册类的。对现有 Worker 而言这种写法仍然受支持,但 exports 是当前的方式,且两者不能在同一个配置文件中共存。
如何从 Worker 调用 Durable Object?
Worker 通过 getByName(name) 从 binding 获取一个 stub,然后像调用普通异步函数那样在该 stub 上调用类的公有方法,从而访问 Durable Object。stub 只是一个本地句柄:在其上发起的调用会被转发到拥有该名称的那个唯一实例。
export default {
async fetch(request, env): Promise<Response> {
const key = request.headers.get("x-api-key") ?? "anonymous";
// `key` is the object's identity. Same key, same instance, everywhere.
const stub = env.RATE_LIMITER.getByName(key);
const { allowed, remaining } = await stub.increment(10, 60_000);
return new Response(allowed ? "ok" : "slow down", {
status: allowed ? 200 : 429,
headers: { "x-ratelimit-remaining": String(remaining) },
});
},
} satisfies ExportedHandler<Env>;
传给 getByName() 的字符串就是路由键。getByName(name) 是旧写法”先 idFromName(name) 再 get(id)”两步操作的简写形式,你在很多示例中仍会看到后者;两者指向的是同一个对象。以 RPC 方式直接在 stub 上调用方法需要兼容性日期为 2024-04-03 或更新,任何新模板都满足这一条件。
Durable Object 的状态存储在哪里?
每个 Durable Object 有两类状态:一个能在重启后保留的私有内嵌 SQLite 数据库,以及仅在对象驻留内存期间存在的普通类字段。SQLite 存储 API 与你的代码运行在同一线程上,因此 exec() 会立即返回一个 SqlStorageCursor,无需 await。请在下一次 await 之前用 .toArray()、.one() 或循环把游标消耗完:跨越 await 仍然保持打开的游标可能会读取到期间写入的行,包括后来被回滚的写入。
类字段是快速通道。在限流器中加入一个类字段就能看出区别:
export class RateLimiter extends DurableObject<Env> {
// In-memory: fast, private to this instance, gone after hibernation.
private lastSeen = 0;
async increment(limit: number, windowMs: number) {
this.lastSeen = Date.now();
// ... SQLite read and write as before (durable)
}
}
lastSeen 能在连续请求之间保留,但生命周期文档指出,在 10 秒内没有任何传入事件(且没有待触发的定时器、标准 API WebSocket 或正在进行的 fetch())之后,对象会进入休眠,其内存会被丢弃。部署和运行时维护也可能在任何时候重启它。而 hits 表能在所有这些情况下存续。根据限制页面,在 Workers Paid 计划下,每个基于 SQLite 的对象最多可存储 10 GB。
应该用 Workers KV 还是 Durable Objects?
当多个位置需要读取相同数据、并且可以接受写入需要一段时间才能在各处可见时,使用 Workers KV;当多个客户端必须在同一时刻对当前值达成一致时,使用 Durable Object。KV 一致性文档对这一取舍直言不讳:KV 用一致性换取了速度,一次变更可能需要一分钟甚至更久才能在其他位置体现,任何需要原子性”读+写”的场景都应改用 Durable Objects。
| Workers KV | Durable Objects | |
|---|---|---|
| 一致性 | 最终一致;缓存副本按 TTL 过期 | 强一致;由单一实例拥有数据 |
| 读取发生在何处 | 任意位置,从缓存读取 | 在唯一的拥有者实例内部 |
| 先读后写的安全性 | 跨请求无任何保证 | 在中间不存在非存储类 await 的情况下有保证 |
| 写入模式 | 每个 key 的写入频率较低 | 每个对象的写入由运行时串行化 |
| 典型用途 | 配置、功能开关、白名单 | 计数器、锁、房间、按实体的状态 |
存储方案对比也是这样区分两者的:KV 适用于配置以及类似的、读取频率远高于变更频率的值;而 Durable Objects 适用于客户端之间的协调,以及需要在每个对象内保持一致的存储。限流器本质上是一个先读后写的计数器,因此属于 Durable Object。而每个请求都要读取的按租户功能开关则属于 KV。
结论
Durable Objects 通过消除”状态存在哪里”这个问题来解决协调难题:传给 getByName() 的名称精确选定唯一一个正在运行的实例,它的 SQLite 数据库与其代码位于同一线程,而中间不含非存储类 await 的”先读后写”不可能被交错执行。下一步是用 npm create cloudflare@latest 脚手架生成 Worker + Durable Objects 模板,把生成的类替换为上面的限流器,然后运行 npx wrangler dev,观察计数在那些原本永远无法达成一致的请求之间稳步递增。
常见问题
Durable Objects 和 D1 有什么区别?
D1 是一个托管的 SQLite 数据库,你的 Worker 通过网络对它发起查询,内置了 HTTP API 和 schema 迁移能力。而 Durable Object 的 SQLite 数据库与对象的代码运行在同一台机器上,只能由 Workers 通过该对象访问。在 Workers Paid 计划下,两者的单库上限都是 10 GB,在免费计划下上限都更低。需要一个共享的关系型数据库时用 D1;需要按用户或按实体维护、且需要协调的状态时用 Durable Objects。
单个 Durable Object 每秒能处理多少请求?
单个 Durable Object 的软性上限约为每秒 1,000 个请求,因为每个对象都在单机上的单线程中运行。超出之后,运行时会尽可能排队,随后以 overloaded 错误拒绝多余的调用。每次调用默认有 30 秒 CPU 时间,可通过 Wrangler 配置中的 limits.cpu_ms 最多调整到 5 分钟。请通过“一个名称一个对象”的方式横向扩展,例如每个 API key 一个对象。
Durable Objects 在 Workers 免费计划上可用吗?
可以。使用 SQLite 存储后端的 Durable Objects 在 Workers Free 计划上即可使用,上限为每个对象 1 GB、每个账户 Durable Objects 总存储 5 GB、以及 100 个 Durable Object 类。Workers Paid 将这些上限提升到每个对象 10 GB、账户存储不限量、以及 500 个类。一旦某个对象存满,写入会以 SQLITE_FULL 错误失败,不过你仍然可以读取行并删除它们以释放空间。
在 Durable Object 中使用 sql.exec() 需要显式事务吗?
通常不需要。每次调用 sql.exec() 本身就运行在自己的事务中,而彼此相邻、中间没有 await 的读写会作为一个原子批次一起提交,所以限流器中的先读后写按上面的写法就是安全的。sql.exec() 无法执行 BEGIN TRANSACTION 或 SAVEPOINT 语句。若要把多条语句归为一组、使其中任一条抛错时全部回滚,请使用 ctx.storage.transactionSync(callback)。该回调必须是完全同步的:不能声明为 async,也不能返回 Promise。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k