mybook

キャッシュ戦略 — 速度を手に入れる

「同じ質問に何度も答える」無駄

EchoTaskのダッシュボードが遅い。ハルトはNew Relicのトレースを眺めながら頭を抱えていた。

1000人のユーザーが毎分リロードすると、まったく同じSQLが1000回実行されている。

-- 1分間に1000回実行される
SELECT projects.*, COUNT(tasks.id) as task_count
FROM projects
LEFT JOIN tasks ON tasks.project_id = projects.id
WHERE projects.user_id = ?
GROUP BY projects.id
ORDER BY projects.updated_at DESC;

データはほとんど変わっていない。なのに毎回DBに問い合わせる——これは純粋な無駄だ。

キャッシュとは、一度取得したデータを高速なストレージ(メモリ)に保存しておき、次のリクエストではDBを経由せずに返す仕組みだ。計算済みの答えを手元に置いておくだけで、システム全体の負荷が劇的に下がる。

Loading diagram...

INFO

キャッシュが効く条件

同じデータへのアクセスが繰り返され、かつデータの更新頻度が低い場合に最も効果を発揮する。「読み込みが多く、書き込みが少ない」データが理想的なキャッシュ対象だ。


キャッシュの種類を整理する

キャッシュは置く場所と目的によって4種類に大別できる。EchoTaskの設計を考えながら整理しよう。

種類場所用途
アプリキャッシュアプリサーバーのメモリRails.cache / RedisDBクエリ結果、計算済みデータ
DBキャッシュDBエンジン内PostgreSQL shared_buffersクエリ結果・インデックス
HTTPキャッシュブラウザ / プロキシETag, Cache-Control静的リソース、APIレスポンス
CDNキャッシュエッジサーバーCloudFront画像、JS、CSS

ハルトが最初に手をつけるべきはアプリキャッシュだ。DBへのクエリを減らすだけで応答時間は劇的に改善する。

INFO

どのレイヤーでキャッシュするか

アプリキャッシュ→DBキャッシュ→HTTPキャッシュ→CDNキャッシュの順に、ユーザーから遠ざかる。ユーザーに近いほど速いが、制御も難しくなる。まずアプリキャッシュから始め、効果を測定しながら外側へ広げていく。


Redis — 最速のキャッシュストア

RedisはIn-Memoryデータストアで、データをすべてRAMに保持する。ディスクI/Oが不要なため、桁違いの速度を実現する。

PostgreSQL: ディスクI/O → 数ms〜数十ms
Redis:      メモリアクセス → 0.1ms以下

Railsへの組み込み

# Gemfile
gem 'redis'
gem 'hiredis-client'  # C拡張でさらに高速化
 
# config/environments/production.rb
config.cache_store = :redis_cache_store, {
  url: ENV['REDIS_URL'],
  expires_in: 1.hour,
  namespace: 'echo_task_cache',
  pool_size: 5,
  pool_timeout: 5
}

Redisのデータ型を使いこなす

Redisは単純なKey-Valueストア以上の存在だ。豊富なデータ型を活用することで、複雑なユースケースにも対応できる。

String — 基本のキャッシュ

# シリアライズしたJSONを保存
redis = Redis.new(url: ENV['REDIS_URL'])
redis.set("user:#{user.id}:profile", user.to_json, ex: 3600)
profile = JSON.parse(redis.get("user:#{user.id}:profile"))

Hash — オブジェクトのフィールド管理

# プロジェクトの各フィールドを個別に更新できる
redis.hset("project:#{project.id}",
  "name",       project.name,
  "task_count", project.tasks.count,
  "updated_at", project.updated_at.to_i
)
 
# フィールド単体の取得
task_count = redis.hget("project:#{project.id}", "task_count").to_i
 
# タスク数だけ更新(プロジェクト名はそのまま)
redis.hincrby("project:#{project.id}", "task_count", 1)

List — 最近のアクティビティ履歴

# 最新の操作ログを50件だけ保持
def record_activity(user_id, activity)
  key = "user:#{user_id}:activities"
  redis.lpush(key, activity.to_json)
  redis.ltrim(key, 0, 49)   # 先頭50件のみ残す
  redis.expire(key, 86400)  # 24時間で期限切れ
end
 
def recent_activities(user_id)
  redis.lrange("user:#{user_id}:activities", 0, 9).map { |a| JSON.parse(a) }
end

Set — タグ管理やユニーク集合

# プロジェクトに付いているタグをSetで管理
def add_tag(project_id, tag)
  redis.sadd("project:#{project_id}:tags", tag)
end
 
