mybook

設計問題: 分散キャッシュ — Memcached/Redisクラスターの設計

「Redisを使えばいいだけじゃないですか?」

「今日の設計問題は分散キャッシュだ」とレイカが言った。「これはほぼすべての大規模システム面接で出てくる。URLショートナーでも、フィードシステムでも、最終的にはキャッシュをどう設計するかを聞かれる。」

ソウタは少し軽く考えていた。「でも、Redisを使えばいいだけじゃないですか?SET key value EX 3600 で終わりでしょう?」

レイカは静かに微笑んだ。「その答えで面接を落ちた人を何人も見てきた。Redisをどこでどう使うかが問われるんだ。キャッシュは正しく使えばP99レイテンシを100msから5msに下げられる。でも間違えると……」

「……間違えると?」

「データの不整合、キャッシュスタンピードで逆にDBが死ぬ、コールドスタート問題、あるいはキャッシュポイズニングによるセキュリティ事故。キャッシュは薬であり毒だ。使い方を知らないと処方箋なしに劇薬を飲ませることになる。」

ソウタはノートを開いた。「どこから始めればいいですか?」

「まず要件を確認するところから。」


Step 1: 要件の確認

面接では最初に問題の範囲を絞ることが重要だ。ソウタはレイカの言葉を思い出しながら、質問を整理した。

機能要件:
- キーバリューストアとしてのキャッシュ操作(GET / SET / DELETE)
- TTL(有効期限)の設定
- データの自動削除(メモリ上限到達時)
- クラスター構成(複数ノード間の分散)

非機能要件:
- スループット: 1,000,000 ops/sec(読み取り80%、書き込み20%)
- レイテンシ: P99 < 10ms(P50 < 1ms)
- 可用性: 99.99%(年間ダウンタイム < 1時間)
- 整合性: 結果整合性を許容(ユーザープロフィール等)
- データ量: 最大10TB のキャッシュデータ

想定外:
- 永続化ストレージ(それはDBの仕事)
- 複雑なクエリ(キーバリューに絞る)
- トランザクション保証(アトミック操作程度でよい)

「この要件確認だけで5分使っていい」とレイカ。「面接官はあなたが何を作るかを先に合意できる人かを見ている。」


Step 2: 規模の概算

「次は数字を出す。感覚じゃなく計算で。」

ソウタはホワイトボードに書いた。

【トラフィックの概算】
- 総リクエスト: 1,000,000 ops/sec
- 読み取り: 800,000 reads/sec
- 書き込み: 200,000 writes/sec

【メモリの概算】
- 平均オブジェクトサイズ: 1KB
- キャッシュデータ量: 10TB = 10 × 1024 × 1024 KB ≈ 10^10 KB
- ノードあたりのメモリ: 128GB
- 必要ノード数: 10TB / 128GB ≈ 80ノード

【ネットワーク帯域の概算】
- 読み取り帯域: 800,000 × 1KB = 800 MB/sec
- 書き込み帯域: 200,000 × 1KB = 200 MB/sec
- 合計: 1 GB/sec(10GbE NIC で十分)

【レプリケーション係数】
- 可用性99.99%のために最低3レプリカ
- 実際のストレージ: 10TB × 3 = 30TB

「この計算ができるだけで面接官の印象は大きく変わる」とレイカ。「感覚じゃなく根拠がある人だとわかるから。」


Step 3: MemcachedとRedisの詳細比較

「ここで面接官が必ず聞くのが『なぜRedisを選んだか』だ。Memcachedとの違いを説明できるか?」

ソウタは考え込んだ。「Redisのほうが機能が多い……くらいしか。」

「それじゃ不十分。具体的な数字と理由が必要だ。」

項目MemcachedRedis
データ構造String のみString / Hash / List / Set / ZSet / Stream
永続化なしRDB + AOF(設定で有効化)
レプリケーションなし(クライアント側)Primary-Replica + Sentinel
クラスターモードなし(クライアント側シャーディング)Redis Cluster(16384スロット)
Lua スクリプトなしあり(アトミックな複合操作)
Pub/Subなしあり
マルチスレッドあり(v1.6から)あり(I/Oはマルチスレッド、コマンド実行はシングル)
メモリ効率高い(シンプルな実装)やや低い(メタデータが多い)
向いているユース単純なセッションキャッシュ、高スループットランキング/カウンタ/セッション/Pub-Sub

