レジデント準備完了 静的コンパニオン
kari_mikumao 研究室

Systems

Mirai Guide:1つのSQLiteでイベントの群衆をさばく

D1は直列書き込みキューを持つ単一プライマリのSQLite。問いは「どう速くするか」ではなく「どうすればほとんど何もD1に触れずに済むか」だった。

  • mirai-guide
  • スケール
  • cloudflare
  • キャッシュ
  • d1

Mirai Guideの利用者の形は、データベースにとって過酷です。1つの展示ホールの中に同時接続およそ1万人。しかも全員が、会場Wi-FiとキャリアグレードNATのわずかな出口アドレスの背後にいます。開場直後のバーストでは、数分のうちに全員がアプリを開きます。三都市のツアー全体では数百万のページビューがあり、さらに自宅から地球儀や在庫ボードを眺める遠方のファンもいます。

バックエンドはすべてCloudflare Workersで、リレーショナルデータベースはD1だけです。その実体は、直列の書き込みキューを持つ単一リージョン・単一プライマリのSQLiteです。並行したステートメントを浴びせればキューが溢れ、500になります。実際に起きたので分かっています。群衆が現れる前、コンテンツパイプラインの実行中のことでした。

つまりアーキテクチャの問いは、最初から「どうD1を速くするか」ではありませんでした。

どうすれば、ほとんど何もD1に触れずに済むか。

D1は台帳であって、提供面ではない

D1はすべての真実の源であり続けます。カタログ、ライブ報告、報告者の信頼度、設定、監査証跡。しかし来場者が台帳を読むことはありません。読むのは派生アーティファクトです。真実が変わるたびにD1から再計算される小さなJSONを、R2に書き、多層のキャッシュを通して配ります。

読み取りのホットパスはすべて同じ形をしています。

D1(真実) --書き込み契機の再構築--> R2アーティファクト --コロキャッシュ--> 来場者

アーティファクトは、カタログバンドル、どのバンドルが有効かを指すポインタ、会場・日ごとの在庫と行列のライブスナップショットとそのポインタ、サーバーサイドレンダリングが必要とするランタイム設定、コミュニティ地球儀、そして完売リスク曲線です。どれも、それを無効化する書き込み――運営の公開、届いた報告、管理者の編集――によって再構築されます。タイマーではなく、まして読み取り時でもありません。

公開はコンテンツアドレス方式です。バージョンは本体のハッシュで、クライアント側でSHA-256の完全性検証を行います。おかげでバンドルのバイト列は1年のimmutableとしてキャッシュでき、鮮度が必要なのは小さなポインタだけになります。

第1層 — クライアントは二度尋ねない

Service Workerは4つのキャッシュ(ページ、アセット、データ、バージョン付きプリキャッシュ)を持ちます。再訪した来場者の再表示は完全に端末上で処理されます。一度見たページは、電波の通らないコンクリートのホールでもオフラインで動きます。これは利用者向けの機能であると同時に、最も外側のキャッシュ層でもあります。メディアは事前に温め、カタログはIndexedDBに置いてポインタと照合するだけで、再ダウンロードはしません。

結果として、「数百万のビュー」の大半はそもそもHTTPリクエストになりません。

第2層 — オリジンが考える前にコロが答える

ポインタ形のエンドポイントはすべて、Workers Cache APIをコロ単位のマイクロキャッシュとして使い、TTLは10〜15秒です。開場直後の起動ストーム――数千台が同じポインタを同時に取りに来る――は、コロあたり・窓あたりおよそ1回のオリジン読み取りに畳まれます。

苦労して得た細部が1つあります。これらにエッジのcache-controlキャッシュは意図的に使いません。ゾーンはキャッシュされたブラウザのTTLを数時間に書き換えてしまい、来場者自身のブラウザキャッシュに古いポインタが固定されると、消す手段のないままライブデータが止まります。そこで、保存するコロ側のコピーはpublic, max-age=15にしつつ、レスポンスは常にno-cache出ていきます。来場者は毎回再検証し、その再検証にデータベースではなくコロのコピーが答えます。鮮度の意味は誠実なまま、オリジン負荷だけが消えます。

