テナントごとに DB を分ける設計と、その代償の払い方
UTSUROI は control DB とテナントごとの DB を分けています。Cloudflare Workers + Turso という構成で何を得て、何を諦めたのかを書きます。
UTSUROI のバックエンドは Cloudflare Workers + Hono で、アプリケーションデータは Turso(libSQL)に置いています。認証だけは Supabase Auth です。
データベースの構成は、よくある「1つの大きな DB にテナント ID カラムを足す」形ではありません。control DB が1つと、テナントごとの DB が1つずつあります。
なぜ分けたのか
理由は3つあります。
1. テナント間の漏れが構造的に起きない。 WHERE tenant_id = ? を1箇所書き忘れただけで他社のデータが見える、という事故がそもそも成立しません。開いている DB が違うからです。レビューで守るものが1つ減ります。
2. 1テナントの負荷が他に波及しない。 SQLite は書き込みが直列なので、共有 DB だと重いテナントが軽いテナントを詰まらせます。分けてあれば起きません。
3. Workers との相性。 エッジで動くランタイムに大きな共有 DB への長い接続を持たせるより、リクエストのたびに必要な小さい DB を1つ開くほうが素直です。
control DB には何を置くか
ここが設計の肝で、うっかりすると control DB が第二の共有 DB に育ちます。
置いてよいのは、テナント DB を選ぶ前に知っていなければならないものだけ、と決めています。
- テナントの名簿と接続先(ルーティングの根拠)
- テナントをまたぐ調整状態(頻度が低いもの)
- テナント解決より前に必要な秘匿情報(暗号化して保存。平文のトークンは置かない)
逆に置かないのは、テナントが分かってから読み書きできるもの全部。テナント内のエンティティ、テナント内の ID、頻度の高い書き込み、キャッシュ、テナント内ワークフローの冪等性レコード。これらはテナント DB 側です。
新しいテーブルを足すときは、毎回この問いを通します。
control プレーンの参照を1回挟めば、これはテナント DB に移せないか?
移せるなら、テナント DB に置きます。
諦めたもの
分けた以上、SQLite は互いに独立です。当たり前ですが、失うものははっきりしています。
- DB をまたぐ外部キーがない。 境界を越えて持つ ID は論理参照でしかありません。ボード ID やレーン ID を受け取ったら、使う前に選択中のテナント DB 側で実在を検証します。
- DB をまたぐ JOIN がない。 欲しければアプリケーション側で2回引いて突き合わせます。
- DB をまたぐトランザクションがない。 ここがいちばん重い。
3つめへの向き合い方が、この設計の実務のほぼすべてです。両方のプレーンを更新するワークフローでは、
- 各ステップを冪等にする(同じ入力で2回走っても壊れない)
- 復旧に必要な状態を永続化する(どこまで進んだかが後から分かる)
- リトライの挙動をあらかじめ決める(曖昧なまま運用に出さない)
を満たします。「失敗したらロールバック」は使えないので、「途中で落ちても、もう一度走らせれば辻褄が合う」に寄せるということです。
受信イベントの重複は、テナント側で潰す
外部からのイベント(チャットの発言など)は同じものが二度届きます。ここで control DB に「受信記録」テーブルを作りたくなりますが、やめています。イベント1件ごとに control プレーンへ書き込みが走る設計は、リクエスト量に比例して共有資源を叩くことになるからです。
代わりに、決定的な ID かテナント DB 側の一意制約で潰します。テナントさえ解決できれば、重複排除はテナント DB の中で完結します。
外部サービスの ID からテナントを引く索引だけは、テナントを特定する前に必要なので control に置きます。逆に言えば、control に置く索引はそれだけです。
マイグレーションはテナント数だけ走る
代償のもう1つがこれです。スキーマを変えると、テナント DB の数だけマイグレーションを流す必要があります。
UTSUROI では Drizzle のスキーマを正として、生成・登録・適用を自動化してあります。スキーマを直して1コマンド叩けば、SQL もレジストリも生成される。手で3箇所に登録して回る運用は、早い段階でやめました。手で同期する場所が残っていると、いつか必ずずれます。
まとめ
テナントごとに DB を分ける構成は、漏洩の心配と隣人の負荷から解放される代わりに、原子性を手放す取引です。
その取引に見合うのは、冪等性とリトライを設計に織り込む覚悟がある場合だけだと思います。逆にそこさえ引き受けられるなら、日々の実装はむしろ単純になります。「今どのテナントの DB を開いているか」だけ意識すればよく、クエリのたびにテナント境界を気にしなくてよくなるからです。