キャッシュ戦略 — 速度を手に入れる
「同じ質問に何度も答える」無駄
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を経由せずに返す仕組みだ。計算済みの答えを手元に置いておくだけで、システム全体の負荷が劇的に下がる。
INFO
キャッシュが効く条件
同じデータへのアクセスが繰り返され、かつデータの更新頻度が低い場合に最も効果を発揮する。「読み込みが多く、書き込みが少ない」データが理想的なキャッシュ対象だ。
キャッシュの種類を整理する
キャッシュは置く場所と目的によって4種類に大別できる。EchoTaskの設計を考えながら整理しよう。
| 種類 | 場所 | 例 | 用途 |
|---|---|---|---|
| アプリキャッシュ | アプリサーバーのメモリ | Rails.cache / Redis | DBクエリ結果、計算済みデータ |
| 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) }
endSet — タグ管理やユニーク集合
# プロジェクトに付いているタグを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)
endSorted 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
end2. 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
end3. 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)
endWARNING
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")
endRussian 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
endINFO
段階的なウォームアップ
全ユーザーのデータを一度にウォームアップすると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 のINCRとEXPIREを組み合わせることで、シンプルかつ高性能なレートリミッターが作れる。
# 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-lru | TTL付きキーからLRUで削除 | TTL管理を細かくしたい場合 |
allkeys-random | ランダムに削除 | アクセスパターンが均一 |
volatile-ttl | TTLが短いキーから削除 | 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
endINFO
キャッシュヒット率の目標
ヒット率が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%以上を目標に監視する