PostgreSQL インデックスの内部構造を徹底解説:B-tree、GiST、GIN の仕組みと使い分け

はじめに

この記事では、PostgreSQLのインデックスの内部実装を深掘りします。

前回の記事(InnoDB B+Tree の内部構造を徹底解説)ではMySQLのB+Treeを解説しました。PostgreSQLはInnoDBと違い、複数のインデックスタイプを提供しています。PostgreSQL 17のソースコードを参照しながら、以下を解説します:

  • HeapとIndexの分離構造(InnoDBのClustered Indexとの違い)
  • B-treeの内部構造(Lehman-Yaoアルゴリズム)
  • GiSTインデックス(空間データ等の汎用検索木)
  • GINインデックス(全文検索・配列検索用の転置インデックス)

HeapとIndexの分離

InnoDBではテーブル自体がClustered Index(B+Tree)でした。PostgreSQLは根本的に異なります。

Heap:順序なしのデータ格納

PostgreSQLのテーブルはHeapと呼ばれる構造で、レコードは挿入された順に格納されます。B+Treeのようなキー順の並びはありません。

Heap(テーブル):
  Page 0: [row5, row1, row8]    ← 挿入順。キー順ではない
  Page 1: [row3, row12, row2]
  Page 2: [row10, row7, row4]

各レコードの物理位置はctid(タプルID)で表されます:

ctid = (page_number, offset_within_page)
例:    (0, 1) = Page 0の1番目のスロット

Indexはctidを指す

PostgreSQLのインデックスのリーフには、ctidが格納されます。InnoDBのSecondary IndexがPK値を格納するのとは対照的です。

InnoDB Secondary Index:
  リーフ: [key=Alice, PK=1] → Clustered Indexを検索(B+Tree走査)

PostgreSQL Index:
  リーフ: [key=Alice, ctid=(0,1)] → Heapに直接アクセス(1回のI/O)
InnoDB PostgreSQL
テーブル構造 Clustered Index Heap(順序なし)
インデックスのリーフ PK値 ctid(物理位置)
テーブルへの戻り PK→B+Tree検索 ctid→Heap直接アクセス
UPDATEの影響 PK変更なければインデックス更新不要 行が移動するとctid変更→全インデックス更新

PostgreSQLのctidアクセスは高速ですが、UPDATEで行が新しいページに移動するとctidが変わり、全インデックスの更新が必要になります。これがPostgreSQLでVACUUMが重要な理由の1つです。

HOT(Heap Only Tuple)

この問題を緩和するのがHOTです。インデックスカラムが変更されないUPDATEでは、同じページ内に新バージョンを作り、旧バージョンからポインタで繋ぎます。インデックスの更新が不要になります。

UPDATE前: Page 0, slot 1: [Alice, age=25]
UPDATE後: Page 0, slot 1: [Alice, age=25] → slot 4: [Alice, age=26]  (HOT chain)
          ↑ インデックスのctid (0,1) はそのまま

B-tree

PostgreSQLのB-treeはInnoDBのB+Treeと似ていますが、重要な違いがあります。

Lehman-Yaoアルゴリズム

PostgreSQLのB-treeはLehman-Yao(L&Y)アルゴリズムを採用しています。各ページに右リンク(right-link)を持ち、ページ分割中でも検索を安全に続行できる設計です。

通常のB+Tree:
  親 [10 | 20]
     /    |    \
  [≤10] [11-20] [21-]

Lehman-Yao B-tree:
  親 [10 | 20]
     /    |    \
  [≤10]→[11-20]→[21-]→NULL
         ↑ 各ページが右隣へのリンクを持つ

なぜこれが重要かというと、ページ分割中のロック範囲を最小化できるからです:

InnoDBの分割:
  1. SX-latchでツリー全体をロック
  2. 分割実行
  3. ロック解放

PostgreSQLの分割(L&Y):
  1. 分割するページだけをロック
  2. 新ページを作成し、右リンクで接続
  3. 親ページをロックしてポインタ追加
  → ツリー全体のロックが不要

検索中にページ分割に遭遇した場合、右リンクをたどれば正しいページに到達できます。

ページ構造

  • PageHeaderData ページ管理情報
  • BTPageOpaqueData B-tree固有情報
  • ├─ btpo_prev (左ページ)
  • ├─ btpo_next (右ページ = right-link)
  • ├─ btpo_level (木の高さ)
  • └─ btpo_flags (リーフ/内部/削除済み)
  • Line Pointers (ItemId配列) 各タプルへのオフセット
  • Free Space
  • Index Tuples キー + ctid

InnoDBとの違い: – InnoDBはレコードをリンクリストで繋ぐが、PostgreSQLはLine Pointer(ItemId)配列でオフセット管理 – InnoDBにはPage Directory(二分探索用スロット)があるが、PostgreSQLのB-treeはBinary Search on Line Pointersで直接二分探索

Deduplication(PostgreSQL 13〜)

同じキー値が複数ある場合、キーを1つにまとめてctidのリストを持つ最適化です:

