はじめに
これまでWebシステムの開発に携わる中で、機能追加やUI改善だけではなく、アクセス集中によって発生するパフォーマンス問題の調査・改善も経験してきました。
今回の記事では、学生向けのWebシステムで実際に発生した、
「普段は問題なく動いているのに、特定の時期になると急激に画面表示が遅くなる」
という問題について、どのように原因を特定し、改善していったのかを整理します。
特に重要だったのは、最初から「DBが悪い」と決めつけるのではなく、
計測 → ボトルネック特定 → SQL調査 → DBアクセス改善 → キャッシュ → 負荷テスト
という順番で進めたことです。
1. 発生した問題
今回対象となったのは、学生がWebブラウザから利用するシステムでした。
通常時は特に問題なく動作していましたが、試験結果の公開や学校側のイベントなど、特定の時期にアクセス数が大きく増えるという特徴がありました。
通常時は、
学生
↓
Webアプリケーション
↓
データベース
↓
画面表示
という処理で問題なくレスポンスが返ってきます。
しかし、アクセスが集中すると、
学生A ─┐
学生B ─┤
学生C ─┤
学生D ─┤
学生E ─┤
... ┤
学生N ─┘
↓
Webアプリ
↓
DB
↓
DB負荷増加
↓
レスポンス遅延
という状態になり、画面表示に時間がかかるようになりました。
場合によってはタイムアウトにつながる可能性もあるため、単純な「画面が少し遅い」という問題ではありませんでした。
2. まず「どこが遅いのか」を調べる
パフォーマンス問題が発生したときに、最初にやったのはコードの修正ではありません。
まず、
「システムのどこで時間を使っているのか?」
を確認しました。
Webシステムでは、例えば以下のような複数の処理があります。
ブラウザ
↓
Webサーバー
↓
Controller
↓
Service / Model
↓
Database
↓
外部API
この中のどこが遅いのかを確認しないまま、
とりあえずSQLを変更する
とりあえずRedisを入れる
とりあえずサーバーを増やす
という対応をすると、根本的な原因を解決できない可能性があります。
そこで、まずAPMやログを確認しました。
3. APMで処理時間を確認する
APMを利用して、リクエスト単位でどの処理に時間がかかっているのかを確認しました。
例えばイメージとしては、
GET /students/result
Total 5.2 sec
Application 0.5 sec
SQL 4.5 sec
Other 0.2 sec
のような状態です。
この結果を見ると、
アプリケーション全体ではなく、DB処理に大きく時間を使っている
ことが分かります。
ここで初めて、
「DB周辺を重点的に調査しよう」
と判断しました。
4. Slow Query Logで遅いSQLを特定
次に、DB側のSlow Query Logを確認しました。
Slow Query Logは、簡単に言えば、
実行に時間がかかったSQLを記録しておく仕組み
です。
例えば、
SELECT *
FROM students
WHERE school_id = 100
AND grade = 3;
というSQLが、
Execution Time: 3.8 sec
のように記録されていたとします。
さらに、アクセスが集中すると同じようなSQLが大量に実行されていることも分かりました。
ここで、
「DBが遅い」
という曖昧な状態から、
「このSQLが大量に実行されていることが問題」
という具体的な状態まで絞り込めました。
5. EXPLAINでSQLの実行計画を確認
遅いSQLを特定した後は、EXPLAINを使って実行計画を確認しました。
例えば、
EXPLAIN
SELECT *
FROM students
WHERE school_id = 100
AND grade = 3;
です。
EXPLAINでは、
- どのテーブルを参照しているか
- インデックスが利用されているか
- どのくらいのレコードを調べているか
- テーブル全体を検索していないか
などを確認できます。
例えば適切なインデックスがない場合、
students table
100万件
↓
1件目
2件目
3件目
...
100万件目
↓
条件に一致するデータを探す
というような全件検索に近い処理になる可能性があります。
データ量が少ないときには問題なくても、データ量やアクセス数が増えると急激に処理が重くなります。
6. インデックスを見直す
そこで、検索条件に合わせてインデックスを見直しました。
例えば、
WHERE school_id = ?
AND grade = ?
という検索が頻繁に発生するのであれば、
CREATE INDEX idx_students_school_grade
ON students(school_id, grade);
のような複合インデックスを検討します。
もちろん、単純にインデックスを増やせば良いわけではありません。
インデックスが増えすぎると、
- INSERT
- UPDATE
- DELETE
などの書き込み処理にも影響します。
そのため、
実際の検索パターンと実行計画を確認したうえで必要なインデックスだけ追加する
ことを意識しました。
7. 次にN+1問題を調査
SQLを改善した後、もう一つ問題になっていたのがN+1問題です。
例えば、学生100人のデータを取得するとします。
最初に、
SELECT *
FROM students;
で学生100人を取得します。
ここまでは1回のSQLです。
しかし、その後に、
students.each do |student|
student.school
end
のように関連データを1件ずつ取得すると、
学生100人取得
↓
学校情報取得 × 100回
となります。
つまり、
1 + 100
= 101回
のSQLが発行される可能性があります。
これがN+1問題です。
8. Eager LoadingでSQLの発行回数を削減
そこで、関連データをあらかじめ取得するEager Loadingを利用しました。
例えばRailsであれば、
Student.includes(:school)
のような形です。
イメージとしては、
Before
students取得
↓
school取得
↓
school取得
↓
school取得
↓
...
After
students
+
schools
↓
まとめて取得
という形に変えます。
これにより、画面表示時に大量のSQLが発行されることを防ぎます。
特にアクセス数が増えるシステムでは、
1ユーザーあたり101 SQL
のような処理が、
1000ユーザー × 101 SQL
になると大きな負荷になります。
そのため、N+1の解消は非常に重要でした。
9. それでもDBアクセスが多い場合はRedisを利用
SQLやN+1を改善した後も、
「毎回DBから取得する必要がないデータ」
が存在しました。
例えば、
- 学校情報
- お知らせ
- マスタ情報
- 頻繁に参照される集計データ
などです。
こうしたデータが毎秒変更されるのでなければ、毎回DBへ問い合わせる必要はありません。
そこでRedisによるキャッシュを導入しました。
10. Redis Cacheの基本的な流れ
例えば、学生がページを開いたとします。
最初のアクセスでは、
ユーザー
↓
Redis確認
↓
データなし
↓
DBから取得
↓
Redisへ保存
↓
ユーザーへ返す
となります。
2人目以降は、
ユーザー
↓
Redis確認
↓
キャッシュあり
↓
Redisから取得
↓
ユーザーへ返す
となります。
つまり、
┌→ Redis → データあり → 返却
User → │
└→ データなし
↓
DB
↓
Redis
↓
返却
という構造です。
これによって、同じデータを何度もDBから取得する必要がなくなります。
11. キャッシュの有効期限にも注意する
Redisを利用する場合、もう一つ重要なのがデータの鮮度です。
例えば、
学校のお知らせ
をRedisに保存したまま何日も更新しなければ、古い情報が表示されてしまいます。
そこで、
TTL = 5分
などの有効期限を設定します。
イメージとしては、
10:00
↓
DBから取得
↓
Redisへ保存
10:01
↓
Redisから取得
10:02
↓
Redisから取得
10:05
↓
Cache Expire
10:06
↓
DBから再取得
↓
Redis更新
という流れです。
このように、
パフォーマンスだけではなく、データの正確性も考えてキャッシュを設計する
必要があります。
12. 改善後に負荷テストを実施
実装を変更して終わりではありません。
最後に、
本当にアクセスが集中したときにも耐えられるのか?
を確認する必要があります。
例えば、
100 users
↓
500 users
↓
1,000 users
↓
3,000 users
のように負荷を段階的に増やして確認します。
確認する項目としては、
- レスポンスタイム
- CPU使用率
- メモリ使用率
- DB CPU
- DBコネクション数
- SQL実行時間
- エラー率
などがあります。
13. 「DBが遅い」だけで終わらせない
今回の経験で特に重要だったのは、
「DBが遅い」という結果だけを見て終わらせないこと
でした。
DBが遅くなっている理由をさらに掘り下げると、
DBが遅い
↓
Slow Queryがある
↓
SQLの実行計画を確認
↓
Indexが不足
↓
さらにN+1も存在
↓
不要なDBアクセスが多い
↓
頻繁に参照されるデータはCache可能
というように、複数の原因が見えてきます。
14. 最終的な改善フロー
今回の対応をまとめると、以下のような流れになります。
アクセス集中
↓
画面表示が遅い
↓
【計測】
APM / Logs
↓
DB処理が遅いと判明
↓
【SQL調査】
Slow Query Log
↓
遅いSQLを特定
↓
【原因分析】
EXPLAIN
↓
Indexを見直し
↓
【アプリケーション調査】
N+1問題を発見
↓
Eager Loading
↓
【さらなる改善】
Redis Cache
↓
【検証】
Load Test
↓
パフォーマンス改善
この順番で対応することで、闇雲にコードを修正するのではなく、実際のデータを見ながら改善ポイントを絞り込むことができました。
15. 今回の経験から学んだこと
パフォーマンス改善では、
「速そうな方法を試す」よりも「遅い場所を測定してから改善する」
ことが重要だと感じています。
特にWebシステムでは、
アプリケーション
↓
SQL
↓
Database
↓
Cache
↓
Infrastructure
がそれぞれ影響し合っています。
そのため、
「Redisを入れれば速くなる」
「インデックスを追加すれば速くなる」
と単純に考えるのではなく、
どこがボトルネックなのかを確認して、その原因に合った対策を選択する
ことが重要です。
まとめ
今回のパフォーマンス改善では、以下のような流れで対応しました。
| Step | 対応 |
|---|---|
| 1 | APM・ログで処理時間を計測 |
| 2 | DBがボトルネックであることを確認 |
| 3 | Slow Query Logで遅いSQLを特定 |
| 4 | EXPLAINで実行計画を確認 |
| 5 | 必要なインデックスを追加・見直し |
| 6 | N+1問題を特定 |
| 7 | Eager LoadingでSQL発行回数を削減 |
| 8 | Redis CacheでDBアクセスを削減 |
| 9 | 負荷テストで改善効果を確認 |
一番大切なのは、
計測 → 原因特定 → 改善 → 再計測
のサイクルを繰り返すことです。
アクセス数が増えてから場当たり的に対応するのではなく、将来的なアクセス増加やデータ量の増加を想定して、アプリケーション・DB・キャッシュを含めたアーキテクチャを設計することが、安定したWebサービスを作るうえで重要だと考えています。