パフォーマンスとキャッシュ — 速いAPIを作る
レスポンスタイムが遅い
「ナツミさん、物件一覧の読み込みが遅いです。2〜3秒かかってる」
林さんからのレポートにナツミは焦った。New Relicのダッシュボードを開くと、GET /api/v1/properties の平均レスポンスタイムは2,400ms。
「これは使い物にならない」
原因を調べると:
- N+1クエリ(100件の物件 × 関連データのクエリ)
- 毎回フルスキャンのSQL
- シリアライゼーションのコスト
- 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
endWARNING
キャッシュキーの設計が重要。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を返す
endLast-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
endAWS 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,400ms | 320ms | 87%削減 |
| Fragmentキャッシュ | 320ms | 45ms | 86%削減 |
| CloudFrontキャッシュ | 45ms | 3ms | 93%削減(キャッシュHIT時) |
まとめ
- まずN+1クエリを
includesで解消する(最もコスパが良い) Rails.cacheでFragmentキャッシュを実装し、キャッシュキーにモデルのupdated_atを含める- ETag/Last-Modifiedで変更がない場合は304を返し、転送データを0にする
Cache-ControlヘッダーでCloudFrontのCDNキャッシュを制御するRack::Attackでレート制限を設け、サービスを保護する
次章では、RESTの次のステップ——GraphQLを学ぶ。必要なデータだけを取得するクエリ言語の設計と実装。