使い分けの基準:

  • Memcached を選ぶとき: 単純なキーバリューキャッシュのみ、メモリ効率を最大化したい、マルチスレッドによる高い書き込みスループットが必要
  • Redis を選ぶとき: ZSet でランキング、List でキュー、Pub/Sub でリアルタイム通知、Lua でアトミック操作、クラスターの自動フェイルオーバーが必要

INFO

今回の設計では Redis を採用する。理由は3つ: (1) クラスターモードによる自動シャーディングとフェイルオーバー、(2) ZSet を使ったランキング機能を将来追加したい、(3) ElastiCache for Redis がマネージドサービスとして AWS で手厚くサポートされている。


Step 4: キャッシュの読み書きパターン3種

「次はパターンだ。面接でキャッシュの話をするとき、この3つを知らないと詰められる。」

Cache-Aside(最も一般的)

アプリケーションがキャッシュとDBを直接制御するパターン。

# app/services/user_cache_service.rb
class UserCacheService
  CACHE_TTL = 3600 # 1時間
 
  def self.find(user_id)
    cache_key = "user:#{user_id}"
 
    # 1. キャッシュを参照
    cached = $redis.get(cache_key)
    return JSON.parse(cached) if cached
 
    # 2. キャッシュミス → DBから取得
    user = User.find(user_id)
    user_data = user.slice(:id, :name, :email, :plan)
 
    # 3. キャッシュに保存
    $redis.setex(cache_key, CACHE_TTL, user_data.to_json)
 
    user_data
  end
 
  def self.invalidate(user_id)
    $redis.del("user:#{user_id}")
  end
end

特徴: 読み取り最適化、キャッシュミス時にDBへフォールバック、書き込み時はキャッシュを削除(次の読み取りで再構築)。

Write-Through(書き込み同期)

書き込みのたびにキャッシュとDBを同時に更新するパターン。

# app/models/concerns/write_through_cacheable.rb
module WriteThroughCacheable
  extend ActiveSupport::Concern
 
  included do
    # DB更新後にキャッシュも更新(after_commit で一貫性を保証)
    after_commit :sync_to_cache, on: [:create, :update]
    after_commit :remove_from_cache, on: :destroy
  end
 
  private
 
  def sync_to_cache
    cache_key = "#{self.class.name.downcase}:#{id}"
    $redis.setex(cache_key, 3600, cache_attributes.to_json)
  end
 
  def remove_from_cache
    cache_key = "#{self.class.name.downcase}:#{id}"
    $redis.del(cache_key)
  end
 
  def cache_attributes
    attributes.slice(*self.class.cached_attributes)
  end
end
 
class User < ApplicationRecord
  include WriteThroughCacheable
 
  def self.cached_attributes
    %w[id name email plan updated_at]
  end
end

特徴: キャッシュとDBが常に一致する。書き込みコストは増えるが、次の読み取りは必ずキャッシュヒット。

Write-Behind(非同期書き込み)

書き込みはキャッシュのみに行い、非同期でDBに反映するパターン。

# app/jobs/cache_flush_job.rb
class CacheFlushJob < ApplicationJob
  queue_as :cache_sync
 
  def perform(model_class, record_id)
    cache_key = "#{model_class.downcase}:#{record_id}:pending"
    data = $redis.get(cache_key)
    return unless data
 
    attrs = JSON.parse(data)
    record = model_class.constantize.find_or_initialize_by(id: record_id)
    record.update!(attrs)
    $redis.del(cache_key)
  end
end
 
# 書き込み時はキャッシュのみ更新し、ジョブをエンキュー
class CounterService
  def self.increment(user_id, event_type)
    cache_key = "counter:#{user_id}:#{event_type}"
    count = $redis.incr(cache_key)
 
    # 100回ごとにDBへフラッシュ(バッチ最適化)
    CacheFlushJob.perform_later("UserCounter", user_id) if count % 100 == 0
 
    count
  end
