あなたは評価プラットフォームを作った。
ユーザーに少し達成感を与えるために、慈悲深く leaderboard まで用意した。
そして DevTools を開き、API の応答時間を見た。3 秒、5 秒、10 秒。
ん?
信じられなくて、SQL ももう一度見直す。
LIMIT 100
上位 100 件だけのはずなのに、なぜここまで遅い?
問題は LIMIT ではない
これはよくある誤解です。
LIMIT 100 は、SQLite が 100 件しか処理しないという意味ではありません。
もし「上位 100」を探す前に、「条件に合う行」を全部集めてソートしないといけないなら……
あなたが言っているのは結局こうです:
「最後に 100 件だけ返してくれればいい。でもその前に必要なことは、全部やってね。」
クエリが遅いのはたいてい、SQLite に「いちばん愚直なやり方」しか残していないからです。
典型的なクエリ
多くの評価プラットフォームの schema は、だいたいこんな感じになります:
jobs:1 回の submit / 1 回の run の基本情報 (status、version、timestamp、queue)job_scores:実際の評価指標 (train / public / private split に分かれることもある)
SQL の例:
SELECT
j.id,
j.model_name,
s.split,
s.score1,
s.score2
FROM jobs j
JOIN job_scores s ON s.job_id = j.id
WHERE j.status = 'SUCCEEDED'
AND j.queue = :queue
AND s.split = :split
ORDER BY
s.score1 DESC,
s.score2 DESC,
j.created_at ASC
LIMIT :limit;
最後の j.created_at ASC が重要です。
これは とても筋の良い設計です: 同点なら時間を tie-breaker にして、ランキングが安定して跳ねないようにする。
でも SQLite から見ると、こういう要求になります:
いま欲しいのは「join あり・条件複数・複合ソート」の Top-N クエリです。
インデックスがズレていると、力技でやるしかありません。
インデックスが無いとき、SQLite はどう動く?
単純化すると、流れはだいたいこうです:
status = 'SUCCEEDED'かつqueue = ?の job を全部探すsplit = ?の score を join する- 結果を全部引っ張り出す
score1 → score2 → created_atでソートする- ソートしたあと 99% を捨てて、上位 100 件だけ残す
遅いのは気のせいではありません。
SQLite は本当に「全部読み、ひと息ついて、並べ替える」ことをやっています。
本当に効くインデックスの貼り方
インデックスは「貼れば速い」わけではありません。
leaderboard のような Top-N クエリを速くするには、正しい順序が 1 つだけあります。逆にすると壊れます:
- まず
WHEREで候補集合を一気に絞る - 次に
ORDER BY ... LIMITがインデックス順に先頭 N 件を吐けるようにする
ソートの段階で SQLite が「もう一回ソート」しているなら、LIMIT 100 はほとんど救ってくれません。必要なものはもう全部ソートし終わっているからです。
例として job_scores(指標テーブル)から見ます。
クエリの並び順がこうなら:
ORDER BY s.score1 DESC, s.score2 DESC, ...
インデックスも同じ並びを先に敷きます:
CREATE INDEX IF NOT EXISTS idx_scores_rank
ON job_scores (
split,
score1 DESC,
score2 DESC
);
このインデックスが狙う効果:
split = ?で欲しいグループに絞る- 絞ったあと、行はインデックス内ですでに
score1 → score2の順になっている - SQLite はインデックスを順に辿るだけで、上位 100 の候補をすぐ取れる
これは SQLite に「全員集合して整列して」と言うのではなく、「最初から並んでいる廊下」に連れて行く感じです。
次は jobs(run のメタ情報)。ここに条件と tie-breaker があります:
- 絞り込み:
queue、status - 最後の tie-breaker:
created_at ASC(同点のとき順位を安定させる)
なので、この 3 つに対応するインデックスを貼ります:
CREATE INDEX IF NOT EXISTS idx_jobs_filter
ON jobs (
queue,
status,
created_at
);
ここでの created_at は、絞り込みのためではなく「最後のひと押し」のためです。
というのも、ORDER BY の最後はこうだから:
... , j.created_at ASC
もし score1/score2 が同じ行が大量にあるなら(同点はよくある)、SQLite は:
- まず同点の巨大な候補の塊を集める
jobsに join してcreated_atを読む- その塊を
created_atで並べ替える - そこで初めて、安定して上位 100 件を選べる
created_at を支えるインデックスが無いと、SQLite はだいたいこうします:
- 一時的なソート構造(temp B-tree)を作る
- 同点候補を突っ込む
- ソートしてから結果を返す
query plan で見えるのがこれ:USE TEMP B-TREE FOR ORDER BY
自分のクエリが追加ソートしているか知りたいなら、EXPLAIN QUERY PLAN で SQLite に聞けます:
EXPLAIN QUERY PLAN
SELECT ...;
見たい計画はだいたいこうです:
USING INDEX ...(貼ったインデックスを使っている)SCAN TABLEが無い(全表スキャンしていない)USE TEMP B-TREE FOR ORDER BYが無い(追加ソートしていない)
もし TEMP B-TREE が見えたら、ほぼこう断定できます:
- まだ追加ソートしている。Top-N の LIMIT は最後にしか効いていない。直そう。
よくある落とし穴
-
数字を TEXT で持って、CAST でソートする
ORDER BY CAST(score1 AS REAL) DESCこの 1 行でインデックスが死にます。
SQLite は「変換後の結果」を使ってインデックスを辿れません。
対策は単純: 数値は最初から
INTEGER / REALで持ちましょう。ソート時の CAST に頼らない。 -
ソート列を足し続けると、インデックスが太り続ける
score1 → score2 → score3 → score4 → …最後に残るのは、書き込みが遅くて容量を食うのに、クエリが必ずしも速くならない“インデックス怪獣”です。
対策:どの指標が本当に順位を変えるか確認し、ソートキーは最小限に。
leaderboard が頻繁にクリックされるようになったら、こんな選択肢もあります:
- Top-N 結果をキャッシュする(leaderboard は典型的に read-heavy / write-light)
- 人気 split だけ partial index を作る
- オフラインでランキングを計算し、結果テーブルを引く
SQLite は「クエリエンジン」として非常に向いています。でも毎回リアルタイムに計算する必要はありません。ちょっとした工夫で、プロダクトはかなり安定します。
まとめ
クエリが遅いとき、よくある理由は SQLite がダメだからではありません。
あなたが SQLite に要求しているのは:
- join(テーブル結合)
- 条件が複数
- 複合ソート
- しかも順位が安定した Top-N クエリ
なのに、それに対応したインデックスを与えていないからです。
だから SQLite は一番保守的で一般的なやり方を選びます:全部読む、全部並べ替える、そのあと大半を捨てる。
本当に調整すべきなのは、SQL の書き方そのものよりも、事前に考えたかどうかです:
- どの条件が「範囲を絞る」のか
- どの列が本当に「順位を変える」のか
- どのソートが SQLite に「追加の並べ替え」をさせてはいけないのか
SQLite はずっと堅実です。
必要なのは、あなたが「本当に欲しいもの」をもう少し精密に伝えることだけ。
参考資料
☕ 1杯のコーヒーが支えになります
AIやフルスタックの情報発信を続けるため、ご支援お願いします。
AI・開発・運用まで一括対応
アイデアからリリースまで、技術面はまるごとお任せください。
対応内容
- 技術相談 + 開発 + デプロイ
- 継続サポート & 拡張
🚀 次のプロジェクト、始めましょう!
カスタム開発や長期支援をご希望の方は、ぜひご相談ください。
