mybook

パフォーマンスの測り方 — ボトルネックを見つける

「なんとなく遅い」は禁句

翌朝、アキラはチームに宣言した。

「今日からうちのチームでは『なんとなく遅い』という言葉を禁止する。遅いなら、どこが、どれだけ遅いのかを数字で言え」

エンジニアのケンジが手を挙げた。「ダッシュボードページが遅いと思います」

「思います、じゃない。何ミリ秒だ?」

「……わかりません」

「だからまず計測する。計測できないものは改善できない」

スケーリングの第一歩は計測だ。計測なしの最適化は暗闇の中で的を射ようとするようなものだ。

INFO

計測の黄金律: 最適化の前に必ず現状を計測し、ベースラインを記録する。改善後に再計測して効果を確認する。「改善した気がする」ではなく「P95が345msから89msに改善した」と言えるようになる。

まず知るべき3つの指標

アキラがホワイトボードに書いた指標。

Loading diagram...

レイテンシ(応答時間)

平均値ではなくパーセンタイルで測る。平均値は「外れ値」に大きく引きずられるからだ。

  • P50(中央値): 50%のユーザーの体験。「通常のユーザー」がどう感じているか
  • P95: 95%のユーザー以下の速度。「遅いユーザー」の体験
  • P99: 99%以下。「最悪ケース」——これが問題になることが多い
# パーセンタイルの意味
# もし1000リクエストのレスポンスタイムが:
# P50 = 100ms  → 500番目に遅いリクエストが100ms
# P95 = 500ms  → 950番目に遅いリクエストが500ms
# P99 = 2000ms → 990番目に遅いリクエストが2秒
# P99.9 = 10000ms → 999番目が10秒(99.9パーセンタイル)
 
# 平均 = 150ms でも、P99 = 10秒 なら
# 1000ユーザーに1人は10秒待たされている

スループット(処理能力)

  • RPS(Requests Per Second): 秒間何リクエスト処理できるか
  • Buzzの現状: 約5 RPS(目標: 500 RPS)

エラー率

  • 5xx エラーは0.1%未満が目標
  • Buzzの現状: 45%(惨状)

rack-mini-profiler で可視化する

まずリクエストごとの内訳を見る。rack-mini-profiler は開発環境でのプロファイリングの定番ツールだ。ページ左上に実行時間のバッジが表示され、クリックすると詳細な内訳が見られる。

# Gemfile
group :development do
  gem 'rack-mini-profiler', require: false
  gem 'flamegraph'   # フレームグラフ(どのメソッドが遅いか)
  gem 'stackprof'    # Ruby プロファイラ
  gem 'memory_profiler'  # メモリ使用量プロファイラ