end

特徴: 書き込みスループットが非常に高い。ただしキャッシュ障害時にデータロスのリスクがある。アクセスカウンタやいいね数など、多少のズレが許容できる場合に適している。

WARNING

after_save ではなく after_commit を使うこと。after_save はトランザクション中に呼ばれるため、DBのロールバックが起きてもキャッシュだけが更新された状態になる(不整合)。after_commit はトランザクションが確定してから呼ばれるため安全。


Step 5: コンシステントハッシングの詳細

「80ノードにどうやってデータを分散させるか。ここが設計の肝だ。」

「単純にハッシュ値 mod ノード数でいいんじゃないですか?」

「もしノードが1台追加されたら?」

「……全てのキーの配置が変わってしまう。キャッシュが全てミスになる。」

「そう。だからコンシステントハッシングを使う。」

コンシステントハッシングの仕組み:

ハッシュリング(0 〜 2^32)上に
- 仮想ノード: 各物理ノードを150個の仮想ノードとしてリング上に配置
- キーはハッシュ値から時計回りで最初の仮想ノードへ割り当て

ノード追加時の影響:
- 通常ハッシュ: キーの 100% が再配置
- コンシステントハッシング: キーの 1/N のみ再配置(N = ノード数)
Loading diagram...

ホットスポット回避: 仮想ノード数を増やすことで(150〜200個推奨)、ノード間の負荷が均等になる。特定のキーに集中するアクセス(人気商品など)は、キーに乱数サフィックスを付けて複数のノードに分散させる。

# ホットスポット対策: ファンアウトキャッシュ
class HotKeyCache
  SHARD_COUNT = 10
 
  def self.get(hot_key)
    # ランダムにシャードを選択して読み取り分散
    shard = rand(SHARD_COUNT)
    $redis.get("#{hot_key}:shard:#{shard}")
  end
 
  def self.set(hot_key, value, ttl: 3600)
    # 全シャードに書き込み
    SHARD_COUNT.times do |shard|
      $redis.setex("#{hot_key}:shard:#{shard}", ttl, value)
    end
  end
end

Step 6: キャッシュエビクションポリシー

「メモリが満杯になったとき、何を消すか。これもよく聞かれる。」

ポリシー概要向いているユースケース
LRU(最近最も使われていないものを削除)アクセス時刻を追跡一般的なウェブキャッシュ、セッション
LFU(最も使用頻度が低いものを削除)アクセス回数を追跡長期的な人気コンテンツのキャッシュ
TTL(有効期限切れのものを削除)時間ベースで失効セキュリティが重要なデータ(トークン等)
Random(ランダムに削除)実装が最も単純アクセスパターンが予測不能な場合
Redisの maxmemory-policy 設定オプション:
- allkeys-lru:     全キーをLRUで削除(最も一般的)
- volatile-lru:    TTL設定済みキーのみLRUで削除
- allkeys-lfu:     全キーをLFUで削除(Redis 4.0+)
- volatile-ttl:    最もTTLが短いキーを優先削除
- noeviction:      削除せずエラーを返す(書き込み禁止)
# LFUに基づくキャッシュスコアリング(カスタム実装)
class CacheScorer
  def self.record_access(key)
    score_key = "lfu:#{key}"
    $redis.incr(score_key)
    $redis.expire(score_key, 86400) # 24時間でリセット
  end
 
  def self.score(key)
    $redis.get("lfu:#{key}").to_i
  end
end

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

「デプロイ直後や障害復旧後、キャッシュが空の状態をコールドスタートと呼ぶ。この瞬間、全リクエストがDBに直撃して死ぬ。」

「どう防ぐんですか?」

「ウォームアップだ。事前にキャッシュを温める。」

