底层原理
我们来用一个生动的比喻来解释这项技术的底层原理。
想象一下传统的“无状态”Serverless(比如普通的 Cloudflare Worker)和“有状态”的 Durable Object 分别是什么。
场景一:普通的“无状态”Serverless(比如常规 Worker)
这就像一个庞大的**“呼叫中心”**,里面有成千上万个接线员。
-
接线员没有固定身份:当你打电话(发起一个请求)进去,系统会随便找一个空闲的接线员为你服务。你这次打和下次打,接电话的几乎不可能是同一个人。
-
接线员没有记忆:每个接线员都是“健忘”的。他帮你处理完当前这件事(比如查询天气)就挂了。他不会留下任何记录。你下次再打电话进去,哪怕是问同一个城市的天气,另一个接线员也要从头再来一遍。
-
优点:非常高效,能同时处理海量电话。因为每个接线员都不需要记东西,所以可以随时增减人手,非常灵活。
这就是**“无状态”(Stateless)**。每个请求都是独立的、一次性的,服务器不保留任何关于你之前操作的记忆。这对于简单的、一次性的任务非常完美。
场景二:Durable Objects(“有状态”的 Serverless)
现在,想象你在这个“呼叫中心”里,可以申请一个**“专属管家”**,这就是 Durable Object。
-
专属管家有唯一身份(Globally-unique name):
- 原理:当你创建一个 Durable Object 时,系统会给它一个全球唯一的“手机号码”(ID)。无论你从世界哪个角落拨打这个号码(发起请求),接电话的永远是同一个管家。这个管家就是你的计算实例。
-
专属管家有随身笔记本(Durable storage attached):
-
原理:这个管家最厉害的地方在于,他随身带着一个笔记本(持久化存储)。每次你和他交流,他都会把重要的信息记下来。比如,你让他帮你管理一个购物车,他会在本子上记下“用户A加入了2件商品”。
-
“计算与存储相结合”的真正含义是:管家(计算单元)和他的笔记本(存储)是绑定在一起、形影不离的。这带来了两个巨大好处:
-
强一致性 (Strongly consistent):因为只有这一个管家能在这本笔记上写东西,所以信息绝对不会混乱或过时。
-
访问飞快 (Fast to access):因为笔记本就在管家手上,他不需要跑去别处(比如一个独立的数据库)翻查资料,所以响应速度极快。
-
-
-
专属管家可以协调工作(Coordinate between multiple clients):
- 原理:如果你和你的朋友想一起编辑一份文档,你们俩都可以拨打同一个“专属管家”的手机号。这个管家会看着他的笔记本,确保你们俩的操作不会互相冲突,他能有序地处理你们的请求,实现协同工作。
-
按需服务,地理就近(Like a Worker...):
- 原理:和你第一次呼叫他时一样,这个管家会被安排在你地理位置附近的分部待命。他不忙的时候会去休息(关闭),一旦你呼叫他,他会立刻出现(快速启动),非常节省资源。
总结
| 特性 | 普通 Worker (无状态) | Durable Object (有状态) |
| :--- | :--- | :--- |
| 身份 | 匿名、随机 | 唯一、固定(像手机号) |
| 记忆 | 没有记忆,用完即忘 | 有持久记忆(像随身笔记本) |
| 核心 | 纯粹的计算 | 计算 + 存储 的结合体 |
| 适用场景 | 查询天气、转换格式等一次性任务 | 聊天室、在线协作文档、游戏状态同步、购物车等需要记住上下文的任务 |
所以,Durable Objects 的底层原理,就是通过给一个计算单元(Worker)分配一个永不改变的全球唯一ID,并把一块专属于它的、与它物理位置靠近的存储和它绑定,从而创造出了一个既有 Serverless 弹性、又能记住状态的“有状态”服务单元。它完美解决了传统 Serverless 无法处理连续、有状态任务的痛点。
计费
好的,Cloudflare Durable Object 的计费模式非常精细,它遵循“按用量付费”的原则,但和你之前可能接触过的 Serverless 模型略有不同。因为它引入了“状态”和“持续活动时间”的概念。
总的来说,它的费用由 四个核心部分 组成:
-
活跃持续时间 (Duration)
-
请求数量 (Requests)
-
存储用量 (Storage)
-
数据传出 (Egress - 适用于所有 Cloudflare 产品)
下面我们用之前的“专属管家”比喻来详细解释每一项是如何计费的。
1. 活跃持续时间 (Duration) - 管家的“工时费”
这是 Durable Object 最独特的计费项。
-
计费内容:你不是为 CPU 的实际运算时间付费,而是为这个“管家”(Durable Object 实例)保持活跃的“墙上时间”(Wall-clock time) 付费。只要它在内存中处于活动状态,哪怕只是在等待你的下一个指令,计时器都在走。
-
如何计算:它的单位是 GB-秒 (GB-seconds)。Cloudflare 会把分配给对象的内存(固定为 128 MB,即 0.125 GB)乘以它保持活跃的秒数。
费用 = 0.125 GB * 活跃秒数
-
何时开始/结束:
-
开始:当一个请求到达,并且该对象实例当前未在内存中时,系统会唤醒它,计时开始。
-
结束:在处理完最后一个请求,并且没有任何挂起的异步任务(如
waitUntil)后,对象会保持一小段空闲时间然后被系统自动关闭,计时结束。
-
-
比喻:就像你雇佣了管家,只要他在“在线状态”为你待命,你就需要按小时支付他工时费,这与他是不是一直在忙着处理你的请求无关。
2. 请求数量 (Requests) - 给管家的“通话费”
这项费用非常直观。
-
计费内容:每次你的代码(比如一个普通的 Worker)向某个 Durable Object 发起请求(比如调用
DurableObjectStub.fetch()),就算一次请求。 -
如何计算:按请求的总次数计费,通常是每百万次请求一个价格。
-
注意:这笔费用是额外的。也就是说,你不仅要为发起调用的那个 Worker 的请求付费,还要为被调用的 Durable Object 接收这个请求付费。
-
比喻:每次你打电话(发起请求)给你的专属管家,都需要支付一笔通话费。
3. 存储用量 (Storage) - 管家笔记本的“材料费”
Durable Object 的状态需要地方存放。
-
计费内容:你在对象内部通过
this.state.storageAPI 存储的所有数据的总量。 -
如何计算:按存储数据的大小和时长计费,单位通常是 GB-月 (GB-months)。这和大多数云存储(如 S3)的计费方式类似。
-
注意:你不需要为读写操作的次数(IOPS)付费,费用只跟你存储的数据“有多大”有关。
-
比喻:管家用来记事的那个笔记本,如果内容写得越来越多、越来越厚(存储数据量变大),你就需要为这本笔记本支付材料费。
4. 数据传出 (Egress)
这部分不是 Durable Object 特有的,而是 Cloudflare 的通用计费项,但仍需考虑。当数据从 Cloudflare 网络传输到外部互联网时,可能会产生费用。
免费额度
Cloudflare 为 Workers 和 Durable Objects 提供了非常慷慨的免费套餐,以上提到的计费项大部分都有免费额度(每月刷新):
-
活跃持续时间:每月有 400,000 GB-秒 的免费额度。
-
请求数量:每月有 100 万次 Durable Object 请求的免费额度。
-
存储用量:每月有 1 GB 的存储免费额度。
这些额度对于小型项目和开发测试来说绰绰有余。只有超出免费额度的部分才会开始计费。
总结
| 计费项 | 解释 | 比喻 |
| :--- | :--- | :--- |
| Duration | 对象在内存中保持活跃的时间 | 管家的工时费 |
| Requests | 调用对象的次数 | 给管家的通话费 |
| Storage | 持久化存储的数据总量 | 笔记本的材料费 |
Durable Object 的计费模型旨在精确反映一个“有状态”服务所消耗的真实资源:既要为它“活着”的时间付费,也要为与它“沟通”的频率和它“记住”东西的多少付费。
使用场景
Durable Objects 技术的核心是为需要“记忆”和“协调”的场景提供一个简单、高效的解决方案。
下面是几个非常适合使用 Durable Objects 的典型场景,以及为什么它能发挥巨大优势的解释。
场景一:实时聊天室或直播评论区
这是什么?
一个允许多个用户实时加入、发送和接收消息的房间。
为什么适合 Durable Objects?
-
唯一身份 (Unique Identity): 每个聊天室都可以被创建为一个 Durable Object,并拥有一个独一无二的 ID(例如
roomId: "general-chat")。所有想加入这个聊天室的用户,都会被定向到这同一个对象实例。 -
持久化状态 (Persistent State): 这个对象可以在其内部存储(
this.state.storage)这个聊天室的消息历史记录。新用户加入时,可以从这个存储中拉取最近的几十条消息。 -
协调能力 (Coordination): 当一个用户发送消息时,请求被发送到这个唯一的对象。对象接收到消息后,将其存入状态,然后通过 WebSocket 连接将这条新消息广播给所有连接到该对象的其他用户。它成为了所有用户的单一协调中心,确保了消息的顺序和分发。
为什么传统 Serverless 不适合?
用普通的无状态 Worker,一个用户的消息进来,Worker A 处理了。另一个用户的消息进来,可能是 Worker B 处理。这两个 Worker 互不相识,没有共享的内存。为了同步消息,它们必须依赖一个外部的数据库或缓存(如 Redis)。这会引入额外的网络延迟、增加系统复杂性,并可能导致消息顺序错乱的“竞态条件”(Race Condition)。Durable Object 将这个协调中心内置了。
场景二:在线协作文档(如 Google Docs 的简化版)
这是什么?
多个用户可以同时打开并编辑同一份文档,并能看到其他人的光标位置和实时修改。
为什么适合 Durable Objects?
-
唯一身份: 每份文档对应一个 Durable Object (
documentId: "project-spec-v1")。所有编辑这份文档的用户都会连接到这个对象。 -
持久化状态: 对象内部存储着文档的完整内容。这是“唯一事实来源”(Single Source of Truth)。
-
协调能力: 这是最关键的一点。当用户A输入一个字符,这个操作被发送到对象。对象负责将这个修改应用到文档内容上,然后通知所有其他协作者这个变更。如果用户A和用户B几乎同时修改了同一句话,Durable Object 作为唯一的仲裁者,可以按顺序处理这些操作,防止数据冲突和丢失。
为什么传统 Serverless 不适合?
这会是一场灾难。如果两个用户的修改请求被两个不同的无状态 Worker 同时处理,它们会各自从数据库读取旧的文档版本,进行修改,然后写回数据库。后写入的那个会覆盖先写入的,导致其中一个用户的修改完全丢失。要解决这个问题需要复杂的数据库事务和锁机制,而 Durable Object 从架构上就避免了这个问题。
场景三:用户购物车
这是什么?
为每个在线购物的用户维护一个独立的购物车,记录他们想要购买的商品。
为什么适合 Durable Objects?
-
唯一身份: 每个用户的购物车都可以是一个 Durable Object,ID 可以是基于用户ID或会话ID生成的(例如
cartId: "user-12345")。 -
持久化状态: 对象内部存储着该用户购物车中的商品列表、数量和价格。这个状态会一直保留,即使用户关闭了浏览器再回来,只要能定位到同一个对象ID,购物车里的东西就还在。
-
原子操作 (Atomicity): “添加商品”、“更新数量”、“清空购物车”等操作都在同一个对象内完成,确保了数据的一致性。
为什么传统 Serverless 不适合?
每个操作(增、删、查)都需要一个无状态 Worker 先去连接外部数据库(如 DynamoDB),根据用户ID查询到购物车数据,在内存中修改,再写回数据库。这个过程重复且低效。Durable Object 把数据和操作它的逻辑放在了一起,省去了大量的外部数据库往返通信。
场景四:API 请求频率限制器 (Rate Limiter)
这是什么?
限制某个用户或IP地址在单位时间内可以调用API的次数,以防止滥用。
为什么适合 Durable Objects?
-
唯一身份: 为每个需要被限制的用户或IP创建一个 Durable Object (
limiterId: "ip-192.168.1.1")。 -
持久化状态: 对象内部存储一个时间戳列表或一个计数器,记录该用户在当前时间窗口内的请求次数。
-
协调能力: 所有来自该IP的请求在到达真正的业务逻辑之前,先经过这个对象。对象检查其内部状态,如果请求次数未超限,就增加计数器并放行请求;如果超限,则直接拒绝。这是一个原子性的检查和更新操作。
为什么传统 Serverless 不适合?
要在全球分布的无状态 Worker 之间精确地共享一个计数器非常困难,通常需要一个延迟极低的集中式缓存(如 Redis),但这会成为性能瓶颈和单点故障。Durable Object 自然地解决了这个问题,每个用户的计数器都封装在自己的对象里。
总结
| 适用场景 | 主要利用的 Durable Object 特性 |
| :--- | :--- |
| 实时聊天室 | 协调能力(广播消息)和 唯一身份(所有用户连同一个房间) |
| 协作文档 | 协调能力(解决编辑冲突)和 持久化状态(作为文档的唯一来源) |
| 购物车 | 持久化状态(跨会话保存商品)和 唯一身份(每个用户有专属购物车) |
| 频率限制器 | 持久化状态(存储请求计数)和 原子操作(检查并更新计数的原子性) |
解释
好的,我们来详细解释这段代码。这是一个设计得非常精妙的 Durable Object 示例,它完美地展示了如何利用内存缓存来最大化性能。
总体目标
这段代码定义了一个Durable Object计数器 (Counter)。它的核心设计思想是:在对象初始化时,从持久化存储(硬盘)中读取一次计数值,并将其缓存在内存中。之后的所有请求都直接从内存中读取这个值,从而实现极速响应,避免了每次请求都去访问较慢的存储。
分步详解
我们来逐行分析这段代码:
export class Counter {
// 构造函数,在对象实例第一次被创建到内存中时执行
constructor(state, env) {
this.state = state;
-
constructor(state, env): 这是类的构造函数。对于 Durable Object 来说,它有一个特殊的生命周期:当一个对象实例因为接收到请求而需要被唤醒(从“冷启动”到“热状态”)时,这个构造函数会且仅会执行一次。 之后只要对象还“活”在内存里,再来多少请求都不会再次执行它。 -
this.state = state;:state是一个由 Cloudflare 运行时自动注入的对象,它提供了访问持久化存储 (state.storage) 和生命周期控制方法(如blockConcurrencyWhile)的能力。这里把它存为this.state方便后续使用。
// `blockConcurrencyWhile()` 确保在初始化完成之前,
// 不会处理任何请求。
this.state.blockConcurrencyWhile(async () => {
let stored = await this.state.storage.get("value");
// 初始化之后,未来的读取操作就不再需要访问存储了。
this.value = stored || 0;
});
}
这是这段代码最核心、最关键的部分。
-
this.state.blockConcurrencyWhile(async () => { ... }):-
作用:这是一个“并发锁”。它告诉 Durable Object 运行时:“请暂停所有即将到来的
fetch请求,不要处理它们。直到我括号里的这个异步函数 (async) 执行完毕,再放行这些请求。” -
为什么必须用它?:想象一下,一个“冷”的(不在内存中)Durable Object 同时收到了两个请求。运行时会为它创建一个实例,并调用构造函数。如果没有
blockConcurrencyWhile,这两个请求可能会并发执行,导致它们都去读取存储 (storage.get),可能会引发竞态条件或不必要的重复工作。这个“锁”保证了初始化逻辑是原子的、安全的,并且在处理任何业务逻辑之前就已完成。
-
-
let stored = await this.state.storage.get("value");:- 作用:这是真正的 I/O 操作。它异步地从与此 Durable Object 实例绑定的持久化存储中,读取键名为
"value"的值。这个操作相对较慢,因为它涉及磁盘或网络。
- 作用:这是真正的 I/O 操作。它异步地从与此 Durable Object 实例绑定的持久化存储中,读取键名为
-
this.value = stored || 0;:-
作用:这是将状态加载到内存的关键步骤。
-
stored || 0:这个逻辑的意思是,如果从存储中读取到的stored是一个有效值(不是undefined或null),那就用它。如果stored是undefined(比如这个对象是第一次被创建,存储里什么都没有),那就使用0作为初始值。 -
this.value = ...:将这个值赋给类的实例属性this.value。现在,这个计数值就被缓存在了对象的内存里。
-
// 处理来自客户端的 HTTP 请求
async fetch(request) {
// 直接使用 this.value,而不是访问 storage
// ... 在这里可以实现增加、减少或读取 this.value 的逻辑 ...
}
}
-
async fetch(request): 这是处理外部请求的入口点。每次有请求发给这个 Durable Object,这个方法就会被调用。 -
注释的含义: 注释
// use this.value rather than storage清楚地表明了设计意图。在fetch方法内部,当需要获取当前计数值时,代码应该直接读取this.value。-
读取
this.value是一个同步的、极快的内存访问。 -
而
await this.state.storage.get("value")是一个异步的、相对慢的存储访问。
-
执行流程模拟
为了更清晰地理解,我们模拟一下请求流程:
场景一:第一个请求到达一个“冷”的 Counter 对象
-
请求到达 Cloudflare 边缘节点。
-
运行时发现
Counter对象不在内存中,于是创建一个新的实例。 -
constructor被调用。 -
blockConcurrencyWhile立即“上锁”,所有外部请求(包括触发这次唤醒的请求)都被暂停在门外。 -
await this.state.storage.get("value")执行,从磁盘读取数据。假设是第一次,返回undefined。 -
this.value被赋值为0。 -
blockConcurrencyWhile内部的函数执行完毕,“锁”被释放。 -
现在,被暂停的那个
fetch请求被放行,fetch()方法开始执行。它会直接从内存中读到this.value是0。
场景二:第二个请求紧接着到达一个“热”的 Counter 对象
-
请求到达。
-
运行时发现
Counter对象已在内存中。 -
constructor不会再次执行。 -
fetch()方法被直接调用。它直接从内存中读取this.value,响应速度极快。
总结
这种模式被称为 “初始化时加载到内存”(Load-on-init),是使用 Durable Objects 的最佳实践之一。
-
优点:极大地提升了对象“热”状态下的性能,因为后续所有读取操作都避免了昂贵的存储 I/O。
-
关键:利用
constructor和blockConcurrencyWhile来安全、原子地完成仅有一次的初始化加载过程。
当然,如果要在 fetch 方法中修改计数值(比如实现 increment),你需要同时更新内存中的值和持久化存储中的值,以确保状态被保存:
// 在 fetch 方法内
async increment() {
this.value++;
await this.state.storage.put("value", this.value); // 更新持久化存储
return this.value;
}