PostgreSQLのパフォーマンス:カバリングインデックスでクエリを高速化する

Database tutorial - IT technology blog
Database tutorial - IT technology blog

ヒープに潜む隠れたコスト

6ヶ月前、私のチームはスケーラビリティの壁に突き当たりました。権限チェック用のマイクロサービスが、高負荷下で処理能力の限界に達していたのです。user_idに標準的なB-treeインデックスを貼っていても、データベースのCPU使用率は80%付近を推移し、I/O待ち時間が急増していました。クエリは非常に単純なものでした:SELECT status FROM user_permissions WHERE user_id = $1;。

PostgreSQLはヒープベースのストレージシステムを採用しています。主キーインデックス内に行データを直接格納できるMySQLのInnoDBとは異なり、Postgresはデータを「ヒープ(Heap)」と呼ばれる別の領域に格納します。クエリを実行すると、データベースはまずインデックス内のエントリを見つけ、その後、実際の列の値を取得するためにヒープへと余分なジャンプ(アクセス)を行います。このジャンプはランダムI/O操作です。秒間10,000クエリという負荷の下では、これらの小さなジャンプがスループットを壊滅させました。

私たちはこれを**カバリングインデックス(Covering Indexes)**の実装によって解決しました。この手法によりヒープへのアクセスが完全に排除され、平均クエリレイテンシは12msから0.5ms未満へと劇的に低下しました。その具体的な方法を以下に示します。

カバリングインデックスの実装

インデックスオンリースキャン(Index-Only Scan)は、クエリが必要とするすべてのデータがインデックス自体に含まれている場合に発生します。PostgreSQL 11以降、INCLUDE句を使用することでこれを簡単に実現できるようになりました。

1. セットアップ

数百万のユーザーアクションを追跡するテーブルを想定してみましょう:

CREATE TABLE user_activity (
    id SERIAL PRIMARY KEY,
    user_id INT NOT NULL,
    activity_type TEXT,
    created_at TIMESTAMP DEFAULT now()
);

2. 標準的なインデックス

user_idに対する典型的なインデックスは次のようになります:

-- 標準的なインデックスの作成
CREATE INDEX idx_user_id ON user_activity(user_id);

-- 実行計画の確認
EXPLAIN ANALYZE 
SELECT created_at FROM user_activity WHERE user_id = 500;

実行計画を確認してください。おそらく**Index Scan**と表示されるはずです。高速ではありますが、データベースは依然としてcreated_atタイムスタンプを取得するためにヒープ領域にアクセスしています。

3. カバリングインデックス

ペイロードとなる列をインデックスに付随させることで、クエリを「カバー」できます:

-- INCLUDE句を使用したカバリングインデックスの作成
CREATE INDEX idx_user_id_covering 
ON user_activity(user_id) 
INCLUDE (created_at);

再度EXPLAIN ANALYZEを実行します。実行計画には**Index Only Scan**と表示されるはずです。データベースはテーブルのデータファイルを完全にバイパスし、インデックスファイルから直接タイムスタンプを取得しました。

ビジビリティマップの役割

なぜすべてのインデックスオンリースキャンが完璧に機能するわけではないのでしょうか?その答えは「行の可視性(Visibility)」にあります。PostgreSQLのインデックスにはトランザクション情報が保存されていません。そのため、通常、データベースはその行が現在のトランザクションから見える状態か、あるいは削除待ちの古いバージョンではないかを確認するために、ヒープをチェックする必要があります。

このチェックをバイパスするために、Postgresは**ビジビリティマップ(Visibility Map)**を使用します。これは、すべての行がすべてのアクティブなトランザクションから可視であることがわかっているデータページを追跡する小さなビットセットです。マップによってページが「クリーン」であると確認できれば、ヒープへのアクセスなしでインデックスオンリースキャンが成功します。しかし、ページが最近更新された場合、データベースは依然としてヒープを確認しなければなりません。そのため、高速なインデックスを維持するにはVACUUMのパフォーマンスが極めて重要になります。

INCLUDE vs. 複合インデックス

