---
title: "データベースのインデックスを図書館のたとえで学ぶ"
url: https://www.stacklogs.io.vn/ja/gijutsu/detabesu-index-toshokan-no-tatoe/
language: ja
author: "Nhu Duy Thin (Thinnd)"
published: 2026-10-30
updated: 2026-10-30
section: "エンジニアリング"
tags: ["データベース", "インデックス", "SQL", "SQLite", "パフォーマンス", "練習問題"]
summary: "インデックスは、テーブル全体を調べなくても行を見つけられるようにする補助の索引です。10万行のSQLite実験では、ある著者の検索が全件走査で約2.2ミリ秒、インデックスありで約0.03ミリ秒となり、約79倍速くなりました。一方で書き込みには代償があり、2万行の追加は、インデックス1つで2.2倍、3つで5倍遅くなりました。"
license: "Cite with the author name and the canonical URL above."
---

# データベースのインデックスを図書館のたとえで学ぶ

> SQLのクエリを書く人、AIに書かせる人なら誰でも、初心者の方でも読めます。データベースの内部の知識は不要です。用語にはどれも身近なたとえを添えています。スクリプトとコードの部分は、結果だけ読みたい方は飛ばせる任意の内容です。
>
> この記事の数字はすべて、SQLite 3.53.4（Python 3.14.8に付属、macOS、arm64）で実際に動かした実験のもので、出力はそのまま貼っています。実験は小さく、1台のマシンで、乱数で作ったデータを使っているので、性能比較表ではなく学ぶための題材として読んでください。データベースが実際にどうプランを選ぶかは複雑で、以下のたとえは覚えやすくするための補助にすぎず、実際はもっと入り組んでいます。

---

## まず要点

- インデックスがないと、データベースは結果を探すために**テーブルの全行を読む**ことがよくあります。10万冊の本の実験では、ある著者の検索に約2.2ミリ秒かかりました。
- 著者の列にインデックスを付けると、同じクエリが約0.03ミリ秒になり、**約79倍速く**なりました（他の回では75倍から82倍）。
- インデックスは**いつも効くわけではありません**。値が2種類しかない列（在庫ありかどうか）では、インデックスを使っても43.6ミリ秒かかり、全件走査の39.9ミリ秒より速くなりませんでした。
- 複数列インデックスでは、**列の順番**でどのクエリが使えるかが決まります。(genre, year)のインデックスはyearだけで絞るクエリに効かず、(year, genre)なら効きます。
- インデックスには**代償**があります。2万行の追加は、インデックス1つで約2.2倍、3つで約5倍遅くなり、最初の1つでファイルが約2.5MB増えました。
- 確認は安上がりです。**クエリプランを読む**だけです（SCANは全件走査、SEARCHは一部の行だけ）。AIにクエリを書かせたときに求めるべき証拠でもあります。

---

## むずかしい言葉をやさしく

| 用語 | やさしい意味 |
|---|---|
| **テーブル（table）** | 蔵書全体を行に並べたもの。1行が1冊で、題名、著者、年などの列があります。 |
| **全件走査（full table scan）** | 棚を端から歩き、1冊ずつ開いて目的の著者かどうか確かめること。 |
| **インデックス（index）** | 図書館のカード目録。あらかじめ著者順に並べてあり、各カードに本のある場所が書いてあります。 |
| **クエリプラン（query plan）** | 司書が先に教えてくれる探し方。「目録を引きます」か「棚を端から歩きます」か。 |
| **選択性（selectivity）** | 条件がどれだけ行を除外できるか。「著者がX」はほぼ全部を除外し、「在庫あり」はほとんど除外しません。 |
| **複数列インデックス（composite index）** | 2段で並べた目録。たとえば、まずジャンル順、その中で年順。 |
| **書き込みコスト（write cost）** | 新しい本が入るたびに、司書がすべての目録にカードを作る手間。 |
| **レイテンシ／中央値（median）** | クエリ1回にかかる時間。中央値は測定値を並べたときの真ん中の値で、たまたま遅かった1回に左右されにくい値です。 |

---

## この記事を読むと説明できること

