2026年のRoomデータベースインデックス:本当に遅いクエリを見つけて直す
AndroidでRoom/SQLiteデータベースにインデックスを張る実践ガイド — EXPLAIN QUERY PLANの読み方、勘に頼らない@Indexの追加、インデックスを静かに無効化してしまう間違い。
テストでは一瞬で動いていたRoomベースのアプリが、数ヶ月後にカクつき始めることがある。実際のユーザーが、検証用に入れた12件ではなく1,000件の取引データを持つようになったときだ。よくある直感的な対応は、どこかにインデックスを追加してうまくいくことを願うことだが、それが効く確率と効かない確率はほぼ同じだ。なぜならインデックスはテーブル全体を速くするのではなく、特定のアクセスパターンを速くするものであり、間違ったインデックスは読み取りの恩恵をまったく生まずに書き込みコストだけを増やすからだ。ここでは、本当に遅いクエリを見つけ、それを確認し、正しくインデックスを張る方法を説明する。
推測するな — 計測せよ
SQLiteは、頼めばクエリをどう実行するつもりかを正確に教えてくれる。任意のクエリの先頭にEXPLAIN QUERY PLANを付け、adb shell経由か、Roomの生クエリで実行する。
@RawQuery
fun explain(query: SupportSQLiteQuery): List<ExplainRow>
EXPLAIN QUERY PLAN
SELECT * FROM transactions WHERE category_id = 7 ORDER BY date DESC;
出力される1行がすべてを物語る。SCAN transactionsは、SQLiteがテーブルの全行を読み、それぞれで条件をチェックしていることを意味する — コストはテーブルサイズに比例して線形に増える。SEARCH transactions USING INDEX idx_transactions_category (category_id=?)は、一致する行に直接ジャンプしていることを意味する。インデックス作成のすべては、実際に頻繁に実行するクエリについてSCANをSEARCHに変え、それ以外はそのままにしておくことに尽きる。
ここで避けるべき間違いは、「重要そうに感じる」列を基準にインデックスを張ることだ。notes列がフィルタされることは滅多にないが、すべてのリスト画面のWHERE句で使われるcategory_idはまったく話が別だ。何かに手を付ける前に、最も頻繁に実行される5〜6個のDAOクエリ — メインのリスト画面を構成し、アプリ起動のたびに走るもの — に対してEXPLAIN QUERY PLANを実行しよう。
Roomでインデックスを追加する
RoomはSQLiteのCREATE INDEXを、@Entityアノテーションのindicesパラメータ経由で公開している。
@Entity(
tableName = "transactions",
indices = [Index(value = ["category_id"]), Index(value = ["date"])],
)
data class Transaction(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val categoryId: Long,
val date: Long,
val amountCents: Long,
)
これはスキーマ変更なので、列を追加するときと同様にMigrationが必要になる。
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id)")
db.execSQL("CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date)")
}
}
Roomのスキーマエクスポート機能(exportSchema = trueとコンパイラ引数room.schemaLocation)は、@Entityのインデックスと手書きのマイグレーションがずれている場合にそれを検知してくれる — 通常、このバグは公開前にこうやって捕まる。
複合インデックスの罠
category_idでフィルタしdateでソートするクエリ — まさに上記のパターン — は、2つの単一列インデックスを別々に用意しても十分な恩恵を受けられない。SQLiteは多くの場合、1つのテーブルにつき1つのインデックスしかクエリで使えないため、より選択性の高い方を選び、結果はメモリ上でソートする羽目になる。両方の列を正しい順序でカバーする複合インデックスなら、SQLiteはフィルタにインデックスを使いつつ、すでにソート済みの行を返せる。
indices = [Index(value = ["category_id", "date"])]
ここでは順序が重要だ。このインデックスはWHERE category_id = ?とWHERE category_id = ? ORDER BY dateの両方に使える。両条件ともインデックスを左から右に読むからだ。dateだけでフィルタするクエリには効かない — それにはdate単独の列インデックス、またはdateを先頭に置いた別の複合インデックスが必要になる。複合インデックスを追加した後は、もう一度EXPLAIN QUERY PLANを確認しよう。プランにまだUSE TEMP B-TREE FOR ORDER BYが出ているなら、列の順序がクエリの要求と一致していない。
インデックスはタダではない
SQLiteが維持する各インデックスは、インデックス対象の列に触れるINSERT、UPDATE、DELETEのたびに更新されなければならない。予算管理アプリのtransactionsのようなテーブルでは、書き込みは読み取りに比べて相対的に少ない — 1日にわずかな追加に対して、リスト表示は何十回も走る — のでトレードオフは容易だ。一方、絶えず書き込まれ、めったに読まれないテーブル(イベントログ、同期キューなど)では、transactionsテーブルを助けた同じ3つのインデックスが、誰も気づかないような読み取りの恩恵のために、書き込みを計測可能なほど遅くしてしまうことがある。スキーマ内のすべてのテーブルではなく、画面が開くたびにクエリされるテーブルをインデックスしよう。
主キーにはすでに暗黙のインデックスが付いている — それをindicesにもう一度含めるのは冗長だ。@PrimaryKeyが付いた列や、別のインデックスですでにunique = trueを宣言した列も同様で、SQLiteが裏側のインデックスを自動的に作成する。
実際に重要になる場面
これは地味な形で、Granynで見つけた。数百行のうちは一瞬で表示されていた取引リストが、実際の利用が数千行を超えたところで、カテゴリでフィルタするたびに目に見える遅延が出始めた。DAOクエリにEXPLAIN QUERY PLANをかけると完全なSCANが出ていた — category_idフィルタが使えるインデックスがなかったのだ。上記の複合インデックスを追加すると、フィルタ済みクエリは全テーブルスキャンからインデックス検索に戻り、カクつきは消えた。アーキテクチャの変更もなく、新しいライブラリもなく、マイグレーションに正しい2行を加えただけだ。
この教訓はこのテーブル1つにとどまらない。ローカルファーストのアプリが遅くなったとき、ページネーションやキャッシュ、書き直しに手を出す前に、本当に遅いクエリにEXPLAIN QUERY PLANをかけてみよう。ほとんどの場合、解決策はインデックスであり、それは小さく、間違えても元に戻せる。
// 関連記事
ジャーナルの他の記事
2026年のRoom TypeConverters: enum・日付・リストをスキーマを壊さずに保存する
AndroidのRoom TypeConvertersに関する実践ガイド — enum、Instant/LocalDate、そしてリスト — さらに、コンバーターを静かなデータ破損バグに変えてしまう間違いについて。
RoomのRelationで一対多データをN+1クエリなしに取得する
Roomの@Relationアノテーションの実践ガイド — カテゴリとエントリのような一対多データを、N+1クエリや手動のjoinなしでモデリングする。
Room の全文検索:2026 年、ローカルファーストな Android アプリに即時検索を追加する
Room の FTS4 サポートを実践的に解説 — 検索用の仮想テーブルを構築し、トリガーで同期を保ち、なぜ FTS5 には手動マイグレーションが必要なのか。