🐯

PostgreSQLのRLSが有効時3分 vs RLS無効時1.7秒のSQL実行時間差はなぜ生まれるの?をパフォーマンス改善内部探訪編

に公開

クリスマスが近いですね。自分の身長より大きいクリスマスツリーを買って満足したしおりん(@jamgodtree)です。

生成AIによりパフォーマンスチューニングが簡単になった世界

前提: ログラスのRLS運用

ログラスでは、PostgreSQLのRLS(Row Level Security)機能を利用し、マルチテナント間での情報漏洩リスクを防ぐ仕組みを導入しています。

アプリケーションでのRLSの実装方法については、以前の資料をご参照ください。

https://www.slideshare.net/slideshow/postgresql-springaop/248464973

RLS運用が4年経過し、SQLの複雑化に伴い、パフォーマンスへの影響が顕著になってきました。都度チューニングを行っていましたが、Anthropic社のClaude Codeなどの生成AIを活用することで、より深く調査し、PostgreSQLの公式ドキュメントや実装と照らし合わせながら理解することが容易になりました。

今回、PostgreSQLの設計思想まで踏み込んで理解が進んだため、その調査結果をまとめます。

注意: RLSを設定した全てのSQLが影響を受けるわけではありません。本記事は、今回調査した特定のSQL、および自社のDB環境に依存する部分があるため、あくまで参考としてご覧いただければ幸いです。

調査対象SQLの実測:約107倍の性能劣化

今回調査したSQL(階層データを再帰的に取得する複雑なクエリの抽象化版)は以下の通りです。

WITH RECURSIVE tree AS (
    SELECT id, parent_id, ARRAY[id] AS path
    FROM items
    WHERE tenant_id = current_setting('app.tenant_id')  -- RLS
      AND parent_id IS NULL
    
    UNION ALL
    
    SELECT i.id, i.parent_id, t.path || i.id
    FROM items i
    JOIN tree t ON t.id = i.parent_id
    WHERE i.tenant_id = current_setting('app.tenant_id')  -- RLS
)
SELECT * FROM tree
WHERE id = ANY(ARRAY['ID1', 'ID2', 'ID3']);

このSQLを実行した際の、RLS有効時とRLS無効時の実行時間の差は以下の通りでした。

メトリクス RLS無効時 RLS有効時 劣化率
クエリ実行時間 1,717ms (約1.7秒) 183,026ms (約3分) 約107倍
Join方式 Hash Semi Join Nested Loop -

問題の核心:Join方式の決定的な違い

クエリ実行時間が劇的に悪化した根本的な違いは、実行計画におけるJoin方式の変更に起因しています。RLS有効時には、効率的なHash Joinが利用されず、非効率なNested Loopが選択されてしまいました。

なぜRLSでは、非効率なJoin戦略が利用されてしまうのでしょうか。

なぜRLSでは、非効率なJoin戦略が利用されたのか?

RLSによる性能低下は、以下のいくつかの要因があります。

ちなみに、公式ドキュメントでも、性能劣化する可能性について言及があります。

Views created with the security_barrier may perform far worse than views created without this option. In general, there is no way to avoid this: the fastest possible plan must be rejected if it may compromise security.

出典: https://www.postgresql.org/docs/current/rules-privileges.html

公式ドキュメントが示唆する「処理順序の強制」

RLSを利用していると、「自動でidをフィルタリング」してくれるのだろうというふわっとした理解はしていましたが、公式で明示されていました。

PostgreSQLのRLSは、単なるWHERE句の追加ではありません。RLSポリシーは、ユーザーのクエリで指定されたWHERE句の条件よりも先に評価される強制フィルタとして機能します。

This expression will be evaluated for each row prior to any conditions or functions coming from the user's query. (The only exceptions to this rule are leakproof
functions...)

出典: PostgreSQL公式ドキュメント - Row Security Policies

実際の処理でみてみます。

  • 元のSQL
SELECT * FROM users 
WHERE user_id = '123';
  • 実際の内部処理順序(RLS有効時)
SELECT * FROM users
WHERE 
tenant_id = current_setting('app.tenant_id') -- RLSが差し込まれる
and account_id = '123';  -- 元々のSQL

プランナーの最適化を阻害する内部メカニズム

PostgreSQLソースコードレベルでも見てみましょう。

RLS適用テーブルには、security_barrierフラグTrueに設定されます。これによりRLSが有効化されているテーブルであることが明示されます。実際にクエリー実行時に統計情報利用する際に 「アンセーフな最適化の意図的禁止」 を指示することができます。

PostgreSQLソースコード(prepjointree.c - PostgreSQL 16):

/* Don't pull up if the RTE represents a security-barrier view; we
 * couldn't prevent information leakage once the RTE's Vars are scattered
 * about in the upper query.
 */
if (rte->security_barrier)
    return false;

出典: https://doxygen.postgresql.org/prepjointree_8c.html (PostgreSQLライセンスに基づく引用)

このsecurity_barrierの存在が、以下の主要な最適化を制限します。

A. 述語プッシュダウンが制限される

例:

  SELECT * FROM (
    SELECT * FROM users  -- 全データ取得
  ) t
  WHERE status = 'active';  -- 後からフィルタ
  • 通常の最適化:外部のWHERE条件をサブクエリの内部に移動させ(プッシュダウン)、データフィルタリングを早期に行います。

      SELECT * FROM (
        SELECT * FROM users
        WHERE status = 'active'  -- 条件を「プッシュダウン」
      ) t;
    
  • RLS制約下:security_barrier(tenant_idによるフィルタリング)を越えて、ユーザー条件(例: status = 'active')を移動させることができなくなります。結果、非効率なSQLのまま実行されてしまいます。

     SELECT * FROM (
        SELECT * FROM users
        WHERE tenant_id = current_setting('app.tenant_id')  -- RLSポリシー(セキュリティバリア)
      ) security_barrier
      WHERE status = 'active';  -- ユーザー条件
    