1. **テーブルが大きくなるとクエリが遅くなる理由**：インデックスがないと時間は行数に比例して増え、あると非常にゆっくり増える。
2. **よいインデックスに必要な3つ**：正しい先頭の列、多くの行を除外できる条件、列を関数や先頭の`%`で「隠さない」クエリ。
3. **インデックスにはどれも代償がある**：書き込みが遅くなり、領域を使う。効果を測れたら付け、誰も使わなければ外す。

---

## 1. まずイメージ：図書館のカード目録

蔵書10万冊の図書館を思い浮かべてください。「Author 1234」という著者の本をすべて探したいとします。1つめの方法は、棚を端から歩いて1冊ずつ開き、著者を確かめること。2つめは、著者順にあらかじめ並んだカード目録を開き、「Author 1234」のところまで行って、そこにあるカードを読むこと。各カードには、本がどの棚にあるかが書いてあります。

2つめの方法のほうがはるかに速いのですが、条件があります。目録が作られていて、新しい本が入るたびに正しく保たれている必要があります。目録が並べていない聞き方（たとえば「名前の末尾が1234の著者」）をすると、司書はやはり棚を端から歩くことになります。

データベースのインデックスは、この目録です。この記事の残りでは、どれだけ速くなるのか、どんなときに効かないのか、代償は何かを、実測の数字で見ていきます。

**ここでの教訓：** インデックスはテーブルを速くするのではなく、質問が近道の並べ方に合っているとき、データベースに近道を与えます。

---

## 2. 実測：10万冊の本

### 2.1 準備

`books`テーブルには、乱数で作った10万行があります。種（seed）を固定してあるので、まったく同じデータを再現できます。題名、著者（2,000人、1人あたり約50冊）、出版年（1950年から2024年）、ジャンル（8種類）、在庫ありの列（約90%が1）です。各クエリを200回実行し、中央値を取りました。データベースはメモリ上（`:memory:`）で動かしたので、数字は実際のディスクを反映しません。下の「限界」を見てください。

完全なスクリプトは7章にあるので再実行できます。以下は、この記事の主な数字として使った回の出力です。

### 2.2 ある著者の検索：全件走査とインデックス

```text
SELECT * FROM books WHERE author = 'Author 1234'    (結果は59行)

インデックスなし:
    plan : SCAN books
    median: 2.204 ms

CREATE INDEX idx_author ON books(author) の後:
    plan : SEARCH books USING INDEX idx_author (author=?)
    median: 0.028 ms
```

プランは`SCAN`（テーブル全体を歩く）から`SEARCH books USING INDEX`（インデックスを使う）に変わり、時間は2.204ミリ秒から0.028ミリ秒に減りました。約79倍の高速化です。スクリプト全体を4回走らせましたが、比は75倍から82倍の範囲でした。測ったのは10万行という1つの大きさだけなので、テーブルが大きくなったときに時間がどう変わるかは、この実験では示せません。原理としては、走査はすべての行を通るのでテーブルが大きいほど時間がかかり、PostgreSQLの文書はインデックスの検索を、探索木を数段たどることとして説明しています。

**ここでの教訓：** ほぼすべての行を除外できる条件なら、インデックスは非常に大きな改善をもたらします。

### 2.3 値が2種類の列：インデックスを使っても速くならない

```text
SELECT * FROM books WHERE in_stock = 1              (結果は89,966行)

インデックスなし:
    plan : SCAN books
    median: 39.896 ms

CREATE INDEX idx_stock ON books(in_stock) の後:
    plan : SEARCH books USING INDEX idx_stock (in_stock=?)
    median: 43.571 ms
```

ここではプランが**確かに**インデックスを使っています（`SEARCH books USING INDEX`）が、時間は減らず、むしろ少し遅くなりました（43.571ミリ秒対39.896ミリ秒）。もっともらしい説明は、クエリがやはり行の約90%を取り出す必要があり、主な作業が89,966行を読んで渡すことなので、インデックス検索ではその部分が減らない、というものです。これは私の説明で、各段階のコストを切り分けて証明したわけではありません。

