読了 約 7 分 Cosoado Lab

Supabase RLS で auth.uid() を毎行呼び出さないための 1 行の書き換え

Supabase の Row Level Security は、書き方を間違えると 行数に比例して遅くなるauth.uid()(select auth.uid()) で包むだけで、公式ベンチで 179ms → 9ms (94.97% 改善)。原因・修正・もう一段速くする to authenticated までの 1 行修正レシピ。

TL;DR

-- 遅い: auth.uid() が行ごとに呼ばれる
using ( auth.uid() = user_id )

-- 速い: 1 ステートメントに 1 回だけ評価される
using ( (select auth.uid()) = user_id )

これだけで、Supabase 公式ベンチ179ms → 9ms(94.97% 改善)。RLS を有効にしてクエリが遅くなった、という個人開発者向けの 1 トピック。

はじめに

Supabase の Row Level Security (RLS) は強力ですが、書き方を間違えると 行数に比例して遅くなる罠があります。

マッチングアプリ (SparMate ほか 2 本) を Supabase で運用していて、profiles テーブルが数千件を超えたあたりから検索系のクエリが目に見えて重くなりました。原因は RLS の auth.uid() を毎行呼び出していたこと。

主要な修正は 1 行、加えてロール指定の 1 行です。この記事では、なぜ遅いのか・なぜ修正で速くなるのか・もう一段階速くする to authenticated の追加までを書きます。

なぜ遅いのか

典型的な「自分のデータだけ見える」ポリシーはこう書きます。

create policy "users can read own profile"
on profiles for select
using ( auth.uid() = user_id );

auth.uid()LANGUAGE sqlSTABLE 関数で、内部では current_setting('request.jwt.claim.sub') を読んで JWT の sub を返しています。STABLE なのでトランザクション内では同じ値を返すはずですが、書き方によってはプランナがこの式を 行ごとに再評価してしまいます。1,000 行のテーブルなら 1,000 回の関数呼び出しです。

これが「RLS にしてから遅くなった」の正体です。

修正 1: auth.uid() を SELECT で包む

修正版はこう。

create policy "users can read own profile"
on profiles for select
using ( (select auth.uid()) = user_id );

(select auth.uid()) と書くと、Postgres プランナが initPlan として認識し、1 ステートメントにつき 1 回だけ評価して結果をキャッシュします。

公式 docs (Supabase — RLS performance) のベンチマークでは:

書き方実行時間
auth.uid() = user_id179 ms
(select auth.uid()) = user_id9 ms

約 95% 改善security definer な関数を呼ぶケースだと 99.993%(178 秒 → 12ms)という極端な事例も載っています。

Wrapping the function causes an initPlan to be run by the Postgres optimizer, which allows it to "cache" the results per-statement, rather than calling the function on each row.

(訳: 関数を SELECT で包むと Postgres オプティマイザが initPlan を生成し、行ごとに関数を呼ぶ代わりにステートメント単位で結果をキャッシュできる)

修正 2: TO authenticated でロールを絞る

ログインユーザー専用のポリシーなら、to authenticated を追加すると更に速くなります。

create policy "users can read own profile"
on profiles for select
to authenticated                       -- これを追加
using ( (select auth.uid()) = user_id );

to authenticated を書くと、匿名ユーザー (anon ロール) のリクエストではポリシー本体の評価をスキップします。Supabase のベンチでは 170ms → 0.1ms 以下(99.78% 改善)

逆に書き忘れると、ログインしていないユーザーがエンドポイントを叩いても (select auth.uid()) = user_id が評価され続けます。空の uid との比較なので結果は false ですが、無駄な計算が走り続ける状態です。

既存ポリシーを書き換えるときの注意

運用中のアプリで適用したときの手順。

1. マイグレーションは drop + create

Postgres の create policy ... if not exists は使えません。修正は drop してから create します。

drop policy if exists "users can read own profile" on profiles;
create policy "users can read own profile"
on profiles for select
to authenticated
using ( (select auth.uid()) = user_id );

2. 全テーブル分のポリシーを棚卸す

pg_policies ビューでポリシーをまとめて確認できます。

select schemaname, tablename, policyname, qual
from pg_policies
where qual like '%auth.uid()%'
  and qual not like '%(select auth.uid())%';

auth.uid() を裸で使っているポリシーがリストアップされます。当該アプリでは 11 ポリシー中 8 つが該当していました。

3. user_id カラムに index を貼る

これは別トピックですが、RLS の比較対象カラム(多くの場合 user_id)には B-tree index が必須です。RLS は WHERE 句に展開されて評価されるため、index がないと毎回 seq scan になります。

create index if not exists profiles_user_id_idx on profiles (user_id);

まとめ

公式の RLS パフォーマンスガイドは短いですが、個人開発で見落とされがちな要点です。Cosoado Lab のアプリも全ポリシーを書き換えてから p95 が 200ms 台から 30ms 台に落ち着きました(条件次第ですが、効果は再現性高いです)。

関連プロダクト

他のプロダクト by Cosoado Lab

TsuriMate 釣り仲間・船割りメンバーマッチング YUMELIA 夢占い・数秘術が無料でできる AI 占い Web アプリ OshiVista 推し活を 1 つにまとめる管理アプリ(多言語対応) 謎かけメーカー AI と「その心は?」を競うなぞかけ・大喜利