行がディスク上でどう置かれ、B-tree がその居場所をどう指すのかを図で描けるようにする回。加えて、第1回で決めた照合順序(collation)が索引に効いてくる「実害」を自分の手で再現する。
テーブルは 8KB のページの集まりであり、索引はその中の1行を指すポインタの集まりである。この物理像を持てると、「なぜこの索引は効き、あの索引は効かないのか」を推測ではなく構造から説明できる。この回では、ヒープ・ページ・タプル・TID・B-tree の関係を押さえ、複合索引の列順・カバリング・部分索引を使い分け、最後に照合順序が前方一致検索(LIKE 'abc%')に落とす影を実測で再現する。
EXPLAIN で列順の良し悪しを読み分けられる。INCLUDE)・部分索引を、いつ使うか判断できる。LIKE 'abc%' が Seq Scan に落ちる理由を説明でき、COLLATE "C" か text_pattern_ops で索引を効かせられる。第1回で作成した PostShop スキーマ(customers 約5万行、orders 約100万行、order_items 約300万行ほか)が投入済みであることを前提とする。バージョンは PostgreSQL 16 系を前提に書く。
計測の前に統計情報を最新化しておく。プランナは統計に基づいて索引を選ぶため、これを怠ると再現が揺れる。
ANALYZE;
psql では実行時間を出す \timing を有効にし、実行計画は EXPLAIN (ANALYZE, BUFFERS) で読む。BUFFERS はページ I/O 量を出すため、索引の効き目を数字で確かめられる。
この回の実測例には EXPLAIN の出力がそのまま登場する。計画の読み方そのものは第8回で扱うので、ここでは次の語だけ意味を取れれば足りる。
| 出力に出る語 | この回で読み取れればよい意味 |
|---|---|
Seq Scan | テーブルの全ページを順に読んだ。索引が使われていない |
Index Scan / Index Only Scan | 索引をたどった。後者はヒープを読まずに完結した |
Bitmap Index Scan / Bitmap Heap Scan | 索引で該当ページの地図を作り、ヒープをまとめ読みした |
Index Cond | 索引をたどる時点で使えた条件。ここに条件が入るほど絞れている |
Filter | 行を読んだ後に捨てるために使われた条件。索引では絞れていない |
Buffers: shared hit=N read=M | 触れたブロック数。hit はキャッシュ命中、read はディスク読み |
Heap Fetches | Index Only Scan がヒープを読みに行った回数。0 が理想 |
postgres=# \timing on
Timing is on.
ページサイズは既定で 8192 バイト。次で確認できる。
SHOW block_size; -- 8192
前半4つは物理構造、後半は索引を効かせるための道具立てである。いずれも本編で詳しく扱うが、先に一覧で押さえておく。回をまたいで使う語は用語集にもまとめている。
| 用語 | 一行での説明 |
|---|---|
| 索引(インデックス) | 表の全ページを読まずに目的の行へ到達するために、列の値と行の住所の対応を別に持たせた補助のデータ構造。B-tree・GIN・GiST・BRIN などの種類がある |
| ヒープ | テーブル本体のデータが格納されるファイル群 |
| ページ | ヒープを分割する固定長 8KB の単位。読み書きはこの単位で行われる |
| タプル | 1行の物理的な姿。ページの中に詰め込まれる |
| TID | 行の物理的な住所。(ページ番号, ページ内の行ポインタ番号)の組 |
| オペレータクラス(opclass) | 索引が値をどの演算子でどう比較するかを決める規則の組 |
IMMUTABLE | 同じ引数なら常に同じ結果を返し、DBの中身を読まない関数につける宣言 |
| 可視性マップ | どのページが全トランザクションから可視かを記録した補助データ |
pg_trgm | 文字列を3文字の断片に分けて、類似度・中間一致検索を可能にする拡張 |
VACUUM | 不要になった古い行の領域を回収して再利用可能にする保守処理(第12回) |
PostgreSQL のテーブル本体はヒープ(heap)と呼ばれるファイル群で、内部は固定長 8KB のページ(ブロックとも呼ぶ)に分割される。1ページの中に、複数のタプル(=行の物理的な姿)が詰め込まれる。
1ページの構造は次のようになっている。ページ先頭にヘッダがあり、そこから「行ポインタ配列」(ItemId、1件4バイト)が下方向へ伸びる。ページ末尾からはタプル本体が上方向へ積まれ、両者が中央の空き領域で出会う。行ポインタは「このページ内の何バイト目にタプルがあるか」を指す間接参照であり、この間接があるおかげで、ページ内でタプルが動いても外から見た住所を変えずに済む。
オフセット 0(ページ先頭)
|
| ページヘッダ(24B)
| 行ポインタ配列 ItemId[] … ヘッダ直後から下へ伸長 ↓
|
| 空き領域(free space) … 両者はここで出会う
|
| タプル本体 … ページ末尾から上へ伸長 ↑
v
オフセット 8191(ページ末尾)
各領域の役割を表でも示す。
| 領域 | 位置 | 役割 |
|---|---|---|
| ページヘッダ | 先頭(約24B) | ページ全体のメタ情報(空き領域境界など) |
| 行ポインタ配列 ItemId | ヘッダ直後、下方向へ | 各行の「ページ内オフセット+長さ」。1件4B |
| 空き領域 | 中央 | 新規タプルが入る余地 |
| タプル本体 | 末尾、上方向へ | 行の実データ(タプルヘッダ+NULLビットマップ+各列値) |
行の物理的な住所を TID(Tuple ID)と呼ぶ。TID は (ページ番号, ページ内の行ポインタ番号) の組である。システム列 ctid で覗ける。
SELECT ctid, id, customer_id, ordered_at
FROM orders
ORDER BY id
LIMIT 5;
ctid | id | customer_id | ordered_at
-------+----+-------------+------------------------
(0,1) | 1 | 41822 | 2023-04-01 09:12:33+09
(0,2) | 2 | 1093 | 2023-04-01 09:15:02+09
(0,3) | 3 | 28740 | 2023-04-01 09:19:47+09
(0,4) | 4 | 50011 | 2023-04-01 09:23:10+09
(0,5) | 5 | 882 | 2023-04-01 09:24:55+09
(5 rows)
(0,1) は「0番ページの1番目の行ポインタ」を意味する。索引が最終的に指すのはこの TID である。なお ctid は行の更新や VACUUM FULL で変わり得るので、アプリのキーには使わない(安定した住所は主キー id)。
索引(インデックス)とは、表の全ページを読まずに目的の行へ到達するために、列の値とその行の住所(TID)の対応を別に持たせた補助のデータ構造である。値をどう並べて持つかで何種類かに分かれ、PostgreSQL の既定は B-tree(正確には B+tree の一種)である。B-tree は値でソートされた多段の木で、次の3種類のノードからなる。
検索は根から始まり、区切りキーを辿って葉に降り、葉のエントリが指す TID でヒープの該当ページを読む。大きなテーブルでも木の高さは3〜4段に収まるため、目的の1行に数回のページ参照で到達できる。これが「索引が速い」の正体である。
root [ ... | 40000 | ... ]
|
+-- branch [10000 | 20000] … 40000 未満へ降りる枝
|
+-- branch [50000 | 60000] … 40000 以上へ降りる枝
|
+-- leaf [41821->TID, 41822->(0,1), ...]
| |
| +-- TID (0,1) をたどってヒープの該当ページを読む
|
+-- leaf [41850->TID, 41851->TID, ...] ← 葉どうしは左右に連結
葉から先が「TID をたどってヒープを読む」2段構えになっている点が要である。索引がキー値だけを持ち、行の残りの列はヒープにしかないため、選択した列が索引に無ければ、索引で住所を得たあとに必ずヒープを読みにいく(これを避ける工夫が後述のカバリング索引)。
複数列に対する 複合索引(composite index)では、列の順序が効き目を左右する。原則は 等値条件で絞る列を先、範囲条件で絞る列を後。理由は B-tree のソート順にある。(customer_id, ordered_at) の索引は、まず customer_id で並び、同じ customer_id の中で ordered_at が並ぶ。したがって「ある customer_id に等値で降り、その中で ordered_at の範囲を連続した一区間として舐める」ことができる。
次のクエリで、列順を入れ替えた2本の索引を比べる。
-- 良い順: 等値(customer_id) → 範囲(ordered_at)
CREATE INDEX idx_orders_good ON orders (customer_id, ordered_at);
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, ordered_at
FROM orders
WHERE customer_id = 12345
AND ordered_at >= '2025-06-01';
Index Scan using idx_orders_good on orders (cost=0.42..8.9 rows=6 width=16)
(actual time=0.031..0.048 rows=7 loops=1)
Index Cond: ((customer_id = 12345) AND (ordered_at >= '2025-06-01 00:00:00+09'))
Buffers: shared hit=5
Planning Time: 0.12 ms
Execution Time: 0.070 ms
両条件が Index Cond に入り、customer_id = 12345 の位置へ一度降りてから ordered_at >= ... の区間だけを読む。触れるページはごく少数(例: Buffers: shared hit=5)。
列順を逆にすると様相が変わる。
DROP INDEX idx_orders_good;
-- 悪い順: 範囲(ordered_at) → 等値(customer_id)
CREATE INDEX idx_orders_bad ON orders (ordered_at, customer_id);
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, ordered_at
FROM orders
WHERE customer_id = 12345
AND ordered_at >= '2025-06-01';
Index Scan using idx_orders_bad on orders (cost=0.42..17240.. rows=6 width=16)
(actual time=0.20..96.4 rows=7 loops=1)
Index Cond: ((ordered_at >= '2025-06-01 00:00:00+09') AND (customer_id = 12345))
Buffers: shared hit=1893
Planning Time: 0.12 ms
Execution Time: 96.5 ms
一見どちらも Index Cond に両条件が並ぶが、先頭列が範囲だと索引は ordered_at >= '2025-06-01' の位置にしか降りられない。以降は ordered_at が 6月以降のエントリをすべて順に舐めながら、各エントリで customer_id = 12345 を突き合わせる。結果の7行は同じでも、途中で触るエントリ数が桁違いに増える(例: Buffers: shared hit=1893、Execution Time はミリ秒オーダー)。先頭に範囲列を置いた瞬間、後続列は「絞り込みの入口」ではなく「1件ずつの再チェック」に格下げされる。これが列順の実害である(実測 ms・行数は環境依存なので上の数字はあくまで例)。
原則をまとめる。
=, IN)で使う列を左に寄せ、範囲(>=, <, BETWEEN)は最後の1列だけにする。(customer_id, ordered_at) は WHERE customer_id = ? ORDER BY ordered_at の並べ替えも索引順で満たせるため、Sort ノードを省ける。「全列にとりあえず索引」を貼るのは、この列順の意味を無視した典型的な失敗である。効くのは先頭列から連続して条件に使える索引だけで、余った索引は更新コストを増やすだけになる。
葉ノードから TID を得たあとにヒープを読む往復が、行数が多いと効いてくる。SELECT する列を索引の葉に同梱しておけば、ヒープを読まずに索引だけで答えられる。これが Index Only Scan で、INCLUDE で実現する。
-- 検索キーは (customer_id, ordered_at)、返す列 status を葉に同梱
CREATE INDEX idx_orders_cov
ON orders (customer_id, ordered_at) INCLUDE (status);
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, ordered_at, status
FROM orders
WHERE customer_id = 12345
AND ordered_at >= '2025-06-01';
Index Only Scan using idx_orders_cov on orders (actual time=0.02..0.03 rows=7 loops=1)
Index Cond: ((customer_id = 12345) AND (ordered_at >= '2025-06-01 00:00:00+09'))
Heap Fetches: 0
Buffers: shared hit=4
INCLUDE 列は探索・並び替えには使われず、葉に値を持つだけの列である。WHERE に使うキー列は左側の () に、返すだけの列は INCLUDE に置く。Heap Fetches: 0 ならヒープ往復ゼロで答えている。ただし Index Only Scan が効くには可視性マップ(どのページが全可視かの地図)が最新である必要があり、更新直後は Heap Fetches が増える。定期的な VACUUM(自動バキュームで足りることが多い)が前提になる。
条件に合う行だけを索引に載せるのが部分索引である。orders.status は大半が delivered で pending はごく一部、という偏りがあるとき、pending だけの索引は小さく速い。
CREATE INDEX idx_orders_pending
ON orders (ordered_at)
WHERE status = 'pending';
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM orders
WHERE status = 'pending'
AND ordered_at >= '2025-06-01';
クエリの WHERE が索引の述語(status = 'pending')を含むとき、プランナはこの索引を使える。索引は pending の行だけを持つので本体が小さく、delivered の大量行を索引に載せる無駄がない。「稼働中の注文だけ速く引きたい」「論理削除フラグが false の行だけ」など、偏った検索対象に有効である。
B-tree 以外にも用途特化の索引がある。対象データの形で選ぶ。名前はいずれも実装方式の略で、GIN は Generalized Inverted Index(汎用転置索引)、GiST は Generalized Search Tree(汎用検索木)、BRIN は Block Range Index(ブロック範囲索引)である。
| 種類 | 得意な検索 | 本スキーマでの例 |
|---|---|---|
| B-tree | 等値・範囲・ソート・前方一致(前方一致にはオペレータクラスか collation の指定が要る。後述) | orders(customer_id, ordered_at)、主キー全般 |
| Hash | 等値(=)のみ | 使いどころは限定的。等値専用でも B-tree で足りることが多い |
| GIN | 「1つの値の中に複数要素」を含む検索。配列・jsonb・全文検索・pg_trgm によるあいまい/中間一致。pg_trgm は文字列を3文字の断片(trigram)に分けて索引する拡張で、これにより中間一致を「断片を含むか」の検索に変換できる | events の jsonb 属性検索、products.name LIKE '%中間%' を pg_trgm で |
| GiST | 範囲型・幾何・近傍検索(KNN: k-Nearest Neighbor)・排他制約 | 第6回の tstzrange × EXCLUDE USING gist(価格の有効期間の重複禁止) |
| BRIN | 物理順に自然に並ぶ巨大テーブルの範囲検索。索引が極小 | events(occurred_at)(追記のみで時刻順に積まれる1000万行) |
要点は、前方一致・完全一致・範囲・ソートは B-tree、中間一致やあいまい検索は GIN + pg_trgm、追記型の巨大ログは BRIN、範囲型の重複禁止は GiST という対応である。第6回で使った有効期間モデルの排他制約が GiST だったのは、tstzrange の「重なる(&&)」を索引で扱えるのが GiST だからである。
ここがこの回の核心である。text 列に既定の索引が張ってあっても、LIKE 'abc%' の前方一致が Seq Scan に落ちることがある。原因は照合順序(collation)にある。
第1回で見たとおり、既定の collation(glibc の ja_JP.UTF-8 や ICU の ja-x-icu など、C 以外)は言語的な並び順を定める。言語的順序は「基底文字 → アクセント → 大小文字」といった多段の重みで決まり、空白や記号の扱いも単純なバイト順とは一致しない。その結果、「abc で始まる文字列の集合」は collation 順では連続した一区間にならない。
B-tree で LIKE 'abc%' を索引にかけるには、内部で col >= 'abc' AND col < 'abd' のような範囲条件へ変換する必要がある。この変換が正しいのは、abc で始まる文字列が必ず 'abc' 以上 'abd' 未満に連続して並ぶときだけ。C 以外の collation ではこの連続性が保証されないため、プランナは変換をあきらめ、既定索引を使わず全行を舐める。
対して C collation は生のバイト列の比較(memcmp)で並ぶ。バイト順なら「abc(バイト列)で始まる文字列」は必ず連続するので、範囲変換が成立し索引が効く。効かせる手は2つ。
まず、既定索引では効かないことを確認する(customers.email にはスキーマの UNIQUE 制約由来の既定 B-tree が既にある)。
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, email FROM customers
WHERE email LIKE 'user1%'; -- プレフィックスは実データに合わせて選ぶ
Seq Scan on customers (actual time=0.03..21.4 rows=... loops=1)
Filter: (email ~~ 'user1%'::text)
Rows Removed by Filter: 49xxx
Buffers: shared hit=...
既定の一意索引があるのに Seq Scan。email ~~ 'user1%' が Filter に落ち、全行を読んでいる。
手段(a): 索引に COLLATE "C" を付ける。
CREATE INDEX idx_email_c ON customers (email COLLATE "C");
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, email FROM customers
WHERE email LIKE 'user1%';
Bitmap Heap Scan on customers (actual time=0.05..0.31 rows=... loops=1)
Filter: (email ~~ 'user1%'::text)
-> Bitmap Index Scan on idx_email_c (actual time=0.03..0.03 rows=... loops=1)
Index Cond: ((email >= 'user1'::text) AND (email < 'user2'::text))
Index Cond が >= 'user1' AND < 'user2' という範囲に変換され、C collation の索引が使われている(この索引はバイト順で並ぶため範囲変換が成立する)。LIKE 自体は Filter で最終確認されるが、走査対象は範囲内に絞られている。
手段(b): text_pattern_ops オペレータクラスで索引を作る。 オペレータクラス(opclass)とは、その索引が値をどの演算子でどう比較するかを決める規則の組である。同じ B-tree でも、どの opclass を指定するかで並び順の基準そのものを差し替えられる。
CREATE INDEX idx_email_pat ON customers (email text_pattern_ops);
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, email FROM customers
WHERE email LIKE 'user1%';
text_pattern_ops は「collation を無視して文字(バイト)単位で比較する」演算子クラスで、列の既定 collation が何であってもパターンマッチの範囲変換を成立させる。EXPLAIN では手段(a)と同様に、範囲へ変換された Index Cond でこの索引が使われる。
両手段の注意点は同じで、役割が分かれること。
| 索引 | LIKE 'abc%'(前方一致) | 通常の <, >, ORDER BY(既定 collation) | 等値 = |
|---|---|---|---|
| 既定 opclass(非 C collation) | 効かない | 効く | 効く |
COLLATE "C" 索引 | 効く | 効かない(並び順が違う) | 効く |
text_pattern_ops 索引 | 効く | 効かない | 効く |
つまりパターン検索と通常の範囲/整列の両方を索引で速くしたいなら、既定 opclass の索引と、text_pattern_ops(または COLLATE "C")の索引を別々に張る。1つの列に用途別の索引を複数持てる。
WHERE lower(email) = 'foo@example.com' のように列に関数を掛けると、email の索引は使えない。索引が持つのは email の値であって lower(email) の値ではないからだ。解決は式インデックス(expression index)。式そのものに索引を張る。
-- 大文字小文字を無視した完全一致・前方一致の両対応
CREATE INDEX idx_email_lower_pat
ON customers (lower(email) text_pattern_ops);
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, email FROM customers
WHERE lower(email) LIKE 'user1%';
クエリの式(lower(email))が索引の式と一致するとき、プランナはこの索引を使える。text_pattern_ops を併せて指定したので、lower(email) に対する前方一致も範囲変換で効く。式インデックスは「常に同じ関数を通して検索する列」(正規化したメール、date_trunc した日付など)に有効だが、その関数が IMMUTABLE であることが条件になる。IMMUTABLE とは「同じ引数なら常に同じ結果を返し、DB の中身も設定も読まない」と宣言された関数のことで、そうでなければ索引に格納した値が後から食い違いうるため索引を張れない。lower() は IMMUTABLE だが、now() やセッションのタイムゾーンに依存する変換はそうではない。
orders に対し WHERE customer_id = ? AND ordered_at >= ? を、列順の異なる2索引で比べ、走査量の差を数字で見る。
やること: 良い順の索引を作って計測 → 落として悪い順を作り直して計測 → Buffers と Execution Time を比較する。
想定解答
-- 1) 等値→範囲(良い順)
CREATE INDEX idx_orders_good ON orders (customer_id, ordered_at);
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, ordered_at FROM orders
WHERE customer_id = 12345 AND ordered_at >= '2025-06-01';
-- Index Scan、Index Cond に両条件、Buffers 数ページ、実行 0.x ms(例)
-- 2) 範囲→等値(悪い順)に貼り替え
DROP INDEX idx_orders_good;
CREATE INDEX idx_orders_bad ON orders (ordered_at, customer_id);
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, ordered_at FROM orders
WHERE customer_id = 12345 AND ordered_at >= '2025-06-01';
-- Index Scan だが Buffers が桁違いに多い、実行が数十 ms(例)
なぜ解けるか: 良い順は customer_id = 12345 の位置へ一度降り、その中の ordered_at >= '2025-06-01' を連続区間として読む。悪い順は ordered_at が6月以降のエントリを全部舐めながら customer_id を1件ずつ照合するため、返す行数が同じでも走査エントリ数が跳ね上がる。Buffers: shared hit の差がその走査量の差を表す。計測後は不要な索引を DROP INDEX idx_orders_bad; で片付ける。
同じ email LIKE 'abc%' が、索引の作り方で Seq Scan にも Index/Bitmap Scan にもなることを再現する。
やること: (1) 既定索引だけの状態で Seq Scan を確認、(2) COLLATE "C" 索引で効かせる、(3) text_pattern_ops 索引でも効かせる、(4) lower(email) を掛けた検索が効かない→式インデックスで解決、を EXPLAIN で並べる。プレフィックスは実データに存在するものを選ぶ。
想定解答
-- (1) 既定の一意索引はあるが LIKE 前方一致では使われない
EXPLAIN SELECT id FROM customers WHERE email LIKE 'user1%';
-- -> Seq Scan(Filter: email ~~ 'user1%')
-- (2) COLLATE "C" 索引で効く
CREATE INDEX idx_email_c ON customers (email COLLATE "C");
EXPLAIN SELECT id FROM customers WHERE email LIKE 'user1%';
-- -> Bitmap/Index Scan(Index Cond: email >= 'user1' AND email < 'user2')
-- (3) text_pattern_ops 索引でも効く
DROP INDEX idx_email_c;
CREATE INDEX idx_email_pat ON customers (email text_pattern_ops);
EXPLAIN SELECT id FROM customers WHERE email LIKE 'user1%';
-- -> Bitmap/Index Scan(範囲に変換された Index Cond)
-- (4) 関数を掛けると上の索引は効かない → 式インデックス
EXPLAIN SELECT id FROM customers WHERE lower(email) LIKE 'user1%';
-- -> Seq Scan(email の索引は lower(email) を持たない)
CREATE INDEX idx_email_lower_pat ON customers (lower(email) text_pattern_ops);
EXPLAIN SELECT id FROM customers WHERE lower(email) LIKE 'user1%';
-- -> Index Scan(lower(email) の式索引が使われる)
なぜ解けるか: 非 C collation では「user1 で始まる文字列」が並び順で連続しないため、既定索引は前方一致を範囲に変換できず Seq Scan になる。COLLATE "C" はバイト順、text_pattern_ops は collation 非依存のバイト比較で、いずれも連続性が保証され範囲変換が成立する。lower(email) は列の値と別物なので、式そのものに索引を張って初めて一致する。
| 症状 | 原因 | 対処 |
|---|---|---|
| 全列に索引を貼ったのに遅い | 複合索引の列順を無視。範囲列を先頭に置いた/条件と先頭列が噛み合わない | 等値で絞る列を先、範囲は最後の1列。EXPLAIN で Index Cond に両条件が入るか確認 |
lower(col) や col::date で検索して Seq Scan | 列に関数を掛けると素の列の索引は使えない | 式インデックス CREATE INDEX ... (lower(col)) を張る。関数は IMMUTABLE であること |
LIKE 'abc%' が Seq Scan に落ちる | 非 C collation の既定索引は前方一致を範囲変換できない | 索引を COLLATE "C" か text_pattern_ops で作る。通常の範囲/整列も要るなら既定索引と併用 |
LIKE '%abc%'(中間一致)が効かない | 前方一致でないので B-tree では原理的に無理 | pg_trgm 拡張+GIN 索引を使う(別テーマ) |
INCLUDE したのにヒープを読む(Heap Fetches が多い) | 可視性マップが古く Index Only Scan が完全に効かない | VACUUM(自動バキューム)を待つ/更新直後の計測は避ける |
エラーではなく「動くが遅い」形で出るのがこの回のつまずきの厄介なところで、EXPLAIN を見て初めて気づく。索引を作ったら必ず計画を確認する癖をつける。
ctid が何を表すか説明でき、SELECT ctid で確認した。EXPLAIN で読み分けた。INCLUDE によるカバリング索引と部分索引を、使う場面ごと説明できる。LIKE 'abc%' が Seq Scan に落ちる理由を説明でき、COLLATE "C" と text_pattern_ops の両方で効かせた。orders(customer_id, ordered_at) と orders(ordered_at, customer_id) で、WHERE customer_id = ? AND ordered_at BETWEEN ? AND ? の EXPLAIN (ANALYZE, BUFFERS) を取り、Buffers と実行時間を before/after 表にまとめる。products.name に対する LIKE '%キーワード%'(中間一致)を、CREATE EXTENSION pg_trgm; と GIN 索引で速くできるか試す。前方一致との違いを一言で説明する。events(occurred_at)(1000万行・時刻順の追記型)に BRIN 索引を張り、月範囲の集計で B-tree とサイズ・速度をどう比べるか予想を書く(第13回のパーティションと BRIN の相性に接続する)。索引を作り分けても、実際に使われているかはプランナ次第である。第8回では EXPLAIN (ANALYZE, BUFFERS) を虫眼鏡として、「なぜ索引が使われない/Seq Scan が選ばれる」を統計情報とコスト見積もりから言語化する。この回で作った良い索引・悪い索引が、そこで格好の教材になる。照合順序そのものの決め方は第1回に、範囲型 × GiST の排他制約は第6回に戻って確認できる。
text_pattern_ops・varchar_pattern_ops)。