B. サブクエリの最適化が制限される

例:

SELECT * FROM (
  SELECT * FROM users 
  WHERE 
    active = true
) u 
WHERE salary > 50000;
  • 通常の最適化:サブクエリを解いて実行されます。

    SELECT * FROM users WHERE active = true AND salary > 50000;
    
  • RLS制約下:security_barrier付きサブクエリとして処理され、メインクエリとの統合最適化が制限されます。

    • 非効率なSQLのまま実行されます。
SELECT * FROM (
  SELECT * FROM users 
  WHERE 
    tenant_id = current_setting('app.tenant_id') -- RLSポリシー(セキュリティバリア)
    and active = true
) u 
WHERE salary > 50000;

C. Join戦略選択の制約

例:

  SELECT u.user_id, u.user_name, r.role_id
  FROM users u
  JOIN roles r ON u.user_id = r.user_id
  WHERE r.role = 'Engineer';
  • 実際の選択(統計情報に基づく最適化)
    • プランナーはテーブルの統計情報に基づいて、最適なHash Tableサイズや行数を正確に予測し、高速なHash Joinを選択できます。
  Hash Join  -- 統計情報から最適と判断
    Hash Cond: (u.user_id = r.user_id)
    -> Seq Scan on users u
         Rows: 10,000  -- 統計情報あり
    -> Hash
         -> Bitmap Scan on roles r
              Filter: (role = 'Engineer')
              Rows: 50  -- 少数と推定

  実行時間: 15ms
  • RLS: RLSポリシーが適用されたテーブルは、Join時にSecurity Barrier付きサブクエリとして処理されます。
SELECT u.user_id, u.user_name, r.role_id
  FROM (
    SELECT * FROM users
    WHERE tenant_id = current_setting('app.tenant_id')  -- RLSポリシー(セキュリティバリア)
  ) u
  JOIN (
    SELECT * FROM roles
    WHERE tenant_id = current_setting('app.tenant_id')  -- RLSポリシー(セキュリティバリア)
  ) r ON r.user_id = r.user_id
  WHERE r.role = 'Engineer';
  • 統計情報の不明確化: RLSポリシーによるフィルタリング後のサブクエリの行数が正確に予測できなくなり、統計情報が機能しにくくなります。
  • 保守的な戦略の選択: 行数予測が困難になるため、プランナーは安全性を優先し、最も保守的で安全なJoin戦略であるNested Loop(入れ子ループ)を選択します。
  • 実行時間の激増: Nested Loopは、外側テーブルの行数分だけ内側テーブルへの検索を繰り返すため、行数が多い場合、実行時間が指数関数的に増加し、今回のように100倍以上の遅延となって現れます。

  Nested Loop  -- 保守的な選択
    -> Subquery Scan on u  -- security_barrier
         Filter: (tenant_id = current_setting('app.current_tenant'))
         Rows: ???  -- 行数不明(材料化)
    -> Index Scan on roles r
         Index Cond: (user_id = u.user_id)
         Filter: (tenant_id = current_setting('app.current_tenant')
                  AND role = 'Engineer')
         Loops: ???  -- 外側の行数分繰り返し

  実行時間: 1,500ms  -- 100倍遅い

解決策

解決策といっても小手先での変更は正直難しい事がわかりました。

1. プラン強制制御(一時的な回避策)

今回のSQLに限定し、SQL実行前にセッション設定を変更することで、非効率なNested Loopの選択を抑制しました。

SET enable_nestloop = off;

これを設定することにより、実行計画がRLS無効時と同じ効率的なプランとなり、3分から1.7秒へと劇的に改善しました。

ただし、正しくNested Loopを選択したい場合においては悪影響を及ぼす可能性があるため、適用範囲に注意が必要です。

2. アプリケーション側実装からの見直し

  • SQLだけでどうにかなる問題ではありませんでした。
  • やっていることを分解し、SQLに任せる所、アプリケーション側で処理させる所を責務分担させ対応するのが一番の近道でした。

その他:影響が小さかった施策

  • 今回のSQL用の複合インデックスの作成
    • 13%程度のレイテンシ改善に留まり、根本的なJoin方式の変更には至りませんでした。
  • VACUUM ANALYZE実行
    • 毎日VACUUM ANALYZE実行が実行されているため、 統計情報が更新されていない影響で非効率なJoinが選択されている訳ではありませんでした。統計情報の更新ごと更新前では実行時間は変化がありませんでした。
  • PostgresSQLの設定値の見直し
    • ハードウェアからコストパラメータを修正する事で、Join方式を選択させやすくすることは可能かどうかも検証しました。
      • random_page_cost=4
    • 設定値がHDD時代のものであり、SSD環境と乖離していましたが、今回のSQLの実行計画の根幹には影響がなく実行時間は変化がありませんでした。

まとめ

RLSはセキュアなマルチテナント環境を構築するために不可欠な機能ですが、PostgreSQLがセキュリティを最優先する設計思想により、クエリの最適化が意図的に制限されます。

  • 性能劣化の要因: RLSによる「security_barrier」がプランナーの統計情報利用や最適化(述語プッシュダウン、Hash Join選択)を阻害し、結果として非効率なNested Loopが選択されてしまいました。

セキュアであり、かつ高速なシステムを目指し、今後も突っ走っていきたいと思います。

では、良いクリスマスをお過ごしください!

株式会社ログラス テックブログ

Discussion