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

Systems

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

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

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

Mirai GuideのバックエンドはすべてCloudflare Workersで、リレーショナルデータベースはD1だけです。その実体は、直列の書き込みキューを持つ単一リージョン・単一プライマリのSQLiteです。

わりと早い段階で、これを一度落としました。コンサートの最中ではありません。群衆が現れる何ヶ月も前、コンテンツパイプラインを流しているときでした。並行したステートメントが書き込みキューに積み上がり、キューが溢れ、全部500で返ってきました。

イベント当日の朝ではなく、静かな部屋で学べてよかったと思います。

Mirai Guideが耐えないといけない条件はこうです。

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

まず思いつくのはD1を速くすることです。インデックスを足す、クエリを調整する、何かをシャーディングする。でもそれではあまり効きません。ボトルネックはクエリの速度ではなく、書き込みキューが直列で、しかも1本しかないことだからです。

だから問いは、最初から「どう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アイソレートのメモリにキャッシュします。

TTLより大事なのは障害時の姿勢で、最後に分かっていた状態へ倒すことです。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. 後片付けは自己巻き戻し可能に。 追い詰められた運営が手に取る道具は、後悔しようのないものであるべきです。

魔法は使っていません。どの層も、吸収できるところに仕事を移しているだけです。そして、どこがまだ遅くていいのかを正直に決めているだけです。