章と記事の一覧
動かすストレージ
Durable Objects
名前ごとに 1 つの実体。状態と直列化
Workers の強みは、状態を持たない(ステートレスである)がゆえに、世界中のエッジで素早くスケールできることだった。しかし現実のアプリケーションは、共同編集のカーソル同期、チャットルームの参加者管理、限定チケットの在庫引き当てなど、「世界でただ一つ、絶対に矛盾してはならない状態」を要求してくる。
グローバル変数に頼れば別々の isolate で値が食い違い、通常のデータベースでは同時書き込みのロック競合に頭を抱える。このジレンマを解くために設計されたのが Durable Objects(DO) である。
単一実体と直列化の保証
Durable Objects は、指定した ID または名前ごとに、同時に動くインスタンスが世界中で 1 つだけであることをプラットフォームが保証する。この一意性は、新しいイベントの開始時とストレージへのアクセス時に確認される。
- 唯一無二の所在地: 東京からアクセスしてもロンドンからアクセスしても、同じ
nameを指定する限り、リクエストは地球上のどこか 1 箇所に配置された同一のインスタンスへとルーティングされる。 - メッセージの直列化: インスタンスは単一スレッドで動くため、メソッド呼び出しや HTTP リクエストの JavaScript が同時に走ることはない。ストレージ操作だけで完結する処理には、ロックを自前で実装する必要がない。
fetch()などストレージ以外の I/O をawaitする間は別のリクエストが割り込むため、blockConcurrencyWhile()や楽観ロックで守る。 - 揮発しない永続ストレージ: インスタンスには専用の組み込み SQLite ストレージ(
ctx.storage)が備わっており、メモリ上の変数だけでなく、専用の SQLite データベースにも同期的に読み書きできる。
開発者が把握すべき特性
- クライアント側からは、バインディングを通じて RPC(リモートプロシージャコール)形式で直感的にメソッドを呼び出せる。
- アクセスが途絶えるとインスタンスは自動的にメモリから退避(ハイバネーション)され、時間課金が発生しなくなる。次のリクエストが届くと
constructor()が走り直し、インスタンスが再生成される。 ctx.storage.setAlarm()を呼ぶことで、将来の特定時刻にインスタンスを自動起動してタイマー処理やクリーンアップを実行させることができる。
インメモリ状態と SQLite ストレージの違い
DO には状態の置き場が 2 つある。クラスのフィールド(インメモリ) と ctx.storage(SQLite) である。
| インメモリ(クラスのフィールド) | SQLite(ctx.storage) | |
|---|---|---|
| 速度 | 最速(メモリ参照のみ) | 速い(同じマシン上のディスク。同期 API で呼べる) |
| 永続性 | ハイバネーション、退避、クラッシュ、コード更新で消える | 再起動やデプロイをまたいで残る |
| 向く用途 | キャッシュ、接続中の WebSocket の一覧、書き込み前のバッチ | 正本のデータ全般 |
消える主な条件は次のとおりである。約 10 秒のアイドルでハイバネーションに入ったとき(保留中のタイマー、標準の WebSocket、未完了の I/O があると入らず、その場合は 70〜140 秒でメモリから外れる)、新しいコードをデプロイしたとき(既定では最大 5 分の猶予がある)、Workers ランタイムが更新されたとき、ランタイムが配置先を変えたときである。再び呼ばれると constructor() が走り直し、フィールドは初期値に戻る。
だから正本は常に SQLite へ先に書き、メモリは読み取りの高速化に使う。コンストラクタで SQLite から読み戻してフィールドを温めるのが定石である。
export class ChatRoom extends DurableObject {
// 退避で消えてよい読み取りキャッシュ
recent: Message[] | null = null;
addMessage(text: string) {
// 先に永続化し、それからキャッシュを捨てる
this.ctx.storage.sql.exec("INSERT INTO messages (text) VALUES (?)", text);
this.recent = null;
}
}WebSocket と Hibernation API
チャットのように接続を持ち続ける用途は、DO に向いている。同じ名前の DO に全員がつながるので、ブロードキャストに別の仕組みが要らない。
標準の ws.accept() を使うと、接続が生きている間は DO がメモリに居座り、その間ずっと時間課金される。Hibernation API は this.ctx.acceptWebSocket(ws) で接続を預け、webSocketMessage() / webSocketClose() ハンドラで受け取る。メッセージが来ない間、DO はメモリから退避されても接続は切れない。
ただし退避すればフィールドは消える。接続ごとの小さな情報(ユーザー名など)は ws.serializeAttachment() に保存し、起こされたときに ws.deserializeAttachment() で戻す。履歴は SQLite に置く。つまり Hibernation は、上の表の使い分けを守ることが前提の仕組みである。
次の章 Durable Object を自分で書く で、2 つの状態の違いをコードを書き換えながら確かめられる。
エディタ上の env.COUNTER は、サイトが用意した Durable Object(名前は lesson-counter)の increment() と read() を中継するもので、本番のバインディングのように idFromName() や getByName() でインスタンスを選ぶことはできない。カウンタは全員で共有していて、24 時間呼ばれないと削除される。コードを繰り返し実行すると、プロセスが再起動してもカウンタが加算され続けることを確認できる。
export default {
async fetch(request, env) {
// 同じ名前(lesson-counter)の実体にルーティングされる
// Routes to the exact same Durable Object instance identified by name
const before = await env.COUNTER.read();
const now = await env.COUNTER.increment();
const again = await env.COUNTER.increment();
console.log("before =", before);
console.log("increment ->", now, again);
return Response.json({
before,
after: again,
// この値は Worker の再起動でも消えない(DO ストレージに永続化。24 時間呼ばれないと削除される)
// Persists across Worker restarts (saved in Durable Object storage; deleted after 24 hours without a call)
note: "Durable Object storage",
});
},
};
実行結果
実行するとここに結果が出ます。ネットワークは塞がれていますが、D1・KV・R2・Durable Object の状態は訪問者全員で共有されます。