mybook

パフォーマンスとキャッシュ — 速いAPIを作る

レスポンスタイムが遅い

「ナツミさん、物件一覧の読み込みが遅いです。2〜3秒かかってる」

林さんからのレポートにナツミは焦った。New Relicのダッシュボードを開くと、GET /api/v1/properties の平均レスポンスタイムは2,400ms

「これは使い物にならない」

原因を調べると:

  1. N+1クエリ(100件の物件 × 関連データのクエリ)
  2. 毎回フルスキャンのSQL
  3. シリアライゼーションのコスト
  4. CDNキャッシュなし

段階的に改善していこう。


パフォーマンス改善の階層

Loading diagram...

上位のキャッシュほど効果が大きい。まず測定してからボトルネックを攻める。


DBクエリの最適化

最初のボトルネックはDBクエリだった。

# Before: N+1クエリ(最悪の実装)
def index
  @properties = Property.all
  # シリアライザ内でuser, photos, reviewsをN回クエリ
end
# After: includesで解決
def index
  @properties = Property
    .includes(:user, :photos, :reviews)
    .where(published: true)
    .page(params[:page]).per(20)
end
-- Before: 100件の物件で303本のクエリ
SELECT * FROM properties WHERE published = true;
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
-- ... 100件分
SELECT * FROM photos WHERE property_id = 1;
-- ... 100件分
SELECT * FROM reviews WHERE property_id = 1;
-- ... 100件分
 
-- After: 4本のクエリ
SELECT * FROM properties WHERE published = true LIMIT 20 OFFSET 0;
SELECT * FROM users WHERE id IN (1, 2, 3, ...);
SELECT * FROM photos WHERE property_id IN (1, 2, 3, ...);
SELECT * FROM reviews WHERE property_id IN (1, 2, 3, ...);

結果:2,400ms → 320ms


Fragmentキャッシュ

個々のリソースをキャッシュする。データが変わるまで再計算しない。

# config/environments/production.rb
config.cache_store = :redis_cache_store, {
  url: ENV['REDIS_URL'],
  expires_in: 1.hour,
  namespace: 'livly_cache'
}
# app/serializers/property_serializer.rb
class PropertySerializer
  include JSONAPI::Serializer
 
  # キャッシュキーをモデルのupdated_atで決定
  cache_options store: Rails.cache, namespace: 'jsonapi', expires_in: 1.hour
 
  attributes :id, :name, :price, :area
 
  # キャッシュを破棄するタイミング:updated_atが変わったとき
  # → property.touch で更新すれば自動的にキャッシュ無効化
end
# コントローラでのフラグメントキャッシュ
def index
  cache_key = "properties/index/#{params.to_json}/#{Property.maximum(:updated_at)}"
 
  @response = Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
    properties = Property.includes(:user, :photos).published
                         .page(params[:page]).per(20)
 
    {
      data: PropertySerializer.new(properties).serializable_hash,
      meta: pagination_meta(properties)
    }
  end
 
  render json: @response
end

WARNING

キャッシュキーの設計が重要Property.maximum(:updated_at) で最新の更新時刻を含めることで、誰かが物件を更新したらキャッシュが自動的に無効化される。ページやフィルター条件もキーに含める。


HTTPキャッシュ — ETagとLast-Modified

HTTPレベルのキャッシュで、クライアントが「前回と同じデータなら再ダウンロード不要」を判断できる。

ETag(コンテンツのハッシュ)

# app/controllers/api/v1/properties_controller.rb
def show
  @property = Property.find(params[:id])
 
  # ETageをセット(updated_atのハッシュ)
  etag = Digest::MD5.hexdigest("#{@property.id}-#{@property.updated_at}")
 
  if stale?(etag: etag, public: false)
    render json: PropertySerializer.new(@property).serializable_hash
  end
  # stale?がfalseなら自動的に304 Not Modifiedを返す
end

Last-Modified(更新日時)

def index
  @properties = Property.published.order(updated_at: :desc).page(params[:page])
  last_modified = @properties.maximum(:updated_at)
 
  if stale?(last_modified: last_modified)
    render json: {
      data: PropertySerializer.new(@properties).serializable_hash,
      meta: pagination_meta(@properties)
    }
  end
end

クライアント側のリクエスト

# 初回リクエスト
GET /api/v1/properties/1

# レスポンス
200 OK
ETag: "abc123def456"
Last-Modified: Mon, 15 Jan 2024 09:23:45 GMT
Cache-Control: max-age=300, private

# 2回目リクエスト(キャッシュ確認)
GET /api/v1/properties/1
If-None-Match: "abc123def456"
If-Modified-Since: Mon, 15 Jan 2024 09:23:45 GMT

# 変更なしの場合
304 Not Modified
# → クライアントはキャッシュを使用、ネットワーク転送なし