反対側も試しました。`in_stock = 0`は10,034行（約10%）だけを返します。新しいデータベースでは、走査が5.551ミリ秒、インデックスが5.029ミリ秒で、約9%速くなっただけでした。クエリが行の90%を除外できても、効果は、著者の79倍に比べて小さいのです。著者は10万行のうち59行にしか一致しないからです。

**ここでの教訓：** インデックスの価値は選択性で決まります。問うべきは「この列はよく絞り込まれるか」ではなく「この条件はどれだけ行を除外するか」です。プランに`USING INDEX`と出ていても、速いとは限りません。

### 2.4 複数列インデックス：列の順番で決まる

ジャンル（`genre`）と年（`year`）の2列に2種類のインデックスを作り、2つのクエリで試しました。

```text
クエリA: WHERE genre = 'tech' AND year >= 2020    (827行)
クエリB: WHERE year = 2015                        (1,321行)

複数列インデックスなし:
    A: SCAN books                                         3.124 ms

インデックス (genre, year):
    A: SEARCH books USING INDEX idx_genre_year (genre=? AND year>?)   0.437 ms
    B: SCAN books                                         2.067 ms

インデックス (year, genre):
    A: SEARCH books USING INDEX idx_year_genre (year>?)     0.561 ms
    B: SEARCH books USING INDEX idx_year_genre (year=?)     0.694 ms
```

読み方：

- クエリAは両方のインデックスを使えますが、(genre, year)は**両方の条件**（`genre=? AND year>?`）を使い、(year, genre)は最初の条件`year>?`しか使えません。時間は0.437対0.561ミリ秒で、違いはありますが大きくはありません。
- 大きな差はクエリBにあります。(genre, year)のインデックスは、`year`だけで絞るクエリには**使えず**（プランは`SCAN`のままで2.067ミリ秒）、(year, genre)なら使えます（0.694ミリ秒）。

まずジャンル順、その中で年順に並んだ目録を想像してください。「ジャンルがtechで2020年以降」なら、techの引き出しにまっすぐ行って読み進められます。「2015年の本すべて」だと、2015年はどのジャンルの引き出しにも散らばっているので、目録は役に立ちません。SQLiteの文書はこの規則を述べています。インデックスの列は左から右へ、飛ばさずに使われます。Use The Index, Luke（Markus Winand）は、姓順の電話帳にたとえています。名前だけでは引けません。

読みすぎないための注意を1つ。両方の列に等号を使うクエリ（たとえば`year = 2015 AND genre = 'tech'`）では、両方の順序を試したところ、**どちらも両方の列を使い**、走査の約1.6ミリ秒に対してそれぞれ約0.07ミリ秒でした。列の順番が最も効くのは、1つの列だけで絞るクエリがあるとき、または先頭の列に範囲条件（`>=`、`BETWEEN`）があるときです。PostgreSQLの文書は、複数列インデックスは控えめに使うよう勧め、3列を超えるインデックスは、テーブルの使い方がよほど規則的でない限りほとんど役に立たないと述べています。PostgreSQL 18では場合によって先頭の列を「飛ばす」こともできるので、ここでの列順の規則は一般則であり、すべてのシステムとすべての版に当てはまる法則ではありません。

**ここでの教訓：** まず実際のクエリを書き出し、先頭の列ができるだけ多くのクエリに現れるように列の順番を選びます。

### 2.5 インデックスがあってもクエリが走査してしまう場合

`idx_author`を残したまま、同じ著者を探す4通りの聞き方を試しました。

```text
WHERE lower(author) = 'author 1234'     plan: SCAN books                  5.539 ms
WHERE author LIKE '%1234'               plan: SCAN books                  3.798 ms
WHERE author LIKE 'Author 1234%'        plan: SCAN books                  2.852 ms
WHERE author GLOB 'Author 1234*'        plan: SEARCH books USING INDEX idx_author (author>? AND author<?)   0.029 ms
```

