綴Photo の勉強ノートに戻る
コードレビュー学習2026.06.11

外部キーを張っても、索引は付いてこない — SQLite と FK 列のインデックス

SQLiteDriftFlutterデータベースインデックス綴Photo

Claude の多段レビュー(7観点の並列レビュー+反証検証)で浮かんだ「外部キーの列にインデックスが無かった」話を、勉強のためにまとめたものです。

綴Photo のモバイル版は、写真と「綴り(気持ち)」をローカルの SQLite に保存していて、綴りテーブルの写真ID列は写真テーブルを指す外部キーになっている。レビューで分かったのは、その外部キーの列にインデックスが張られていなかったこと。やや直感に反するこの落とし穴をまとめておく。

コードで起きていたこと

SQLite は、主キーや UNIQUE 制約を付けた列には自動でインデックスを張ってくれます。ところが外部キーの列には自動でインデックスを張りません。綴Photo では、綴りテーブルの写真ID列がまさにこの「外部キーだがインデックスが無い列」だった。

行も索引も端末 SQLite のファイルに保存する。メモリにも prefs にも無い。主キー / UNIQUE制約を書いた列索引が自動で付く例: photos.id閉じても索引が残る外部キー制約を書いた列索引は付かないmeanings.photo_id がここ閉じても列だけ残る

写真の詳細画面は、この写真ID列で綴りを絞り込む(「この写真に紐づく綴りを全部出す」)クエリを watch していた。本棚の一覧画面も、写真テーブルと綴りテーブルを写真IDで突き合わせて1つの表にする(join)操作で表示していた。

行は端末 SQLite の meanings に残る。届く結果はメモリ。保存しない。詳細画面本棚関所 watchphoto_id で絞る。置き場所ではない関所 joinphotos と突き合わせ。置き場所ではない端末 SQLite meanings.photo_id索引が無い。全件走査閉じても行は残る

索引が無いと、SQLite はその場限りの一時インデックスを内部で構築することもあります。綴りの件数が少ないうちは問題にならないが、増えるほど効いてきます。しかも watch なので、データが変わるたびにこの走査が繰り返されます。1回読んで終わる SELECT なら「重くなったら気づく」で済むかもしれないが、watch は画面が開いている間ずっと張り続けるため、機会は何倍にも膨らみます。

なぜそうなりやすいか

主キーと UNIQUE に自動でインデックスが付くので、「制約を張った列には索引も付く」という感覚が育ちやすいです。外部キーも「制約」の一種なので、同じように索引も付いていると錯覚しがちです。これは SQLite に限らず多くのデータベースで共通の挙動だが、知らないと「結合に使っている列なのに遅い」原因として見落とします。

「親(写真)を起点に子(綴り)を引く」「親と子を結合する」というのは、外部キーがある関係ならほぼ必ずやる操作です。その操作で使う列にインデックスが無いと、関係の主要な使い道がそのまま遅い経路になります。外部キーを張ったら、その列で結合・絞り込みをするかどうかを考え、するならインデックスもセットで張ります。この2ステップはセットだが、「制約を書いた = 索引も付く」という先入観があると抜けやすいです。

改善の方向

綴りテーブルの写真ID列にインデックスを明示的に追加した。Drift ではテーブル定義の上に @TableIndex を書くと、新規インストール時のスキーマ作成にインデックスが含まれます。

dart
@TableIndex(name: 'idx_meanings_photo_id', columns: {#photoId})
class Meanings extends Table {
  // ...
}

新規インストールはこれで索引付きのテーブルが作られます。ただし、既にアプリを使っている人のデータベースには @TableIndex を書いただけでは索引が追加されません(テーブルは既に作られているので)。そこでスキーマのバージョンを一つ上げて、既存のデータベースにも索引を足すマイグレーションを書いた。

dart
// 既存DB向け: 索引が無ければ追加する
await customStatement(
  'CREATE INDEX IF NOT EXISTS idx_meanings_photo_id ON meanings(photo_id)',
);

IF NOT EXISTS を付けているのは、新規インストール側では @TableIndex 経由で既に同名の索引が張られているので、二重作成でエラーにしないためです。地味だが大事な辻褄合わせです。

索引は端末 SQLite の meanings に保存する。メモリにも prefs にも無い。今日入れた人使い続けている人関所 @TableIndex → createAllソースの定義。置き場所ではない関所 マイグレーション v9→v10IF NOT EXISTS の生 SQL。置き場所ではない端末 SQLite meaningsidx_meanings_photo_id が1つ閉じても残る。二重には作らない

学びポイント

  • 外部キー列に索引が自動で付かないのは、SQLite を含む多くのDBで共通
  • 外部キーを張ったら「その列で結合・絞り込みするか?」を考え、するならインデックスもセットで張る
  • @TableIndex(スキーマ定義)だけでは既存ユーザーのDBは更新されない。バージョンを上げてマイグレーションで CREATE INDEX IF NOT EXISTS を足す

この同じ綴りテーブルの外部キーには、もう一つ別の落とし穴があった。「宣言した外部キーが PRAGMA foreign_keys = ON 無しでは強制されていなかった」という話で、別記事「宣言しただけでは守られない」にまとめた。どちらも「スキーマに書いた/宣言しただけでは、思った挙動になっていない」という根は同じです。

この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。