# lib/tasks/cache_warmup.rake
namespace :cache do
  desc "人気コンテンツのキャッシュをウォームアップ"
  task warmup: :environment do
    puts "キャッシュウォームアップ開始..."
 
    # 1. 過去24時間のアクセスログから人気商品を取得
    popular_product_ids = AccessLog
      .where(created_at: 24.hours.ago..)
      .group(:product_id)
      .order("count_all DESC")
      .limit(10_000)
      .count
      .keys
 
    # 2. バッチでキャッシュをロード(DBへの負荷を分散)
    popular_product_ids.each_slice(100) do |ids|
      products = Product.includes(:variants, :images).where(id: ids)
      products.each do |product|
        cache_key = "product:#{product.id}"
        $redis.setex(cache_key, 3600, product.to_cache_json)
      end
      sleep(0.1) # DBへの負荷を抑制
    end
 
    puts "完了: #{popular_product_ids.size}件のキャッシュをウォームアップ"
  end
end

Railsデプロイ時のウォームアップフロー:

1. 新バージョンをデプロイ(ローリングデプロイ)
2. 旧バージョンのキャッシュをそのまま引き継ぎ(キー名を変えない)
3. デプロイ後に rails cache:warmup を実行
4. ロードバランサーから新バージョンへのトラフィックを徐々に増加

Step 8: キャッシュスタンピード対策

「これが一番難しい問題かもしれない」とレイカは言った。「人気商品のキャッシュが同時に1000件のリクエストから期限切れになったらどうなる?」

「1000件全部がDBに直撃する。」

「そう。これがキャッシュスタンピードだ。2つの対策がある。」

方法1: ミューテックスロック方式

class StampedeProtectedCache
  LOCK_TTL = 5 # 5秒間ロック
 
  def self.fetch(key, ttl: 3600, &block)
    # キャッシュを確認
    cached = $redis.get(key)
    return JSON.parse(cached) if cached
 
    lock_key = "lock:#{key}"
 
    # ロック取得を試みる(NX = 存在しない場合のみ)
    locked = $redis.set(lock_key, "1", nx: true, ex: LOCK_TTL)
 
    if locked
      begin
        # ロック取得成功 → DBから取得してキャッシュに保存
        value = block.call
        $redis.setex(key, ttl, value.to_json)
        value
      ensure
        $redis.del(lock_key)
      end
    else
      # ロック取得失敗 → 少し待ってリトライ
      sleep(0.05)
      fetch(key, ttl: ttl, &block)
    end
  end
end

方法2: PDE(Probabilistic Early Departure)方式

TTL切れの前に確率的に早期再計算することで、スタンピードを防ぐ。

class PDECache
  # beta: 再計算の積極性(1.0が標準、大きいほど早期に再計算)
  def self.fetch(key, ttl: 3600, beta: 1.0, &block)
    cache_entry = $redis.hgetall("pde:#{key}")
 
    if cache_entry.present?
      remaining = $redis.ttl("pde:#{key}").to_f
      delta = cache_entry["delta"].to_f
 
      # PDE公式: delta * beta * log(rand) が残りTTLを超えたら早期再計算
      early_recompute = delta * beta * (-Math.log(rand)) > remaining
 
      return JSON.parse(cache_entry["value"]) unless early_recompute
    end
 
    start_time = Time.now.to_f
    value = block.call
    compute_time = Time.now.to_f - start_time
 
    $redis.hset("pde:#{key}",
      "value", value.to_json,
      "delta", compute_time
    )
    $redis.expire("pde:#{key}", ttl)
 
    value
  end
end

Step 9: Golangでのキャッシュ層実装

「バックエンドがGoの場合の実装も見ておこう。Genericsを使うと型安全なキャッシュが書ける。」

// internal/cache/cache.go
package cache
 
import (
	"context"
	"encoding/json"
	"fmt"
	"sync"
	"time"
 
	"github.com/redis/go-redis/v9"
)
 
// Cache は型安全なキャッシュクライアント
type Cache[T any] struct {
	client *redis.Client
	prefix string
	mu     sync.RWMutex
}
 
func New[T any](client *redis.Client, prefix string) *Cache[T] {
	return &Cache[T]{
		client: client,
		prefix: prefix,
	}
}
 
func (c *Cache[T]) key(id string) string {
	return fmt.Sprintf("%s:%s", c.prefix, id)
}
 