- **列を関数で包む**（`lower(author)`）：インデックスは`author`の順に並んでいて、`lower(author)`の順ではないので無視されます。元の名前順の目録に、名前の小文字版で聞くようなものです。
- **先頭の`%`**（`LIKE '%1234'`）：目録は頭の文字順に並んでいるので、末尾で聞いても使えません。
- **先頭が固定のLIKE**（`LIKE 'Author 1234%'`）：理屈の上ではインデックスを使えますが、ここでは`SCAN`のままです。SQLiteの文書によると、LIKEは既定で大文字小文字を区別しないため、列がNOCASEの照合順序を使っているときだけインデックスを使えます。私の既定のインデックスはバイト単位（BINARY）で比べるので、使えません。`author COLLATE NOCASE`のインデックスを追加で作ると、プランは`SEARCH`に変わりました。
- **GLOB**は大文字小文字を区別するので既定のインデックスに合い、0.029ミリ秒でした。

この細部はSQLite固有です。PostgreSQLにはLIKEとインデックスについて独自の規則があり（照合順序とインデックスの種類に依存します）、今回は確認していません。それでも一般的な教訓は変わりません。**推測せず、プランを読むこと。**

### 2.6 代償：書き込みが遅くなり、領域も増える

```text
100,000行のテーブルに20,000行を追加 (7回の中央値、メモリ上):

インデックス0個                            6.4 ms   (x1.00)   ファイル 4.98 MB
インデックス1個 (author)                  13.9 ms   (x2.18)   ファイル 7.48 MB
インデックス3個 (author, genre+year, title) 31.8 ms   (x5.00)   ファイル 12.23 MB
```

1行を追加するたびに、データベースはテーブル**と**すべてのインデックスを更新します。インデックス3つで、追加は約5倍遅くなり、ファイルは2.5倍近く大きくなりました（4.98MBから12.23MB）。PostgreSQLの文書も、インデックスはデータを変更する操作に負担を加えると述べ、ほとんど、またはまったく使われないインデックスは削除するよう勧めています。

注意：この例はメモリ上で、1つのトランザクションでの一括追加、同時アクセスなし、ディスクなしの条件です。実際のシステムでは、どちらの方向にもかなり違う可能性があります。この数字は**向き**（インデックスが増えると書き込みが重くなる）を見るためのもので、予測に使うものではありません。

**ここでの教訓：** 実際によく走らせるクエリのためにインデックスを付け、また測り直します。「念のため」にすべての列に付けるのは、使われないものに多くを払う方法です。

---

## 3. クエリプランの読み方

司書が探し方を先に教えてくれるところを想像してください。SQLiteでは、クエリの前に`EXPLAIN QUERY PLAN`と書きます。

```sql
EXPLAIN QUERY PLAN SELECT * FROM books WHERE author = 'Author 1234';
```

SQLiteの文書によると、知っておくべき語は2つです。

| 表示 | 意味 | どうするか |
|---|---|---|
| `SCAN books` | テーブルのすべての行を通る | テーブルが大きく条件が絞り込みに向くなら、インデックスを検討 |
| `SEARCH books USING INDEX インデックス名 (列=?)` | 一部の行だけを調べ、括弧内の条件にインデックスを使う | よいが、どの条件が使われたかを確認 |
| `USING COVERING INDEX` | クエリに必要な列をすべてインデックスが持つので、テーブルは読まない | たいてい非常に速い |

間違えやすい点が3つあります。

1. `SEARCH`は速さを保証しません（2.3の例は、インデックスを使っても走査と同じくらいかかりました）。
2. 括弧は、インデックスが**どの条件**を使うかを示します。2.4の`(year>?)`は、条件の半分しか使われなかったことを示しています。
3. SQLiteの文書は、この出力が人間が読むためのもので、版によって変わりうると明記しています。この形式に依存するコードは書かないでください。

他のシステムにも似た道具がありますが、形式は違います。PostgreSQLは`EXPLAIN`（実際に動かして測る`EXPLAIN ANALYZE`も）を使うので、SQLiteの読み方をそのまま持ち込まないでください。

---

## 4. インデックスでよくある間違い

司書が1人しかいない図書館を想像してください。目録が増えるほど、本が入るたびに忙しくなります。