# 特定タグを持つプロジェクトIDの積集合(AND検索)
def projects_with_all_tags(*tags)
  keys = tags.map { |t| "tag:#{t}:projects" }
  redis.sinter(*keys)
end

Sorted Set — ランキング・スコア管理

# タスク完了数でリーダーボードを管理
redis.zincrby("leaderboard:#{Date.today}", 1, user_id.to_s)
redis.expire("leaderboard:#{Date.today}", 86400 * 7)
top_users = redis.zrevrange("leaderboard:#{Date.today}", 0, 9, with_scores: true)

INFO

適切なデータ型を選ぶ

StringはJSON全体の保存、Hashはフィールド単位の更新、ListはFIFO/LIFOキュー、SetはユニークIDの集合、Sorted SetはスコアつきランキングやTTL付き優先度キューに最適。


キャッシュパターン

1. Read Through(Cache Aside)

最も一般的なパターン。アプリがキャッシュを確認し、なければDBから取得してキャッシュに保存する。

# app/models/project.rb
class Project < ApplicationRecord
  def self.for_user_with_counts(user_id)
    Rails.cache.fetch("user:#{user_id}:projects", expires_in: 5.minutes) do
      includes(:tasks)
        .where(user_id: user_id)
        .map { |p| { id: p.id, name: p.name, task_count: p.tasks.size } }
    end
  end
end
# app/controllers/api/v1/projects_controller.rb
def index
  projects = Project.for_user_with_counts(current_user.id)
  render json: projects
end

2. Write Through

データ書き込み時にキャッシュも同時に更新する。キャッシュとDBが常に同期している。

# app/models/project.rb
class Project < ApplicationRecord
  after_save :update_cache
  after_destroy :invalidate_cache
 
  private
 
  def update_cache
    Rails.cache.write(
      "project:#{id}",
      { id: id, name: name, updated_at: updated_at },
      expires_in: 1.hour
    )
    Rails.cache.delete("user:#{user_id}:projects")
  end
 
  def invalidate_cache
    Rails.cache.delete("project:#{id}")
    Rails.cache.delete("user:#{user_id}:projects")
  end
end

3. Write Behind(Write Back)

書き込みをキャッシュだけに行い、DBへの保存を非同期で行う。書き込みが超高速になるが、クラッシュ時にデータを失うリスクがある。

WARNING

Write Behindはデータ損失リスクがある

RedisはRAMベースのため、クラッシュするとキャッシュのデータが失われる可能性がある。重要なデータにWrite Behindを使う場合は、RedisのAOF永続化(appendonly yes)を必ず設定すること。EchoTaskのタスクデータのような重要情報には原則使用しない。


Cache Stampede問題とmutex lockによる解決

ハルトはキャッシュを導入した翌朝、奇妙な現象に気づいた。毎朝9時になるとDBのCPUが急スパイクする。原因はCache Stampede——キャッシュの一斉期限切れだ。

TTLが切れた瞬間、数百リクエストが同時にDBへ殺到する。全員が「キャッシュにない」と判断してクエリを発行してしまうのだ。

Redis NX/EXによるmutex lock

# app/services/cache_service.rb
class CacheService
  def self.fetch_with_lock(key, ttl:, &block)
    cached = Rails.cache.read(key)
    return cached if cached
 
    lock_key = "lock:#{key}"
    # NX=存在しない場合のみセット、EX=10秒で自動解放
    acquired = $redis.set(lock_key, "1", nx: true, ex: 10)
 
    if acquired
      begin
        data = block.call
        Rails.cache.write(key, data, expires_in: ttl)
        data
      ensure
        $redis.del(lock_key)
      end
    else
      sleep 0.05
      Rails.cache.read(key) || fetch_with_lock(key, ttl: ttl, &block)
    end
  end
end
 
# 使用例
projects = CacheService.fetch_with_lock("user:#{user_id}:projects", ttl: 5.minutes) do
  Project.for_user_with_counts(user_id)
end

WARNING

TTLのバラつきも有効な対策

全キャッシュのTTLを同一にすると一斉期限切れが起きやすい。expires_in: 5.minutes + rand(60).seconds のようにランダムなジッターを加えることで、期限切れのタイミングを分散させる手法も広く使われる。mutex lockと組み合わせると効果的だ。


キャッシュの無効化

「プログラミングで最も難しい問題の1つは、キャッシュの無効化だ」——フィル・カールトン

# よくある失敗: 更新後もキャッシュが残る
def update
  @task.update!(task_params)
  # キャッシュを消し忘れると、古いデータが表示され続ける
end
 
