缓存时间:
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/)上的文档、基准测试和正确性契约。
快速启动会断言其声明,并在失败时以非零状态退出。如果你发现某个声明不成立,我将在修复中注明你的贡献。