PostgreSQLのSlow Queryを改善した話:たった1行の変更でパフォーマンスが劇的に改善したケーススタディ
はじめに
最近担当したEC系プロジェクトで、よくある機能を実装していました。
- 「次の注文(Next)」
- 「前の注文(Previous)」
いわゆる詳細画面での前後ナビゲーションです。
一見シンプルですが、重要な制約がありました:
全件データをメモリにロードせずに実現すること
そのため、Offset Paginationではなく、**Keyset Pagination(カーソルベース)**を採用しました。
初期実装
ソート条件は以下の2つ:
-
paid_at(支払い日時) -
id(同一時刻のタイブレーク)
Laravel Eloquentでの「次の注文」の実装はこんな感じでした:
->where(function (Builder $q) use ($order) {
$q->where('paid_at', '>', $order->paid_at)
->orWhere(function (Builder $q1) use ($order) {
$q1->where('paid_at', $order->paid_at)
->where('id', '>', $order->id);
});
})
->orderBy('paid_at')
->orderBy('id')
ロジックとしては非常に自然です:
paid_atが大きいもの、
もしくは同じ場合はidが大きいものを取得する
発生した問題
データが数百万件規模に増えたタイミングで:
- Slow Queryが発生
- Query Planが以下のように悪化:
Bitmap Heap Scan- 最悪の場合
Seq Scan
一方で、DBには以下のインデックスがありました:
(paid_at, id)
つまり、インデックスは正しく張られているのに遅い状態です。
根本原因
原因はシンプルで、OR句です。
PostgreSQLのQuery PlannerはORを含む条件に対して:
- 条件を分岐として扱う
- 複数のスキャンを組み合わせる
- Composite Indexをうまく活用できない
結果として、意図したIndex Scanにならず、パフォーマンスが低下します。
解決策
ここで使ったのが、PostgreSQLの**Tuple Comparison(行値比較)**です。
修正後
->whereRaw('(paid_at, id) > (?, ?)', [$order->paid_at, $order->id])
「前の注文」は逆方向:
->whereRaw('(paid_at, id) < (?, ?)', [$order->paid_at, $order->id])
なぜこれで速くなるのか?
Composite Index (paid_at, id) は、内部的に以下の順序でソートされています:
paid_at- 同じ場合は
id
これは辞書順(lexicographical order)です。
(paid_at, id) > (x, y)
という条件を書くことで、PostgreSQLは:
- インデックス上の
(x, y)の位置に直接ジャンプ - そこから順方向にスキャン(Index Scan)
つまり、ORのような分岐処理が不要になります。
結果
この変更により:
- Query Planは Index Scan に改善
- Slow Queryが解消
- レスポンスが安定
さらに、副次的なメリットとして:
コードがシンプルに
Before:
- ネストしたクロージャ
- 可読性が低い
After:
->whereRaw('(paid_at, id) > (?, ?)', [...])
かなりスッキリしました。
学び
1. Keyset PaginationではORを避ける
特に:
- 複数カラムでソートしている場合
- Composite Indexを使っている場合
2. ORMを過信しない
ORMは便利ですが:
最適なSQLを常に生成してくれるわけではない
必要な場面では、Raw SQLを使う判断も重要です。
3. Tuple Comparisonは強力
PostgreSQLでは以下のような書き方が可能です:
(col1, col2) > (val1, val2)
ユースケース:
- Keyset Pagination
- 複数カラムの比較
まとめ
たった1行の変更で:
- パフォーマンス改善
- Slow Query解消
- コードの簡潔化
が実現できました。
このケースから言えるのは:
パフォーマンス改善は、大きなリファクタではなく「DBの仕組み理解」で解決できることが多い