# 正しい実装
def update
  @task.update!(task_params)
  Rails.cache.delete("task:#{@task.id}")
  Rails.cache.delete("project:#{@task.project_id}:tasks")
  Rails.cache.delete("user:#{current_user.id}:projects")
end

Russian Doll Caching

コンテンツが変わるとキャッシュキーも変わる「入れ子」のキャッシュ戦略。updated_atをキーに含めることで、明示的なキャッシュ削除が不要になる。

# app/models/task.rb
class Task < ApplicationRecord
  belongs_to :project, touch: true  # taskが更新されるとprojectのupdated_atも更新
 
  def cache_key_with_version
    "#{cache_key}/#{updated_at.to_i}"
  end
end
 
# app/models/project.rb
class Project < ApplicationRecord
  belongs_to :user, touch: true
 
  def cache_key_with_version
    "#{cache_key}/#{updated_at.to_i}"
  end
end
 
# キャッシュキーにupdated_atを含めることで、更新時に自動でキャッシュが無効化される
Rails.cache.fetch(project.cache_key_with_version, expires_in: 1.hour) do
  { project: project.as_json, tasks: project.tasks.map(&:as_json) }
end

キャッシュウォームアップ戦略

デプロイ直後やRedis再起動後は、キャッシュが空の状態(コールドスタート)になる。この状態でトラフィックが来ると、全リクエストがDBに集中して一時的に過負荷が発生する。

ハルトはこの問題をウォームアップRakeタスクで解決した。

# lib/tasks/cache_warmup.rake
namespace :cache do
  desc "キャッシュをウォームアップする(デプロイ後に実行)"
  task warmup: :environment do
    puts "キャッシュウォームアップ開始..."
 
    # アクティブユーザーのプロジェクト一覧をプリロード
    User.active.find_each(batch_size: 100) do |user|
      Project.for_user_with_counts(user.id)
      print "."
    end
 
    # タグ一覧(更新頻度が低い)
    Rails.cache.fetch("global:tags", expires_in: 24.hours) do
      Tag.all.order(:name).pluck(:id, :name)
    end
 
    # プラン機能フラグ
    Plan.all.each do |plan|
      Rails.cache.fetch("plan:#{plan.id}:features", expires_in: 6.hours) do
        plan.features.to_a
      end
    end
 
    puts "\nウォームアップ完了!"
  end
end

INFO

段階的なウォームアップ

全ユーザーのデータを一度にウォームアップするとDBに負荷がかかる。find_each(batch_size: 100) でバッチ処理し、アクティブユーザー(直近30日以内にログイン)に絞るとDBへの負荷を大幅に削減できる。デプロイ後のCI/CDで rails cache:warmup を自動実行しよう。


Golangでのキャッシュ操作

EchoTaskのAPIゲートウェイ層はGolangで実装されている。go-redis v9を使ったRead Through + mutex lock実装だ。

// pkg/cache/redis.go
package cache
 
import (
	"context"
	"encoding/json"
	"time"
 
	"github.com/redis/go-redis/v9"
)
 
type RedisCache struct{ client *redis.Client }
 
// GetOrSet — キャッシュHITなら即返す、MISSならfetchFnを呼んで保存
func (c *RedisCache) GetOrSet(ctx context.Context, key string, ttl time.Duration, fetchFn func() (any, error)) (any, error) {
	val, err := c.client.Get(ctx, key).Result()
	if err == nil {
		var result any
		if e := json.Unmarshal([]byte(val), &result); e == nil {
			return result, nil
		}
	}
	data, err := fetchFn()
	if err != nil {
		return nil, err
	}
	if b, e := json.Marshal(data); e == nil {
		c.client.Set(ctx, key, b, ttl)
	}
	return data, nil
}
 
// FetchWithLock — SetNX(NX=存在しなければセット)でロックを取りStampede防止
func (c *RedisCache) FetchWithLock(ctx context.Context, key string, ttl time.Duration, fetchFn func() (any, error)) (any, error) {
	lockKey := "lock:" + key
	acquired, _ := c.client.SetNX(ctx, lockKey, "1", 10*time.Second).Result()
	if acquired {
		defer c.client.Del(ctx, lockKey)
		return c.GetOrSet(ctx, key, ttl, fetchFn)
	}
	// ロック取得失敗 → 50ms待ってキャッシュを再確認
	time.Sleep(50 * time.Millisecond)
	return c.GetOrSet(ctx, key, ttl, fetchFn)
}

INFO

go-redis v9はコンテキスト必須

v9以降、全メソッドが context.Context を第一引数に取る。本番環境では context.WithTimeout を設定してRedis障害時のタイムアウトを保証しよう。


キャッシュを使ったレートリミット実装