// Get はキャッシュから値を取得する
func (c *Cache[T]) Get(ctx context.Context, id string) (*T, error) {
	c.mu.RLock()
	defer c.mu.RUnlock()
 
	data, err := c.client.Get(ctx, c.key(id)).Bytes()
	if err == redis.Nil {
		return nil, nil // キャッシュミス
	}
	if err != nil {
		return nil, fmt.Errorf("cache get error: %w", err)
	}
 
	var value T
	if err := json.Unmarshal(data, &value); err != nil {
		return nil, fmt.Errorf("cache unmarshal error: %w", err)
	}
	return &value, nil
}
 
// Set はキャッシュに値を保存する
func (c *Cache[T]) Set(ctx context.Context, id string, value T, ttl time.Duration) error {
	c.mu.Lock()
	defer c.mu.Unlock()
 
	data, err := json.Marshal(value)
	if err != nil {
		return fmt.Errorf("cache marshal error: %w", err)
	}
 
	return c.client.Set(ctx, c.key(id), data, ttl).Err()
}
 
// GetOrSet はキャッシュミス時にfetch関数を呼び出してキャッシュに保存する
func (c *Cache[T]) GetOrSet(
	ctx context.Context,
	id string,
	ttl time.Duration,
	fetch func(ctx context.Context) (*T, error),
) (*T, error) {
	if cached, err := c.Get(ctx, id); err == nil && cached != nil {
		return cached, nil
	}
 
	value, err := fetch(ctx)
	if err != nil {
		return nil, err
	}
	if value == nil {
		return nil, nil
	}
 
	_ = c.Set(ctx, id, *value, ttl)
	return value, nil
}
 
// Delete はキャッシュを削除する
func (c *Cache[T]) Delete(ctx context.Context, id string) error {
	return c.client.Del(ctx, c.key(id)).Err()
}
 
