結論
アプリケーション側で発行したSQLが「N+1」となっていて、かつ「インデックス不足によるフルスキャン」がRDSのCPU使用率のボトルネック。
調査〜改修の流れと、実際の数値をまとめます。
環境: Aurora MySQL 8.0
調査方法
-
CloudWatch Database Insightsで負荷の高い時間帯に絞る
-
上位の SQLを確認する(AASベース)
-
Avg Latency・Rows examinedだけでなく Calls/sec も見る
1回のSQLが軽くても頻度が多ければ上位にあがる。「重いSQL」を1回の遅さだけで判断すると見誤る。

-
Calls/secが異常なら、アプリケーション側でN+1になっていないか確認する
-
N+1なら
IN (?)でまとめて取得するよう改修する -
同時に対象カラムのインデックスも見直す(IN句にしても無ければ結局フルスキャン)
-
EXPLAIN ANALYZEでインデックス前後の効果を計測する
重要なのは4〜6。1発の重いSQLだけ見ているとN+1を見落とすし、N+1だけ直してもインデックスが無ければフルスキャンのコストは残ります。
実例:チャット機能の添付ファイル取得
状況
チャット画面を開くと、
- 画像メッセージの数だけ「添付ファイル情報を取得するSQL」がループ内で1件ずつ呼ばれていました(N+1)。
- 画面は数秒間隔でポーリングする仕様
- 検索カラムにインデックスがなく、数万行のテーブルを毎回フルスキャンしていました。
1回あたり1.1秒ほどですが、 これが画像枚数分×ポーリング間隔ごとに発行され続けていたのが正体でした。
改修1: N+1解消
ループに入る前に対象IDを集約し、1回のIN検索でまとめて取得するように改修しました。
また、結果はIDをキーにした連想配列に変換し、ループ内は参照だけにしました。
// Before: ループ内で1件ずつ取得
foreach ($events as $event) {
$file = $repository->findByEventId($event['id']);
}
// After: 事前にIDを集約して1回だけ取得
$eventIds = array_column(
array_filter($events, fn($e) => $e['type'] === 'image'),
'id'
);
$fileList = $repository->findByEventIds($eventIds);
// ループ内は $fileList[$event['id']] ?? [] を参照するだけ
改修2: 複合インデックス追加
検索条件のカラムに複合インデックスを追加し、Index lookupで一発アクセスできるようにしました。
効果測定(EXPLAIN ANALYZE)
| 指標 | Before | After |
|---|---|---|
| 実行時間 | 1,110 ms | 0.029 ms(約38,800倍) |
| コスト | 55,135 | 0.35 |
| 読み取り行数 | 583,355行(全件スキャン) | 1行 |
N+1解消で呼び出し回数もN回→1回に減っているので、コスト削減と回数削減が両方効いています。
まとめ
- 「重いSQL」は実行時間だけでなく、呼び出し頻度(Calls/sec)とセットで見る
- Calls/secが高いSQLは、アプリのループ内でN+1になっていないかソースで確認する
- N+1解消とインデックス追加はセットで行う。片方だけでは負荷は残る
- 改善効果はEXPLAIN ANALYZEのrows/cost/actual timeで確認する