RLSが有効なテーブルにおけるクエリ高速化の取り組み

はじめに

こんにちは、田中です。

今年(2025年)の3月に大学を無事卒業し、今はポケットサインでエンジニアとして働いています。

今回は、私が開発を担当する防災アプリ「ポケットサイン防災」で実施したクエリ高速化について解説します。このアプリケーションはマルチテナント構成をPostgreSQLのRLS (Row-Level Security) で実現しています。

サービスの成長に伴いユーザー数が増加し、管理コンソールの一部機能でパフォーマンスの悪化が見られるようになりました。特に住民一覧画面の応答速度が低下しており、改善が急務となっていました。

本記事では、この問題に対して行った調査と改善の過程を紹介します。

調査対象の機能とクエリ

問題となっていたのは、各自治体の管理者が住民情報を検索・一覧表示する機能です。住所、氏名、安否ステータス、年齢など、様々な条件での絞り込みとソートが可能です。

対象のクエリは、複数のテーブルをJOINし、動的な条件が含まれるものでした。(一部簡略化)

SELECT
  u.id AS user_id,
  u.name AS user_name,
  lstatus.status AS status
  -- (その他カラム) ...
FROM users u
INNER JOIN latest_statuses lstatus ON u.id = lstatus.user_id
WHERE 
  u.address LIKE (@address || '%')
  AND lstatus.status = ANY(@status)
  -- 他にも様々なフィルター条件
ORDER BY u.name ASC
LIMIT @limit_count OFFSET @offset_count;

このクエリのパフォーマンスを、大量のモックデータを注入した開発環境(Docker, CPU 0.5コア, メモリ 256MBに制限)で測定したところ、36.7秒の実行時間がかかっていました。

改善のプロセスと対策

Step 1: RLSポリシーの型キャスト修正

最初の問題は、RLSポリシーの記述にありました。RLSは、テーブルの行単位でアクセスを制御するPostgreSQLの機能で、定義したポリシーに基づきクエリへ自動的にWHERE条件が付与されるように動作します。

私たちのシステムでは、このRLSを用いてマルチテナントを実現しています。具体的には、アプリケーションがDBセッションの開始時にSELECT set_config('context.tenant_id', '...')のようにパラメーターを設定します。各テーブルにはポリシーが定義されており、このパラメーターを参照してデータを絞り込みます。

これにより、アプリケーションコードでWHERE tenant_id = ...と記述することなく、アクセスが該当テナントのデータのみに自動で制限されるよう運用しています。

最初のボトルネックは、tenant_idのインデックスが全く使われていなかったことです。ポリシー内で不要な型キャストがあり、それが原因でした。tenant_idカラムはuuid型ですが、ポリシー内でtext型にキャストして比較していたため、uuid型のインデックスが適用できない状態でした

Before:

CREATE POLICY tenant_only ON users
USING ((current_setting('context.tenant_id'::text) = (tenant_id)::text));

After:

CREATE POLICY tenant_only ON users
USING (tenant_id = current_setting('context.tenant_id')::uuid);

この修正によって、tenant_idのインデックスが正常に利用されるようになり、実行時間は30.7秒に短縮されました。

Step 2: 複合インデックスの追加

Step1の対応でtenant_idのインデックスが効くようになったため、他の検索条件についてもインデックスが効率的に利用されるよう、tenant_idと組み合わせた複合インデックスを各種検索カラムに追加しました。

この対応により、多くの検索パターンの実行時間は0.05秒程度まで改善しました。しかし、依然として特定の条件下で遅い2種類のクエリが残りました。

  • クエリA: addressLIKE検索が絡むと 7.8秒
  • クエリB: 特定のステータスで絞り込むと 29.5秒

Step 3: LIKE演算子とLEAKPROOF属性の調査

次に、7.8秒かかるクエリAの調査を行いました。問題を切り分けるため、LIKE検索部分を抜き出した以下のシンプルなクエリで検証しました。

EXPLAIN (ANALYZE, BUFFERS)
SELECT name
FROM users u
WHERE
  u.address LIKE ('宮城県' || '%')
ORDER BY address
LIMIT 50 OFFSET 0;

RLSをバイパスできるrootユーザーと、RLSが有効な一般ユーザーとで実行計画を比較したところ、明確な差が現れました。

rootユーザーでの実行計画

Limit  (cost=0.55..94.20 rows=50 width=63) (actual time=1.709..4.397 rows=50 loops=1)
  Buffers: shared read=54
  ->  Index Scan using idx_users_address_is_app_user_tenant_id on users u  (cost=0.55..55001.44 rows=29366 width=63) (actual time=1.637..4.254 rows=50 loops=1)
"        Index Cond: ((address >= '宮城県'::text) AND (address < '宮城眍'::text))"
"        Filter: (address ~~ '宮城県%'::text)"
        Buffers: shared read=54
Planning:
  Buffers: shared read=1
Planning Time: 12.976 ms
Execution Time: 4.789 ms

Index Condから、addressのインデックスが範囲検索 (>= and <) に利用されていることが分かります。

この範囲検索は LIKE演算子で前方一致検索をしている時の、BTreeインデックスを効果的に使用するための最適化です。ASCIIの例を挙げると、col LIKE 'abc%'では文字列の辞書順での範囲検索(col >= 'abc' AND col < 'abd')に置き換わります。*1

RLS有効ユーザーでの実行計画

Limit  (cost=0.56..2622.28 rows=50 width=63) (actual time=5246.391..5247.702 rows=50 loops=1)
  Buffers: shared hit=132921 read=112971
  ->  Index Scan using idx_users_tenant_id_address on users u  (cost=0.56..394569.12 rows=7525 width=63) (actual time=5246.270..5247.454 rows=50 loops=1)
