GIN 逆引きインデックスと配列・JSONB の検索
配列・全文・JSONB の「含む」検索が GIN でなぜ速いのかが分かります。複合値を要素キーへ展開する posting list 構造と、pending list による挿入最適化まで内部から解説します。
- 1.GIN は1行が複数の値を持つ複合型(配列・tsvector・JSONB)向けの汎用逆引き索引。値を要素キーへ分解し、各キーごとに「そのキーを含む行 TID の一覧(posting list)」を持つ。
- 2.B+Tree が値1つ→1行を引くのに対し、GIN は要素キー→多数の行を引く。@>(含む)・配列の重なり・全文一致のような『どの行がこの要素を含むか』に最適。
- 3.1行で多数のキーを挿入するため更新が重い。pending list に追記をためてから一括反映(bulk insert)し、本体ツリーの更新回数を償却する。
GIN が解く問題
B+Tree インデックスは「キー1つ → 行1つ(または連続範囲)」を引くのが得意です。しかし扱いたい値が 1行に複数含まれる複合型 になると、この前提が崩れます。たとえば tags text[] 列で「'db' を含む行をすべて出せ」、tsvector 列で「データベース という語を含む文書」、jsonb 列で「{"role":"admin"} を含むドキュメント」——いずれも「1つの行が複数の検索可能な要素を持ち、検索側はその要素のどれか1つにマッチさせたい」形をしています。
GIN(Generalized Inverted Index、汎用逆引き索引) は、まさにこの形のために作られた PostgreSQL の索引です。名前のとおり 全文検索 で使う 転置インデックス(inverted index) を一般化したもので、対象を全文だけに限らず、配列・JSONB・範囲型・trigram など「要素へ分解できる任意の複合値」へ拡張しています。
値を要素キーへ展開する
GIN の核心は 1つの列値を複数のキーへ分解(展開)してから索引化する 点にあります。B+Tree が「値そのもの」をキーにするのに対し、GIN は型ごとに定義された 抽出関数(extractValue) を使い、値を要素の集合へ割り開きます。
| 列の型 | 1つの値の例 | 展開されるキー集合 |
|---|---|---|
| text[] (配列) | {'db','sql','index'} | db / sql / index の3キー |
| tsvector (全文) | 'データベース 設計 入門' | データベース / 設計 / 入門 の語キー |
| jsonb (既定の jsonb_ops) | {'role':'admin','age':30} | パスと値を符号化した複数キー |
| text + gin_trgm_ops | 'database' | 3文字ずつの trigram (dat,ata,tab…) |
この展開によって、検索の問いが「行が値Xに等しいか」ではなく 「行が要素キーKを含むか」 に変わります。tags @> ARRAY['db'](db を含む)も、全文の @@ to_tsquery('データベース') も、内部では「キー db / キー データベース を持つ行はどれか」という同一の問いに帰着します。1つの抽象構造で多様な「含む」検索を捌けるのが GIN の汎用性です。
posting list と posting tree
展開した各要素キーに対して、GIN は そのキーを含む行の位置一覧 を持ちます。行の位置は TID(ヒープ上のタプル識別子、ページ番号+オフセット) で表され、この一覧を posting list(ポスティングリスト) と呼びます。構造を図式化すると次のようになります。
エントリツリー(キーの B+Tree)
キー "db" -> [TID2, TID5, TID9, ...] ← posting list
キー "sql" -> [TID5, TID7]
キー "index" -> posting tree(別の B+Tree) ← TID が多すぎる場合
キーそのものは エントリツリー と呼ばれる B+Tree で管理され、ここはキーで二分探索できます。各キーにぶら下がる TID 一覧の扱いが、件数で2段階に分かれます。
- TID が少なければ、エントリと同じページ内に posting list として圧縮格納する。TID は昇順ソートされ、差分(delta)を可変長で符号化するため非常にコンパクトです。
- TID が膨大なら(人気タグなど)、一覧が1ページに収まらないので posting tree という独立した B+Tree に逃がす。キーは TID そのもので、巨大な行集合を効率よく走査・マージできます。
検索の実体は posting list 同士の集合演算 です。tags @> ARRAY['db','sql'](両方を含む)なら、キー db と sql の TID 一覧を取り出し 共通部分(AND) を取ります。どちらも TID 昇順なので、2つのリストを先頭から並走させる マージ交差 で線形時間に処理でき、ランダムアクセスを伴いません。これが「複数要素のAND/OR検索」を GIN が高速にこなせる理由です。
B+Tree は「列値 → 行」の写像で、等値・範囲・整列に強い汎用索引です(B+Tree の内部構造)。GIN は「要素キー → 行集合」の写像で、1行が多数のキーを生む複合値の 包含検索 に特化します。逆に言うと、GIN は単純な等値・範囲スキャンや ORDER BY には向きません。葉の左右リンクで順序走査する B+Tree と違い、GIN の出力は TID の集合であって順序を保証しないからです。「どの行がこの要素を含むか」を問うなら GIN、「この値の行はどれか/範囲はどこか」を問うなら B+Tree、と問いの形で使い分けます。
なぜ挿入が重いのか
GIN の弱点は 更新コスト です。1行を挿入すると、その行が展開する すべての要素キー について posting list を更新する必要があります。tags に20個のタグがあれば、エントリツリーを20回たどって20本の posting list へ TID を挿し込みます。しかも posting list は昇順ソートを保つため、末尾追記では済まず、途中への割り込みが起きがちです。1行あたりの索引更新が、B+Tree の比でなく重くなります。
さらに、要素キーは多数の行で共有されます。人気のキーの posting list は頻繁に書き換えられ、競合とページ書き換えの温床になります。素朴に毎回本体を更新すると、書き込みワークロードで GIN は急速に遅くなります。
pending list による挿入の償却
この更新コストを抑えるのが pending list(ペンディングリスト) と、それを使った bulk insert(一括挿入) の最適化です(PostgreSQL では fastupdate として既定で有効)。
挿入時、GIN は新しい行のエントリを すぐには本体のエントリツリーへ反映しません。代わりに、未整理のエントリを pending list という追記専用の領域(連結ページ)へ順次書き足す だけにします。pending list は単なる追記なので、1行あたりの挿入が 本体ツリーの構造更新を伴わない軽い操作 で済みます。多数行の挿入が来ても、まずはここへ溜め込みます。
溜まったエントリは、次のいずれかの契機で まとめて本体へ反映(フラッシュ) されます。
- pending list のサイズが
gin_pending_list_limit(既定4MB)を超えたとき。 - その GIN への
VACUUMが走ったとき。 - 明示的に
gin_clean_pending_list()を呼んだとき。
フラッシュでは、溜まった大量のエントリを キーごとにグループ化してソートし、同じ posting list への挿入を一括で適用 します。1本の posting list を何度も開いて1件ずつ挿すのではなく、対象キーの TID をまとめて1回でマージするため、本体ツリーの更新回数が劇的に減ります。これが「個々の挿入コストを多数の挿入にわたって償却(amortize) する」一括挿入の効果です。インデックス構築(CREATE INDEX)時も同様に、全行を一度ソートしてからキーごとに posting list を組み立てる方式で、1件ずつ挿すより桁違いに速く作れます。
pending list 上のエントリは まだソート済みの本体構造に入っていない ため、検索時には「本体のエントリツリー」に加えて「pending list 全体」も毎回スキャンして結果に合流させる必要があります。pending list が肥大すると、この線形スキャンが検索のたびに重くのしかかります。書き込みは速くなる一方で読み取りが遅くなる、というトレードオフです。挿入が多く検索遅延が気になる表では、gin_pending_list_limit を小さめにするか定期 VACUUM でこまめにフラッシュし、逆に大量ロード時は一時的に大きくして書き込みを稼ぐ、と用途で調整します。
GIN と GiST の使い分け
同じ「含む」系の検索を扱える索引に GiST もありますが、性質が異なります。
| 観点 | GIN | GiST |
|---|---|---|
| 内部構造 | 要素キー → posting list の逆引き | 境界を入れ子にした平衡木 |
| 検索速度 | 速い(完全一致・包含で有利) | GIN より遅いことが多い |
| 索引サイズ | 大きくなりやすい | 比較的コンパクト |
| 更新コスト | 重い(pending list で緩和) | 軽い |
| 得意領域 | 全文・配列・JSONB の静的寄りなデータ | 幾何・範囲型・更新の多いデータ |
おおまかには 「検索が多く更新が少ない」なら GIN、「更新が多い/幾何や範囲を扱う」なら GiST が定石です。配列・JSONB・全文のように「1行が多くの要素を持ち、含む検索を高速にしたい」典型ケースでは GIN が第一候補になります。
JSONB での実際
JSONB に GIN を張ると、ドキュメント DB 的な「このキーと値を含む文書」検索が索引で効くようになります。演算子クラスの選択が重要です。
-- 既定の jsonb_ops: 全パスと値をキー化。@>, ?, ?|, ?& を支援。索引は大きめ
CREATE INDEX ON docs USING gin (data);
-- jsonb_path_ops: 「パス+値」をハッシュ符号化。@> 専用だが索引が小さく高速
CREATE INDEX ON docs USING gin (data jsonb_path_ops);
SELECT * FROM docs WHERE data @> '{"role":"admin"}';
jsonb_ops はキーの存在演算子(? など)も含め幅広く支援する代わりに、JSON の各パスと値を個別キーへ展開するため索引が肥大します。jsonb_path_ops は「ルートからのパスと値」をまとめて1つのハッシュキーにするので、扱える演算子は包含(@>)に絞られる代わりに 索引が小さく、包含検索が速い のが利点です。アプリが包含検索しかしないなら、後者を選ぶのが定石です。
まとめ
GIN は「1行=1キー」を前提とする B+Tree(B+Tree の内部構造)では扱えない、複合値の包含検索 を一手に引き受ける逆引き索引です。値を要素キーへ展開し、各キーに posting list(多すぎれば posting tree) で行 TID を束ね、検索を TID 集合のマージ演算 へ落とし込みます。代償である重い更新は pending list への追記と一括フラッシュ で償却し、書き込みと読み取りのトレードオフを設定で調整できます。配列・全文・JSONB の「含む」を速くしたいとき、その内側で動いているのがこの構造です。インデックスが物理行をどう指すかという観点は クラスタ化インデックスと非クラスタ化インデックスの内部差 も併せて押さえると、TID を介した逆引きの位置づけがより鮮明になります。
データベース Article
GIN 逆引きインデックスと配列・JSONB の検索を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
GIN
比較で見る軸
難易度: advanced / カテゴリ: データベース / タグ数: 5
導入後に効く点
B+Tree が値1つ→1行を引くのに対し、GIN は要素キー→多数の行を引く。@>(含む)・配列の重なり・全文一致のような『どの行がこの要素を含むか』に最適。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- データベース
- タグ数
- 5
判断チェックリスト
- 自社の用途が「GIN / 逆引きインデックス」に近いか確認する。
- 強みである「GIN は1行が複数の値を持つ複合型(配列・tsvector・JSONB)向けの汎用逆引き索引。値を要素キーへ分解し、各キーごとに「そのキーを含む行 TID の一覧(posting list)」を持つ。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。