| 間違い | なぜ失敗するか | 見つけ方 |
|---|---|---|
| 「念のため」すべての列にインデックスを付ける | 書き込みが遅くなり領域を使う。使われないインデックスが多い（3つで書き込みは5倍遅くなった） | 書き込みコストを測り、実際のクエリのプランに現れないインデックスは外す |
| 値が少ない列にインデックスを付ける | クエリはやはりテーブルの大半を取るので、主な作業が減らない | 「この条件は行の何%を除外するか」と問う |
| 複数列インデックスの列順の誤り | 後ろの列だけで絞るクエリが全件走査になる | 実際のクエリを書き出し、最も多く現れる列を先頭に置く |
| 絞り込みで列を関数で包む | インデックスは元の値順で、関数の結果順ではない | プランを読み、列に直接条件を書く方法を探す |
| LIKEの先頭に`%` | 目録は頭の文字順に並んでいる | 前方一致で探す、または専用の全文検索の道具を使う |
| あるシステムのプランを別のシステムに当てはめる | SQLiteとPostgreSQLはLIKE、先頭列の飛ばし方、プランの表示形式が違う | 実際に使うシステムでEXPLAINを実行する |
| プランをまったく見ない | テーブルが小さいうちは何でも速く、大きくなって初めて表に出る | リリース前のチェックリストにプランを読むことを入れる |

### 4.1 AIが書いたクエリは？