end
bundle install
# config/initializers/rack_profiler.rb
if Rails.env.development?
  require 'rack-mini-profiler'
 
  Rack::MiniProfiler.config.tap do |config|
    config.storage = Rack::MiniProfiler::MemoryStore
    config.enabled = true
    config.show_sensitive_sql_warnings = true
    config.backtrace_includes = [/app\//]  # アプリコードのスタックトレースのみ
  end
end

ブラウザでBuzzのタイムラインページを開くと、左上にプロファイリングバッジが現れた。クリックすると詳細が展開される。

Buzz - Timeline
──────────────────────────────────────────────
Total: 2,847ms
  sql: 2,401ms (84%) ← ここが問題!
  rendering: 312ms (11%)
  gc: 89ms (3%)
  other: 45ms (2%)

SQL Queries (143個):
  SELECT * FROM users WHERE id = 1           (0.8ms)
  SELECT * FROM posts WHERE user_id = 1      (1.1ms)
  SELECT * FROM posts WHERE user_id = 2      (1.0ms)
  SELECT * FROM posts WHERE user_id = 3      (0.9ms)
  SELECT * FROM posts WHERE user_id = 4      (1.2ms)
  ... (あと138個)

メモリ使用量:
  リクエスト開始: 245 MB
  リクエスト終了: 312 MB
  差分: +67 MB(!)

「143個のクエリ……」ユイが眉をひそめた。「N+1クエリだ。しかもメモリが毎回67MB増えている。これはメモリリークに近い」

N+1クエリ問題

N+1クエリはRailsで最も頻繁に発生するパフォーマンス問題だ。「1」回の主クエリと、各行に対して「N」回のクエリが走る。

問題のコード

# app/controllers/timeline_controller.rb
def index
  @users = User.limit(50)  # 1クエリ: SELECT * FROM users LIMIT 50
end
<%# app/views/timeline/index.html.erb %>
<% @users.each do |user| %>
  <div class="user-card">
    <%= user.username %>
    <% user.posts.each do |post| %>  <%# N クエリ: ユーザーごとにSELECT %>
      <div class="post"><%= post.content %></div>
    <% end %>
    <% user.followers.count %>人のフォロワー  <%# さらに N クエリ! %>
  </div>
<% end %>
-- 発行されるSQL(50ユーザーの場合)
SELECT * FROM users LIMIT 50;              -- 1クエリ
SELECT * FROM posts WHERE user_id = 1;    -- +1クエリ
SELECT COUNT(*) FROM follows WHERE followee_id = 1;  -- +1クエリ
SELECT * FROM posts WHERE user_id = 2;    -- +1クエリ
SELECT COUNT(*) FROM follows WHERE followee_id = 2;  -- +1クエリ
-- ... 合計101クエリ(1 + N×2)
-- 50ユーザー × 2種類のクエリ = 100 + 1 = 101クエリ!

解決策: eager loading

# app/controllers/timeline_controller.rb
def index
  @users = User.includes(:posts, :followers).limit(50)
  # includes は N+1 を解消する魔法ではなく、
  # 必要なデータを先読み(eager load)する
end
-- 発行されるSQL(修正後)
SELECT * FROM users LIMIT 50;
-- users id: (1, 2, 3, ..., 50) を覚えておく
 
SELECT * FROM posts WHERE user_id IN (1, 2, 3, ..., 50);
-- 1クエリで全ユーザーの投稿を取得
 
SELECT follows.*, users.*
FROM follows
INNER JOIN users ON follows.follower_id = users.id
WHERE follows.followee_id IN (1, 2, 3, ..., 50);
-- 1クエリで全ユーザーのフォロワーを取得
 
-- 合計3クエリ!(101 → 3)

レスポンスタイムが 2,847ms → 234ms に改善した。12倍の高速化だ。

# includes の使い分け
 
# includes(preload相当): レコードをRubyオブジェクトとして読み込む
User.includes(:posts)
# SELECT * FROM users
# SELECT * FROM posts WHERE user_id IN (...)
# → posts にアクセスするとき、既にメモリ上にある
 
# joins: SQLのJOINを発行する(WHERE条件やORDERに使う)
User.joins(:posts).where(posts: { published: true })
# SELECT users.* FROM users
# INNER JOIN posts ON posts.user_id = users.id
# WHERE posts.published = true
# → Rubyオブジェクトとして posts は読み込まない
 
# eager_load: LEFT OUTER JOIN + includes の挙動
User.eager_load(:posts)
# SELECT users.*, posts.*
# FROM users LEFT OUTER JOIN posts ON posts.user_id = users.id
# → 1回のSQLで全部取得(N+1解消 + WHERE句でfilterも可能)

WARNING

includes の代わりに joins を使うケースもある。includes はオブジェクトをRubyに読み込むが、joins はSQLの結合のみで不要なデータを読み込まない。集計クエリには joins が適切なことが多い。また、includes で大量のデータを読み込むとメモリが膨らむので注意。

Bullet gem で自動検出

N+1クエリは手動で見つけるのは大変だ。bullet gemが自動検出してくれる。開発中に常時動かしておくと、見落としを防げる。

# Gemfile
group :development do
  gem 'bullet'
end
# config/environments/development.rb
Rails.application.configure do
  config.after_initialize do
    Bullet.enable = true
    Bullet.alert = true           # ブラウザアラートで通知
    Bullet.rails_logger = true    # Railsログに出力
    Bullet.add_footer = true      # ページ下部に表示
    Bullet.counter_cache_enable = true  # カウンターキャッシュの提案
 
    # Slack通知(チーム全員に周知)
    Bullet.slack = {
      webhook_url: ENV['SLACK_WEBHOOK_URL'],
      channel: '#performance-alerts',
      username: 'Bullet Bot'
    }
  end
end

Bulletはログに警告を出してくれる。

USE eager loading detected:
  User => [:posts]
  GET /timeline
  Add to your query: .includes([:posts])
  Call stack: app/controllers/timeline_controller.rb:5

N+1 Query detected:
  User#followers
  GET /timeline
  Add to your query: .includes([:followers])
  Call stack: app/views/timeline/index.html.erb:8

AVOID eager loading detected:
  Profile => [:avatar]
  GET /users/1
  Remove from your query: .includes([:avatar])
  avatar is not used in this action

最後のメッセージに注目。「avatar をeager loadしているが使っていない」というのも教えてくれる。不要なデータの読み込みはメモリの無駄遣いだ。

スロークエリの特定

N+1以外にも、単体で遅いクエリがある。まずPostgreSQLのスロークエリログを有効にする。

-- postgresql.conf または AWS RDS パラメータグループ
log_min_duration_statement = 100   -- 100ms以上のクエリをログ
log_statement = 'none'             -- 全クエリはログしない(重い)
log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h '
# AWS RDS のパラメータグループ設定(AWS CLI)
aws rds modify-db-parameter-group \
  --db-parameter-group-name buzz-pg-params \
  --parameters "ParameterName=log_min_duration_statement,ParameterValue=100,ApplyMethod=immediate"

Railsからは EXPLAIN ANALYZE を使う。

# Railsコンソールで実行(問題クエリの調査)
result = ActiveRecord::Base.connection.execute(
  "EXPLAIN ANALYZE
   SELECT * FROM posts
   WHERE content LIKE '%Ruby%'
   ORDER BY created_at DESC
   LIMIT 20"
)
puts result.map { |row| row['QUERY PLAN'] }.join("\n")
-- 出力例(問題のあるクエリ)
Seq Scan on posts  (cost=0.00..45234.00 rows=150 width=1024)
                   (actual time=0.045..8934.233 rows=150 loops=1)
  Filter: ((content)::text ~~ '%Ruby%'::text)
  Rows Removed by Filter: 2250000
Planning Time: 1.2 ms
Execution Time: 8934.8 ms  ← 8秒!
 
-- Seq Scan = 225万行を全件スキャン
-- これがインデックスを使えない LIKE '%keyword%' の問題
# より便利な方法: Active Record でEXPLAIN
Post.where("content LIKE ?", "%Ruby%").order(created_at: :desc).limit(20).explain
# => ActiveRecord::Explain オブジェクト
 
# もっと詳しく(ANALYZE付き)
Post.where("content LIKE ?", "%Ruby%").explain(analyze: true)

「Seq Scan(シーケンシャルスキャン)が出てる」とユイが言った。「225万行をフルスキャンしてる。インデックスが必要だ」

EXPLAIN の読み方マスター

EXPLAINの出力を読めるようになることは、パフォーマンス改善の基本スキルだ。

Loading diagram...
-- 良いEXPLAIN出力の例
Index Scan using idx_posts_user_created on posts
  (cost=0.43..8.45 rows=1 width=512)
  (actual time=0.082..0.089 rows=1 loops=1)
  Index Cond: ((user_id = 123) AND (created_at > '2024-01-01'))
Heap Fetches: 1
Planning Time: 0.234 ms
Execution Time: 0.123 ms  ← 速い!
 
-- 悪いEXPLAIN出力の例(ボトルネックのサイン)
Seq Scan on posts
  (actual time=0.012..8901.234 rows=18234 loops=1)
  Rows Removed by Filter: 2250000  ← 225万行スキャン
Execution Time: 8934.8 ms  ← 遅い!
# Rails の queryスタック調査ヘルパー
# config/initializers/query_logger.rb
if Rails.env.development?
  ActiveSupport::Notifications.subscribe('sql.active_record') do |*args|
    event = ActiveSupport::Notifications::Event.new(*args)
    if event.duration > 100  # 100ms以上
      puts "  [SLOW] #{event.duration.round}ms: #{event.payload[:sql]}"
      puts "  Caller: #{caller.grep(/app\//).first(3).join(', ')}"
    end
  end
end

New Relic / Datadog での本番監視

開発環境での計測だけでは不十分だ。本番環境のモニタリングも必要だ。

# Gemfile
gem 'newrelic_rpm'
# または
gem 'ddtrace', require: 'datadog/auto_instrument'
# config/newrelic.yml
production:
  license_key: <%= ENV['NEW_RELIC_LICENSE_KEY'] %>
  app_name: Buzz Production
 
  transaction_tracer:
    enabled: true
    transaction_threshold: 0.5  # 0.5秒以上のリクエストを記録
    record_sql: obfuscated       # SQLをログに残す(マスキングあり)
    stack_trace_threshold: 0.5
 
  error_collector:
    enabled: true
    capture_params: false  # プライバシー保護のためパラメータは記録しない
 
  # スロートランザクションのトレース
  slow_sql:
    enabled: true
    explain_threshold: 0.5  # 0.5秒以上のSQLを EXPLAIN で記録
    explain_enabled: true
    record_sql: obfuscated

AWSを使っているなら、CloudWatchも活用できる。

# CloudWatch アラーム設定(Terraform)
resource "aws_cloudwatch_metric_alarm" "response_time_p99" {
  alarm_name          = "buzz-p99-response-time"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = "5"
  metric_name         = "TargetResponseTime"
  namespace           = "AWS/ApplicationELB"
  period              = "60"
  statistic           = "p99"
  threshold           = "1"  # 1秒
  alarm_description   = "P99レスポンスタイムが1秒を超えました"
  alarm_actions       = [aws_sns_topic.alerts.arn]
 
  dimensions = {
    LoadBalancer = aws_alb.buzz.arn_suffix
  }
}
 
resource "aws_cloudwatch_metric_alarm" "error_rate" {
  alarm_name          = "buzz-5xx-error-rate"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = "3"
  metric_name         = "HTTPCode_Target_5XX_Count"
  namespace           = "AWS/ApplicationELB"
  period              = "60"
  statistic           = "Sum"
  threshold           = "10"  # 1分間に10件以上の5xxエラー
  alarm_actions       = [aws_sns_topic.alerts.arn]
}

負荷テストでボトルネックを再現する

本番環境のトラフィックスパイクを事前に再現するには負荷テストが必要だ。k6はシンプルなAPIとリッチなメトリクスで優れた負荷テストツールだ。

# k6 のインストール
brew install k6
// buzz_load_test.js
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Counter, Rate, Trend } from 'k6/metrics';
 
