管理パネルのインデックス:まず上位のフィルタを最適化
管理パネル向けのインデックス:ユーザーが最もクリックするフィルタ(ステータス、担当者、日付範囲、テキスト検索)を、実際のクエリパターンに基づいて優先して最適化する方法。

なぜ管理パネルのフィルタが遅くなるのか\n\n管理パネルは最初は素早く感じることが多いです。リストを開き、スクロールし、レコードをクリックして次へ──という流れ。しかし人々が実際に使うフィルタ(「未対応チケットのみ」「Mayaに割り当て」「先週作成」「注文IDに1047を含む」など)を頻繁に使い始めると遅延が目立ってきます。クリックのたびに待ちが発生し、リストがもっさりして感じられます。\n\n同じテーブルでも、あるフィルタでは速く、別のフィルタでは非常に遅くなることがあります。ステータスフィルタは行を小さく絞ってすぐ返ることがありますが、"ある期間に作成された"フィルタは巨大な範囲を読ませることがあります。担当者フィルタは単体では速くても、ステータスやソートと組み合わせると遅くなることがあります。\n\nインデックスはデータベースが全表を読むことなく該当行を見つけるための近道です。ただしインデックスは無料ではありません。空間を取り、挿入や更新をやや遅くします。過度に追加すると書き込みが遅くなり、本当のボトルネックは解決しないままになることがあります。\n\nすべてをインデックスするのではなく、優先すべきフィルタは次の特徴を持つものです:\n\n- 常に使われている\n- 多くの行に触れる\n- 目に見える待ち時間を生む\n- 単純で適合したインデックスで安全に改善できる\n\n焦点を意図的に狭くしています。管理リストの最初のパフォーマンス苦情はほとんどの場合、同じ4種類のフィルタ(ステータス、担当者、日付範囲、テキスト)によるものです。これらがなぜ挙動が違うのかを理解すれば、次の手順は明快です:実際のクエリパターンを確認し、それに合うもっとも小さなインデックスを追加し、遅い経路が改善されたか検証します。\n\n## 実際の管理作業に現れるクエリパターン\n\n管理パネルが遅くなるのは巨大なレポートが原因というより、数画面が四六時中使われており、それらが小さなクエリを何度も繰り返すことが多いからです。\n\nOpsチームは通常、チケット、注文、ユーザー、承認、内部リクエストといった少数の作業キューを使い続けます。これらのページでよく現れるフィルタは次の通りです:\n\n- ステータス(New、Open、Pending、Doneなど)\n- 担当者(自分の項目、未割り当て)\n- 日付範囲("先週何が起きた?")\n- 検索(注文番号やメールで既知の項目へジャンプ、あるいはメモやプレビューのテキストをスキャン)\n\nデータベースの仕事は意図によって変わります:\n\n- ブラウズ(最新順で流し見る)はスキャンに近いパターン。通常「最新アイテムを表示、場合によってはステータスで絞る、createdでソート」といった形でページングされます。\n- 特定アイテムを見つける(IDやメール、チケット番号を知っている)はルックアップ型で、少数の行にジャンプしてほしい期待があります。\n\n管理パネルではフィルタが予測可能な組み合わせで使われます:"Open + Unassigned"、"Pending + Assigned to me"、"過去30日で完了"など。インデックスは列の一覧に合わせるより、そうした実際のクエリ形状に合うときに最も効果的です。\n\nもしAppMaster (appmaster.io)で管理ツールを作るなら、最も使われるリスト画面とデフォルトフィルタを見ればこれらのパターンはだいたい把握できます。つまり日常業務を駆動する部分にインデックスを集中しやすくなります。\n\n## どのインデックスを優先するかの選び方\n\nインデックス作業はトリアージのように扱ってください。フィルタのドロップダウンに現れるすべての列に手を付けるのではなく、常に実行されて人々を最も苛立たせている少数のクエリから始めます。\n\n### 実際に使われているフィルタを見つける\n\n誰も使わないフィルタを最適化しても無駄です。ホットパスを見つけるには複数のシグナルを組み合わせます:\n\n- UI解析:どの画面がよく見られているか、どのフィルタがよくクリックされるか\n- DBやAPIログ:最も頻繁に実行されるクエリと最も遅い上位パーセント\n- 内部からのフィードバック:"検索が遅い"は通常特定の画面を指します\n- デフォルトのランディングリスト:管理者がパネルを開いたときにすぐ走るもの\n\n多くのチームでは、デフォルトビューが「Open tickets」や「New orders」のようなもので、リフレッシュやタブ切り替え、返信後の復帰ごとに実行されます。\n\n### フィールド名ではなくクエリ形状ごとにグループ化する\n\nインデックスを追加する前に、よく使われるクエリをその振る舞いごとにグループ化してください。管理リストのクエリは大抵以下のいずれかです:\n\n- 等価フィルタ:status = 'open'、assignee_id = 42\n- 範囲フィルタ:created_atがある2つの日時の間\n- ソートとページング:ORDER BY created_at DESCでページ2を取得\n- テキスト検索:完全一致(注文番号)、プレフィックス(email starts with)、含有検索\n\n各トップ画面についてWHERE・ORDER BY・ページングを含む形状を書き出してください。UI上は似て見えても、データベース上の挙動は大きく異なることがあります。\n\n### 小さな最初のバッチを選ぶ\n\n優先ターゲット1つから始め、次に頻度の高い2〜3クエリを選びます。これだけで最大の遅延を減らせることが多く、データベースをインデックス博物館にしてしまう事態を避けられます。\n\n例:サポートチームがTicketsリストをstatus = 'open'で開き、最新順にソートし、オプションで担当者と日付範囲を指定するとします。まずはその正確な組み合わせを最適化してください。速くなったら利用状況に応じて次の画面へ移ります。\n\n## ステータスフィルタのインデックス化(やりすぎない)\n\nステータスは最初に追加されがちで、正しくインデックス化しないと効果が出ないことも多いフィールドです。\n\n多くのステータス列はカード性が低い(open, pending, closedなど少数の値)。インデックスは結果を小さく絞れる場合に効果を発揮します。もし80〜95%の行が同じステータスを持っているなら、status単独のインデックスはほとんど効果がなく、インデックス自体がオーバーヘッドになります。\n\n効果が出やすいのは次のケースです:\n\n- あるステータスが稀(例:escalated)\n- ステータスが別条件と組み合わさって結果が小さくなる\n- ステータスとソートが共通のビューに合致する\n\n一般的なパターンは"未解決を最新順で表示"です。その場合、フィルタとソートを組み合わせたインデックスは、status単体のインデックスより有利になります。\n\n効果が出やすい組み合わせ例:\n\n- status + updated_at(ステータスで絞って最近の変更でソート)\n- status + assignee_id(ワークキュービュー)\n- status + updated_at + assignee_id(その正確なビューが非常に多く使われる場合のみ)\n\n支点として部分インデックスは有用です。openが主要なビューならopenだけをインデックスするようにするとインデックスが小さく保て、書き込みコストも低くなります。\n\n```sql
-- PostgreSQL example: index only open rows, optimized for newest-first lists
CREATE INDEX CONCURRENTLY tickets_open_updated_idx
ON tickets (updated_at DESC)
WHERE status = 'open';
```\n\n実用的なテスト:遅い管理クエリをステータスフィルタあり・なしで実行してみてください。どちらも遅いなら、status単独のインデックスでは救えません。まずはソートと、結果を本当に小さくする第二のフィルタに注目しましょう。\n\n## 担当者フィルタ:等価インデックスとよく使われる組み合わせ\n\n多くの管理パネルではassignee_idがレコード上のユーザーIDとして保存されています。これは典型的な等価フィルタで、単純なインデックスで速くなることが多いです。\n\n担当者フィルタは他のフィルタと組み合わせてよく使われます。例えばサポートリードが"Alexに割り当てられたもの"と絞った後で"Open"に絞る、といった具合です。こうしたビューが遅い場合は単一列インデックスより複合インデックスが必要です。\n\n出発点として適切なのは、よく使われる組み合わせに合わせた複合インデックス:\n\n- (assignee_id, status) は「自分に割り当てられた未解決」向け\n- (assignee_id, status, updated_at) は一覧が最近の活動でソートされる場合に有効\n\n複合インデックスでは順序が重要です。等価フィルタを先に(多くの場合assignee_id)、ソートや範囲列(updated_at)を後に置きます。これがデータベースが効率よく利用できる形です。\n\n未割り当てはよくある落とし穴です。多くのシステムがassignee_idをNULLで未割り当てを表現しており、NULLはデータベースやクエリ形状によって実行計画を変えるので、担当者にうまく効くインデックスが未割り当てには効かないことがあります。\n\n未割り当てが主要なワークフローなら、明確な方針を決めてテストしてください:\n\n- assignee_idをnullableのままにする場合、WHERE assignee_id IS NULLをテストして必要ならインデックス化する。\n- 特別な「Unassigned」ユーザー値を使うのはデータモデルに合う場合に検討する。\n- データベースがサポートするなら未割り当て行に対する部分インデックスを作る。\n\nAppMasterで管理パネルを作るなら、チームが最も使うフィルタとソートをログに取り、それらのパターンに合わせた少数のインデックスを用意するのが効果的です。\n\n## 日付範囲:ユーザーのフィルタ方法に合うインデックス\n\n日付フィルタは「過去7日」「過去30日」といったプリセットや、開始・終了を選ぶカスタムピッカーでよく使われます。見た目は単純でも、大きなテーブルでは非常に異なる作業を発生させます。\n\nまず、ユーザーがどのタイムスタンプ列を指しているかを明確にしてください。使い分けの例:\n\n- created_at は「新しいアイテム」ビュー用\n- updated_at は「最近変更された」ビュー用\n\nその列に通常のbtreeインデックスを張ってください。これがないと「過去30日」のクリックが全表スキャンに変わることがあります。\n\nプリセット範囲はよく created_at >= now() - interval '30 days' のようになります。これは範囲条件なので、created_atインデックスは効率的に使われます。UIが最新順にソートする場合、ソート方向(例:created_at DESC)に合わせるとヘビーユースされるリストでは有利です。\n\n日付が他のフィルタ(ステータス、担当者)と組み合わさるときは選択的に。複合インデックスはその組み合わせが頻繁に使われる場合にのみ作るべきで、そうでなければ書き込みコストだけが増えます。\n\n実践的なルール:\n\n- ほとんどのビューがステータスで絞ったあと日付で絞るなら、(status, created_at)が役立つ。\n- ステータスは任意だけど日付が常にあるなら、単純なcreated_atインデックスを保ち、複合を乱立させない。\n- すべての組み合わせを作らない。インデックスは増えるほどストレージを食い、書き込みを遅くする。\n\nタイムゾーンと境界は「レコードが足りない」バグを引き起こしやすいです。ユーザーが日付だけを選ぶ場合、終了日をどう解釈するかを決めてください。安全なパターンは開始を包含で終了を排他にすること:created_at >= start と created_at < end_next_day のようにし、タイムスタンプはUTCで保存してユーザー入力をクエリ前にUTCに変換します。\n\n例:運用管理者が1月10日から12日を選んで1月12日の全日分を見たいと期待したとします。もしクエリが <= '2026-01-12 00:00' を使っていると、1月12日のほとんどを落としてしまいます。インデックスの問題ではなく境界処理の問題です。\n\n## テキストフィールド:完全一致と包含検索の分離\n\nテキスト検索は多くの管理パネルで遅くなるポイントです。なぜなら人々は一つの検索ボックスにすべてを期待しがちだからです。まずは二つのニーズを分けてください:完全一致(速く予測可能)と包含検索(柔軟だが重い)。\n\n完全一致フィールドには注文ID、チケット番号、メール、電話、外部参照などが含まれます。管理者がIDやメールを貼り付けて検索することが多ければ、通常のインデックスで即時性を実現できます。\n\n包含検索は、ユーザーが「refund」や「john」の断片で名前やメモを探す場合で、しばしば LIKE %term% として実装されます。先頭がワイルドカードのため通常のB-treeインデックスは使えず、多くの行をスキャンしてしまいます。\n\n負荷をかけずに検索を設計する実用的な方法:\n\n- 完全一致検索(ID、メール、ユーザー名)を第一級の使い方として明確にする。\n- 「先頭一致」(term%)は通常のインデックスで助かるため、名前検索などには十分なことが多い。\n- 包含検索はログや不満が示す場合にのみ追加する。\n- 追加するなら、LIKE %term%に通常インデックスで期待するのではなく、PostgreSQLの全文検索やトリグラムインデックスなど適切なツールを使う。\n\n入力ルールは多くのチームが過小評価しているほど効果的です。負荷を減らし結果を一貫させます:\n\n- 包含検索の最小文字数を設定(例:3文字以上)\n- 大文字小文字を正規化するか、大文字小文字無視の比較を一貫して使う\n- 前後のスペースをトリムし、連続スペースを縮める\n- メールやIDはデフォルトで完全一致として扱う(一般検索ボックスに入っても)\n- 条件が広すぎる場合はユーザーに絞り込みを促す代わりに大規模クエリを実行しない\n\n小さな例:サポートマネージャーが「ann」で顧客を探す場合、システムが名前・メモ・住所で LIKE %ann% を実行すると数千行をスキャンすることになります。まずIDやメールなどの完全一致を確認し、必要に応じて賢いテキストインデックスにフォールバックすることで検索負荷を抑えられます。\n\n## インデックスを安全に追加するための段階的ワークフロー\n\nインデックスは追加は簡単ですが、後悔も簡単です。安全なワークフローは管理者が頼るフィルタに集中し、「たぶん役立つ」インデックスが後で書き込みを遅くする事態を避けます。\n\n実使用から始めてください。上位クエリを二つの観点で抽出します:\n\n- 最も頻繁に実行されるクエリ\n- 最も遅いクエリ\n\n管理パネルでは通常、これらはフィルタとソートを伴うリストページです。\n\n次に、データベースが見る正確なクエリ形状を取得します。WHEREやORDER BY、ソート方向や一般的な組み合わせ(例:status = 'open' AND assignee_id = 42 ORDER BY created_at DESC)を正確に書き出してください。小さな違いがどのインデックスが効くかを変えます。\n\nシンプルなループを回しましょう:\n\n- 1つの遅いクエリと1つのインデックス変更を選ぶ。\n- 単一のインデックスを追加または調整する。\n- 同じフィルタ・同じソートで再測定する。\n- 挿入や更新が著しく遅くなっていないか確認する。\n- 対象クエリを明確に改善する場合のみ変更を保持する。\n\nページングも別途チェックが必要です。OFFSET 20000のようなoffsetベースのページングは深く行くほど遅くなりがちです。ユーザーが非常に深いページへ頻繁に飛ぶなら、カーサー型ページング(「このタイムスタンプ/IDより前の項目を表示」)を検討してください。インデックスが大きなテーブルで一貫した仕事をできるようになります。\n\n最後に、小さな記録を残してインデックス一覧が数ヶ月後でも理解できるようにします:インデックス名、テーブル、列(順序)と、そのインデックスがサポートするクエリ。\n\n## 管理パネルでよくあるインデックスの誤り\n\n人々が実際にどのようにフィルタ・ソート・ページングするかを確認せずにインデックスを追加すると、管理パネルはかえって遅く感じられることがあります。インデックスはストレージを使い、すべての挿入と更新に追加作業を生みます。\n\n### よく見られる誤り\n\n次のパターンが多くの問題を生みます:\n\n- 「念のため」にすべての列にインデックスを張る\n- 誤った列順で複合インデックスを作る\n- ソートとページングを無視する\n- LIKE '%term%'のような包含検索を通常インデックスで直ると期待する\n- UI変更後に古いインデックスを残し続ける\n\nよくあるシナリオ:サポートチームがStatus = Openでチケットをフィルタし、updated時間でソートしてページングするとします。statusだけにインデックスを張ると、データベースはすべてのOpenチケットを収集してソートしなければならないかもしれません。フィルタとソートの両方に合うインデックスがあればページ1を素早く返せます。\n\n### これらの問題を発見する簡単な方法\n\n管理UIの変更前後で簡単にレビューを行ってください:\n\n- 上位のフィルタとデフォルトのソートを列挙し、それに合うWHERE + ORDER BYパターンをサポートするインデックスが存在するか確認する。\n- 先頭ワイルドカード(LIKE '%term%')をチェックし、包含検索が本当に必要か判断する。\n- 重複や重なり合うインデックスを探す。\n- 未使用インデックスをしばらく追跡し、不要なら削除する。\n\nAppMasterでPostgreSQLを使って管理パネルを作る場合、画面を公開するたびにこのレビューを組み込むと良いです。UIが実際に使うフィルタとソートから適切なインデックスが導かれることが多いからです。\n\n## 簡単なチェックと次の一手\n\nさらに多くのインデックスを追加する前に、既存のインデックスが日常的に使われるフィルタに効いているかを確認してください。良い管理パネルは、稀な検索ではなく「よく使われる経路」が即時に感じられるものです。\n\nいくつかのチェックで多くの問題を防げます:\n\n- 最も一般的なフィルタの組み合わせ(ステータス、担当者、日付範囲、デフォルトソート)を開き、テーブルが大きくなっても高速か確認する。\n- 各遅いビューについて、クエリがWHEREとORDER BYの両方に合うインデックスを使っているか確認する。\n- インデックス一覧は短く保ち、各インデックスの目的を一文で説明できる程度にする。\n- インデックス追加後に作成・更新が遅くなっていないか監視する。遅くなっているならインデックスが多すぎるか重複している可能性がある。\n- UIでの「検索」が何を意味するか(完全一致、プレフィックス、包含)を決め、それに合うインデックス計画を立てる。\n\n実用的な次の一手は、ゴールデンパスを平易な文章で書き出すことです。例:"サポート担当は未解決チケットを、自分に割り当てられたもの、過去7日分、最新順でフィルタする"。その文を使って、それらを明確にサポートする小さなインデックス集合を設計してください。\n\nまだ構築の初期段階なら、あまり多くの画面を作る前にデータとデフォルトフィルタをモデル化しておくと良いです。AppMaster (appmaster.io)を使えば管理ビューを素早く反復でき、実際の使用でホットパスが明らかになってから少数のインデックスを追加できます。
よくある質問
まずは常に実行されるクエリから始めてください:管理者が最初に見るデフォルトのリストビューと、日中に何度もクリックされる2〜3個のフィルタです。頻度と体感(最も遅い・最も使われるもの)を計測し、特定のクエリ形状で待ち時間を確実に短縮するものだけにインデックスを付けます。
異なるフィルタが異なる作業量をデータベースに課すからです。あるフィルタは行を小さく絞り込み即座に結果を返しますが、別のフィルタは広い範囲を読み取ったり大量の結果をソートしたりするため時間がかかります。あるクエリはインデックスをうまく使え、別のクエリはスキャンや大きなソートを余儀なくされます。
常にではありません。多くの行が同じステータスを共有している場合、status単独のインデックスではほとんど改善しないことが多いです。ステータスが稀である場合、あるいはステータスとソートや他のフィルタを組み合わせたときに結果セットが本当に絞り込める場合に効果が出ます。
ユーザーの操作に合わせた複合インデックスを使います。例えばステータスで絞ってアクティビティ順に並べるビューなら、statusを含む複合インデックスや部分インデックス(PostgreSQLの例など)が有効です。部分インデックスは、あるステータス(例:open)が主要なビューである場合に小さく保てるので書き込みコストを抑えられます。
assignee_idは多くの場合レコード上のユーザーID(外部キー)として保存され、等価フィルタとして単純なインデックスで効果を発揮します。チームのワークフローで「自分に割り当てられた未解決」を頻繁に使うなら、複合インデックスの方が単一列インデックスより高速になることが多いです。順序としては等価フィルタ(assignee_id)を先に、ソートや範囲の列(updated_atなど)を後ろに置きます。
多くのシステムで未割り当てはNULLで表現され、WHERE assignee_id IS NULLはWHERE assignee_id = 123と挙動や実行計画が変わることがあります。未割り当てキューが重要なら、そのクエリを個別にテストし、必要なら部分インデックスや明示的な対処を行ってください。
人が通常意味するタイムスタンプ列を明確にし、まずその列に対してB-treeインデックスを張ります。created_atは新規アイテム用、updated_atは最近変更されたもの用と使い分けてください。UIが「最新順」にソートするのであれば、ソート方向に合うインデックス(例:PostgreSQLでのcreated_at DESC)が重用されるリストで効果的です。ただし複合インデックスは、コンビネーションが頻繁に使われる場合だけに限定するのが安全です。
LIKE %term%のような先頭ワイルドカードを伴うcontains検索は通常のB-treeインデックスを利用できず、多くの行をスキャンしてしまいます。IDやメールアドレスなどの完全一致をまず優先して高速にし、contains検索が本当に必要な場合はPostgreSQLの全文検索やトリグラムインデックスなど適切な手段を検討してください。
すべてのフィルタ可能な列に無差別にインデックスを追加すると、ストレージが増え、挿入や更新が遅くなります。また実際のボトルネックがWHERE + ORDER BYの不一致である場合はそれでも解決しません。安全なループは一度に1つのインデックス変更を加え、そのクエリで再測定して明確に改善が確認できたものだけを残すことです。管理画面をAppMasterで作るなら、チームが最も使うフィルタとソートをログに取り、それに合わせた小さなインデックス集合を作るのが良いでしょう。


