はじめに
索引を作ったことと、その索引が使われていることは別です。使われるかどうかは、問い合わせのたびに SQLite の実行計画(クエリプランナ)が決めます。
この記事では、索引が使われているかを確かめる方法を、手元の SQLite での実験と、Cloudflare D1 で使える道具の両方で紹介します。
-
EXPLAIN QUERY PLANで実行計画を見る -
PRAGMA optimize/ANALYZEで統計を作り、中身を読む - D1 の応答に含まれる「読んだ行数」(
meta.rows_read)を見る - D1 の問い合わせ単位の集計から、重い問い合わせを探す
この記事の要点
- 索引が使われるかは実行計画が決める。作ったら
EXPLAIN QUERY PLANで使われていることを確かめる - 部分索引は、問い合わせの条件が索引の条件を含意するときだけ使える
-
LIMITは該当0件の問い合わせを止めない。D1 ならmeta.rows_readで1回に読んだ行数が分かる
環境
| 項目 | 内容 |
|---|---|
| 手元の実験 | SQLite 3.50.4(Python 3.13.7 付属の sqlite3)・メモリ上のデータベース |
| D1 | Cloudflare D1(SQLite 互換)・Workers から利用 |
| データ | 20万行の架空の表(下のコードで作成) |
1. 実験用の表を作る
20万行の表を作ります。7割が active、画像の URL は6割が空です。
import random, sqlite3, time
random.seed(42)
con = sqlite3.connect(":memory:")
con.execute("""CREATE TABLE items (
id INTEGER PRIMARY KEY, status TEXT NOT NULL, url TEXT, image_url TEXT,
updated_at TEXT NOT NULL, processed_at TEXT)""")
rows = []
for i in range(200_000):
status = "active" if random.random() < 0.7 else "closed"
url = f"https://example.com/{i}" if random.random() < 0.95 else None
image = None if random.random() < 0.6 else f"https://img.example.com/{i}.jpg"
upd = f"2026-0{random.randint(1, 8)}-{random.randint(10, 28)} {random.randint(10, 23)}:00:00"
processed = upd if random.random() < 0.999 else None
rows.append((i, status, url, image, upd, processed))
con.executemany("INSERT INTO items VALUES (?,?,?,?,?,?)", rows)
調べる問い合わせは「公開中で画像がまだ無いものを、更新日の新しい順に10件」です。
SELECT id, url FROM items
WHERE status = 'active' AND url IS NOT NULL AND url != ''
AND (image_url IS NULL OR image_url = '')
ORDER BY updated_at DESC LIMIT 10;
2. EXPLAIN QUERY PLAN で実行計画を見る
問い合わせの前に EXPLAIN QUERY PLAN を付けると、どう読むつもりかが返ります(SQLite: EXPLAIN QUERY PLAN)。
def plan(sql):
return [r[3] for r in con.execute("EXPLAIN QUERY PLAN " + sql)]
索引が無い状態の結果です。
plan: ['SCAN items', 'USE TEMP B-TREE FOR ORDER BY']
平均 18.96 ms
-
SCAN items… 表を先頭から最後まで読む -
USE TEMP B-TREE FOR ORDER BY… 条件に合う行を全部集めてから、一時的な木で並べ替える
欲しいのは10件だけですが、並べ替えるために該当行を全部集めるので、LIMIT 10 があっても表全体を読みます。
3. 条件と並び順を持つ部分索引を作る
条件の一部(status・url)を WHERE に持ち、並び順(updated_at DESC)を列に持つ部分索引を作ります(SQLite: Partial Indexes)。
CREATE INDEX idx_items_active_updated ON items(updated_at DESC)
WHERE status = 'active' AND url IS NOT NULL AND url != '';
作った直後の結果です。
sqlite_stat1 表: まだ無い(ANALYZE を一度も実行していない)
plan: ['SCAN items USING INDEX idx_items_active_updated']
平均 0.01 ms
計画が SCAN items USING INDEX … に変わり、USE TEMP B-TREE が消えました。索引を新しい順にたどり、条件に合う行が10件そろった時点で止まれるようになったためです。
この実験では、統計(次の章の sqlite_stat1)がまだ無い状態でも索引が使われました。ただ、どの索引を選ぶかは表の大きさや条件によって変わり、作った直後には前の計画のまま、ということもありえます。Cloudflare の D1 のドキュメントは、索引を作ったあとに PRAGMA optimize を実行するよう案内しています。どちらにしても、「作った」で終わらせず、実行計画で使われていることまで確かめるのが確実です。
4. PRAGMA optimize で統計を作り、中身を読む
con.execute("PRAGMA optimize")
print(con.execute("SELECT tbl, idx, stat FROM sqlite_stat1 ORDER BY idx").fetchall())
stat: [('items', None, '200000'), ('items', 'idx_items_active_updated', '132819 59')]
sqlite_stat1 は、プランナが索引を選ぶときに使う統計の表です(SQLite: The sqlite_stat1 table)。
-
('items', None, '200000')… 表itemsはおよそ20万行 -
('items', 'idx_items_active_updated', '132819 59')… この索引にはおよそ 132,819 行が入っていて(部分索引なので条件に合う行だけ)、updated_atが同じ値の行は平均およそ59行
2つ目の数は見積もりです。最近の SQLite の PRAGMA optimize は、時間をかけすぎないよう読む行数を絞って統計を作ります(SQLite: PRAGMA optimize)。
Cloudflare の D1 のドキュメントは、索引を作ったあとの PRAGMA optimize について「各表に ANALYZE を実行し、表と索引の統計を集める」と説明しています(D1: Use indexes)。
5. 部分索引が候補にならない条件
部分索引は、問い合わせの WHERE が索引の WHERE を含意すると証明できる場合にだけ使えます。条件を少し変えて試しました。
-- url が NULL の行も「OR image_url があれば」対象になる。索引の条件(url IS NOT NULL)を含意しない
SELECT id, url FROM items
WHERE status = 'active' AND (url IS NOT NULL OR image_url IS NOT NULL)
ORDER BY updated_at DESC LIMIT 10;
plan: ['SCAN items', 'USE TEMP B-TREE FOR ORDER BY']
この問い合わせは「url が無いが image_url がある行」も対象にします。その行は索引に入っていないので、索引だけでは答えられません。プランナは索引を使わず、表全体を読む計画に戻りました。
部分索引は、問い合わせの条件が索引の条件と同じ形(またはそれより狭い形)で書けるときに頼るものです。問い合わせ側の条件を変えたら、実行計画を見直します。
6. LIMIT は「該当0件」の問い合わせを止めない
LIMIT で読むのを止められるのは、条件に合う行が指定の件数見つかったときです。該当が0件なら、最後まで読んでから0件を返します。次の2つを比べました。
-- 該当0件(どの行にも当てはまらない)
SELECT id FROM items WHERE status = 'archived' AND processed_at IS NULL LIMIT 10;
-- 該当が多い(先頭近くで10件そろう)
SELECT id FROM items WHERE status = 'active' LIMIT 10;
該当件数: 0
plan: ['SCAN items']
平均 8.67 ms
(比較)該当が多い問い合わせ plan: ['SCAN items'] 平均 0.004 ms
どちらも計画は同じ SCAN items ですが、該当の多い問い合わせは先頭の数十行で10件そろって止まり、該当0件の問い合わせは20万行を読み切っています。時間にして2,000倍以上の差です。
「処理待ちのものを探す」ような定期実行は、仕事が片付くほど該当が0件に近づき、毎回最後まで読む問い合わせになります。結果(0件)は正しく、エラーも出ないので、実行計画だけ見ていても気づけません。次の章の「読んだ行数」を見る理由がここにあります。
7. D1 では「読んだ行数」が応答に入っている
Cloudflare D1 は、問い合わせの結果と一緒に meta を返し、そこに読んだ行数 rows_read と書いた行数 rows_written が入っています(D1: Return objects)。
// Workers の中で
const r = await env.DB.prepare(
"SELECT user_id, expires_at FROM sessions WHERE token_hash = ?"
).bind(hash).all();
console.log(r.meta.rows_read, r.meta.rows_written);
wrangler d1 execute --json でも同じ値が見られます。D1 のドキュメントも、meta.rows_read と meta.rows_written を見て費用の大きい問い合わせを探すよう案内しています(D1: Use indexes)。
実行計画は D1 でも同じように見られます。
CREATE INDEX idx_sessions_token_hash ON sessions(token_hash);
EXPLAIN QUERY PLAN
SELECT user_id, expires_at FROM sessions WHERE token_hash = ?;
-- detail: SEARCH sessions USING INDEX idx_sessions_token_hash (token_hash=?)
SEARCH … USING INDEX は、索引で目的の行へ直接たどる計画です。SCAN(全件)とは読む量が桁で違います。D1 の料金は、読んだ行数・書いた行数・保存量の3つで決まります。読んだ行数には、返した行数ではなく走査した行数が数えられます(D1: Pricing)。そのため、rows_read はそのまま費用の指標にもなります。
8. 問い合わせ単位で「重い常用の問い合わせ」を探す
1回ずつ見るのではなく、1日分をまとめて見るなら、Cloudflare の GraphQL Analytics API の d1QueriesAdaptiveGroups を使います(D1: Metrics and analytics)。問い合わせの文ごとに、回数と読んだ行数が集計されます。
query ($a: String!, $d: Date!) {
viewer {
accounts(filter: { accountTag: $a }) {
d1QueriesAdaptiveGroups(
limit: 25
filter: { date: $d }
orderBy: [sum_rowsRead_DESC]
) {
count
sum { rowsRead }
dimensions { query databaseId }
}
}
}
}
rowsRead ÷ count が1回あたりの読み取り行数です。これが大きく、しかも回数が多い問い合わせが「索引の効いていない常用の問い合わせ」です。D1 は問い合わせの文を記録しますが、バインドした値は記録しません。手元で確かめるなら wrangler d1 insights も使えます。毎日の監視なら、たとえば次のような判定で拾えます。
// 1回5,000行以上を1日200回以上、または1本で1日5,000万行以上
const PER_CALL = 5000, CALLS = 200, SINGLE = 50_000_000;
const hot = groups.filter((g) => {
const n = g.sum?.rowsRead || 0, c = g.count || 1;
return (n / c >= PER_CALL && c >= CALLS) || n >= SINGLE;
});
しきい値は表の大きさと利用規模で変わるので、自分の環境の平常時の値を見てから決めてください。データベース単位の合計だけでは「どの問い合わせか」まで分からないので、問い合わせ単位で見るのが近道です。
よくある質問
索引を作ったのに SCAN のままなのはなぜ?
よくある原因は3つです。問い合わせの条件が部分索引の条件を含意していない(第5章)、列を関数で包んでいて普通の索引が使えない、統計が無いか古い(第4章)。EXPLAIN QUERY PLAN の結果を見ながら、1つずつ確かめます。
SCAN と SEARCH はどう違う?
| 表示 | 意味 | 読む量 |
|---|---|---|
SCAN 表 |
表を先頭から読む | 全件(LIMIT で早く止まることはある) |
SCAN 表 USING INDEX 索引 |
索引の順にたどる | 索引に入っている行まで |
SEARCH 表 USING INDEX 索引 (列=?) |
索引で目的の行へ直接たどる | 該当する行の近くだけ |
USE TEMP B-TREE FOR ORDER BY |
並べ替えのために行を集める | 条件に合う行を全部 |
まとめ
- 索引が使われるかは、実行計画が決める。作ったら
EXPLAIN QUERY PLANで確かめる - 実行計画の読み方は3種類。
SCAN 表(索引なし)は全件を読む。SCAN 表 USING INDEX 索引は索引の順にたどる(部分索引なら条件に合う行だけで、LIMITの件数がそろえば止まる)。SEARCH 表 USING INDEX 索引 (列=?)は索引で目的の行へ直接たどる。USE TEMP B-TREE FOR ORDER BYは並べ替えのために行を集めている印 - 索引を作ったら
PRAGMA optimize(D1 の公式の案内)。統計はsqlite_stat1で読める - 部分索引は、問い合わせの条件が索引の条件を含意するときだけ使える
-
LIMITは、該当0件の問い合わせを止めない - D1 なら
meta.rows_readで1回の読み取り行数、d1QueriesAdaptiveGroupsで1日の問い合わせ単位の集計が見られる
参考
- SQLite: EXPLAIN QUERY PLAN
- SQLite: Partial Indexes
- SQLite: The sqlite_stat1 table
- SQLite: PRAGMA optimize
- Cloudflare D1: Use indexes
- Cloudflare D1: Return objects
- Cloudflare D1: Metrics and analytics
- Cloudflare D1: Pricing
筆者(合同会社My Agent Works)は、宅建士試験の学習アプリ「宅建BOOST」(https://takken-boost.jp/)を開発しています。開発で得た知見を今後も投稿します。
X: https://x.com/takkenboost