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 是一个有效值(不是 undefinednull),那就用它。如果 storedundefined(比如这个对象是第一次被创建,存储里什么都没有),那就使用 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.value0

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

  1. 请求到达。

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

  3. constructor 不会再次执行

  4. fetch() 方法被直接调用。它直接从内存中读取 this.value,响应速度极快。

总结

这种模式被称为 “初始化时加载到内存”(Load-on-init),是使用 Durable Objects 的最佳实践之一。

  • 优点:极大地提升了对象“热”状态下的性能,因为后续所有读取操作都避免了昂贵的存储 I/O。

  • 关键:利用 constructorblockConcurrencyWhile 来安全、原子地完成仅有一次的初始化加载过程。

当然,如果要在 fetch 方法中修改计数值(比如实现 increment),你需要同时更新内存中的值和持久化存储中的值,以确保状态被保存:

  
// 在 fetch 方法内
  
async increment() {
  
  this.value++;
  
  await this.state.storage.put("value", this.value); // 更新持久化存储
  
  return this.value;
  
}