最初に、いちばん大事なことを言い切ります。RLS(行レベルセキュリティ)のポリシーは、テーブルへのあらゆるクエリに自動で付く「暗黙の WHERE 句」です(Supabase: Row Level Security)。だから述語(USING / WITH CHECK の中身)が遅ければ、その遅さはそのテーブルを触る全クエリ・全行に等しく乗ります。1本のポリシーの書き方が、アプリ全体のレイテンシを決めることがある——これがRLSのパフォーマンスを軽視できない理由です。
そして厄介なのは、RLSは「正しく」書いても遅くなることです。auth.uid() = user_id という一見なんでもない述語が、行数が増えた本番で突然100倍遅くなる。原因は認可ロジックの間違いではなく、PostgreSQLがその述語を「1行ごとに」評価してしまうという、実行計画の問題です。
この記事は、Supabase(PostgreSQL)公式が示す6つのRLSパフォーマンス最適化を、「なぜ効くのか」という実行計画レベルの理由と、EXPLAIN ANALYZE で自分の環境で計測する手順つきで解説します。題材は、私が一人で構築した複数人同時編集のリアルタイム試合記録アプリ(Expo + Next.js + Supabase のモノレポ)で、69テーブル全てにRLSを有効化し、約280本のポリシーを本番運用している実装です。ポリシーがこの規模になると、書き方一つの差が体感速度に直結します。
この記事の立ち位置:RLSをどう設計するか(マルチテナント分離・
tenant_id・membership)はSupabase RLS本番設計ガイド、USINGとWITH CHECKの正しさは書き込みバイパスの記事、退行を止めるテストはpgTAPの記事で扱っています。本記事はそれらと重複せず、**「正しく書けたRLSを、いかに速くするか」**だけに集中します。設計・正しさ・テストが前提にある上での、性能の話です。
honesty contract:本文中のベンチマーク数値(179ms→9ms 等)は、Supabase公式ドキュメントが示す合成テーブルでの計測値です。あなたの行数・分布・ハードウェア・インデックス状況では再現しない可能性が高い。数値は「効果のオーダー(桁)」の目安として読み、必ず後述の
EXPLAIN ANALYZEで自分のデータで計測してください。仕様はSupabase公式・PostgreSQL公式(いずれも2026年6月時点)に基づきます。
0. なぜRLSは遅くなるのか:3行で言うと
最適化の前に、遅さの正体を3つに分解します。これが分かれば、6つの対策が「バラバラの小技」ではなく「3つの病に対する処方」だと見えてきます。
| 遅さの正体 | 何が起きているか | 効く対策 |
|---|---|---|
| per-row 関数評価 | auth.uid() 等が行ごとに呼ばれる | §2 (select)ラップ |
| 索引の不在 | ポリシー述語がフルスキャンを誘発 | §3 索引・§5 JOIN削減・§6 関数化 |
| 無駄なポリシー評価 | 関係ないロールにまで述語が走る | §4 TO 指定・§7 JWT |
PostgreSQL公式は、ポリシー式の評価順序をこう定めています——「この式は、ユーザーのクエリ由来の条件や関数より前に、各行に対して評価される」(PostgreSQL: Row Security Policies)。つまりRLSの述語は最も内側のホットパスで回る。だからここが遅いと、他のどんな最適化も効きません。例外は leakproof(情報を漏らさないと保証された)関数だけで、その場合のみオプティマイザがRLSチェックより先にユーザー条件を適用できます。
ポイントは順序です。まず計測(§1)、次に効果の大きい順(§2→§7)。 推測で索引を乱立させるのは、RLSでも普通のクエリチューニングでも悪手です。
1. まず計測する:EXPLAIN ANALYZE と pg_stat_statements
「RLSが遅い」と言う前に、どのポリシーの、どの述語が、何msかを数字で掴みます。RLSは透過的に効くので、普通に EXPLAIN ANALYZE を流せば、ポリシーは実行計画の中にフィルタ条件として現れます。
1-1. 認証済みユーザーになりきって計測する
RLSは「誰として実行するか」で結果が変わります。service_role(RLSバイパス)で測っても無意味です。authenticated ロールになりすまし、request.jwt.claims に sub(ユーザーID)を入れてから計測します。
-- セッションを「特定の認証済みユーザー」に偽装して計測する
begin;
-- このトランザクションだけ authenticated ロールで実行する
set local role authenticated;
-- auth.uid() が返すユーザーIDを注入する(本番のJWTと同じ経路)
set local request.jwt.claims = '{"sub":"00000000-0000-0000-0000-000000000001","role":"authenticated"}';
explain (analyze, buffers, verbose)
select * from public.matches where game_id = '...';
rollback; -- 計測は副作用を残さない(冪等)
rollback で閉じるのは、計測を何度流しても状態を変えないためです(冪等性)。実行計画に Filter: (matches.user_id = auth.uid()) のような行が出て、その下の Rows Removed by Filter と実時間を見れば、ポリシーがどれだけ仕事をしているかが分かります。
1-2. 「per-row評価」を実行計画で見抜く
最適化前後で何が変わるのかを、計画の言葉で押さえます。
- 遅い形:述語が
Filterとして全行に適用され、auth.uid()が各行で呼ばれる。Seq Scan+大きなRows Removed by Filter。 - 速い形:
(select auth.uid())がInitPlan 1として計画の先頭に一度だけ現れ、本体はIndex Cond: (user_id = $0)のように索引で引ける。
つまり目標は明確です——述語を Filter(行ごと)から Index Cond(索引一発)に移すこと。以下の6つは、すべてそのための手段です。
1-3. 本番では pg_stat_statements で「効いている遅さ」を探す
単発の EXPLAIN は仮説検証用です。本番で「どのクエリの累積コストが大きいか」は pg_stat_statements で探します(Supabaseはデフォルト有効)。
select calls, mean_exec_time, total_exec_time, query
from pg_stat_statements
order by total_exec_time desc
limit 20;
mean_exec_time × calls = total_exec_time が大きいクエリが、最適化のROIが最も高い対象です。**「1回が遅いクエリ」より「そこそこ遅いが大量に呼ばれるクエリ」**を先に直す——これはRLSに限らないチューニングの鉄則です。
2. 最強の一手:auth.uid() を (select auth.uid()) で包む
効果が最も大きく、コストが最も低いのがこれです。ポリシー内の関数呼び出しを、相関のないスカラ副問い合わせ (select ...) で包むだけ。
-- ❌ 遅い:auth.uid() が行ごとに評価される
create policy "rls_select" on public.test_table
for select to authenticated
using ( auth.uid() = user_id );
-- ✅ 速い:(select auth.uid()) は一度だけ評価され、InitPlanに畳まれる
create policy "rls_select" on public.test_table
for select to authenticated
using ( (select auth.uid()) = user_id );
公式ベンチマークでは、この1文字レベルの違いで 179ms → 9ms(約95%改善)。is_admin() のような重い関数を含む例では 11,000ms → 7ms、security definer関数を絡めた例では 178,000ms → 12ms という桁違いの差が報告されています(Supabase: RLS Performance)。
なぜ効くのか:InitPlan という「定数畳み込み」
auth.uid() は、実体としては current_setting('request.jwt.claims') からユーザーIDを読む関数です。その値は、1つのクエリ実行の間ずっと同じ——行ごとに変わりません。
ところがPostgreSQLは、ポリシー述語に裸で auth.uid() と書かれていると、これを各行で律儀に呼び直します。(select auth.uid()) と副問い合わせで包むと、PostgreSQLはこれを外側の行と相関のない式(uncorrelated subquery)と認識し、InitPlan として実行の最初に一度だけ評価して結果を定数のように再利用します。実行計画では InitPlan 1 (returns $0) として先頭に現れ、本体は $0(評価済みの定数)を参照するだけになります。
これは「キャッシュ」というより定数畳み込みです。だから副作用として、user_id に索引があれば user_id = $0 を索引で引けるようになり、§3の効果まで引き出せます。
適用条件(重要):この最適化が正しいのは、包む式の結果が全行で同じときだけです。
auth.uid()・auth.jwt()・現在ユーザーに依存する関数はOK。逆に、row.columnのようにその行に依存する値を含む式を(select ...)で包んではいけません——意味が変わります。「ユーザー単位で一定の値」だけを畳む、と覚えてください。
3. ポリシーが参照する列に索引を張る
RLSポリシーは暗黙の WHERE です。ならば普通のクエリと同じく、絞り込みに使う列には索引が要ります。user_id = (select auth.uid()) で絞るなら、user_id に索引を張る。
create index if not exists idx_test_table_user_id
on public.test_table using btree (user_id);
公式ベンチでは 171ms → 0.1ms未満(約99.9%改善)。§2で述語が Index Cond に乗れる形になった上で索引があると、索引一発で引けるので劇的に速くなります。
実務での索引設計の指針:
- 単一テナント所有:
user_idに btree 単一列索引。 - マルチテナント:
tenant_idが全ポリシーの共通述語になるので、tenant_idを複合索引の先頭に置く(例:(tenant_id, created_at))。クエリのorder by created_atまで索引で賄える。 - membershipテーブル(§5で多用):
(user_id, team_id)の複合索引と、逆引き用の(team_id)索引。
索引は万能ではありません。書き込みが多くカーディナリティの低い列(例:
is_activeのような真偽値)への索引は、更新コストに見合わないことがあります。必ず §1 のEXPLAIN ANALYZEで「Index Scanに変わったか」を確認してから残してください。PostgreSQL本体の索引設計の詳細は『PostgreSQL本体・性能設計』クラスタに譲ります。
4. TO でロールを絞り、無駄なポリシー評価を消す
ポリシーに TO authenticated を書かないと、そのポリシーは anon(未認証)を含む全ロールに対して評価されます。未認証ユーザーのリクエストでも auth.uid() 比較が走り、無駄に遅くなる。
-- ❌ TO なし:anon のリクエストでもこの述語が評価される
create policy "rls_select" on public.rls_test
using ( (select auth.uid()) = user_id );
-- ✅ TO authenticated:認証済みにだけ評価され、anon は即スキップ
create policy "rls_select" on public.rls_test
for select to authenticated
using ( (select auth.uid()) = user_id );
公式ベンチでは、anon がアクセスするケースで 170ms → 0.1ms未満(約99.8%改善)。anon には端からこのポリシーが適用されないので、述語評価そのものが消えます。
これはパフォーマンスと正しさが一致する良いケースです。RLSの設計原則として、「誰として実行されるか(anon / authenticated / service_role)」を全ポリシーで明示するのは、本番設計ガイドでも推奨した型です。TO を書く癖は、速さと安全の両方に効きます。
5. ポリシー内の JOIN を「集合参照」に書き換える
複雑な認可——「このユーザーが、この行の属するチームのメンバーか?」——を、ポリシー内でテーブル結合(JOIN)として書くと、その結合が対象テーブルの行ごとに走り、急激に遅くなります。
-- ❌ 遅い:source(対象表) と target(team_user) を結合している
create policy "rls_select" on public.test_table
for select to authenticated
using (
(select auth.uid()) in (
select user_id from team_user
where team_user.team_id = test_table.team_id -- 外側の行に相関=行ごとに実行
)
);
-- ✅ 速い:先に「自分が所属するteam_idの集合」を作り、行はそれに含まれるか確認するだけ
create policy "rls_select" on public.test_table
for select to authenticated
using (
team_id in (
select team_id from team_user
where user_id = (select auth.uid()) -- 外側に相関しない=一度だけ実行
)
);
公式ベンチでは 9,000ms → 20ms(約99.8%改善)。
なぜ効くのか:相関を断ち切る
最初の形は、副問い合わせが test_table.team_id を参照しているため外側の行に相関し、行ごとに team_user を引き直します(N回のルックアップ)。
後者は、副問い合わせが (select auth.uid()) にしか依存しないため外側と相関せず、一度だけ実行されて「自分の所属チームID集合」を作ります。あとは各行の team_id がその集合に入るかを in で確認するだけ。O(N回のJOIN) が O(1回の集合構築 + N回のメンバ判定) に変わります。「対象表と認可表をJOINする」のではなく「認可データを集合として先に引く」——これがRLSにおける最重要のリライトパターンです。
6. 複雑な認可は security definer 関数に逃がす
in (select ...) でも表現しきれない多段の認可(役割・期限付き権限・項目別開示など)は、security definer 関数に閉じ込めます。この関数は呼び出し元の権限ではなく関数所有者の権限で実行され、内部でRLSをバイパスするので、ポリシーの述語評価がそこで完結します。
-- 認可ロジックを private スキーマ(API非公開)の関数に閉じ込める
create or replace function private.has_team_access(target_team_id uuid)
returns boolean
language sql
security definer
set search_path = '' -- search_path注入を塞ぐ(必須)
stable -- 同一トランザクション内で結果が変わらないと明示
as $$
select exists (
select 1 from public.team_user
where user_id = (select auth.uid())
and team_id = target_team_id
);
$$;
revoke execute on function private.has_team_access(uuid) from anon, authenticated;
grant execute on function private.has_team_access(uuid) to authenticated;
-- ポリシーからは (select ...) で包んで呼ぶ(§2の最適化を効かせる)
create policy "rls_select" on public.test_table
for select to authenticated
using ( (select private.has_team_access(team_id)) );
公式ベンチでは、security definer 関数を (select ...) で包んだケースで 178,000ms → 12ms という極端な改善が報告されています。
ただし security definer はRLSをバイパスする刃物でもあります。3点を必ず守ってください。
- 公開スキーマに置かない。Supabase公式が明言する通り、security definer関数を「Exposed schemas(API設定で公開されたスキーマ)」に作ってはいけません。
privateなど非公開スキーマに置きます。 set search_path = ''を必ず付け、関数内のオブジェクトはpublic.team_userのように完全修飾する。これを怠ると、悪意あるsearch_pathで別オブジェクトを掴まされる注入経路になります。- 最小権限。
anonからexecuteを剥がし、必要なロールにだけ付与する。
この関数をRLSをすり抜ける穴にしないための設計と、search_path の落とし穴は、security definer 関数とsearch_pathの記事で詳述しています。性能のために導入した関数が認可の穴になっては本末転倒なので、必ず併読してください。
7. 一段上の最適化:JWTクレームでルックアップ自体を消す
ここまでは「DBへの問い合わせを速くする」話でした。最後は発想を変えます——そもそもDBを引かない。
ユーザーの所属チームや役割が頻繁に変わらないなら、それをJWTの app_metadata に載せてしまえば、ポリシーはDBを一切引かずにトークンの中身だけで判定できます。team_user テーブルへのルックアップ(§5・§6)すら消えます。
-- JWTのapp_metadataに格納された teams 配列で判定する(DBルックアップなし)
create policy "user is in team" on public.my_table
for select to authenticated
using (
team_id in (
select jsonb_array_elements_text(
(select auth.jwt() -> 'app_metadata' -> 'teams')
)::uuid
)
);
重要な注意が2つあります。
app_metadataを使う(user_metadataではなく)。Supabase公式は、raw_user_metadataはユーザー自身が書き換え可能なので認可に使ってはいけない、認可には書き換え不可のraw_app_metadataを使え、と明記しています。ここを間違えると、ユーザーが自分の権限を昇格できる致命的な穴になります。- トークンの鮮度とのトレードオフ。JWTは発行時点のスナップショットです。チームから外れても、トークンが失効するまで(リフレッシュまで)古い権限が残ります。即時失効が要る権限はDBルックアップ(§5/§6)、変化が緩い権限はJWT、と使い分けます。これは性能と一貫性の古典的なトレードオフです。
同じく、MFA(多要素認証)の強度をRLSで要求するのもJWTクレームの応用です。restrictive(AND結合)ポリシーで「aal2 でなければ更新不可」を全経路に強制できます。
-- 二要素認証を通したセッションでなければ更新を拒否する(全UPDATEにANDで効く)
create policy "require mfa for updates" on public.profiles
as restrictive -- 既存のpermissiveポリシーとANDで結合される
for update to authenticated
using ( (select auth.jwt()->>'aal') = 'aal2' );
restrictive ポリシーは、他の permissive ポリシー(OR結合)とANDで結合されます(PostgreSQL公式)。「許可を足す」のがpermissive、「全体に制約をかける」のがrestrictive——テナント境界やMFA要求のような絶対に外せない条件はrestrictiveで固定します。
8. 本番投入の順番:効く順に当て、テストで守る
6つ(+JWT)の最適化を、ROIの高い順に並べると次の通りです。上から順に当て、その都度 §1 の EXPLAIN ANALYZE で効果を確認するのが最短経路です。
| 優先 | 最適化 | コスト | 効果(公式ベンチの桁) | いつ効くか |
|---|---|---|---|---|
| 1 | (select auth.uid()) ラップ | 極小 | 〜100倍 | ほぼ全ポリシー |
| 2 | ポリシー列に索引 | 小 | 〜1000倍 | 行数が多い表 |
| 3 | TO authenticated 明示 | 極小 | anon経路で〜1000倍 | 公開+認証混在 |
| 4 | JOIN→集合参照のリライト | 中 | 〜数百倍 | membership認可 |
| 5 | security definer関数化 | 中 | 多段認可で桁違い | 複雑な認可 |
| 6 | JWTクレーム化 | 中 | ルックアップ消滅 | 変化の緩い権限 |
そして、最後に必ずやることがあります。
速くなったRLSが、まだ正しいか?を再検証する。
パフォーマンス最適化は述語を書き換える行為です。(select) ラップ・JOINリライト・関数化は、うっかり認可の意味を変えるリスクを常に伴います。「速いが、他人の行が見える」RLSは、遅いRLSより遥かに危険です。だから最適化の前後で、pgTAPの記事で示した**「許可されるべきが許可される」「拒否されるべきが拒否される」の両方**を自動テストで回し、CIで退行を止めてください。性能と正しさは、片方だけでは本番に出せません。
私のリアルタイム試合記録アプリでは、280本のポリシー全てにこの6つを設計段階から織り込み、pgTAPで許可/拒否を検証した上で、複数人が同時に同じデータを別の見え方で触る画面を実用的なレイテンシで動かしています。RLSは「書いて終わり」でも「速くして終わり」でもなく、設計 → 性能 → 検証の3点セットで初めて本番に耐えます。
まとめ:RLSの遅さは「実行計画の病」であって認可の間違いではない
- RLSポリシーは全クエリに付く暗黙のWHERE。述語が遅ければ全行に効くので、性能は設計の一部です。
- 遅さの正体は3つ——per-row関数評価・索引の不在・無駄なポリシー評価。6つの最適化はこの3つへの処方です。
- 最大の一手は
(select auth.uid())ラップ。相関のないスカラ副問い合わせがInitPlanに畳まれ、per-row評価が消え、索引にも乗る。 - 索引・
TO明示・JOINの集合参照化・security definer関数・JWTクレーム化を、EXPLAIN ANALYZEで計測しながら効く順に当てる。 - 数値は環境依存。最適化後はpgTAPで許可/拒否を再検証し、「速いが漏れる」退行を絶対に出さない。