クエリを書くAIアシスタントは、こちらが伝えない限り、テーブルが100行になるのか1億行になるのかを知りません。書かれたクエリは、たいてい**結果としては正しく**、テスト用データでは速いので、レビューする人は見逃しがちです。AIが生成したクエリにインデックスが欠けている頻度を測った独立した数字は見つけられなかったので、これは数字ではなく仕組みからの注意です。最も安上がりな対処は、証拠を求めることです。[AIの結果に自分で証明させる：証拠を求める5つの方法](https://www.stacklogs.io.vn/ja/gijutsu/ai-no-kekka-ni-shoko-wo-motomeru-5tsu-no-hoho/)の方法2のように、実際のクエリプランと想定する行数を貼らせます。

---

## 5. 例（想定）：最初は問題なく、数か月後に遅くなるクエリ

これは想定した例で、実在の人物や製品には触れていません。大きな規模の数字は私が置いた掛け算であり、測定値ではありません。

- **10:00。** 開発者がエージェントに頼みます。「著者で本を検索する画面を追加して。」
- **10:20。** エージェントは`WHERE author = ?`のクエリを書き、1,000冊で試して1ミリ秒未満で動きました。結果が正しくテストも緑なので、開発者は承認します。
- **3か月後の09:30。** 蔵書は500万冊になりました。走査の時間が行数に比例して増えると仮定すると（例のための仮定です）、10万行での2.2ミリ秒は、500万行で約110ミリ秒になります（2.2の50倍）。著者の検索1回が、気づくほどの時間マシンを占有し、多くの人が同時に検索すると画面が遅く感じられます。
- **対処：** クエリプランを実行し、`SCAN books`を確認し、`author`にインデックスを付けて測り直します。インデックス検索は行数が増えてもごくゆっくりしか伸びないという仮定のもとでは、時間はごく小さいままです。

10:20の時点でやるべきだったこと：新しいクエリをレビューするとき、**クエリプランと想定する行数を尋ねる**。この想定例は何も証明しません。証拠は2章の実験とPostgreSQLの文書にあります。インデックスは書き込みにコストがかかるので、あちこちに足すのではなく、意図して選び、測ります。学ぶ必要性については、ITviec（検索結果を通じて、求人プラットフォーム自身のデータ）が、2025年第3四半期にSQLを求める求人が前年同期比で約38.7%増えたと報告しているので、AIのおかげで時代遅れになったスキルではありません。

---

## 6. 解答つきの練習問題

7章のスクリプトを1回実行してデータを用意し、3問を解いてください。実行する前に結果を予想してみましょう。

**問題1。** クエリ`WHERE year = 2015 AND genre = 'tech'`にインデックスを付けます。`(year, genre)`と`(genre, year)`の両方を試してください。どちらのインデックスが両方の条件を使え、時間はどう違いますか。

**問題2。** `in_stock`にインデックスを作り、`WHERE in_stock = 0`と`WHERE in_stock = 1`を実行してください。著者での検索と効果が大きく違うのはなぜですか。

**問題3。** `author`に既定のインデックスしかない状態で、`WHERE author LIKE 'Author 1234%'`を実行してください。なぜ`SCAN`のままですか。直す方法は2つあります。見つけられますか。

### 解答（私の実行結果から）

**問題1。** どちらの順序も、両方が等号なので、両方の条件を使います。

```text
インデックスなし: SCAN books                                  1.602 ms (149行)
(year, genre):    SEARCH books USING INDEX a (year=? AND genre=?) 0.071 ms
(genre, year):    SEARCH books USING INDEX b (genre=? AND year=?) 0.074 ms
```

違いはほとんどありません。覚えておくこと：すべての列に等号だけを使うクエリでは、順番はあまり重要ではありません。1つの列だけで絞るクエリ（2.4のように）や範囲があるときに重要になります。

**問題2。** 測定ごとに新しいデータベースで：

```text
in_stock = 0  (10,034行): 走査 5.551 ms、インデックス 5.029 ms
in_stock = 1  (89,966行): 走査 39.209 ms、インデックス 41.749 ms
```

`= 0`では効果がごくわずか（約9%）で、`= 1`ではありません。多くの行を取り出して渡すことに、時間の大半がかかります。教訓：選択性と返す行数が、インデックスの価値を決めます。この列に対する他のクエリでは、まだ恩恵がある場合もあります（たとえば在庫のある本の数を数えるのにカバリングインデックスを使えるかもしれません）。この記事では、そのケースは別に測っていません。

**問題3。** SQLiteの文書によると、LIKEは既定で大文字小文字を区別しないので、LIKEの最適化はNOCASEの照合順序のインデックスだけを使います。既定のインデックスはBINARYなので使われません。直し方は2つ。`GLOB 'Author 1234*'`と書く（GLOBは大文字小文字を区別し、BINARYのインデックスを使えて、0.029ミリ秒）、または`CREATE INDEX nc ON books(author COLLATE NOCASE)`を作ると、`LIKE 'Author 1234%'`が`SEARCH`に変わります（試した結果：0.029ミリ秒）。

---

## 7. 再実行用のスクリプト（任意）

必要なのはPython 3だけです（`sqlite3`ライブラリは付属しています）。`index_demo.py`として保存し、`python3 index_demo.py`で実行します。30秒ほどかかります。数字はマシンによって違いますが、結果の形（SCANとSEARCHの違い、時間の大小の順）は似るはずです。

```python
import itertools
import random
import sqlite3
import statistics
import time

N = 100_000
REPS = 200
GENRES = ["novel", "science", "history", "children", "cooking", "tech", "poetry", "comic"]


def make_rows(n, seed):
    rnd = random.Random(seed)
    rows = []
    for i in range(n):
        rows.append((
            f"Book {rnd.randrange(10**6):06d}",
            f"Author {rnd.randrange(2000):04d}",
            rnd.randrange(1950, 2025),
            rnd.choice(GENRES),
            1 if rnd.random() < 0.9 else 0,
        ))
    return rows


def new_db(rows):
    db = sqlite3.connect(":memory:")
    db.execute("CREATE TABLE books (id INTEGER PRIMARY KEY, title TEXT, author TEXT, "
               "year INTEGER, genre TEXT, in_stock INTEGER)")
    db.executemany("INSERT INTO books (title, author, year, genre, in_stock) VALUES (?,?,?,?,?)", rows)
    db.commit()
    return db


_n = itertools.count()


def plan(db, sql):
    # the counter comment makes each EXPLAIN text unique, so Python never reuses a stale cached plan
    return [r[3] for r in db.execute(f"EXPLAIN QUERY PLAN /*{next(_n)}*/ " + sql)]


def timed(db, sql):
    samples = []
    count = 0
    for _ in range(REPS):
        t = time.perf_counter()
        count = len(db.execute(sql).fetchall())
        samples.append((time.perf_counter() - t) * 1000)
    return statistics.median(samples), count


def show(db, label, sql):
    p = plan(db, sql)
    ms, count = timed(db, sql)
    print(f"[{label}] {sql}")
    print(f"    plan : {' | '.join(p)}")
    print(f"    rows : {count}   median: {ms:.3f} ms over {REPS} runs")


rows = make_rows(N, seed=42)
db = new_db(rows)
print("SQLite", sqlite3.sqlite_version, "rows", N)
print()

print("=== 1. One author: scan then index ===")
q1 = "SELECT * FROM books WHERE author = 'Author 1234'"
show(db, "no index", q1)
db.execute("CREATE INDEX idx_author ON books(author)")
show(db, "index on author", q1)
print()

print("=== 2. A column with two values (in_stock: 90% are 1) ===")
q2 = "SELECT * FROM books WHERE in_stock = 1"
show(db, "no index", q2)
db.execute("CREATE INDEX idx_stock ON books(in_stock)")
show(db, "index on in_stock", q2)
db.execute("DROP INDEX idx_stock")
print()

print("=== 3. Two columns: order matters ===")
q3 = "SELECT * FROM books WHERE genre = 'tech' AND year >= 2020"
q4 = "SELECT * FROM books WHERE year = 2015"
show(db, "no composite index, q3", q3)
db.execute("CREATE INDEX idx_genre_year ON books(genre, year)")
show(db, "index (genre, year), q3", q3)
show(db, "index (genre, year), q4 uses year only", q4)
db.execute("DROP INDEX idx_genre_year")
db.execute("CREATE INDEX idx_year_genre ON books(year, genre)")
show(db, "index (year, genre), q3", q3)
show(db, "index (year, genre), q4 uses year only", q4)
db.execute("DROP INDEX idx_year_genre")
print()

print("=== 4. Queries an index on author cannot help ===")
q5 = "SELECT * FROM books WHERE lower(author) = 'author 1234'"
q6 = "SELECT * FROM books WHERE author LIKE '%1234'"
q7 = "SELECT * FROM books WHERE author LIKE 'Author 1234%'"
show(db, "index on author, lower()", q5)
show(db, "index on author, LIKE '%1234'", q6)
show(db, "index on author, LIKE 'Author 1234%'", q7)
q8 = "SELECT * FROM books WHERE author GLOB 'Author 1234*'"
show(db, "index on author, GLOB 'Author 1234*'", q8)
print()

print("=== 5. The price: size and inserts (20,000 new rows, 7 runs each) ===")
extra = make_rows(20_000, seed=7)


def insert_time(index_sqls):
    samples = []
    pages = 0
    for _ in range(7):
        d = new_db(rows)
        for s in index_sqls:
            d.execute(s)
        t = time.perf_counter()
        d.executemany("INSERT INTO books (title, author, year, genre, in_stock) VALUES (?,?,?,?,?)", extra)
        d.commit()
        samples.append((time.perf_counter() - t) * 1000)
        pages = d.execute("PRAGMA page_count").fetchone()[0] * d.execute("PRAGMA page_size").fetchone()[0]
        d.close()
    return statistics.median(samples), pages / (1024 * 1024)


setups = [
    ("0 indexes", []),
    ("1 index (author)", ["CREATE INDEX i1 ON books(author)"]),
    ("3 indexes (author, genre+year, title)", [
        "CREATE INDEX i1 ON books(author)",
        "CREATE INDEX i2 ON books(genre, year)",
        "CREATE INDEX i3 ON books(title)",
    ]),
]
base = None
for name, sqls in setups:
    ms, mb = insert_time(sqls)
    base = base or ms
    print(f"{name:40s} insert 20,000 rows: {ms:8.1f} ms  (x{ms / base:.2f})   size after: {mb:6.2f} MB")
```

---

## 8. クエリを追加、レビューする前のチェックリスト

1. このテーブルは1年後に何行になる見込みで、その規模で試したか。
2. クエリプランを読んだか。最初の行は`SCAN`か`SEARCH`か。
3. 絞り込みの条件は、行の何%くらいを除外するか。
4. クエリは列を関数で包んでいないか、LIKEの先頭に`%`を置いていないか。
5. 複数列インデックスなら、先頭の列は実際のクエリに現れるか。
6. プランの括弧は、インデックスがどの条件を使っていることを示しているか。
7. 新しいインデックスの書き込みコストは、よく変更されるこのテーブルで許容できるか。
8. 実際のどのクエリのプランにも現れなくなった古いインデックスはないか。外せるか。
9. AIがクエリを書いた場合、プランと想定する行数を貼ってもらったか。

---

## 9. バランスのよい見方

**かなり確か（測定または文書にある）：** インデックスは、10万行前後では、絞り込みの強いクエリを数十倍速くできる。インデックスが効くかどうかは、選択性、列の順番、条件の書き方で決まる。インデックスは書き込みを遅くし、領域を使う。

**注意が必要：**

- 実験は、1台のマシンで、メモリ上で、均等に分布する乱数のデータで行いました。実際のデータは偏ることが多く（少数の著者が非常に多くの本を持つなど）、ディスク、キャッシュ、同時アクセスで結果は変わります。
- SQLiteはPostgreSQLやMySQLではありません。それぞれ独自のプランナー、LIKEの規則、インデックスの種類、最適化があります（たとえばPostgreSQL 18は場合によって先頭の列を飛ばせます）。今回PostgreSQLを直接は試しておらず、それに関する記述は文書によるものです。
- この記事は、プランナーの統計（`ANALYZE`）、部分インデックス、より深いカバリングインデックス、キャッシュ、テーブルのディスク上の保存のされ方には触れていません。次に学ぶとよい題材です。
- 79倍という数字は、この実験のものです。自分のシステムへの約束として使わないでください。

**大げさにしない：** インデックスは、遅いクエリすべてを治す薬ではありません。遅い原因が、行や列を取りすぎていること、`LIMIT`がないこと、テーブル設計にあることもあります。**軽く見ない：** 大きなテーブルでは、クエリプランを見ないことが、直しやすい原因のなかで最も安上がりなことが多いのです。

---

## 10. まとめ

インデックスはデータベースのカード目録です。質問が並べ方に合っていればとても速く、合っていなければ役に立たず、保つにはいつも手間がかかります。正しく使うには、3つの習慣が必要です。クエリプランを読むこと、条件がどれだけの行を除外するかを問うこと、付けた後にまた測ること。AIが書いたクエリには、同じプランを証拠として求めてください。これらはどんな道具よりも長く役に立ちます。

先に進みたい方へ：[AIが書いたコードのレビュー](https://www.stacklogs.io.vn/ja/gijutsu/ai-ga-kaita-code-no-review/)には変更のチェックリストがあり、[AIが書いたコードのテスト](https://www.stacklogs.io.vn/ja/gijutsu/ai-ga-kaita-code-no-test-tsuneni-midori-wo-sakeru/)は、クエリを直すときに守ってくれるテストを持つのに役立ちます。

---

## 参考資料

この記事の測定は私がSQLite 3.53.4（Python 3.14.8、macOS arm64）で行ったもので、お使いのマシンでは違うかもしれません。以下の文書は、記事で述べた点を確かめるために開きました。ITviecの数字は検索結果を通じてのみ確認し、元の報告書は開いていません。

- SQLite, [The SQLite Query Optimizer Overview](https://www.sqlite.org/optoverview.html)（複数列インデックスを左から右へ使う規則、LIKEとGLOBの最適化の条件）
- SQLite, [EXPLAIN QUERY PLAN](https://www.sqlite.org/eqp.html)（SCAN、SEARCH、USING INDEXの意味、形式が変わりうるという注意）
- PostgreSQL, [Multicolumn Indexes](https://www.postgresql.org/docs/current/indexes-multicolumn.html)と[Introduction to indexes](https://www.postgresql.org/docs/current/indexes-intro.html)（バージョン18の文書）
- Markus Winand, [Use The Index, Luke: Concatenated Indexes](https://use-the-index-luke.com/sql/where-clause/the-equals-operator/concatenated-keys)
- ITviec, [ベトナムのIT採用市場の概況 2025年Q3](https://itviec.com/blog/vietnam-it-job-market-q3-2025-shifting-skill-requirements/)（検索結果を通じてのみ確認。自社プラットフォームの求人だけを測定）
