Durable Object 的底层原理解释

底层原理

我们来用一个生动的比喻来解释这项技术的底层原理。

想象一下传统的“无状态”Serverless(比如普通的 Cloudflare Worker)和“有状态”的 Durable Object 分别是什么。


场景一:普通的“无状态”Serverless(比如常规 Worker)

这就像一个庞大的**“呼叫中心”**,里面有成千上万个接线员。

  1. 接线员没有固定身份:当你打电话(发起一个请求)进去,系统会随便找一个空闲的接线员为你服务。你这次打和下次打,接电话的几乎不可能是同一个人。

  2. 接线员没有记忆:每个接线员都是“健忘”的。他帮你处理完当前这件事(比如查询天气)就挂了。他不会留下任何记录。你下次再打电话进去,哪怕是问同一个城市的天气,另一个接线员也要从头再来一遍。

  3. 优点:非常高效,能同时处理海量电话。因为每个接线员都不需要记东西,所以可以随时增减人手,非常灵活。

这就是**“无状态”(Stateless)**。每个请求都是独立的、一次性的,服务器不保留任何关于你之前操作的记忆。这对于简单的、一次性的任务非常完美。


场景二:Durable Objects(“有状态”的 Serverless)

现在,想象你在这个“呼叫中心”里,可以申请一个**“专属管家”**,这就是 Durable Object。

  1. 专属管家有唯一身份(Globally-unique name):

    • 原理:当你创建一个 Durable Object 时,系统会给它一个全球唯一的“手机号码”(ID)。无论你从世界哪个角落拨打这个号码(发起请求),接电话的永远是同一个管家。这个管家就是你的计算实例。
  2. 专属管家有随身笔记本(Durable storage attached):

    • 原理:这个管家最厉害的地方在于,他随身带着一个笔记本(持久化存储)。每次你和他交流,他都会把重要的信息记下来。比如,你让他帮你管理一个购物车,他会在本子上记下“用户A加入了2件商品”。

    • “计算与存储相结合”的真正含义是:管家(计算单元)和他的笔记本(存储)是绑定在一起、形影不离的。这带来了两个巨大好处:

      • 强一致性 (Strongly consistent):因为只有这一个管家能在这本笔记上写东西,所以信息绝对不会混乱或过时。

      • 访问飞快 (Fast to access):因为笔记本就在管家手上,他不需要跑去别处(比如一个独立的数据库)翻查资料,所以响应速度极快。

  3. 专属管家可以协调工作(Coordinate between multiple clients):

    • 原理:如果你和你的朋友想一起编辑一份文档,你们俩都可以拨打同一个“专属管家”的手机号。这个管家会看着他的笔记本,确保你们俩的操作不会互相冲突,他能有序地处理你们的请求,实现协同工作。
  4. 按需服务,地理就近(Like a Worker...):

    • 原理:和你第一次呼叫他时一样,这个管家会被安排在你地理位置附近的分部待命。他不忙的时候会去休息(关闭),一旦你呼叫他,他会立刻出现(快速启动),非常节省资源。

总结

| 特性 | 普通 Worker (无状态) | Durable Object (有状态) |

| :--- | :--- | :--- |

| 身份 | 匿名、随机 | 唯一、固定(像手机号) |

| 记忆 | 没有记忆,用完即忘 | 有持久记忆(像随身笔记本) |

| 核心 | 纯粹的计算 | 计算 + 存储 的结合体 |

| 适用场景 | 查询天气、转换格式等一次性任务 | 聊天室、在线协作文档、游戏状态同步、购物车等需要记住上下文的任务 |

所以,Durable Objects 的底层原理,就是通过给一个计算单元(Worker)分配一个永不改变的全球唯一ID,并把一块专属于它的、与它物理位置靠近的存储和它绑定,从而创造出了一个既有 Serverless 弹性、又能记住状态的“有状态”服务单元。它完美解决了传统 Serverless 无法处理连续、有状态任务的痛点。

计费

好的,Cloudflare Durable Object 的计费模式非常精细,它遵循“按用量付费”的原则,但和你之前可能接触过的 Serverless 模型略有不同。因为它引入了“状态”和“持续活动时间”的概念。

总的来说,它的费用由 四个核心部分 组成:

  1. 活跃持续时间 (Duration)

  2. 请求数量 (Requests)

  3. 存储用量 (Storage)

  4. 数据传出 (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.storage API 存储的所有数据的总量。

  • 如何计算:按存储数据的大小和时长计费,单位通常是 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" 的值。这个操作相对较慢,因为它涉及磁盘或网络。
  • 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 对象

  1. 请求到达 Cloudflare 边缘节点。

  2. 运行时发现 Counter 对象不在内存中,于是创建一个新的实例。

  3. constructor 被调用。

  4. blockConcurrencyWhile 立即“上锁”,所有外部请求(包括触发这次唤醒的请求)都被暂停在门外。

  5. await this.state.storage.get("value") 执行,从磁盘读取数据。假设是第一次,返回 undefined。

  6. this.value 被赋值为 0。

  7. blockConcurrencyWhile 内部的函数执行完毕,“锁”被释放。

  8. 现在,被暂停的那个 fetch 请求被放行,fetch() 方法开始执行。它会直接从内存中读到 this.value 是 0。

场景二:第二个请求紧接着到达一个“热”的 Counter 对象

  1. 请求到达。

  2. 运行时发现 Counter 对象已在内存中。

  3. constructor 不会再次执行。

  4. 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;
  
}