Language: 日本語
その RLS、1行ごとに関数をループしていませんか? サブクエリと InitPlan で解決する行単位評価の罠
PostgreSQL や Supabase で RLS(Row Level Security)を使っているみなさん、**「データが増えるにつれて、なぜか一覧クエリだけがどんどん重くなる……」** と悩んだことはありませんか?
PostgreSQL や Supabase で RLS(Row Level Security)を使っているみなさん、「データが増えるにつれて、なぜか一覧クエリだけがどんどん重くなる……」 と悩んだことはありませんか?
「どうせインデックスが効いてないんだろう」「スロークエリを EXPLAIN で見直すか」と思って DB のチューニングを始めたくなりますよね。私も完全にそのつもりでした。
しかし、実行計画を詳しく調べてみたところ、インデックスの問題ではなく、「1 行スキャンするごとに、RLS の認可関数とテーブル検索が $N$ 回ループしていたこと」 が原因でした。
みなさんのプロジェクトの RLS ポリシー、もしかして関数の引数に行のカラムを渡していませんか?
この記事では、セキュリティ要件(返す行の集合)は 1 ミリも変えずに、ポリシーの評価単位を「行」から「SQL 文」へ変えることで、100 万件のクエリ実行時間を 3,463ms から 311ms(約 11.1 倍高速化)へ改善したアプローチ を解説します。
得られること
- PostgreSQL RLS でやりがちな「行単位の評価($O(N)$ ループ)」による性能劣化の仕組みがわかる
- 認可条件を変えずにクエリを 11 倍高速化する「文単位の評価(ハッシュ集合化)」への書き換えパターンが学べる
EXPLAIN ANALYZE BUFFERSを使って、自分の RLS ポリシーがループしていないか確認できるようになる!
行ごとのポリシー評価が引き起こす $O(N)$ ループ
アノテーション基盤では、ユーザーが所属するプロジェクトごとにデータを隔離するため、RLS を使って「アクセス権のあるプロジェクトのタスクだけを返す」という認可をかけていました。
最初に書かれていた RLS ポリシーがこちらです。みなさんなら、この SQL のどこに罠があるかわかりますか?
-- Before: 一見きれいだが大惨事を引き起こすポリシー
CREATE POLICY task_access_policy ON tasks
FOR SELECT
USING (can_access_project(project_id));
この can_access_project(project_id) という関数の中では、現在のログインユーザーがそのプロジェクトにアクセスできるかを判定するため、projects テーブルや project_members テーブルに対して EXISTS クエリを発行しています。
TypeScript やアプリケーションコードの感覚で見ると、ごく自然で綺麗なモジュール分割に見えますよね。しかし、データベースの内部実行では大惨事が起きていました。
なぜ $N$ 回のループになってしまうのか
これ、何がまずいのでしょうか?
最大の落とし穴は、関数の引数に スキャン対象の行のカラム(project_id)を渡していること です。
行ごとに引数の値が変わりうるため、PostgreSQL のオプティマイザは「クエリ全体で 1 回だけ評価して結果を使い回す(InitPlan)」という賢い最適化ができません。「この行の project_id で関数を実行」「次の行の project_id でまた関数を実行……」と愚直に繰り返すしかなくなります。
その結果、内部では以下のような地獄の $N$ 回ループが回っていました。
tasks テーブルから 10,000 行をスキャン
↓
1 行ごとに can_access_project(project_id) を実行(10,000 回 💥)
↓
その関数の中で projects / project_members テーブルを参照(10,000 回 💥)
↓
行数に比例してクエリ時間が爆発
一覧画面のように、多くの行をスキャンして絞り込むクエリほど、まったく同じユーザーに対するアクセス権チェックを何千回、何万回と無駄に繰り返すことになります。
手元の環境で EXPLAIN ANALYZE BUFFERS を取ってみてください。訪問した行数とまったく同じ回数だけ RLS の内部関数が呼ばれ、バッファ読み込みが跳ね上がっていれば、まさにこの罠を踏んでいます。
文単位の評価への書き換え
解決策は、認可のロジックそのものは一切変えずに、「PostgreSQL がクエリ開始時に 1 回だけ評価できる形」 に書き直すことでした。
-- After: 文単位で 1 回だけ評価されるポリシー
CREATE POLICY task_access_policy ON tasks
FOR SELECT
USING (project_id = ANY (accessible_project_ids()));
新しく用意した accessible_project_ids() は、引数を一切取りません。「現在のログインユーザーがアクセスできるプロジェクト ID の一覧」を配列(またはサブクエリ)として返します。
ハッシュ集合による $O(1)$ 判定への変化
引数に行のカラムが含まれないため、PostgreSQL はクエリ実行の最初にこの関数を 1 回だけ呼び出し、結果をメモリ上にハッシュ集合として保持(InitPlan) してくれます。
クエリ開始時に accessible_project_ids() を 1 回だけ実行(1 回 ✨)
↓
アクセス可能な project_id のハッシュ集合を作成
↓
tasks テーブルの各行をスキャン
↓
各行では「自分の project_id がハッシュ集合に含まれるか」を確認するだけ(O(1) のメモリ判定 ⚡️)
| 比較項目 | Before(行単位評価) | After(文単位評価) |
|---|---|---|
| ポリシーの記述 | can_access_project(project_id) | project_id = ANY (accessible_project_ids()) |
| 各行での処理 | 関数の呼び出し + テーブル lookup | メモリ上のハッシュ集合に対する所属確認 |
| 関連テーブルの参照回数 | スキャン行数分($N$ 回) | クエリ開始時の 1 回のみ |
| 評価の単位 | 行ごと(Row-level) | SQL 文ごと(Statement-level) |
100 万件のベンチマーク実測結果
隔離した検証用の PostgreSQL 環境を用意し、約 100 万件のタスクデータを投入して EXPLAIN ANALYZE で Execution Time を比較しました。
-- 旧ポリシー(Before)
Execution Time: 3,463.889 ms
-- 新ポリシー(After)
Execution Time: 311.042 ms
- Before: 3,463.889 ms
- After: 311.042 ms(約 11.1 倍の高速化!)
※ これは本番の API レスポンスタイムではなく、DB 単体で 100 万件のデータに対して EXPLAIN ANALYZE を実行した純粋なクエリ実行時間です。
RLS を外してアプリケーション層に認可判定を逃がしたわけではありません。 「DB レベルで認可を強制する」という堅牢なセキュリティ要件は完全に維持したまま、PostgreSQL のオプティマイザが効率よく処理できる形に整えただけ で、ここまでの性能差が生まれます。
RLS を本番運用するためのガードレール
この改善を進めるにあたって、単に SQL を書き換えるだけでなく、周辺のアーキテクチャやテスト体制も整えました。
1. トランザクション境界で認可スコープを揃える
Web バックエンド(Cloud Run など)では、エンドユーザーごとに DB 接続を張り直すわけにはいかないため、「共通の DB ユーザーでコネクションプールを維持し、リクエストごとにトランザクションを開始して SET LOCAL app.current_user_id = '...' でユーザーコンテキストを注入して照会する」 という構成をとります(Supabase や PostgREST と同様の一般的な定石パターンです)。
BEGIN;
-- トランザクション内だけで有効なユーザーコンテキストを設定
SET LOCAL app.current_user_id = 'user_123';
-- RLS が current_setting('app.current_user_id') を参照して行を絞り込む
SELECT * FROM tasks;
COMMIT;
このとき、読み取りクエリが意図せず複数のトランザクションに散らばっていたり、コンテキスト設定漏れがあると、認可スコープが狂う原因になります。そのため、RLS を適用する読み取り処理は必ず同一トランザクション内に閉じ、コンテキストの注入からクエリ実行までを確実にスコープ化する設計に揃えました。
2. 多層的なテスト体制
RLS の変更は、セキュリティとパフォーマンスの両方に直結します。そのため、検証も役割を分けて多層で用意しました。
- ユニットテスト / 統合テスト: 権限のない行が絶対に SELECT されないこと(認可の正しさ)
- 静的検査(ast-grep / linter): ポリシー内に行のカラムを引数に取る危険な関数呼び出しが混入していないか
- ベンチマークテスト: データ量が増えても実行時間が跳ね上がらないか
一般化できる教訓
今回の教訓は、RLS に限らず SQL 全般に共通する原則です。
「WHERE 句やポリシーに置く関数・サブクエリに、スキャン対象の行のカラムを渡すと、評価単位が『行($O(N)$)』になる」
認可判定のように「ログインユーザーが決まれば、アクセス可能な対象の集合が決まる」という性質の処理は、行の値を引数に取るのではなく、「ユーザーに紐づく ID 集合を文単位(InitPlan)で 1 回だけ作ってハッシュ比較する」 形に落とし込むのが定石です。
最近は AI(コーディングエージェント)に SQL やマイグレーションを書かせる機会も増えましたが、エージェントはコードとして綺麗に見える「1 行ごとに判定する関数」を無邪気に生成しがちです。実行計画というコンテキストがなければ、AI も最適な SQL を判断できません。
だからこそ、AI に「SQL 直して」と丸投げするのではなく、「まず EXPLAIN ANALYZE BUFFERS で実行計画を取得させて、内部ループやバッファ消費の事実をコンテキストとして得てから改善させる」 ような指示設計が鍵になります。
みなさんの手元の RLS ポリシーや、AI が生成したマイグレーションファイルにも、同じような「行単位の関数呼び出し」が潜んでいませんか? ぜひ一度、手元のプロジェクトでも git grep "CREATE POLICY" でポリシーの形をチェックしてみてください!