0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

D1の複合インデックスを逆順にすると何が変わるか、検索計画で比べた

0
Posted at

WHERE user_id = ? AND created_at >= ?で通知を検索する場合、(user_id, created_at)(created_at, user_id)は同じ働きにはなりません。

ローカルD1で比較すると、前者の検索計画にはユーザーと作成時刻の両方が探索条件として現れ、後者には作成時刻だけが現れました。調べたのは、インデックスのどの範囲を探す計画になるかです。レスポンスタイムは測っていません。

比較するクエリを先に固定する

SquadNoteの通知テーブルにあるユーザーと作成時刻を題材に、必要な列だけのテーブルを作りました。運用中のDBやインデックスを変更した実験ではありません。

CREATE TABLE notification (
  id INTEGER PRIMARY KEY,
  user_id TEXT NOT NULL,
  created_at INTEGER NOT NULL
);

日時は連続する整数で代用します。1から10,000までの行を作り、ユーザーを100種類に振り分けます。

WITH RECURSIVE seq(n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 10000
)
INSERT INTO notification(id,user_id,created_at)
SELECT n, 'user-' || (n % 100), n FROM seq;

比較対象のSELECTは変えません。

SELECT id FROM notification
WHERE user_id = 'user-42' AND created_at >= 9000
ORDER BY created_at DESC;

user_idは等価条件、created_atは範囲条件と並び順です。どちらの列が一般に重要かを決めるのではなく、このクエリに対する列順を比べます。

WranglerのローカルDBで実行する

検証日は2026年9月11日、Node.js v24.15.0、Wrangler 4.81.1です。再現用のSQLと設定を使います。

npx wrangler@4.81.1 d1 execute article-index-lab --local \
  --config experiments/article-stock-2026-09/d1-index/wrangler.jsonc \
  --file experiments/article-stock-2026-09/d1-index/compare.sql \
  --persist-to /tmp/article-stock-d1-index-lab --json

再現SQLは実験用テーブルを作り直します。--localと専用の保存先を指定し、本番DBへ接続しない構成です。設定内のDB IDもローカル実験用のダミー値です。公式CLIリファレンスに、--local--persist-toの役割が記載されています。

SQLite内部のバージョンを調べるためにsqlite_version()も試しましたが、この環境では関数の利用を拒否されました。内部版の値は取得できていません。

indexなし、等価条件が先、範囲条件が先を比べる

SELECTの前にEXPLAIN QUERY PLANを付け、インデックスを一つずつ入れ替えました。

CREATE INDEX idx_user_created ON notification(user_id, created_at);

逆順の場合はこの定義です。同時に両方を置いた比較ではありません。

CREATE INDEX idx_created_user ON notification(created_at, user_id);

結果のdetailは次のようになりました。

indexなし:
SCAN notification
USE TEMP B-TREE FOR ORDER BY

(user_id, created_at):
SEARCH notification USING COVERING INDEX idx_user_created (user_id=? AND created_at>?)

(created_at, user_id):
SEARCH notification USING COVERING INDEX idx_created_user (created_at>?)

インデックスなしでは全体を走査し、並べ替えにも一時的なB-treeを使う計画です。(user_id, created_at)はユーザーを絞った範囲内で時刻を探索します。逆順では時刻の範囲から探し、ユーザーの条件を残りの絞り込みとして扱います。

計画の表示はcreated_at>?ですが、実行したSQLはどちらもcreated_at >= 9000です。実際に返るIDも検証しています。

SQLiteのQuery Planningでは、複合インデックスが左側の列から並ぶ仕組みを説明しています。今回の差は、先頭列を等価条件で固定してから、その中の時刻範囲へ進めるかどうかで読むと理解できます。

COVERINGだけで列順の良し悪しを決めない

今回の二つの計画は、どちらもCOVERING INDEXでした。取得するのはidだけで、このテーブルのINTEGER PRIMARY KEYはrowidの別名です。必要な値をインデックスから取り出せる構成でも、探索する範囲が同じとは限りません。

したがって「COVERINGになったから、逆順でも同じ」とは判断できません。SEARCHの括弧の中に、どの条件が現れているかも読みます。

Cloudflareのインデックスの説明も、クエリに合わせたインデックスを確認する入口になります。ただし、今回は本番の読取り行数や課金への影響を計測していません。

返る行が同じことも確認する

両方のインデックスで10行が返り、IDの合計は94,920でした。さらに次のID列が同じ順序になることをassertで確認しました。

9942, 9842, 9742, 9642, 9542, 9442, 9342, 9242, 9142, 9042

検索計画を改善するつもりで、SELECTまで変更していないかを確認するためです。行数と合計だけでは異なる集合が一致する場合があるため、今回の小さな結果ではID列そのものも比べています。

このデータは均等に作った実験用の分布です。特定ユーザーに通知が偏る場合や、時刻だけを検索するクエリでは、同じ判断をそのまま適用できません。書込み時のインデックス更新コストも比較していません。

今回のクエリには、等価条件のuser_idを先に置く列順が、両方の条件を探索へ使う計画を作りました。別のクエリを検討するときも、列名だけで決めず、SELECTと検索計画と返る行を一組で比べます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?