// カスタムメトリクス
const timelineErrors = new Counter('timeline_errors');
const timelineLatency = new Trend('timeline_latency');
const successRate = new Rate('success_rate');
 
export let options = {
  stages: [
    { duration: '1m',  target: 100  },  // 1分でユーザー100人まで増加(ウォームアップ)
    { duration: '3m',  target: 500  },  // 3分でユーザー500人まで増加
    { duration: '5m',  target: 1000 },  // 5分でユーザー1000人まで増加(ピーク)
    { duration: '2m',  target: 500  },  // 2分で縮小
    { duration: '1m',  target: 0    },  // 1分でゼロに
  ],
  thresholds: {
    http_req_duration: ['p(95)<2000', 'p(99)<5000'],  // SLO
    http_req_failed: ['rate<0.01'],  // エラー率1%未満
    success_rate: ['rate>0.99'],
  },
};
 
// テストシナリオ
export default function() {
  group('タイムライン', function() {
    const response = http.get('https://staging.buzz-app.com/timeline', {
      headers: { 'Authorization': `Bearer ${__ENV.TEST_TOKEN}` }
    });
 
    const success = check(response, {
      'ステータス200': (r) => r.status === 200,
      'レスポンス2秒以内': (r) => r.timings.duration < 2000,
      'Content-Type確認': (r) => r.headers['Content-Type'].includes('application/json'),
    });
 
    timelineLatency.add(response.timings.duration);
    successRate.add(success);
    if (!success) timelineErrors.add(1);
  });
 
  sleep(1);
}
k6 run buzz_load_test.js
k6 実行結果:
✓ ステータス200 (98.7%)
✗ レスポンス2秒以内 (46% failed)  ← 半分以上が2秒超え

