无需 Cloudflare 的 Durable Objects,使用您已有的数据库

Lobsters Hottest 工具

摘要

介绍 Solid Objects,一个开源库,它在现有 SQL 数据库上实现了 Cloudflare 的 Durable Objects 模型,让开发者可以将其作为自托管依赖使用,无需额外基础设施。

<p><a href="https://lobste.rs/s/5tctgb/durable_objects_without_cloudflare_on">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/08/27 15:26

# 无需 Cloudflare,基于你现有数据库的持久对象 来源:https://lucascarlsson.net/2026/08/24/introducing-open-source-durable-objects/ 多年来,我一直反复思考 Cloudflare Durable Objects 中的一个核心概念:一个带有名称的对象。每个标识符对应一个单线程对象,通过名称访问,并附带持久化状态。无论是购物车、聊天室、游戏桌、设备、文档还是智能体运行实例——每一个都是一个对象。对同一标识符的调用按顺序执行,因此两个请求不会破坏同一购物车的数据。不同标识符的调用则并行处理。Kenton Varda 的团队实现了这一模型,它消除了我们通常用数据库、Redis、消息队列和大量锁来完成的工作。 过去二十年,我一直在 Rails 应用、Node 服务,甚至遗憾地在电子表格导入器中构建这样的锁机制。当我终于理解 Durable Objects 模型时,我主要的 frustration 是它仅运行在单一供应商的边缘网络上。希望实现这一点的不止我一人。Ryan Dahl 的 celld(https://celld.dev/)将这一模型重构为自托管守护进程:在你的虚拟机、对象存储桶中运行,无需 Cloudflare 即可使用 Workers API。这是一个优秀的项目,但 celld 在基础设施层面解决问题,你需要通过添加节点、存储桶、监控和更多扩展组件来运行它。 我想要更接近大多数开发者工作层次的方案:如果 Durable Objects 只是一个库,运行在你已有的 SQL 数据库上呢?于是我构建了它,称为 Solid Objects(https://solidobjects.dev/),它采用 MIT 许可证,提供两种共享同一设计的实现:一个已在超过 10 万用户的应用中投入生产的 Ruby gem(https://github.com/cardmagic/solid-objects-ruby),以及一个用于 Node 的 TypeScript 包(https://github.com/cardmagic/solid-objects-js)。本文将介绍 TypeScript 版本,它最近增加了一个我最初未计划的功能:整个运行时可以在浏览器标签页中运行。 ## 参与者(Actor)就是一个类 以下是完整的编程模型: ```javascript import { Actor, createRuntime } from "solid-objects" import { sqlite } from "solid-objects/database/sqlite" class TicketSale extends Actor { static override readonly actorType = "TicketSale" remaining = 100 holds: Record<string, number> = {} reserve({ buyer }: { buyer: string }): boolean { if (this.remaining === 0 || buyer in this.holds) return false this.remaining -= 1 this.holds = { ...this.holds, [buyer]: Date.now() } this.schedule({ at: new Date(Date.now() + 600_000), key: buyer }).expire!({ buyer }) return true } expire({ buyer }: { buyer: string }): void { if (!(buyer in this.holds)) return const rest = { ...this.holds } delete rest[buyer] this.holds = rest this.remaining += 1 } } const runtime = createRuntime({ database: sqlite({ path: "sale.sqlite3" }), authorizeMessage: () => true, authorizeQuery: () => true, }) await runtime.install() const sale = runtime.ref(TicketSale, "event-42") await Promise.all([ sale.reserve({ buyer: "ava" }), sale.reserve({ buyer: "kai" }) ]) ``` 每个 `reserve` 调用都会进入 `event-42` 的持久化邮箱并按顺序执行,即使不同的请求或不同的 Node 进程同时触发它们。由于对 `remaining` 的检查和随后的写入不会交错,因此销售不会超卖。不同事件并行运行,因此一个繁忙的销售永远不会阻塞另一个。状态仅存储在 SQLite、Postgres 或 MySQL 的行中。空闲的销售只占用这些行,不消耗其他资源——没有进程,没有守护进程在后台等待。这种低空闲成本是我采用此方式构建的主要原因。 Cloudflare 将此模型作为托管平台运行,celld 以自托管集群方式运行,而 Solid Objects 则是 package.json 中的一个依赖项。你无需操作任何新组件,因为它所需的数据库已在运行。十分钟的保留使用持久化提醒实现。通过 `schedule` 配合买家键为该保留设置一个定时器,再次使用相同键调用时会替换而非添加第二个定时器。定时器与状态存储在同一数据库中,因此在部署或崩溃后仍然有效。无需 cron 扫描程序,也无需维护 `expires_at` 列。 这是一个简单的示例,但展示了受保护的写入、每个项目的计时器和有序并发——全部在一个类中。通常你需要通过字段、后台作业和锁来组装这些功能,并维护它们之间的衔接。你可以用以下命令验证这些声明: ```bash npm exec --yes --package=solid-objects@latest -- solid-objects quickstart ``` 该命令会对一个标识符发起 25 个并发调用,并检查它们是否序列化为最终状态 25,返回完整的 1 到 25 序列,同时无关标识符可自由并行。每个检查都是一个断言,因此如果有任何失败,命令将以非零状态退出。我希望这些声明是可以验证的,而非仅凭信任。 ## 事务如何提交 正确性核心非常小。用伪代码表示,一个轮次如下: ```python # 当你调用 sale.reserve({ buyer }) 时发生的事情 insert durable message "reserve on event-42" # 从此处起崩溃也能恢复 worker claims TicketSale "event-42" (one lease) # 同时只有一个工作者 state = load(TicketSale, "event-42") result = state.reserve(buyer) # 你的代码在此运行, # 位于任何事务之外 transaction do # 一个原子提交: assert the lease is still valid # 过期的工作者在此失败 save the new state # remaining 和 holds save everything the handler staged # 10 分钟过期提醒 mark the message done end reply to the caller with result ``` 一次执行可能运行多次,但只有一次提交会生效。调用成为参与者邮箱中的持久化消息。工作者在带有防护令牌的租约下认领参与者。你的处理程序在*任何*数据库事务*之外*运行,因此缓慢的处理程序永远不会持有任何锁。当处理程序返回时,一个带防护的事务将新状态与其暂存的所有内容一起提交:发送给其他参与者的消息、计划的提醒、外部效果意图。如果租约在处理程序运行期间过时,该提交将在尝试写入的事务中被拒绝。 投递是“至少一次”且严格保持每个标识符的顺序。外部效果可能运行两次,因此它们携带稳定的效果 ID,你需要确保它们的幂等性。我不声称“恰好一次”投递,因为一旦涉及外部副作用,就不存在这种情况。 上周有人挑战了防护声明,这是我见过的最尖锐的反对意见。他们认为:死亡是简单情况;危险的情况是持有者并未死亡。工作者认领参与者后遇到长时间 GC 暂停,失去租约,第二个工作者接管并提交,然后第一个工作者恢复并尝试提交其过时的写入。如果防护检查和写入是两个步骤,延迟的写入将生效,你的历史记录将分叉。于是我进行了精确测试:两个工作者进程,250 毫秒租约,以及一个同步阻塞事件循环 2.5 秒的处理程序(这与 GC 暂停一样会冻结租约续期)。观察到的时间线如下: ``` t+0ms 工作者 A 认领消息,暂停 t+261ms 租约过期;工作者 B 认领、执行、提交 t+2500ms 工作者 A 唤醒,完成处理程序,尝试提交 最终状态仅包含第二次尝试;A 的写入被防护拒绝 ``` 在任何运行中,延迟的写入都未生效,因为防护重检查在提交事务内运行。系统的其他一切都依赖于此属性。 这个挑战的来源是我发布中最喜欢的部分。我将项目发布在一个 AI 智能体参与的论坛上,并请它们尝试破坏它。一个构建结算系统的智能体回复了三个故障探测,按此类模型通常容易出错的位置排序。上面的停滞测试是它的第一个探测。它的第三个探测——两个独立恢复从相同数据库快照以相同顺序重放所有邮箱——也通过了。第二个探测是一个好主意,但我尚未实现,因此它现在是一个开放 issue(https://github.com/cardmagic/solid-objects-js/issues/23)。这次审查比我能写的任何内容都更有助于改进项目。 ## 整个运行时可在浏览器中运行 版本 0.14 将完整的运行时(相同的邮箱、租约、防护、提醒和效果)运行在浏览器模块工作者内。数据库是编译为 WASM 的 SQLite。持久化存储使用 OPFS(浏览器的原生私有文件系统),因此提交的参与者状态在页面重载和浏览器重启后仍然存在。参与者看起来与在 Node 中完全一样: ```javascript import { Actor, configure, sharedSqliteWasm } from "solid-objects/browser/host" class Counter extends Actor { static actorType = "Counter" count = 0 increment({ amount = 1 } = {}) { this.count += amount return this.count } } const runtime = configure({ database: sharedSqliteWasm({ path: "app.db" }), authorizeMessage: () => true, authorizeQuery: () => true, }) await runtime.install() await Counter.ref("page-hits").increment() ``` 该代码在源的所有标签页中运行方式相同。更困难的部分在底层:浏览器不提供进程监管器,因此运行时用网络原语构建一个。Web Locks API 为每个源选举一个数据库持有者。其他每个标签页通过 BroadcastChannel 将 SQL 转发给持有者。当持有者的标签页关闭时,锁释放,下一个标签页提升自己,运行时从相同的 OPFS 状态恢复。仲裁 Node 进程的租约和防护机制同样仲裁你的浏览器标签页,无需更改。 你不必相信我的话。主页(https://solidobjects.dev/js#demo)在页面上运行该运行时。那里的演示是一个持久化参与者,提交到你自己浏览器中的 SQLite WASM,加载页面本身就是一个提交的参与者调用。重载页面状态仍然存在。在第二个标签页中打开,关闭持有数据库的标签页,观察另一个标签页接管相同状态。你也可以在不安装任何东西的情况下尝试它。模块工作者中的一次导入即可从 CDN 拉取运行时和 WASM: ```javascript import { Actor, configure, sharedSqliteWasm } from "https://esm.sh/solid-objects@latest/browser/host" ``` ## 离线写入可传输到 Node 或 Rails 持久化浏览器参与者最终需要与服务器通信。为此存在 transmit 系列方法。参与者在自身状态变更的同一事务中暂存出站调用,只需额外一行: ```javascript class Counter extends Actor { static actorType = "Counter" count = 0 increment({ amount = 1 } = {}) { this.count += amount this.transmit().increment({ amount }) // 在同一提交中暂存 return this.count } } ``` 因为意图与状态一起提交,崩溃永远不会导致本地写入不被服务器感知,也不会导致服务器调用回滚的写入。然后,drain 工作者以“至少一次”投递和每个参与者顺序传递每个信封,你提供传输层。离线时失败,效果将带退避重试: ```javascript registerTransmit({ runtime, deliver: async (envelope) => { const response = await fetch("/sync", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(envelope), }) if (!response.ok) throw new Error(`sync failed with ${response.status}`) }, }) ``` 在 Node 服务器上,接收只需一个调用。它将内部消息按 `transmit:` 键入队,因此重放的信封恰好应用一次: ```javascript import { receiveTransmitEnvelope } from "solid-objects" async function handleSyncRoute(request) { const sender = await authenticate(request) if (!sender) return new Response("Forbidden", { status: 403 }) await receiveTransmitEnvelope({ runtime, envelope: await request.json() }) return Response.json({}) } ``` 但接收端不必是 Node。Ruby gem 使用相同的线路协议,由提交到两个仓库并在两侧测试的固定 fixture 文件保证。其 Rails 引擎已挂载 `POST /solid_objects/transmit`,在默认拒绝的策略之后,因此 Rails 后端只需说明谁可以投递: ```ruby SolidObjects.configure do |configuration| configuration.authorize_transmission = lambda do |envelope:, authorization_context:| ActiveSupport::SecurityUtils.secure_compare( authorization_context.request.headers["Authorization"].to_s, "Bearer #{Rails.application.credentials.transmit_token}" ) end end ``` 将浏览器的 `deliver` 回调指向该路由,你就有了一个离线优先的前端传输到普通 Rails 后端:一个契约,双向,因为 Rails 参与者也可以向外传输。自从我第一次读到本地优先软件以来,我就一直希望实现这一点:在标签页和服务器中都有持久化参与者,即使网络中断也能保持工作的协调路径。 ## 不是什么 上述每个声明都有边界。在投入真实时间之前,请了解这些: - 版本低于 1.0。预期有破坏性更改。 - 投递是“至少一次”,因此效果必须幂等。只有状态提交是“恰好一次”。 - 一个热标识符按设计序列化,因此无法通过添加工作者来扩展单个标识符。 - 同步参与者调用不是索引行读取的替代品。在联网 MySQL 上测量,普通读取中位数为 4.7 毫秒,参与者调用为 60 毫秒。如果单个 SQL 事务能解决问题,请使用它。 - OPFS 支持因浏览器和 WebView 而异。持久化适配器在缺失时会快速失败。 - 发布的基准测试是笔记本电脑数据,附带方法和偏差来源。用它们理解权衡,而非用于需要你自己硬件的容量规划。 项目站点(https://solidobjects.dev/js)在其声明的功能旁边保留了此列表的更完整版本。 ## 去破坏它吧 我认为这个模型应该在任何地方运行,而不仅在一个平台上。Cloudflare 证明了它作为托管服务的可行性,celld 证明了它作为自托管集群的可行性。Solid Objects 代表最小版本:一个库,你自己的数据库,现在还有一个浏览器标签页。所有内容均为 MIT 许可:solid-objects-js(https://github.com/cardmagic/solid-objects-js)、solid-objects-ruby(https://github.com/cardmagic/solid-objects-ruby),以及 solidobjects.dev(https://solidobjects.dev/)上的文档、基准测试和正确性契约。 快速启动会断言其声明,并在失败时以非零状态退出。如果你发现某个声明不成立,我将在修复中注明你的贡献。

相似文章

Celld:自托管的分布式 Durable Objects

Hacker News Top

Deno 的 celld 是一个开源守护进程,可在你自己的机器上运行 Cloudflare Workers 和 Durable Objects,每个对象使用 SQLite,并使用兼容 S3 的存储桶进行复制和协调,无需控制平面。

celld — Durable Objects, self-hosted

Lobsters Hottest

celld is a self-hosted, distributed server that runs Cloudflare Workers and Durable Objects code unchanged, using your own S3 bucket for storage and coordination. It claims significantly lower costs and sub-millisecond warm latencies at scale, with data stored in SQLite/LTX format.

为何我们放弃Cloudflare Durable Objects

Hacker News Top

Wire是一个面向AI代理上下文容器的平台,因结构限制——向量索引分离、计算与数据共置、部署灵活性及缺乏自托管支持——正从Cloudflare Durable Objects迁移至Fly Machines上的自定义运行时。新架构降低了延迟并实现了专用容量。