// 使用例
type User struct {
	ID    int64  `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}
 
func NewUserCache(client *redis.Client) *Cache[User] {
	return New[User](client, "user")
}

Step 10: マルチレイヤーキャッシュ設計

「実際の本番システムは1層じゃない。L1、L2、L3と多層で考える。」

Loading diagram...
// internal/cache/multilayer.go
package cache
 
import (
	"context"
	"time"
 
	lru "github.com/hashicorp/golang-lru/v2"
)
 
type MultiLayerCache[T any] struct {
	l1    *lru.Cache[string, T]
	l2    *Cache[T]
	fetch func(ctx context.Context, id string) (*T, error)
}
 
func NewMultiLayer[T any](
	l1Size int,
	l2 *Cache[T],
	fetch func(ctx context.Context, id string) (*T, error),
) (*MultiLayerCache[T], error) {
	l1, err := lru.New[string, T](l1Size)
	if err != nil {
		return nil, err
	}
	return &MultiLayerCache[T]{l1: l1, l2: l2, fetch: fetch}, nil
}
 
func (m *MultiLayerCache[T]) Get(ctx context.Context, id string) (*T, error) {
	// L1: アプリ内メモリ(~0.1ms)
	if v, ok := m.l1.Get(id); ok {
		return &v, nil
	}
 
	// L2: Redis(~1ms)
	if v, err := m.l2.Get(ctx, id); err == nil && v != nil {
		m.l1.Add(id, *v)
		return v, nil
	}
 
	// L3: DB(~10ms)
	v, err := m.fetch(ctx, id)
	if err != nil {
		return nil, err
	}
	if v != nil {
		_ = m.l2.Set(ctx, id, *v, 1*time.Hour)
		m.l1.Add(id, *v)
	}
	return v, nil
}

各レイヤーの特徴:

レイヤー実装レイテンシ容量用途
L1Go LRUキャッシュ / Rails.cache0.1ms256MB〜1GBホットデータ(上位1%)
L2Redis / Memcached1ms10TB一般的なキャッシュデータ
L3RDS / DynamoDB10ms無制限永続ストレージ

Step 11: AWSアーキテクチャ詳細

「次はインフラ構成だ。ElastiCacheでどう組むか説明してみて。」

Loading diagram...

ElastiCache設計のポイント:

クラスター設定:
- エンジン: Redis 7.x
- クラスターモード: 有効(シャーディング対応)
- シャード数: 10(初期) → 最大250まで自動スケール
- レプリカ数: シャードあたり2(Multi-AZ)
- ノードタイプ: cache.r7g.2xlarge(メモリ最適化)

ネットワーク:
- VPC内のプライベートサブネットに配置
- セキュリティグループでアプリサーバーのSGからのみ許可(ポート6379/6380)
- TLS暗号化 + Redis AUTH(トークン認証)

バックアップ:
- 自動スナップショット: 毎日午前3時(保持7日間)
- クロスリージョンレプリケーション: DR用にus-west-2へ

Step 12: ジオレプリケーション(複数リージョン)

「グローバルサービスの場合、東京ユーザーに東京のキャッシュを使わせたい。」

# config/initializers/redis_geo.rb
REDIS_POOLS = {
  "ap-northeast-1" => Redis.new(url: ENV["REDIS_URL_TOKYO"]),
  "us-east-1"      => Redis.new(url: ENV["REDIS_URL_VIRGINIA"]),
  "eu-west-1"      => Redis.new(url: ENV["REDIS_URL_IRELAND"]),
}.freeze
 
class GeoCache
  def self.redis_for(request)
    region = request.headers["CloudFront-Viewer-Country-Region"] || "ap-northeast-1"
    REDIS_POOLS[region] || REDIS_POOLS["ap-northeast-1"]
  end
 
  def self.get(key, request:)
    redis_for(request).get(key)
  end
 
  def self.set(key, value, ttl:, request:)
    redis_for(request).setex(key, ttl, value)
 
    # 非同期で全リージョンに伝播
    GeoReplicationJob.perform_later(key, value, ttl)
  end
end

整合性モデルの選択:

  • 結果整合性(推奨): ユーザープロフィール、商品情報 → 各リージョンのRedisに書き込み後、数秒以内に同期
  • 強一貫性(特殊ケース): 在庫数、決済状態 → メインリージョンのみ参照(レイテンシは増えるが正確)

Step 13: セキュリティ

「キャッシュのセキュリティはよく忘れられる。」

# キャッシュポイズニング対策: キーのサニタイズ
class SafeCacheKey
  ALLOWED_CHARS = /\A[a-zA-Z0-9:_\-\.]+\z/
 
  def self.generate(namespace, *parts)
    key = parts.map(&:to_s).join(":")
    raise ArgumentError, "Invalid cache key: #{key}" unless key.match?(ALLOWED_CHARS)
 
    "#{namespace}:#{key}"
  end
end
 
# 使用例
key = SafeCacheKey.generate("user", user_id, "profile") # => "user:123:profile"
Redis セキュリティ設定:
1. Redis AUTH: requirepass <strong_password>
2. TLS: tls-port 6380, tls-cert-file, tls-key-file
3. rename-command FLUSHALL ""  # 危険なコマンドを無効化
4. bind: プライベートIPのみ(パブリックIPへのバインド禁止)
5. VPC Security Group: アプリサーバーのSGからのみ許可

ElastiCache 固有:
- 転送暗号化(TLS): enabled
- 保存時暗号化: enabled(AWS KMS)
- Redis AUTH トークン: 必須
- IAM認証: ElastiCache for Redis 7.0+で利用可能

Step 14: キャッシュ設計のアンチパターン

INFO

以下のデータはキャッシュしてはいけない(またはキャッシュに非常に慎重になるべき)。面接でアンチパターンを語れると設計力の高さを示せる。

アンチパターン問題対策
決済・残高情報キャッシュと実態のズレが直接損害にキャッシュしない、常にDBを参照
権限・認可情報権限変更が即座に反映されない → セキュリティ事故TTLを短く(< 60秒)またはイベント駆動で無効化
大きすぎるオブジェクトメモリ浪費、シリアライズコスト増必要なフィールドのみキャッシュ(プロジェクション)
変更頻度が高いデータキャッシュヒット率が低く、ミスのオーバーヘッドだけ増えるキャッシュ対象から外す
User固有 + ページ全体のキャッシュ異なるユーザーに誤ったコンテンツが表示されるフラグメントキャッシュに分割
ネストしたオブジェクトグラフ一部変更で全体を無効化する必要があるフラットに正規化してキャッシュ

Step 15: 監視(CloudWatch設定)

# config/initializers/cache_monitoring.rb
class CacheMetrics
  def self.track(operation, &block)
    start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    result = block.call
    duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
 
    hit = result.present?
    StatsD.increment("cache.#{operation}.#{hit ? 'hit' : 'miss'}")
    StatsD.measure("cache.#{operation}.duration_ms", duration_ms)
 
    result
  end
end

CloudWatch 監視項目:

必須アラーム:
1. CacheHits / (CacheHits + CacheMisses) < 80%
   → キャッシュ設計の見直し
2. FreeableMemory < 10%
   → スケールアップまたはエビクション設定の変更
3. Evictions > 1000/min
   → メモリ不足の可能性
4. CurrConnections > 最大接続数の80%
   → 接続プールの調整
5. EngineCPUUtilization > 80%
   → ノードタイプのアップグレード

ダッシュボードで可視化:
- キャッシュヒット率(時系列グラフ)
- メモリ使用量(ノード別)
- エビクション数(スパイク検知)
- P99レイテンシ(SLA確認)
- ネットワーク帯域使用率

Step 16: キャッシュの一貫性問題(after_commitの重要性)

「さっきWrite-Throughの実装で after_commit を使ったけど、なぜ重要か理解できた?」

ソウタは考えた。「トランザクションが確定してからキャッシュを更新するから……ですね。」

「そう。典型的なバグを見せよう。」

# NG例: after_save はトランザクション中に呼ばれる
class User < ApplicationRecord
  after_save :update_cache  # 危険!
 
  def update_cache
    # トランザクションがロールバックしても、
    # キャッシュはすでに更新されてしまう
    $redis.set("user:#{id}", attributes.to_json)
  end
end
 
# OK例: after_commit はトランザクション確定後に呼ばれる
class User < ApplicationRecord
  after_commit :update_cache, on: [:create, :update]
  after_commit :delete_cache, on: :destroy
 
  private
 
  def update_cache
    # DBへの書き込みが確定した後にのみ実行される
    $redis.setex("user:#{id}", 3600, attributes.to_json)
  end
 
  def delete_cache
    $redis.del("user:#{id}")
  end
end

「さらに2フェーズ削除パターンもある。これはレプリカ遅延対策だ。」

# 2フェーズ削除: 書き込み前と書き込み後の2回削除
def update_user_with_cache(user, attrs)
  # フェーズ1: 書き込み前にキャッシュ削除
  $redis.del("user:#{user.id}")
 
  # DB更新
  user.update!(attrs)
 
  # フェーズ2: 書き込み後にキャッシュ削除(レプリカ遅延対策)
  # after_commit で実行することで確実性を高める
  $redis.del("user:#{user.id}")
end

面接官との深掘り会話

レイカが面接官役を務めた。ソウタは設計全体を説明し終えた後、深掘り質問に答えていった。


Q1: 「キャッシュの一貫性問題はどう解決しますか?」

「DBを更新したのにキャッシュが古い値を返す問題ですね。3つのアプローチがあります。

  1. TTLベース: シンプルにTTLを設定して自動失効させる。整合性が緩やかでもいいデータに向いています。
  2. イベント駆動の無効化(推奨): after_commit で更新と同時にキャッシュを削除。次のリクエストで再構築されます。
  3. 2フェーズ削除: 書き込み前と書き込み後の2回キャッシュを削除する(レプリケーション遅延対策)。」

Q2: 「Redisが1台障害になったら?」

「ElastiCache のCluster Modeでは、各シャードにPrimaryとReplicaがあります。Primaryが落ちると、ElastiCacheが自動でReplicaをPrimaryに昇格させます(フェイルオーバー時間: 通常30秒〜1分)。この間のリクエストはキャッシュミスとして扱い、DBに直接問い合わせるフォールバック実装が重要です。

アプリ側にサーキットブレーカーも入れておきます。Redisがダウン中はDB直接参照モードに切り替え、Redisが復旧したらキャッシュウォームアップを実行します。」


Q3: 「キャッシュキーの設計で気をつけることは?」

「3つあります。

  1. 名前空間: {サービス}:{エンティティ}:{ID}:{バージョン} の形式(例: shop:product:12345:v2
  2. バージョニング: スキーマ変更時にバージョンを上げて古いキャッシュを自然に失効させる
  3. 長さ制限: Redisはキーの最大長が512MBですが、実用的には64バイト以内を推奨(メモリ効率)

一般的にキャッシュキーはユーザーの入力を直接使わず、必ずサニタイズとバリデーションを行います。これはキャッシュポイズニング対策でもあります。」


Q4: 「100万 ops/secを1台のRedisで捌けますか?」

「通常のRedisは1台で約10万〜20万 ops/sec程度です。100万 ops/secには理論上5〜10台が必要です。クラスターモードで10台以上のPrimaryを使えば余裕を持って捌けます。

また、読み取りは全てReplicaに向けることで、Primaryへの負荷を書き込みのみに限定します。今回の要件では読み取り80万 ops/sec、書き込み20万 ops/secなので、Replica読み取りを活用すればPrimary当たりの負荷はかなり下がります。」


Q5: 「ホットキー(特定のキーへのアクセス集中)はどう対策しますか?」

「3つの方法を組み合わせます。

  1. ファンアウトキャッシュ: 1つのキーをkey:shard:0key:shard:9のように10個に分散させ、読み取り時にランダムにシャードを選択
  2. ローカルキャッシュ(L1): アプリサーバーのメモリにインメモリキャッシュを持ち、ホットなデータはRedisにすら問い合わせない
  3. CloudFrontキャッシュ: 商品詳細ページのような静的コンテンツはCDNレイヤーでキャッシュし、オリジンへのリクエスト自体を減らす」

Q6: 「キャッシュヒット率を上げるためにどう分析しますか?」

「まず現状把握から始めます。CloudWatchのCacheHits/CacheMissesメトリクスを確認し、ヒット率が低い場合は以下を分析します。

  • ミスの原因: TTLが短すぎないか?キャッシュキーが不必要に細かく分割されていないか?
  • アクセスパターン: Redisの redis-cli --hotkeys でホットキーを特定
  • ワークロード特性: 特定の時間帯(深夜バッチ後など)にヒット率が落ちるなら、バッチ処理後のウォームアップが必要

目標は80%以上のヒット率。90%超えれば良好、95%超えれば優秀と考えます。」


まとめ:設計のポイント整理

ソウタは会話を振り返りながら、設計の全体像をまとめた。

分散キャッシュ設計チェックリスト:

✅ 要件確認(スループット・レイテンシ・一貫性レベル)
✅ 規模概算(メモリ・ノード数・ネットワーク帯域)
✅ ミドルウェア選定(Memcached vs Redis の根拠)
✅ 読み書きパターン(Cache-Aside / Write-Through / Write-Behind)
✅ シャーディング戦略(コンシステントハッシング + 仮想ノード)
✅ エビクションポリシー(LRU / LFU / TTL + maxmemory-policy)
✅ コールドスタート対策(ウォームアップ戦略)
✅ キャッシュスタンピード対策(ミューテックスロック / PDE)
✅ マルチレイヤーキャッシュ(L1:アプリ / L2:Redis / L3:DB)
✅ AWS構成(ElastiCache Cluster Mode + Multi-AZ)
✅ セキュリティ(TLS + AUTH + キャッシュポイズニング対策)
✅ アンチパターンの理解(決済・権限はキャッシュしない)
✅ 監視設計(ヒット率・エビクション・メモリ使用量)
✅ 一貫性問題(after_commit + 2フェーズ削除)

「よくなってきた」とレイカが言った。「最初の『Redisを使えばいいだけ』から、ずいぶん遠くまで来たね。」

「キャッシュがこんなに奥深いとは思いませんでした。」

「それが本当の設計だ。『何を使うか』より『なぜ、どう使うか』。面接官が見たいのは、あなたがトレードオフを理解していること。完璧な答えはない。でも根拠のある答えはある。」