第3層 — 小さなホットリードのためのアイソレート内キャッシュ

描画経路に残るもの――機能フラグ、ロックダウン状態、開催設定――は、約30秒のTTLでWorkerアイソレートのメモリにキャッシュします。障害時の姿勢は明確で、最後に分かっていた状態へ倒すことです。D1が一瞬詰まったからといってキルスイッチが機能を再びオンにしてはいけません。1本のクエリが失敗したからといって言語一覧が消えてはいけません。どこでもstale-if-errorで、空になるのは本当のコールドスタートのときだけです。

ここでの最後の大きな改善がランタイム設定アーティファクトでした。イベント索引と言語レジストリは、新しいアイソレートごとに5ステートメントのD1バッチを要していました。そして開場直後こそ、Cloudflareが数百の新しいアイソレートを立ち上げる瞬間です。まさにキュー溢れの形をした、同期した読み取りスパイクです。いまはコールドなアイソレートの初回ページビューがR2読み取り1回で済みます。アーティファクトは欠けていれば自己修復し、背後でstale-while-revalidateで更新され、管理者の書き込みは明示的に再公開して伝播を速めます。

結果として、サーバーレンダリングのページビューは定常状態でD1に0回触れます。

ロックダウン — 運用上の凍結をキャッシュの原始概念にする

各イベント日の前に、運営はロックダウンを入れます。リクエストフックの1つのゲートが、短く意図的な許可リスト――ライブピン、在庫リセット、キルスイッチ、当日の安全告知、曲線の再公開――を除くすべての変更系管理ルートを423で止めます。デプロイができない朝9時に必要になるものだけです。

ここでの洞察は、ロックダウンが証明可能な不変性のシグナルでもあるということです。何も公開できないなら、カタログのポインタはブラウザで1分間安全にキャッシュでき、イベント索引と言語レジストリはアイソレートのTTLを30秒から5分へ伸ばせ、旧来のD1依存のポーリングエンドポイントは完全に沈黙します。運営を事故から守る凍結が、そのままピーク時の読み取り負荷を平らにするスイッチになります。

同じくらい重要なのは、ロックダウン対応のキャッシュが触れてはいけないものです。当日告知のフィードは、朝7時の安全情報を凍結中でも出せるよう、書き込み側でロックダウン免除にしてあります。だからそのキャッシュは短いコロTTLのままで、ロックダウンで伸ばしてはいけません。キャッシュ方針は雰囲気ではなく、書き込み経路の実態に従います。

書き込み経路 — 群衆は3往復に絞られる

読み取りはキャッシュで消せますが、書き込みは消せません。来場者の在庫・行列報告はすべて、単一で直列のSQLiteに着地します。受理される報告あたりの予算はD1への3往復です。

  1. 事前チェックのバッチ1回 — キルスイッチ、対象の妥当性(この会場のこの日に承認されたオファーか)、冪等性の照合、そして永続的なレート計数。4ステートメントを1往復で。
  2. トランザクションのバッチ1回 — 報告者のupsert、報告の挿入、45分の合意窓の読み戻し。読み取りが同じトランザクションに乗るので、たった今挿入した行が見えます。
  3. 最後のバッチ1回 — 単調増加カーソルの取得、集約のupsert、報告者信頼度の更新を1トランザクションで。

合意形成そのものは、往復2と3のあいだにJavaScriptで計算します。会場・日のスナップショット再公開は遅延実行かつデバウンスされるので、1つのブースへの報告の集中は、報告ごとではなく1回のアーティファクト再構築になります。

そして安価な順に並ぶ盾があります。

  • アイソレート内のメモリレート制限。本文を解析する前に効きます。さもないとゴミの洪水がリクエストごとにed25519の検証を発生させます。
  • 永続的なIP帯域単位の計数。個人ではなくホールの規模で設定します。
  • ゾーンWAFのレートルールを全体の最後の砦に。Worker内の制限はアイソレート単位で、台数とともに倍増します。Workerが呼ばれる前に洪水を止められるのはゾーンだけです。
  • サーキットブレーカー。負荷でD1が落ち始めたら、もう1つの500を生むためにWorker呼び出しを払うのではなく、即座に503とretry-afterで流量を捨てます。クライアントの報告キューはそれを「報告は保持して待て」と解釈します。失われるものはなく、遅れるだけです。

