コンテンツにスキップ

Multi-Region

plasma のデフォルトのデプロイは単一リージョンです: 1 つの Worker、1 つの D1、DB が割り当てる単調増加の row_version を持つ 1 つの change log。これは単一の D1 が push できるスループットまで機能します。それを超えると multi-region です。

なぜ単一リージョンが破綻するか

Section titled “なぜ単一リージョンが破綻するか”

D1(ほとんどの SQLite ベースのストアと同様)は書き込みを直列化します。すべての mutation は同じ DB にヒットします。持続的に毎秒数百書き込みを超えると、競合下で SQLITE_BUSY が見え始め、change log の INSERT がボトルネックになります。

Hyperdrive 経由の Postgres はさらにスケールしますが、すべての書き込みでリージョンをまたぐホップのコストがかかります。グローバルなアプリは最終的に、mutation をユーザーの近くで適用したくなります。

plasma v1.0 は、multi-region デプロイに組み合わせるプリミティブを提供します:

  • SequencerDOstorage.transaction を介して単調なバージョン ID を発行する、リージョンごとの Durable Object。各リージョンはリージョン ID でキーが付いた自身の DO インスタンスを所有します。
  • 因果 cookie — pull cookie はすでにリージョンごとのカーソル({ "0": "123", "1": "456" })を運ぶため、クライアントは書き込みを取りこぼすことなく任意のリージョンから pull できます。

SequencerDO — リージョンローカルの id ソース

Section titled “SequencerDO — リージョンローカルの id ソース”
// worker.ts(DO 自身のスクリプト)
import { SequencerDO, MemorySequencerStorage } from "@sh1n4ps/plasma-server"
export class Sequencer extends SequencerDO {
constructor(state: DurableObjectState, env: Env) {
super({ storage: state.storage })
}
}
// 呼び出し側はバージョン ID を予約:
const stub = env.SEQUENCER.get(env.SEQUENCER.idFromName(`region-${regionID}`))
const res = await stub.reserve(1)
// { start: "12345", end: "12345" } — bigint 精度を保つため文字列

reserve(count) は連続した [start, end] 範囲を割り当てるため、書き込みのバッチは書き込みごとのラウンドトリップなしに連続 ID の連なりを得ます。

ID は文字列化された bigint です — DO は JS の number 精度を失うことなく 2^53 を超えて発行でき、DO とその呼び出し側の間の JSON 境界が完全な値を保ちます。

リージョン ID はリクエストの Cloudflare メタデータから割り当てます:

async function fetchWithRegion(req: Request, env: Env) {
const cf = req.cf as { region?: string } | undefined
const regionID = mapCfRegionToPlasmaRegion(cf?.region ?? "default")
// ... その regionID に対して env.SEQUENCER 経由でバージョン ID を予約。
}

change log の origin_region column は、各書き込みをどのリージョンが生成したかを識別します:

_plasma_changes:
row_version origin_region table row_id op value
123 0 todos t1 put {...}
456 1 todos t2 put {...}

リージョン 0(例えば US)とリージョン 1(例えば EU)は両方とも自身の SequencerDO から row_version を割り当てます。それぞれリージョンの DB に書き込み、変更をリージョンをまたいでレプリケートすることで協調します — pull ループは最も近いリージョンからクライアントに提供します。

pull cookie は、リージョンでキーが付いた base64 エンコードの JSON オブジェクトです:

c1:eyIwIjoiMTIzIiwiMSI6IjQ1NiJ9
= c1: + base64({ "0": "123", "1": "456" })

各エントリは「このリージョンから見た最も高い row_version」です。pull 時、(クライアントがヒットしたどのリージョンにいても)サーバーは、すべてのリージョンについて row_version > cookie[origin_region] のすべての変更を返します。

advanceCookieFromChanges(cookie, rows) は、受信した変更を cookie 形状に折り込み直します。

pullPredicate(cookie) は、「この cookie がまだ見ていないすべての row_version」を選択する SQL の WHERE 句を生成します:

const { text, params } = pullPredicate(cookie)
// text: "(_plasma_changes.origin_region = ? AND _plasma_changes.row_version > ?)
// OR (_plasma_changes.origin_region = ? AND _plasma_changes.row_version > ?)"
// params: [0, 123, 1, 456]

Postgres ベースの multi-region デプロイには、Hyperdrive を使って各リージョンに共有 Postgres インスタンスへの低レイテンシアクセスを与えます:

import { fromPostgres } from "@sh1n4ps/plasma-server/postgres-adapter"
const executor = fromPostgres(env.HYPERDRIVE.connectionString)

change log は Postgres に置かれます。書き込みは依然として DB レイヤーで直列化しますが、Hyperdrive が読み取りと pull のリージョンをまたぐレイテンシを償却します。

ほとんどのアプリは multi-region を必要としません:

  • 単一リージョン + Cloudflare エッジキャッシュ: GET /sync/pull レスポンスは Cache API レイヤーでエッジにキャッシュされるため、新しいクライアントの初期ハイドレーションが D1 に 2 回ヒットしません。
  • 単一リージョン + compactChangeLog: これをスケジュールされた Worker から実行して、同じ (table, row_id) 上の中間的な put を最新のものに折り畳みます。change log を小さく保ち、pull を高速に保ちます。

単一の D1 が扱える以上の持続的な書き込み帯域があるとき、または規制 / レイテンシ要件がデータのローカリティを強制するときに multi-region を使ってください。