はじめに
日時カラムから、特定の日付のデータだけを取得したいことがあります。
そのとき、次のように書くと分かりやすいです。
WHERE DATE(created_at) = '2026-07-01'
しかし、created_atにインデックスを設定していても、この書き方ではインデックスの範囲検索を利用しにくくなります。
そこで今回は、次の2種類のSQLを比較します。
WHERE DATE(created_at) = '2026-07-01'
WHERE created_at >= '2026-07-01 00:00:00'
AND created_at < '2026-07-02 00:00:00'
今回は実行時間の細かな数値よりも、EXPLAIN ANALYZEに表示されるアクセス方法の違いに注目します。
検証条件
- MySQL 8.4を対象
- InnoDB
- データ件数:100,000件
-
created_atに通常のインデックスを設定
EXPLAIN ANALYZEはSQLを実際に実行し、実行計画に加えて、実測時間、実際に返した行数、ループ回数などをTREE形式で表示します。
テーブルを作る
次のordersテーブルを使います。
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
amount INT NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
INDEX idx_orders_created_at (created_at)
) ENGINE=InnoDB;
created_atには、次のインデックスが設定されています。
INDEX idx_orders_created_at (created_at)
10万件のデータを作る
まず、0から9までの数字を持つ一時テーブルを作ります。
CREATE TEMPORARY TABLE digits (
n TINYINT UNSIGNED NOT NULL PRIMARY KEY
);
INSERT INTO digits (n)
VALUES
(0), (1), (2), (3), (4),
(5), (6), (7), (8), (9);
このテーブルを5回直積し、0から99,999までの連番を作ります。
INSERT INTO orders (amount, created_at)
SELECT
100 + MOD(seq.n, 9901),
TIMESTAMP('2026-05-01 00:00:00')
+ INTERVAL seq.n MINUTE
FROM (
SELECT
d0.n
+ d1.n * 10
+ d2.n * 100
+ d3.n * 1000
+ d4.n * 10000 AS n
FROM digits AS d0
CROSS JOIN digits AS d1
CROSS JOIN digits AS d2
CROSS JOIN digits AS d3
CROSS JOIN digits AS d4
) AS seq;
これで、2026年5月1日から1分刻みのデータが10万件作られます。
統計情報も更新しておきます。
ANALYZE TABLE orders;
件数を確認します。
SELECT COUNT(*) FROM orders;
結果は10万件です。
100000
取得結果が同じか確認する
まずは、2つのSQLが同じ結果を返すことを確認します。
DATE()を使う場合
SELECT COUNT(*)
FROM orders
WHERE DATE(created_at) = '2026-07-01';
結果は1,440件です。
1440
範囲検索を使う場合
SELECT COUNT(*)
FROM orders
WHERE created_at >= '2026-07-01 00:00:00'
AND created_at < '2026-07-02 00:00:00';
こちらも1,440件です。
1440
取得結果は同じです。
それでは、データの探し方に違いがあるのかを調べます。
DATE(created_at)を調べる
次のSQLをEXPLAIN ANALYZEで確認します。
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE DATE(created_at) = '2026-07-01';
このSQLでは、各行のcreated_atに対してDATE()を評価します。
通常のインデックスはcreated_atの値に対して作られていますが、検索条件では加工後のDATE(created_at)を比較しています。
そのため、元のインデックスから対象日の開始位置と終了位置を直接決める条件にはなっていません。
実行計画では、次のような部分を確認します。
Filter: (date(created_at) = ...)
-> Table scan on orders
SELECTするカラムやオプティマイザの判断によっては、テーブルスキャンではなく、インデックス全体を読むIndex scanになることもあります。
ただし、いずれの場合も、対象日の区間だけを読むIndex range scanとは異なります。
範囲検索を調べる
続いて、日時をそのまま比較するSQLです。
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE created_at >= '2026-07-01 00:00:00'
AND created_at < '2026-07-02 00:00:00';
こちらは、インデックスが設定されているcreated_atそのものを定数と比較しています。
MySQLのB-treeインデックスでは、インデックスのキーと定数を>=や<で比較する条件を、範囲条件として利用できます。
実行計画では、次のような表示を確認します。
Index range scan on orders
using idx_orders_created_at
この場合は、インデックス全体を先頭から確認するのではなく、2026年7月1日に対応する範囲を探せます。
比較結果
結果を簡単にまとめると、次のようになります。
| SQL | 取得件数 | 主なアクセス方法 |
|---|---|---|
DATE(created_at) = '2026-07-01' |
1,440件 | テーブル全体またはインデックス全体を走査して絞り込み |
created_at >= ... AND created_at < ... |
1,440件 |
created_atのインデックス範囲検索 |
返されるデータは同じでも、データの探し方が異なります。
DATE(created_at)は読みやすい一方、通常のcreated_atインデックスを直接的な範囲検索に利用しにくい書き方です。
範囲条件に書き換えることで、検索対象となるインデックス区間を明確にできます。
なぜ23:59:59までにしないのか
次のようにも書けそうです。
WHERE created_at BETWEEN
'2026-07-01 00:00:00'
AND '2026-07-01 23:59:59'
しかし、カラムが小数秒を持つ場合、次のようなデータを取りこぼす可能性があります。
2026-07-01 23:59:59.123456
そのため、翌日の開始時刻を含めない半開区間で書く方が安全です。
WHERE created_at >= '2026-07-01 00:00:00'
AND created_at < '2026-07-02 00:00:00'
「7月1日の最後の時刻」を指定するのではなく、「7月2日より前」と考えます。
インデックスがあっても必ず使われるとは限らない
インデックスが存在しても、MySQLが必ずインデックスを使うとは限りません。
例えば、次のような場合です。
- テーブルが非常に小さい
- 条件に一致する行が多い
- インデックスを使うより全件走査の方が安い
- 統計情報が古い
MySQLの公式マニュアルでも、小さいテーブルでは全件走査が適切な場合があると説明されています。
そのため、
インデックスがある = 必ず速い
ではありません。
実際にどのような方法でデータを取得しているかを、EXPLAIN ANALYZEで確認することが重要です。
まとめ
今回のポイントは次のとおりです。
-
created_atにインデックスを設定しても、SQLの書き方によって使われ方が変わる -
DATE(created_at)は、通常のcreated_atインデックスによる直接的な範囲検索になりにくい - 日付検索は、開始時刻以上かつ翌日の開始時刻未満で書ける
- 実行時間だけでなく、
Table scan、Index scan、Index range scanの違いを見る - 小さいテーブルでは、範囲条件でも全件走査が選ばれる場合がある
インデックスを貼ったのにSQLが遅いときは、インデックスの有無だけでなく、WHERE句でカラムを加工していないかも確認した方がよさそうです。