マルチテナントとRLS — 公開APIが速い理由

ShareDan CMS はマルチテナント構成です。ひとつのデータベースに多数のテナントのコンテンツが同居しますが、公開API(各テナントのWebサイトが叩く読み取り専用API)はシンプルで速く保てています。その理由は2つ —テナント分離をアプリではなくデータベースに寄せたこと、そしてバージョン付きのレスポンスキャッシュです。

テナント分離はアプリではなくDBで

分離を「アプリのクエリに毎回where('tenant_id', …)を足す」で守ろうとすると、1か所書き忘れるだけで他テナントのデータが漏れます。ShareDan CMS はこれをPostgreSQL の Row Level Security(RLS)に任せています。

プレーンごとにDBロールと接続を分けています。運営プレーンはテーブル所有者ロールで接続し(RLSをバイパスして横断管理する)、テナントプレーンはapp_tenant、公開APIはapi_publicという非所有者ロールで接続します。非所有者にはRLSが必ず適用されます。

各テナント表には、次のポリシーが貼られています。

-- 分離境界の解決関数:app.tenant_id が未設定なら NULL を返す
CREATE FUNCTION current_tenant_id() RETURNS bigint LANGUAGE sql STABLE AS $$
    SELECT NULLIF(current_setting('app.tenant_id', true), '')::bigint
$$;

-- 各テナント表のポリシー(tenant_id で分離)
CREATE POLICY tenant_isolation ON t_posts
    USING      (tenant_id = current_tenant_id())
    WITH CHECK (tenant_id = current_tenant_id());

ポイントは、app.tenant_id(セッション変数=GUC)が未設定だとcurrent_tenant_id()がNULLを返し、どの行も見えなくなる点です。つまりfail-closed— 境界を設定し忘れたら「全部見える」ではなく「何も見えない」に倒れます。

公開APIの1リクエストは、まず所有者接続でX-API-KEY/X-SITE-CODEからテナントとサイトを特定し、そのあとapi_public接続へ切り替えて境界をセットします。

// テナントを解決したら、公開ロール接続にリクエストの境界をセットする
DB::connection('pgsql_public')
    ->statement("SELECT set_config('app.tenant_id', ?, false)", [$tenantId]);

// … このリクエストの処理 …

// 接続再利用(永続接続・RDS Proxy 等)で他テナントへ漏れないよう、finally で必ず空へ戻す
DB::connection('pgsql_public')
    ->statement("SELECT set_config('app.tenant_id', '')");

結果として、コントローラのクエリからテナント分離の心配が消えます。Post::query()->where('site_id', …)->published()->…のように素直なクエリを書くだけで、他テナントの行はDBが原理的に返しません。分離が1本のインデックス付き述語に収まり、アプリ側の防御ロジックも要らない — これは正しさと速さの両取りです。

最小権限の読み取り専用ロール

api_publicは公開APIが必要とするものしか触れません。テナント系テーブルへのSELECT(+公開フォーム送信のための限定的なINSERT)だけを持ち、運営アカウント・操作ログ・セッションやジョブといった枠組みテーブルへの権限は明示的に剥奪してあります。仮にSQLに想定外の参照が混ざっても、公開ロールにはそもそも見る権限がありません。

速さの主役:バージョン付きレスポンスキャッシュ

分離の話と並ぶもう一本の柱がキャッシュです。公開の読み取りレスポンスはPublicApiCacheServiceを通し、組み立て済みのJSONをキャッシュから返します。キャッシュキーにはコンテンツ種別・サイトID・リクエストパラメータに加えて、バージョン番号を含めます。

// public-api:{種別}:site:{siteId}:v{version}:{パラメータのハッシュ}
$key = sprintf('public-api:%s:site:%d:v%d:%s',
    $contentType, $siteId, $version, md5(json_encode($keyParts)));

return Cache::remember($key, 3600, $callback); // 3600 は旧バージョン掃除用のTTL

ヒットすればDBには一切触れません。一覧・個別・ページ・カテゴリといったバリエーションはキー違いで個別にキャッシュされます。

パージはバージョンを1つ進めるだけ

このキャッシュの気持ちよさは無効化にあります。パージはバージョン番号をインクリメントするだけです。

// CachePurgeService::purge
Cache::forever($versionKey, $this->version($contentType, $siteId) + 1);

バージョンが上がると、古いv{n}を含むキーは二度と参照されなくなり、TTLで自然に消えます。個々のキーを列挙して消す必要がないので、タグ機能を持たないキャッシュストア(database ドライバなど)でも確実に動きます。

無効化はピンポイントです。管理画面でのコンテンツ更新や、公開日時を反映するバッチ(BAT-001)が、更新のあったサイト×種別のバージョンだけを上げます。他サイト・他種別のキャッシュはそのまま生き続けます。

ステートレスだから前段にも置ける

公開APIの認証はCookieやセッションではなくヘッダ(X-API-KEY/X-SITE-CODE)です。GETのレスポンスはサイトごとに決定的なので、アプリ内キャッシュだけでなく、必要なら手前にCDNを置くのも容易です。一方で、フォーム送信やプレビューといった動的な経路にはCache-Control: no-storeを付け、キャッシュ経路に載らないようにしています。

まとめ

公開APIが速くシンプルでいられるのは、分離をDB(RLS)へ寄せてクエリと権限を素直に保ち、バージョン付きキャッシュでヒット時はDBに触れず、パージはバージョンを1つ進めるだけにしているからです。SDK・エンドポイントの詳細はAPIリファレンスを参照してください。

FEEDBACK

このページはお役に立ちましたか?

改善要望・不具合・ドキュメントの分かりにくい点など、お気軽にお寄せください。いただいた内容は今後の改善に役立てます。

返信をご希望の場合のみご記入ください。

スパム対策(レートリミット・ハニーポット・送信速度チェック)はサーバ側で自動適用されます。