APIのレートリミットはRedisで実装するのが定番だ。Redis のINCREXPIREを組み合わせることで、シンプルかつ高性能なレートリミッターが作れる。

# app/middleware/rate_limiter.rb
class RateLimiter
  LIMITS = {
    api:    { limit: 1000, window: 1.hour },
    upload: { limit: 10,   window: 1.minute },
    login:  { limit: 5,    window: 15.minutes }
  }.freeze
 
  def self.check!(user_id, action)
    config = LIMITS[action] || LIMITS[:api]
    key = "rate_limit:#{action}:#{user_id}:#{window_key(config[:window])}"
 
    count = $redis.incr(key)
    # 初回アクセス時のみTTLをセット(EXISTSチェック不要でアトミック)
    $redis.expire(key, config[:window].to_i) if count == 1
 
    if count > config[:limit]
      ttl = $redis.ttl(key)
      raise RateLimitExceededError.new(
        "レート制限超過。#{ttl}秒後にリトライしてください。",
        retry_after: ttl
      )
    end
 
    { count: count, limit: config[:limit], remaining: config[:limit] - count }
  end
 
  private
 
  def self.window_key(window)
    (Time.current.to_i / window.to_i).to_s
  end
end
// Golangでの実装(APIゲートウェイ層)
func (c *RedisCache) CheckRateLimit(ctx context.Context, userID string, action string, limit int, window time.Duration) (bool, int, error) {
	windowKey := time.Now().Unix() / int64(window.Seconds())
	key := fmt.Sprintf("rate_limit:%s:%s:%d", action, userID, windowKey)
 
	// パイプラインでINCRとEXPIREをアトミックに実行
	pipe := c.client.Pipeline()
	incr := pipe.Incr(ctx, key)
	pipe.Expire(ctx, key, window)
	_, err := pipe.Exec(ctx)
	if err != nil {
		return false, 0, err
	}
 
	count := int(incr.Val())
	remaining := limit - count
	if remaining < 0 {
		remaining = 0
	}
 
	return count <= limit, remaining, nil
}

WARNING

INCR + EXPIREは厳密にはアトミックではない

INCR後にプロセスがクラッシュするとEXPIREが実行されず、キーが永続化してしまう。Luaスクリプトまたはパイプラインを使い、必ずEXPIREをセットする実装にすること。また、スライディングウィンドウが必要な場合はSorted Setを使ったより精密な実装を検討する。


AWS ElastiCache

本番環境ではElastiCacheでRedisをマネージドサービスとして運用する。

クラスターモード vs スタンドアロン比較

項目スタンドアロンクラスターモード
最大メモリ1ノード分最大500ノード分
書き込みスループット1台分シャード数倍
フェイルオーバー手動 or 自動(レプリカ有時)自動
コスト高(シャード×レプリカ分)
Multi-AZ対応対応
キーの分散不要ハッシュスロット(16384分割)
向いている用途〜数GBのキャッシュ数十GB以上 / 高スループット

EchoTaskの現フェーズではスタンドアロン(プライマリ + 1レプリカ)で十分だ。

# ElastiCache Redis(スタンドアロン + レプリカ)CloudFormation
ElastiCacheReplicationGroup:
  Type: AWS::ElastiCache::ReplicationGroup
  Properties:
    ReplicationGroupDescription: "EchoTask Cache"
    AutomaticFailoverEnabled: true
    MultiAZEnabled: true
    CacheNodeType: cache.r7g.large   # 13.07 GiB メモリ
    Engine: redis
    EngineVersion: "7.2"
    NumCacheClusters: 2              # プライマリ + レプリカ1台
    CacheSubnetGroupName: !Ref CacheSubnetGroup
    SecurityGroupIds:
      - !Ref CacheSecurityGroup
    AtRestEncryptionEnabled: true
    TransitEncryptionEnabled: true
    SnapshotRetentionLimit: 7        # 7日間のスナップショット保持
    SnapshotWindow: "03:00-04:00"    # 日本時間 12:00-13:00 にスナップショット
# config/environments/production.rb
config.cache_store = :redis_cache_store, {
  url: ENV['ELASTICACHE_URL'],
  ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_NONE },
  expires_in: 1.hour,
  namespace: "echo_task:#{Rails.env}",
  pool_size: ENV.fetch('RAILS_MAX_THREADS', 5).to_i,
  pool_timeout: 5
}

INFO

ElastiCacheはVPC内に配置する

ElastiCacheはパブリックアクセス不可で、同一VPCのEC2/ECSからのみ接続できる。セキュリティグループでアプリサーバーからポート6379のみを許可し、インターネットからのアクセスを完全に遮断しよう。


