真实场景 MVP:每个用户一个「价格提醒」Durable Object
交易 App 常见需求:「BTC 涨破 X 时通知我」。用户设好提醒后就算关掉 App,也要有人一直盯着价格,触发时推送到他所有在线设备。
已在本地 wrangler dev 实测跑通:两条提醒写进 SQLite,Alarm 10 秒后拉 Hyperliquid allMids,BTC 那条触发并通过 WebSocket 推送到客户端,ETH 那条没触发;重启 dev server 后数据还在;另一个用户地址查出来是空列表,互不干扰。
为什么这个场景特别适合 DO
按用户天然切分DO 名字 = 钱包地址。每个用户的提醒、连接、定时器全在自己的 DO 里,规模扩展就是多开 DO。
Alarm 替代 cron + 队列每个 DO 带一个持久闹钟。有未触发的提醒才续约,没活就停,DO 被回收不计费。
WebSocket 汇聚点手机、网页同时在线,都连到同一个 DO;触发时遍历 getWebSockets() 广播。
不用锁「新增提醒」和「Alarm 标记已触发」都在同一个单线程里串行执行,不会重复推送。
数据流
POST /users/0xabc…/alerts:Worker 调 env.ALERTS.getByName(地址) 拿到这个用户的 DO,再 RPC addAlert(),写 SQLite,没有闹钟就挂一个。
GET /users/0xabc…/ws:Worker 把升级请求转给同一个 DO,DO 用 ctx.acceptWebSocket() 接住;连接期间 DO 仍可休眠。
- 10 秒后平台调用
alarm():拉 HL 价格,命中的提醒写上 fired_at、广播;还有未触发的就再挂 10 秒。
代码 · src/index.ts(整份,138 行)
import { DurableObject } from "cloudflare:workers";
interface Env {
ALERTS: DurableObjectNamespace<PriceAlerts>;
}
type Direction = "above" | "below";
interface Alert {
id: number;
coin: string;
direction: Direction;
price: string;
fired_at: number | null;
}
const HL_INFO = "https://api.hyperliquid.xyz/info";
const CHECK_EVERY_MS = 10_000;
/**
* 每个用户一个 DO:名字 = 用户地址。
* - SQLite 存该用户的提醒列表(持久)
* - Alarm 每 10s 拉一次 HL allMids 检查是否触发(有未触发提醒才续闹钟)
* - 该用户所有在线设备的 WebSocket 都挂在这里,触发时广播
*/
export class PriceAlerts extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
coin TEXT NOT NULL,
direction TEXT NOT NULL CHECK (direction IN ('above','below')),
price TEXT NOT NULL,
fired_at INTEGER
)`);
});
}
// ---------- RPC:Worker 直接调用 ----------
async addAlert(coin: string, direction: Direction, price: string): Promise<Alert> {
const row = this.ctx.storage.sql
.exec<Alert>(
"INSERT INTO alerts (coin, direction, price) VALUES (?, ?, ?) RETURNING *",
coin.toUpperCase(),
direction,
price,
)
.one();
// 没有闹钟就挂一个;已有则不动(同一 DO 只有一个闹钟)
if ((await this.ctx.storage.getAlarm()) === null) {
await this.ctx.storage.setAlarm(Date.now() + CHECK_EVERY_MS);
}
return row;
}
listAlerts(): Alert[] {
return this.ctx.storage.sql.exec<Alert>("SELECT * FROM alerts ORDER BY id").toArray();
}
// ---------- WebSocket:用户设备订阅触发推送 ----------
async fetch(req: Request): Promise<Response> {
if (req.headers.get("Upgrade") !== "websocket") {
return new Response("expected websocket", { status: 426 });
}
const { 0: client, 1: server } = new WebSocketPair();
// Hibernation API:DO 可以休眠而连接不断
this.ctx.acceptWebSocket(server);
server.send(JSON.stringify({ type: "hello", pending: this.pending().length }));
return new Response(null, { status: 101, webSocket: client });
}
webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) {
if (msg === "ping") ws.send("pong");
}
// ---------- Alarm:定时检查 ----------
async alarm() {
const pending = this.pending();
if (pending.length === 0) return; // 没活了,不续闹钟 → DO 可被回收
const res = await fetch(HL_INFO, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "allMids" }),
});
const mids = (await res.json()) as Record<string, string>;
for (const a of pending) {
const mid = mids[a.coin];
if (mid === undefined) continue;
// demo 用 Number 比较;Berth 仓库里金融比较必须走 BigNumber
const hit =
a.direction === "above" ? Number(mid) >= Number(a.price) : Number(mid) <= Number(a.price);
if (!hit) continue;
this.ctx.storage.sql.exec("UPDATE alerts SET fired_at = ? WHERE id = ?", Date.now(), a.id);
const event = JSON.stringify({ type: "fired", alert: a, mid });
for (const ws of this.ctx.getWebSockets()) ws.send(event);
}
if (this.pending().length > 0) {
await this.ctx.storage.setAlarm(Date.now() + CHECK_EVERY_MS);
}
// alarm() 抛错时平台会自动重试
}
private pending(): Alert[] {
return this.ctx.storage.sql
.exec<Alert>("SELECT * FROM alerts WHERE fired_at IS NULL")
.toArray();
}
}
/** Worker:无状态路由,按用户地址把请求转给对应的 DO */
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url);
const m = url.pathname.match(/^\/users\/(0x[0-9a-fA-F]{40})\/(alerts|ws)$/);
if (!m) return new Response("not found", { status: 404 });
const [, address, action] = m;
const user = env.ALERTS.getByName(address.toLowerCase());
if (action === "ws") return user.fetch(req);
if (req.method === "GET") return Response.json(await user.listAlerts());
if (req.method === "POST") {
const body = (await req.json()) as { coin: string; direction: Direction; price: string };
return Response.json(await user.addAlert(body.coin, body.direction, body.price), {
status: 201,
});
}
return new Response("method not allowed", { status: 405 });
},
};
代码 · wrangler.jsonc
{
"name": "price-alert-do",
"main": "src/index.ts",
"compatibility_date": "2025-09-01",
"durable_objects": {
"bindings": [{ "name": "ALERTS", "class_name": "PriceAlerts" }]
},
// 声明 DO 类,SQLite 后端。已发布的 tag 只增不改
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["PriceAlerts"] }]
}
实测记录(原样输出)
# 客户端先连 WS,然后加两条提醒
POST BTC above 1 → {"id":1,"coin":"BTC","direction":"above","price":"1","fired_at":null}
POST ETH above 1000000 → {"id":2,"coin":"ETH","direction":"above","price":"1000000","fired_at":null}
# WS 客户端收到
01:40:50 WS <- {"type":"hello","pending":0}
01:41:01 WS <- {"type":"fired","alert":{"id":1,"coin":"BTC",...},"mid":"80799.5"} ← Alarm 约 10s 后触发
# 14s 后查询:BTC 已触发,ETH 仍在等
GET → [{"id":1,"coin":"BTC",...,"fired_at":1792806061771},{"id":2,"coin":"ETH",...,"fired_at":null}]
# 另一个用户 0x2222… → 独立 DO,空
GET → []
# 杀掉 wrangler dev 重启后再查 0x1111… → 数据还在(SQLite 持久化)
GET → [{"id":1,...,"fired_at":1792806061771},{"id":2,...,"fired_at":null}]
注:hello 里 pending:0 是因为 WS 先连上、提醒后加的,属于正常时序。
自己跑一遍
cd /private/tmp/claude-501/-Users-ziyi-repos-berthlab-berth/f99d6d2b-97ff-4c9d-8960-127c209a46c8/scratchpad/price-alert-do
/Users/ziyi/repos/berthlab/berth/node_modules/.bin/wrangler dev --port 8799
# 另开终端
node ws-client.mjs 0x1111111111111111111111111111111111111111 30000 &
curl -XPOST localhost:8799/users/0x1111111111111111111111111111111111111111/alerts \
-d '{"coin":"BTC","direction":"above","price":"1"}'
从 MVP 到生产还差什么
| 缺口 | 做法 |
| 没鉴权,谁都能写别人的地址 | Worker 先验会话,拿到 userId 再 getByName(就是 apps/bot 里 BotOwner 的做法) |
价格比较用了 Number | Berth 铁律 3:改成 BigNumber |
| 每个用户各拉一次 allMids,1 万用户 = 每 10 秒 1 万次请求 | 拆一个全局「价格」DO 或 Worker 订阅 HL WS,按 coin 分发;用户 DO 只负责存储和推送 |
| App 关闭时收不到 WS | 触发时额外发 APNs / FCM 推送 |
| 输入没校验 | zod 校验 coin / direction / price |