Chapter 06

Durable Objects:有状态边缘 Actor

Workers 是无状态函数,Durable Object 是全局唯一、强一致的单例。协同文档、聊天室、游戏房、速率限制——这些"必须有一致状态"的场景,就是 DO 的舞台。

Durable Object 的四个关键词

Unique Instance
每个 DO id 对应全球唯一一个实例。10 万个聊天室 = 10 万个 DO,不重合。
Strong Consistency
一个 DO 内的 storage 写入立刻对自己的后续读可见,没有最终一致问题。
Single-threaded
一个 DO 同时只处理一个请求——天然互斥,不需要锁。也意味着 DO 是单点瓶颈,不适合极高并发的单对象。
Co-located Storage
每个 DO 有自带的存储(2025 后默认 SQLite,可存几 GB 且支持完整 SQL)。读写是本地 IO,零网络延迟。

最小示例:计数器

// src/counter.ts
import { DurableObject } from 'cloudflare:workers';

export class Counter extends DurableObject {
  async increment(): Promise<number> {
    const cur = (await this.ctx.storage.get<number>('n')) ?? 0;
    const next = cur + 1;
    await this.ctx.storage.put('n', next);
    return next;
  }
}
// src/index.ts
app.post('/counter/:name', async (c) => {
  const id = c.env.COUNTER.idFromName(c.req.param('name'));
  const stub = c.env.COUNTER.get(id);
  const n = await stub.increment();
  return c.json({ count: n });
});
# wrangler.toml
[[durable_objects.bindings]]
name = "COUNTER"
class_name = "Counter"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["Counter"]  # 2025 新 DO 全部用 SQLite storage

重点:idFromName("room-123") 把字符串 hash 到一个 DO 上——同名字符串永远到同一个 DO。这就是"按 key 分片到全球唯一实例"的入口。

SQLite Storage:DO 的新武器

2025 年起,新建 DO 默认带 SQLite。每个 DO 有最多 10GB 空间,支持完整 SQL:

export class ChatRoom extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    ctx.storage.sql.exec(`
      CREATE TABLE IF NOT EXISTS messages (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user TEXT, body TEXT, ts INTEGER
      )
    `);
  }

  async post(user: string, body: string) {
    this.ctx.storage.sql.exec(
      'INSERT INTO messages (user, body, ts) VALUES (?, ?, ?)',
      user, body, Date.now(),
    );
  }

  async recent(limit = 50) {
    return this.ctx.storage.sql
      .exec('SELECT * FROM messages ORDER BY id DESC LIMIT ?', limit)
      .toArray();
  }
}

WebSocket Hibernation

DO 的杀手应用:用 WebSocket 做协同。普通 WebSocket 长连接意味着 DO 永远在内存——账单爆炸。Hibernation 解决这个:连接保持,DO 睡眠不收费,只在真正收到消息时唤醒。

export class ChatRoom extends DurableObject {
  async fetch(req: Request) {
    if (req.headers.get('upgrade') !== 'websocket') {
      return new Response('expects ws', { status: 426 });
    }
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);
    this.ctx.acceptWebSocket(server);  // 启用 hibernation
    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(ws: WebSocket, msg: string) {
    // DO 只在这里被唤醒
    const peers = this.ctx.getWebSockets();
    for (const p of peers) if (p !== ws) p.send(msg);
  }

  async webSocketClose(ws: WebSocket, code: number) {
    ws.close(code);
  }
}

500 个观众的直播房间,99% 时间没人说话——DO 都在睡眠,只有消息/上下线才唤醒,成本是连接不收费 + 唤醒一次几厘钱。

速率限制

按 user_id 路由到 DO,DO 内部记滑动窗口:

export class RateLimiter extends DurableObject {
  async check(window: number, max: number): Promise<boolean> {
    const now = Date.now();
    const cutoff = now - window;
    this.ctx.storage.sql.exec(
      `CREATE TABLE IF NOT EXISTS hits (ts INTEGER)`,
    );
    this.ctx.storage.sql.exec('DELETE FROM hits WHERE ts < ?', cutoff);
    const [{ c }] = this.ctx.storage.sql
      .exec('SELECT COUNT(*) AS c FROM hits').toArray();
    if (c >= max) return false;
    this.ctx.storage.sql.exec('INSERT INTO hits (ts) VALUES (?)', now);
    return true;
  }
}

Alarm:延时 / 定时

DO 可以给自己设 alarm:

await this.ctx.storage.setAlarm(Date.now() + 60_000);

async alarm() {
  // 一分钟后被 Cloudflare 唤醒执行这里
  console.log('timeout fired');
}

典型用法:会话超时、未支付订单关闭、订阅续费检查。

路由策略:按什么 key 分 DO

按 room / doc / channel
协同编辑:一个文档一个 DO,所有该文档的操作都在这里序列化。
按 user
速率限制、会话状态:每个用户一个 DO。
按资源
库存、座位图:一个座位一个 DO,扣减强一致。
不要把所有东西路到一个 DO
idFromName("global") 全局单例会立刻成为瓶颈——DO 是单线程的。分片永远把 key 设计成有独立热点的维度(用户、文档、对象)。

容量与成本

维度数字
单 DO SQLite 上限10GB
DO 数量实际无上限(按 class 计费)
请求成本$0.15 / 百万请求
SQLite 读行$0.001 / 百万
活跃持续时长$12.50 / 百万 GB-秒

本章小结