Webサービスのパフォーマンスチューニング入門——計測・キャッシュ・DBが三本柱

「遅い」を直すにはまず計測。Webサーバ・キャッシュ・DBの3層でのチューニング手法と、サーバ/インフラを支える技術で整理した実務の考え方をまとめる。

「サービスが遅い」という報告ほど対応に困るものはない。

どこが遅いのかわからない状態で手を動かすと、関係ない箇所を最適化して時間を溶かす。パフォーマンスチューニングは「計測→特定→改善→再計測」のサイクルが基本だと、『24時間365日 サーバ/インフラを支える技術』を読んで改めて整理できた。

まず計測する——勘で直してはいけない

チューニングの鉄則は「計測なき最適化はしない」だ。

問題箇所を特定せずに「なんとなくDBが遅そう」「キャッシュを入れれば速くなるはず」と手を動かすのは最悪の進め方だ。改善したつもりが別のボトルネックを生み、状況が悪化することもある。

計測に使うツール

# Webサーバのアクセスログからレスポンスタイムを確認
awk '{print $NF}' /var/log/nginx/access.log | sort -n | tail -20

# 特定URLのレスポンスタイム計測
curl -o /dev/null -s -w "time_total: %{time_total}s\n" https://example.com/api/posts

# MySQLのスロークエリログを有効化
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;  -- 1秒以上のクエリを記録

計測結果からボトルネックがWebサーバ層なのかDB層なのかアプリ層なのかを特定してから動く。

Webサーバ層のチューニング

同時接続数の設定

Nginxのワーカープロセス数はCPUコア数に合わせる。

# /etc/nginx/nginx.conf
worker_processes auto;  # CPUコア数に自動設定

events {
    worker_connections 1024;  # ワーカーあたりの最大接続数
}

デフォルト設定のまま本番運用しているサイトは意外と多い。コア数と接続数が噛み合っていないと、CPUが余っているのにリクエストを捌けない状況になる。

静的ファイルのキャッシュヘッダー設定

location ~* \.(js|css|png|jpg|svg|ico|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

変更のない静的ファイルに長期キャッシュを設定するだけで、リピート訪問時のロードが劇的に速くなる。

キャッシュ層の活用

キャッシュの種類と配置

種類場所用途
ブラウザキャッシュクライアント静的ファイル・APIレスポンス
CDNキャッシュエッジサーバ静的ファイル・SSGコンテンツ
アプリキャッシュRedisなどDBクエリ結果・セッション
DBキャッシュDBバッファテーブルデータ・インデックス

キャッシュは多層に置くほど効果が高いが、「どのデータをどこにキャッシュするか」と「キャッシュの有効期限をどう設定するか」の設計が重要だ。

Redisでのアプリキャッシュ

頻繁に読まれる・更新頻度が低いデータをRedisにキャッシュすることで、DBへのクエリ数を大幅に削減できる。

// キャッシュ付きデータ取得の例
async function getUser(userId: string) {
  const cacheKey = `user:${userId}`;
  
  // キャッシュを確認
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
  
  // DBから取得してキャッシュに保存(TTL: 5分)
  const user = await db.users.findById(userId);
  await redis.setex(cacheKey, 300, JSON.stringify(user));
  
  return user;
}

キャッシュを入れるときに必ず考えるのが「キャッシュ更新のタイミング」だ。データが更新されたのにキャッシュが古いまま、という状態(キャッシュ汚染)は利用者に誤情報を見せることになる。

DB層のチューニング

スロークエリの特定とインデックス

DBのチューニングで最初に取り組むべきはインデックスだ。

-- クエリの実行計画を確認
EXPLAIN SELECT * FROM posts WHERE user_id = 123 AND published = true;

-- インデックスがない場合はフルテーブルスキャンになる
-- user_id と published に複合インデックスを追加
CREATE INDEX idx_posts_user_published ON posts(user_id, published);

大量のレコードへの検索がインデックスなしで実行されると、全行をスキャンするためクエリが劇的に遅くなる。スロークエリログで遅いクエリを特定→EXPLAINで実行計画を確認→インデックス追加、という流れで改善できることが多い。

N+1問題を避ける

フロントエンド寄りの経験しかないとはまりやすいのがN+1問題だ。

// 悪い例(N+1):投稿一覧を取得後、各投稿のユーザーを個別取得
const posts = await db.posts.findAll();
for (const post of posts) {
  post.user = await db.users.findById(post.userId); // N回クエリが走る
}

// 良い例(JOIN):1回のクエリで取得
const posts = await db.posts.findAll({
  include: [{ model: db.users }]  // JOINで一括取得
});

投稿が100件あれば100回DBにアクセスする、という状況がN+1問題だ。ORMを使っていると見えにくいが、発行されているSQLログを確認すると気づける。

治験データインポート処理の「計測から始めた」チューニング実体験

治験管理システムで、外部のEDCシステムから数百件単位の被験者データを取り込むバッチ処理があった。最初は数十件のテストデータで動いていたため問題に気づかず、本番の治験開始後に「データ取り込みが終わらない」という報告が来た。

最初の対応は「サーバのスペックが足りないのでは」という方向に向かいかけたが、本書で「計測なき最適化はしない」を学んでいたため、まず計測した。

slow_query_log を有効にしてみると、1回のインポート処理で数秒かかるクエリが何十回も実行されていた。原因はN+1問題だった。被験者1名ごとに「同意記録の確認」「スケジュール照合」「有害事象の重複チェック」がそれぞれ別クエリで走っており、100名のデータ取り込みで300回以上DBにアクセスしていた。

対応はJOINと一括クエリへの書き換えで、処理時間が8分から40秒に短縮した。「サーバを増やす」という選択をしていたら、コストをかけても半分以下の改善にしかならなかっただろう。

もうひとつ効いたのがキャッシュだ。EDCシステムのマスターデータ(治験施設情報・評価スケール定義など)は変更頻度が低いのに毎回DBから取得していた。Redisに5分TTLでキャッシュするだけで、インポート中の読み取りクエリが大幅に減った。

「遅い」という報告を受けてから解決まで2日かかったが、「計測→特定→改善→再計測」のサイクルをきちんと回したことで、再発を防ぐ根本対応ができた。

まとめ:チューニングの優先順位

経験上、改善効果が高い順に並べるとこうなる。

  1. インデックス追加——N+1解消含めて最も効果が出やすい
  2. キャッシュ導入——読み取り頻度が高いデータに絞って適用
  3. 静的ファイルの長期キャッシュ——ブラウザ・CDNで簡単に設定できる
  4. Webサーバの同時接続数チューニング——スループット改善に効く

「とりあえずサーバを増やす」はコストがかかる割に根本解決にならないことが多い。計測してボトルネックを特定し、一番効果の高いところに集中することがチューニングの正攻法だ。