http_req_duration:
  avg=3421ms  ← 平均3.4秒
  min=45ms
  med=2890ms  ← 中央値2.9秒
  p(90)=6234ms
  p(95)=8934ms  ← P95が8.9秒!
  p(99)=15234ms

http_req_failed: 12.3%  ← エラー12.3%(目標: 1%未満)

ボトルネック特定:
- DB接続プール枯渇: 450/5000リクエストでタイムアウト
- スロークエリ(>1秒): 78%のリクエストに含まれる
- メモリ使用率: 94%(GCが頻繁に発生)

INFO

負荷テストは必ずステージング環境で行う。本番環境でやると本物のユーザーに影響が出る。また、テスト後はデータのクリーンアップを忘れずに。ダミーユーザーやダミー投稿が残ると本番データが汚染される。

Flamegraph でCPUホットスポットを見つける

レスポンスタイムのうち、SQLだけでなくRubyコードの実行時間も確認する。Flamegraphは視覚的に「どのメソッドが時間を食っているか」を表示する。

# 開発環境でフレームグラフを生成
# ブラウザで /timeline?pp=flamegraph にアクセス
 
# または stackprof で直接プロファイリング
# config/initializers/profiling.rb(開発環境のみ)
if Rails.env.development?
  require 'stackprof'
end
 
# app/controllers/application_controller.rb
around_action :profile_action, if: -> { params[:pp] == 'stackprof' && Rails.env.development? }
 