従来:     [Alice, ctid(0,1)] [Alice, ctid(1,3)] [Alice, ctid(2,5)]
Dedup後:  [Alice, {ctid(0,1), ctid(1,3), ctid(2,5)}]

インデックスサイズが大幅に削減されます。

GiST(Generalized Search Tree)

B-treeは等値検索と範囲検索に強いですが、「この点から半径1km以内」のような空間検索には向きません。そこで登場するのがGiSTです。

GiSTとは

GiSTは「汎用検索木」のフレームワークです。B-treeのようにキーの大小で分岐するのではなく、データ型ごとに定義された「一致」「包含」「重なり」などの演算子で分岐します。

-- 空間検索(PostGIS)
CREATE INDEX idx_geom ON places USING gist(geom);
SELECT * FROM places WHERE geom && ST_MakeEnvelope(139.7, 35.6, 139.8, 35.7);

-- 範囲型の検索
CREATE INDEX idx_period ON events USING gist(period);
SELECT * FROM events WHERE period @> '2026-04-01'::date;

-- 全文検索(tsvector)
CREATE INDEX idx_fts ON docs USING gist(tsv);
SELECT * FROM docs WHERE tsv @@ to_tsquery('database & index');

内部構造

GiSTはバランス木で、各内部ノードがバウンディングボックス(境界領域)を持ちます:

空間インデックスの例:

Root: [日本全体の矩形]
       /              \
  [関東の矩形]      [関西の矩形]
   /      \           /      \
[東京]  [神奈川]   [大阪]   [京都]
  ↓       ↓         ↓       ↓
ctid群   ctid群    ctid群   ctid群

検索時は、クエリの領域と各ノードのバウンディングボックスの重なりを判定し、重なるブランチだけを探索します。

B-treeとの使い分け

B-tree GiST
得意な検索 =, <, >, BETWEEN &&(重なり), @>(包含), <->(距離)
データ型 スカラー値 空間、範囲、全文検索
挿入性能 高速 B-treeより遅い
検索性能 等値・範囲は最速 空間検索はB-treeでは不可能

GIN(Generalized Inverted Index)

GINとは

GINは転置インデックスです。1つの行が複数の値を持つ場合(配列、全文検索、JSONB)に威力を発揮します。

-- 全文検索
CREATE INDEX idx_fts ON docs USING gin(to_tsvector('english', body));
SELECT * FROM docs WHERE to_tsvector('english', body) @@ to_tsquery('postgresql & index');

-- JSONB
CREATE INDEX idx_json ON products USING gin(attributes);
SELECT * FROM products WHERE attributes @> '{"color": "red"}';

-- 配列
CREATE INDEX idx_tags ON articles USING gin(tags);
SELECT * FROM articles WHERE tags @> ARRAY['database', 'postgresql'];

内部構造

GINは「キー → 行のリスト」という転置構造です:

GINの構造:

Entry Tree(B-tree):
  [apple] → Posting List: [ctid(0,1), ctid(2,3), ctid(5,1)]
  [banana] → Posting List: [ctid(1,2), ctid(3,4)]
  [cherry] → Posting Tree: (大量のctidはB-treeで管理)
  • Entry Tree:キー(単語など)をB-treeで管理
  • Posting List:少数のctidはリストで保持
  • Posting Tree:大量のctidは別のB-treeで管理

GiSTとGINの使い分け(全文検索の場合)

GiST GIN
インデックスサイズ 小さい(非可逆圧縮) 大きい(正確)
検索速度 遅い(偽陽性あり→Heap再チェック) 速い(正確な結果)
更新速度 速い 遅い(Pending Listで緩和)
用途 更新が多いテーブル 検索が多いテーブル

GINの更新が遅い問題はPending Listfastupdateオプション)で緩和されます。新しいエントリを一時的にリストに溜めておき、まとめてインデックスに反映します。

PostgreSQLのインデックスタイプ一覧

タイプ 用途 構造
B-tree 等値・範囲検索(デフォルト) バランス木(Lehman-Yao)
Hash 等値検索のみ ハッシュテーブル
GiST 空間・範囲・全文検索 汎用バランス木
GIN 全文検索・配列・JSONB 転置インデックス
BRIN 大きなテーブルの範囲検索 ブロック範囲の要約
SP-GiST 非バランス木(四分木、基数木等) 空間分割木

InnoDBがB+Tree一本なのに対し、PostgreSQLはデータの特性に応じたインデックスを選べる柔軟性があります。

まとめ

トピック InnoDB PostgreSQL
テーブル構造 Clustered Index Heap + 独立したIndex
主要インデックス B+Tree のみ B-tree, Hash, GiST, GIN, BRIN, SP-GiST
並行制御 SX-latch(ツリー全体) Lehman-Yao(ページ単位)
重複キー最適化 なし Deduplication(13〜)
UPDATE時のコスト PK変更なければ低い 行移動で全インデックス更新(HOTで緩和)

次回はOracle Indexを同じ切り口で解説します。

参考文献

コメントする