"        Index Cond: (tenant_id = (current_setting('context.tenant_id'::text, true))::uuid)"
"        Filter: (address ~~ '宮城県%'::text)"
        Rows Removed by Filter: 242555
        Buffers: shared hit=132921 read=112971
Planning Time: 3.864 ms
Execution Time: 5248.316 ms

一方こちらは、Index Condにはtenant_idの条件しかありません。address LIKE '宮城県%'の条件はFilter句で処理されています。これは、tenant_idで絞り込んだ全行をメモリに読み出した後、LIKE検索でフィルタリングしていることを意味します。

この差が生まれる原因は、LIKE演算子がLEAKPROOFではないことにあります。RLSはポリシーで除外されるべき行の情報が、演算子のエラーなどを通じて漏洩することを防ぎます。LEAKPROOFでない演算子は、RLSポリシーが適用される前の行に対しては安全に実行できないと判断され、オプティマイザによるインデックスを利用した最適化の対象から外れます。*2

問題となっていたのは前方一致検索だったので、アプリケーション側でLIKEを使わず、明示的に範囲検索のクエリを生成するように変更しました。

  • Before (SQL内): WHERE u.address LIKE '宮城県%'
  • After (アプリケーションで生成): WHERE u.address >= '宮城県' AND u.address < '宮城眍'

この対応により、クエリAの実行時間は7.8秒から0.2秒に改善しました。

Step 4: 統計情報の偏りへの対応

最後に残ったクエリB(29.5秒)は、避難済みのステータス(4)を含んで絞り込んだ場合にのみ遅くなるものでした。問題となっていた実行計画は以下のusersテーブルとのJOIN処理に関わる部分でした。

->  Index Scan using latest_status_pkey on latest_status lstatus  (cost=0.43..0.60 rows=1 width=28) (actual time=0.001..0.001 rows=0 loops=749725)
      Index Cond: (user_id = lstatus.user_id)
      Filter: ((status = ANY ('{4}'::integer[])) AND (tenant_id = (current_setting('context.tenant_id'::text))::uuid))
      Rows Removed by Filter: 1
      Buffers: shared hit=2989784 read=9118 written=9

この結果からusersテーブルのスキャン結果が749,725行であったことがわかります。

不思議だったのは(tenant_id, user_id, status) についてINDEXを貼っているのにも関わらず、Filter条件 (status = ANY ('{4}'::integer[]))を75万件にも適用していることです。

その原因を探るため、オプティマイザが参照するテーブルの統計情報をpg_statsビューで確認しました。

SELECT attname, most_common_vals, most_common_freqs
FROM pg_stats WHERE tablename = 'latest_statuses' AND attname = 'status';

-- 結果
attname | most_common_vals | most_common_freqs
--------+------------------+--------------------------------------
status  | {0,4,1,2,3}      | {0.906,0.092,0.0002,0.0002,0.0002}

most_common_freqsを見ると、status=4の頻度は0.092、つまりテーブル全体では約9.2%を占めるという統計になっています。そのためオプティマイザは、INDEXで先にstatusを絞らなくてもLIMITの高々10倍程度を読み込めば対象の行が見つかる、と見積もってこの計画を採用しました。

しかし、問題が発生していたテナントで実際のデータ数を確認したところ、 status=4 は2件しかなく、ほとんどが status=0でした。

statusの偏りは検証環境のデータ生成によるものですが、この「統計情報の偏り」がボトルネックであるという仮説を最終的に検証するため、 latest_statusテーブルを tenant_idでテーブルをパーティショニングする対応を試みました。 tenant_idAAAであった場合に latest_status_AAA テーブルを利用するイメージです。パーティショニングにより、テナントごとに統計情報が収集されるようになりました。

パーティショニングの結果、クエリBの実行時間は29.5秒から0.4秒となり、以下に示す実行計画についてもINDEXが利用されるようになっていて、仮説は正しかったと考えています。

->  Index Scan using latest_status_tenant_75e77f_tenant_id_status_created_at_idx on latest_status_tenant_75e77f1fc3bd4940b3501a249ade30d6 lstatus_1  (cost=0.43..10.64 rows=1 width=52) (actual time=0.394..0.396 rows=2 loops=1)
"     Index Cond: ((tenant_id = (current_setting('context.tenant_id'::text))::uuid) AND (status = ANY ('{4}'::integer[])))"
      Buffers: shared hit=7 read=3

改善結果のまとめ

一連の対応によるパフォーマンスの変化を、ボトルネックとなったクエリごとに整理します。

ステップ 対策内容 パフォーマンスの変化
初期状態 - 代表的なクエリで 36.7秒
対策1 RLSポリシーの型キャスト修正 代表的なクエリが 30.7秒 に短縮
対策2 複合インデックスの追加 多くの検索パターンが 0.05秒 程度に改善
対策3 LIKEを範囲検索に変更 LIKE検索でインデックス使われなかったクエリAが7.8秒 → 0.2秒 に改善
対策4 (検証) テーブルパーティショニング データの偏りが実行計画に悪影響を与えていたクエリBが29.5秒 → 0.4秒 に改善

おわりに

この記事ではパフォーマンスチューニング、特にRLSが有効なマルチテナントアプリケーション特有の問題とその対応を紹介しました。

今まで統計情報がクエリに与える影響を考えたことがあまりなかったので、パーティショニングの対応をしているときは新鮮で特に楽しかったです。

引き続き、クエリのパフォーマンスにも十分注意を払って開発をしていこうと思います。

読んでいただきありがとうございました。

*1:qiita.com 前方一致検索のための範囲検索 が参考になりました

*2:buildersbox.corp-sansan.com が参考になりました