def profile_action
  result = StackProf.run(mode: :wall, interval: 1000) { yield }
  report = StackProf::Report.new(result)
  report.print_text(false, 30)  # 上位30メソッドを出力
end
StackProf出力例:
==================================
  Mode: wall(1000)
  Samples: 1234 (0.12% miss rate)
  GC: 89 (7.21%)
==================================
TOTAL    (pct)     SAMPLES    (pct)     FRAME
  456  (36.9%)        456  (36.9%)     ActiveRecord::Result#column_types
  234  (18.9%)        234  (18.9%)     String#gsub  ← ここが怪しい
  123   (9.9%)        123   (9.9%)     Post#format_content
   89   (7.2%)         89   (7.2%)     GC

String#gsub が18.9%を占めている」とユイが指摘した。「format_content メソッドで正規表現を大量に使っているな。キャッシュが必要だ」

メモリプロファイリング

# メモリリークの調査
require 'memory_profiler'
 
report = MemoryProfiler.report do
  # 問題のあるコードを実行
  100.times { User.includes(:posts).limit(10).to_a }
end
 
report.pretty_print(
  to_file: '/tmp/memory_report.txt',
  scale_bytes: true,
  normalize_paths: true
)
メモリプロファイラ出力:
Total allocated: 234.56 MB (1234567 objects)
Total retained:  12.34 MB (45678 objects)

allocated memory by class:
  89.23 MB  String
  45.67 MB  Array
  34.56 MB  Hash
  23.45 MB  ActiveRecord::Result  ← ここが多い

retained objects by class:
  12345  String
  4567   Hash

ActiveRecord::Result がメモリを食っている」アキラが言った。「select で必要なカラムだけ取得すれば、大幅に削減できるはずだ」

計測結果のまとめとアクションプラン

アキラはチームと計測結果を整理した。

Loading diagram...

問題と改善優先度

問題現状目標優先度工数目安
N+1クエリ143クエリ/リクエスト5クエリ以下最高1日
スロークエリ8,934ms100ms以下2日
DB接続プール5接続25接続+PgBouncer半日
メモリ使用量+67MB/リクエスト+5MB以下2日
レスポンスタイム(P99)15,234ms500ms以下上記の結果
エラー率12.3%0.1%以下最高上記の結果
# 今後のパフォーマンス監視を自動化する
# config/initializers/performance_monitoring.rb
if Rails.env.production?
  ActiveSupport::Notifications.subscribe('process_action.action_controller') do |*args|
    event = ActiveSupport::Notifications::Event.new(*args)
    payload = event.payload
 
    # 1秒以上かかったリクエストをログ
    if event.duration > 1000
      Rails.logger.warn({
        type: 'slow_request',
        controller: payload[:controller],
        action: payload[:action],
        duration_ms: event.duration.round,
        db_ms: payload[:db_runtime]&.round,
        view_ms: payload[:view_runtime]&.round,
        status: payload[:status]
      }.to_json)
    end
  end
end

「計測してみると、問題は明確だった」アキラは言った。「データベースだ。次はデータベースを徹底的に最適化する」

コラム: パフォーマンスバジェット

Googleのエンジニアリングチームが使っている「パフォーマンスバジェット」という考え方がある。各エンドポイントに「予算(目標時間)」を設定し、それを超えたらアラートが発動する。

# config/initializers/performance_budget.rb
PERFORMANCE_BUDGETS = {
  'timeline#index'   => 500,   # 500ms
  'posts#show'       => 200,   # 200ms
  'users#profile'    => 300,   # 300ms
  'search#index'     => 800,   # 800ms(検索は少し緩め)
}.freeze
 
if Rails.env.production?
  ActiveSupport::Notifications.subscribe('process_action.action_controller') do |*args|
    event = ActiveSupport::Notifications::Event.new(*args)
    payload = event.payload
    key = "#{payload[:controller]}##{payload[:action]}"
    budget = PERFORMANCE_BUDGETS[key]
 
    if budget && event.duration > budget
      ratio = (event.duration / budget).round(1)
      Rails.logger.error "[BUDGET EXCEEDED] #{key}: #{event.duration.round}ms (budget: #{budget}ms, #{ratio}x over)"
      # Datadog / New Relic にカスタムイベントを送る
    end
  end
end

「計測してみると、問題は明確だった」アキラは言った。「データベースだ。次はデータベースを徹底的に最適化する」

次章では、インデックス設計とクエリ最適化によって、Buzzのデータベースを劇的に改善する方法を探る。