メモリ使用量の監視とEvictionポリシー

Redisのメモリは有限だ。設定メモリを超えると、eviction(追い出し)ポリシーに従って古いキーが削除される。

Evictionポリシーの選択

# redis.conf(ElastiCacheのパラメータグループで設定)
maxmemory-policy allkeys-lru
ポリシー動作用途
noevictionメモリ不足でエラー永続データ(非推奨)
allkeys-lru全キーからLRUで削除一般的なキャッシュ用途
volatile-lruTTL付きキーからLRUで削除TTL管理を細かくしたい場合
allkeys-randomランダムに削除アクセスパターンが均一
volatile-ttlTTLが短いキーから削除TTLを優先度として使う場合

キャッシュ専用のRedisには allkeys-lru を推奨する。

メモリ使用量のモニタリング

# lib/tasks/redis_monitor.rake
namespace :redis do
  desc "Redisのメモリ・ヒット率状況を確認"
  task stats: :environment do
    redis = Redis.new(url: ENV['REDIS_URL'])
    info = redis.info
 
    hits   = info['keyspace_hits'].to_i
    misses = info['keyspace_misses'].to_i
    total  = hits + misses
    hit_rate = total > 0 ? (hits.to_f / total * 100).round(2) : 0
 
    puts "=== Redis Stats ==="
    puts "Used Memory:      #{info['used_memory_human']}"
    puts "Max Memory:       #{info['maxmemory_human']}"
    puts "Evicted Keys:     #{info['evicted_keys']}"
    puts "Cache Hit Rate:   #{hit_rate}%"
    puts "Connected Clients:#{info['connected_clients']}"
    puts "Total Commands:   #{info['total_commands_processed']}"
  end
end

INFO

キャッシュヒット率の目標

ヒット率が80%以上ならキャッシュが効いている。60%を下回る場合は、TTLを短くしすぎているか、キャッシュ対象を見直す必要がある。evicted_keys が増加している場合はメモリ不足のサインでノードタイプのアップグレードを検討しよう。


キャッシュのTTL設計

TTLはデータの更新頻度とリアルタイム性の要件を天秤にかけて決める。

# TTL設計の目安
CACHE_TTL = {
  user_profile:   1.hour,      # 変わりにくい
  plan_features:  6.hours,
  tag_list:       24.hours,
  project_list:   10.minutes,  # そこそこ新鮮さが必要
  task_count:     2.minutes,
  notifications:  30.seconds,  # ほぼリアルタイム
}.freeze
 
# ジッターでStampedeを防ぐ
expires_in: CACHE_TTL[:project_list] + rand(60).seconds

フラグメントキャッシュとカウンターキャッシュ

カウンターキャッシュ

Railsの counter_cache を使えば COUNT(*) クエリを完全に排除できる。

class Task < ApplicationRecord
  belongs_to :project, counter_cache: true
end
 
project.tasks_count  # → tasks_countカラムを参照するだけ(SQLなし)

EchoTaskの改善結果

ハルトがElastiCacheを導入してから2週間後、メトリクスの変化は劇的だった。

Before: DBへの直接クエリ
  - タスク一覧API:  平均 120ms
  - ダッシュボード: 平均 340ms
  - DB CPU使用率:  80%(ピーク時95%)

After: ElastiCache Redis導入後
  - タスク一覧API:  平均   8ms(キャッシュHIT時)
  - ダッシュボード: 平均  15ms(キャッシュHIT時)
  - キャッシュヒット率: 91%
  - DB CPU使用率:  25%(ピーク時40%)
  - evicted_keys: 0(メモリ余裕あり)

レートリミットも正常に機能し、スクレイピングボットによるDB負荷も消えた。キャッシュの効果は絶大だった。

しかし、画像やJSファイルは毎回サーバーから配信していることに気づいた。「静的ファイルはもっとユーザーに近い場所に置けるのではないか?」

次の章では、CDNと静的配信を学ぶ。

INFO

この章のキーポイント

  • キャッシュにはアプリ・DB・HTTP・CDNの4レイヤーがある
  • Redisは String / Hash / List / Set / Sorted Set の5つのデータ型を使い分ける
  • Read Through / Write Through / Write Behind の3パターンを場面で選ぶ
  • Cache Stampede対策にはmutex lock(Redis NX/EX)とTTLジッターが有効
  • ウォームアップRakeタスクでコールドスタートを回避する
  • レートリミットはRedis INCR + EXPIREで実装できる
  • ElastiCacheはスタンドアロンから始め、スケール要件でクラスターモードへ移行
  • eviction policyは allkeys-lru、ヒット率80%以上を目標に監視する