結果:変更がない場合は転送データが0バイト


Cache-Controlヘッダーの設計

# app/controllers/api/v1/properties_controller.rb
 
def index
  @properties = Property.published.page(params[:page])
 
  # 公開データ:CDNキャッシュ可能
  response.headers['Cache-Control'] = 'public, max-age=300, s-maxage=600'
  # max-age=300: クライアントは5分キャッシュ
  # s-maxage=600: CDN(CloudFront)は10分キャッシュ
 
  render json: PropertySerializer.new(@properties).serializable_hash
end
 
def show
  @property = Property.find(params[:id])
 
  if @property.published?
    # 公開物件:CDNキャッシュ可能
    response.headers['Cache-Control'] = 'public, max-age=60, s-maxage=300'
  else
    # 非公開物件:キャッシュしない
    response.headers['Cache-Control'] = 'private, no-cache'
  end
 
  render json: PropertySerializer.new(@property).serializable_hash
end

AWS CloudFrontでのCDNキャッシュ

Loading diagram...
// CloudFrontのキャッシュポリシー設定
{
  "CachePolicyConfig": {
    "DefaultTTL": 300,
    "MaxTTL": 86400,
    "MinTTL": 0,
    "ParametersInCacheKeyAndForwardedToOrigin": {
      "CookiesConfig": { "CookieBehavior": "none" },
      "HeadersConfig": {
        "HeaderBehavior": "whitelist",
        "Headers": { "Quantity": 1, "Items": ["Accept"] }
      },
      "QueryStringsConfig": {
        "QueryStringBehavior": "whitelist",
        "QueryStrings": {
          "Quantity": 3,
          "Items": ["page", "per_page", "prefecture"]
        }
      }
    }
  }
}

Rack::Attackでレート制限

キャッシュとは別の観点で、過剰なリクエストを制限する。

# Gemfile
gem 'rack-attack'
# config/initializers/rack_attack.rb
class Rack::Attack
  # Redisをストレージに使う
  Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(
    url: ENV['REDIS_URL']
  )
 
  # IPアドレスでのレート制限
  throttle('req/ip', limit: 300, period: 5.minutes) do |req|
    req.ip unless req.path.start_with?('/health')
  end
 
  # 認証エンドポイントはより厳しく
  throttle('logins/ip', limit: 5, period: 20.seconds) do |req|
    req.ip if req.path == '/api/v1/auth/sign_in' && req.post?
  end
 
  # APIキー別のレート制限
  throttle('req/api_key', limit: 1000, period: 1.hour) do |req|
    req.env['HTTP_X_API_KEY'] if req.env['HTTP_X_API_KEY'].present?
  end
 
  # レート制限されたリクエストへの応答
  self.throttled_responder = lambda do |req|
    retry_after = (req.env['rack.attack.match_data'] || {})[:period]
 
    [
      429,
      {
        'Content-Type' => 'application/json',
        'Retry-After' => retry_after.to_s
      },
      [{
        type: 'https://api.livly.jp/errors/rate_limit_exceeded',
        title: 'Rate Limit Exceeded',
        status: 429,
        detail: "リクエスト制限を超えました。#{retry_after}秒後に再試行してください"
      }.to_json]
    ]
  end
end

パフォーマンス計測と改善サイクル

# config/initializers/active_support_notifications.rb
# SQLクエリの計測
ActiveSupport::Notifications.subscribe('sql.active_record') do |_, start, finish, _, payload|
  duration = ((finish - start) * 1000).round(2)
  if duration > 100  # 100ms以上のクエリをログ
    Rails.logger.warn "SLOW QUERY (#{duration}ms): #{payload[:sql]}"
  end
end
# Gemfile(開発環境のみ)
group :development do
  gem 'rack-mini-profiler'  # レスポンスタイムのプロファイリング
  gem 'memory_profiler'     # メモリ使用量
  gem 'stackprof'           # CPUプロファイリング
end

改善結果のまとめ

施策改善前改善後効果
N+1解消2,400ms320ms87%削減
Fragmentキャッシュ320ms45ms86%削減
CloudFrontキャッシュ45ms3ms93%削減(キャッシュHIT時)

まとめ

  • まずN+1クエリを includes で解消する(最もコスパが良い)
  • Rails.cache でFragmentキャッシュを実装し、キャッシュキーにモデルの updated_at を含める
  • ETag/Last-Modifiedで変更がない場合は304を返し、転送データを0にする
  • Cache-Control ヘッダーでCloudFrontのCDNキャッシュを制御する
  • Rack::Attack でレート制限を設け、サービスを保護する

次章では、RESTの次のステップ——GraphQLを学ぶ。必要なデータだけを取得するクエリ言語の設計と実装。