すべては端末ごとのed25519鍵で署名されるので、不正対策はネットワーク層だけに依存しません。報告者ごとの信頼度が合意に重みを与え、繰り返し矛盾する者は重み0へシャドウバンされ、作りたての使い捨て鍵は弱い状態から始まります。

そもそもD1に触れないもの

共有グループカートはDurable Objects(カートごとのSQLite)にあり、ホットな操作は構造上D1ゼロです。小さなD1レジストリは、バックアップのためにカートを列挙できるようにするためだけに存在します。テレメトリと運用メトリクスはAnalytics Engineへ送られ、夜間にR2へ生データを書き出します。当日のダッシュボードはその面を読むので、監視が監視対象に負荷を足しません。快適さのヒートマップ、コミュニティ地球儀、スケジュール、ガイドも、すべて同じパターンの公開アーティファクトです。

デプロイなしの運用

イベント中に運営が必要とするものは、コードではなくデータに乗ります。キルスイッチはライブポインタ上にあり、サーバー側で強制されるので古いクライアントにも安全です。公式ピンは群衆の合意を権威的に上書きし、在庫リセットは会場を「在庫ありと見なす」基準へ戻し、どちらも即座に再公開されます。予測の調整つまみは曲線アーティファクトに乗るので、デプロイではなく再公開です。破壊的な道具はできる限り自己巻き戻し可能にしてあります。古い会場・日をライブ面から消しても、消えるのは派生アーティファクトだけで、次の報告がゼロから作り直します。毎時のcronがライブテーブルを有界に保ち、各リーパーは隔離されているので1つの失敗が他を巻き込みません。

スコアボード

  • 最後の最適化前の定常状態のD1トラフィック:サイト全体で1日およそ4.5万リード(毎秒0.5回程度)。その大半はすでにR2へ移りました。
  • サーバーレンダリングのページビュー:D1クエリ0回(以前は7ステートメント以上)。
  • 来場者がライブ在庫を見るとき:D1クエリ0回。R2のポインタとスナップショットをコロキャッシュで。
  • 受理された群衆報告:4層の流量制御の背後でD1に3往復
  • データベース自体:26MB、101テーブル、単一リージョン。物理的に、それが支えるホールの隣に置かれています。

正直に要約すれば、D1が1万人をさばいているのではありません。D1は台帳として働き、Service Worker、コロキャッシュ、R2アーティファクト、Durable Objectsがそれぞれ一桁ずつ吸収してからでないと、何もSQLiteには届きません。そして、どうしても書かねばならない唯一の経路は、直列の書き込みキューが展示ホールの存在に気づくずっと前に、バッチ化され、デバウンスされ、信頼度で重み付けされ、必要なら捨てられます。

一般化できること

  1. 単一ライターのデータベースは、提供面ではなく台帳としてスケールする。 書き込み時に読み取り用アーティファクトを導出し、ファンアウトはオブジェクトストレージとキャッシュに任せる。
  2. 不変性を証明可能にしてから、それを利用する。 変わらないものはコンテンツアドレス化し、運用上の凍結を使って心置きなくTTLを伸ばす。
  3. 誠実な意味でキャッシュする。 バイト列はコロでキャッシュしても、古さが利用者に嘘をつくならクライアント側はno-cacheのままにする。CDNに鮮度の契約を書き換えさせない。
  4. 最後に分かっていた状態へ倒す。 キャッシュミスの劣化先は古いデータであるべきで、別の答えであってはならない。
  5. IP単位の発想は会場で破綻する。 キャリアNATはスタジアム1つを1アドレスの背後に置く。制限は群衆の規模で設計し、個人は身元――署名された報告、信頼度、シャドウバン――で取り締まる。
  6. 避けられない書き込みはバッチ化し、往復の合間に計算し、全体の前段にサーキットブレーカーを置く。
  7. 運営にデータ面のレバーを渡す。 イベント当日にデプロイが必要にならないように。
  8. 後片付けは自己巻き戻し可能に。 追い詰められた運営が手に取る道具は、後悔しようのないものであるべきです。