なぜ(user_id, created_at)のような標準的な複合(マルチカラム)インデックスを使わないのか、と疑問に思うかもしれません。主な理由は2つあります:

  • 目的: インデックスの主要部分にある列は、フィルタリングやソートに使用されます。一方、INCLUDE句に含まれる列は単なる「ペイロード」です。これらはリーフノードに格納されますが、B-treeの探索ロジックには影響しません。
  • メンテナンス: 含まれる列(Included columns)は、インデックス의 再構成(リバランシング)時に同様のオーバーヘッドを必要としません。これにより、書き込み時のインデックス効率をわずかに高めつつ、他の開発者に対しても、どの列がロジックのためではなく純粋にパフォーマンスのために存在しているのかを明確に示すことができます。

応用戦略:部分的なカバリングインデックス

カバリングインデックスを部分インデックス(Partial Index)のロジックと組み合わせることは、大規模システムにおいて非常に効果的です。これは、行の特定のサブセットのみに関心がある「アクティブ」なデータパターンに理想的です。

たとえば、「保留中(Pending)」の注文のtotal_amountを頻繁に照会する場合、テーブル全体にインデックスを貼る必要はありません。ターゲットを絞ったインデックスを使用します:

-- ステータスが'PENDING'のデータのみを対象としたカバリングインデックス
CREATE INDEX idx_pending_orders_amount 
ON orders(customer_id) 
INCLUDE (total_amount) 
WHERE status = 'PENDING';

このインデックスは小さく高速な状態を維持します。ストレージを肥大化させたり、完了した注文の挿入を遅延させたりすることなく、ダッシュボードにほぼ瞬時の結果を提供できます。

本番環境で得られた教訓

私たちの権限システムをこのモデルに移行したことで、データベースメンテナンスに関するいくつかの実践的な教訓が得られました。

1. Vacuumの状態を注視する

インデックスオンリースキャンの成否はビジビリティマップに依存しています。テーブルに大量のUPDATEトラフィックがあり、autovacuumが追いつかない場合、パフォーマンスは低下します。EXPLAIN計画の「Heap Fetches」カウンターを監視してください。この値が高い場合、ビジビリティマップが古くなっており、カバリングインデックスの恩恵が失われています。

2. インデックスの肥大化を避ける

INCLUDE句に列を追加するたびに、インデックスのサイズは大きくなります。私たちのテストの一つでは、大きなVARCHAR列を追加したことでインデックスサイズが60%増加しました。インデックスが大きくなると、より多くのshared_buffers(RAM)を消費し、バックアップも遅くなります。最も頻繁に実行され、レイテンシに敏感なクエリに不可欠な列のみを含めるようにしてください。

3. 成功を測定する

実際の環境でインデックスが実際にインデックスオンリースキャンを実行しているか確認するには、次のクエリを使用します:

-- インデックスの使用統計を確認するクエリ
SELECT 
    schemaname, relname, indexrelname, 
    idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes 
WHERE idx_scan > 0 
ORDER BY idx_scan DESC;

健全なカバリングインデックスは、idx_tup_read(インデックスから読み取られた数)の値が高く、idx_tup_fetch(テーブルから取得された数)の値が非常に低くなります。これは、データがテーブル本体ではなくインデックスから直接取得されたことを示しています。

4. スリムに保つ

カバリングインデックスにJSONBや大きなTEXTフィールドを含めてはいけません。インデックスはメモリ内に収まるほどコンパクトであるべきです。インデックスがキャッシュに収まりきらないほど大きくなると、一つのI/O問題を別のI/O問題に置き換えるだけになってしまいます。整数、タイムスタンプ、または短い列挙型(enum)などの、サイズが小さく固定幅の型に限定しましょう。

インデックスを単なる検索ツールとしてだけでなく、最も重要なクエリパスのためにデータを戦略的に配置する手段として捉えてみてください。ヒープへのアクセスを排除することで、同じハードウェア構成のままでも、PostgreSQLインスタンスの処理能力をさらに引き出すことができるはずです。

Share: