1
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?

SQLのフルスキャンとインデックスを理解する

1
Posted at

はじめに

普段の業務ではAndroidアプリをメインに担当していますが、この度サーバ側の設計をすることがありました。
その際に「フルスキャン」になっていないか確認する、「インデックス」を追加するというお話が出てきて、あまり意識したことのない領域だったので、改めて調べてみました。

フルスキャン(Full Table Scan)とは

テーブルの 全行を先頭から末尾まで順番に読み取る 検索方法のこと。

-- shopsテーブルが100万件あるとする
SELECT * FROM shops WHERE name = 'かふぇ';

->インデックスがない場合、このクエリは 100万行すべてをスキャン してから「かふぇ」を探す。

このようなことが原因で、「クエリが遅い」「DBの負荷が高い」という問題に当たってしまう。

インデックスとは

テーブルとは別に作られる 「目次」のようなデータ構造
特定のカラムの値と、その行がどこにあるかの対応表を持っている。

インデックスなし:1,000万行を全件チェック
インデックスあり:木構造を辿って絞り込む(データが2倍になっても、探す手順は1回増えるだけ)

実際に起こり得る例

ECサイトの注文検索

-- 管理画面から特定ユーザーの注文を検索する
SELECT * FROM orders WHERE user_id = 12345;

orders テーブルに1,000万件のデータがあり、user_id にインデックスがない場合:

  • DBは1,000万行を全件スキャン
  • ページ表示に数秒〜数十秒かかる
  • 同時アクセスが増えるとDBサーバーのCPU/IOが逼迫する

ログ検索

-- 直近1時間のエラーログを取得する
SELECT * FROM app_logs WHERE level = 'ERROR' AND created_at >= '2026-01-01 10:00:00';

created_at にインデックスがなければ、ログが積み上がるほどクエリが遅くなっていく。


インデックスを作ると何が変わるか

-- インデックスを追加する
CREATE INDEX idx_orders_user_id ON orders(user_id);

追加後、同じクエリを実行すると:

SELECT * FROM orders WHERE user_id = 12345;

DBはインデックスを使って user_id = 12345 の行を 直接ピンポイントで特定 できるようになる。
1,000万件あっても、アクセスする行数は劇的に減る。


フルスキャンが起きる典型パターン

インデックスを貼っていても、書き方によってはインデックスが効かずフルスキャンになることがある。

パターン1:カラムに関数を使う

-- ❌ インデックスが効かない
SELECT * FROM users WHERE YEAR(created_at) = 2026;
 
-- ✅ インデックスが効く
SELECT * FROM users WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';

パターン2:LIKE の前方一致以外

-- ❌ 前方があいまい → フルスキャン
SELECT * FROM products WHERE name LIKE '%シャツ';
 
-- ✅ 前方一致 → インデックスが効く
SELECT * FROM products WHERE name LIKE 'ドライ%';

パターン3:型が暗黙変換される

-- user_id が INT型のとき
-- ❌ 文字列で渡すと型変換が走りインデックスが効かないことがある
SELECT * FROM users WHERE user_id = '12345';
 
-- ✅ 型を合わせる
SELECT * FROM users WHERE user_id = 12345;

インデックスのトレードオフ

インデックスは万能ではない。追加するとき・しないときを判断できるようにしておく。

内容
メリット SELECT(特に絞り込み)が高速になる
デメリット① INSERT / UPDATE / DELETE が遅くなる(インデックスの更新コストがかかる)
デメリット② ストレージ容量(ディスク・メモリ)を消費する
インデックスは元のテーブルデータとは完全に別のオブジェクトとして作成・保存される
向いているカラム WHERE句やJOINで頻繁に使うカラム。カーディナリティ(値の種類数)が高いカラム(user_id、emailなど)ほど効果が高い
向いていないカラム カーディナリティが低いカラム(削除フラグの0/1など)。絞り込み効果が薄くコストだけかかる

EXPLAINで確認する

クエリがフルスキャンになっているかどうかは EXPLAIN で確認できる。

EXPLAIN SELECT * FROM orders WHERE user_id = 12345;
  • typeALL → フルスキャン(要注意)
  • typerefrange → インデックスが効いている
  • rows の数字が小さいほど効率的

AndroidのRoomでは?

  • Roomでのインデックスについて確認してみる
    • Roomについては割愛
  • RoomはKotlin/Javaのアノテーションを内部でSQLiteコマンドに変換する
  • @Entity の indices パラメータでインデックスを定義すると、CREATE INDEX 文が自動生成される

単一カラムインデックス

WHERE句で頻繁に参照するカラムに貼る基本形。

@Entity(indices = [Index(value = ["cafe_name"])])
data class Cafe(
    @PrimaryKey val id: Long,
    val cafe_name: String,
    val address: String
)

複合インデックス

複数カラムで同時に絞り込む場合に有効。ただし左から右のルールがあり、["cafe_name", "address"]cafe_name 単独または cafe_name AND address には効くが、address 単独には効かない。

@Entity(indices = [Index(value = ["cafe_name", "address"])])
data class Cafe(...)

ユニーク制約

unique = true を設定すると、重複データの書き込みを禁止できる。cafe_nameaddress の組み合わせで一意のキーになる。

@Entity(indices = [Index(value = ["cafe_name", "address"], unique = true)])
data class Cafe(...)

Entityのクラス継承について
親クラスのインデックスは子に引き継がれない
inheritSuperIndices = true を明示的に指定する必要がある
これまであまり使用する機会はなかったが覚えておくと良さそう

// 親クラス側は @Entity アノテーションをつけてindicesを定義
@Entity(indices = [Index(value = ["date"])])
open class BaseEntity(
    var date: Long = System.currentTimeMillis()
)

// 子クラスで inheritSuperIndices = true を指定
@Entity(tableName = "products", inheritSuperIndices = true, primaryKeys = ["id", "userId"])
data class Product(
    val id: Int,
    val userId: Int,
    val name: String,
    ...
) : BaseEntity() // dateはBaseEntityから継承される

マイグレーション

  • 本番リリース済みのアプリにインデックスを追加する場合は、DBバージョンを上げてマイグレーションオブジェクトを書く必要がある
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("CREATE INDEX IF NOT EXISTS index_shops_cafe_name ON shops(cafe_name)")
    }
}

AndroidでのDBのデバッグ

RoomにはEXPLAINの専用機能はないが、@Query にそのまま書けるのでDAOから実行できる。

// 結果を受け取るデータクラス
data class ExplainResult(
    val id: Int,
    val parent: Int,
    val notused: Int,
    val detail: String  // "SCAN shops" → フルスキャン / "SEARCH shops USING INDEX ..." → インデックスが効いている
)
 
@Dao
interface ShopDao {
    @Query("EXPLAIN QUERY PLAN SELECT * FROM shops WHERE cafe_name = :name")
    fun explainQuery(name: String): List<ExplainResult>
}

EXPLAIN QUERY PLANの出力の読み方について、詳しくはSQLite公式ドキュメントを参照。

Android Studio の Database Inspector からも直接クエリを叩いて確認できるので、手軽に試したい場合はこちらが便利。

参考

まとめ

  • フルスキャンは全行を読み取るため、データ量が増えると線形に遅くなる
  • インデックスを使うと、データが増えても探索回数がわずかしか増えない構造で検索できる
  • ただしインデックスは更新コスト・容量のトレードオフがある
  • 関数適用・LIKE前方以外・型の不一致はインデックスを無効化するので注意
  • MySQLでは EXPLAIN、RoomではDatabase Inspectorや EXPLAIN QUERY PLAN でフルスキャンかどうかを確認できる
  • 実際の計測は別途試してみたい
1
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
1
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?