設計問題: 分散キャッシュ — 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のほうが機能が多い……くらいしか。」
「それじゃ不十分。具体的な数字と理由が必要だ。」
| 項目 | Memcached | Redis |
|---|---|---|
| データ構造 | 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 = ノード数)
ホットスポット回避: 仮想ノード数を増やすことで(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
endStep 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
endStep 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
endRailsデプロイ時のウォームアップフロー:
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
endStep 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と多層で考える。」
// 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
}各レイヤーの特徴:
| レイヤー | 実装 | レイテンシ | 容量 | 用途 |
|---|---|---|---|---|
| L1 | Go LRUキャッシュ / Rails.cache | 0.1ms | 256MB〜1GB | ホットデータ(上位1%) |
| L2 | Redis / Memcached | 1ms | 10TB | 一般的なキャッシュデータ |
| L3 | RDS / DynamoDB | 10ms | 無制限 | 永続ストレージ |
Step 11: AWSアーキテクチャ詳細
「次はインフラ構成だ。ElastiCacheでどう組むか説明してみて。」
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
endCloudWatch 監視項目:
必須アラーム:
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つのアプローチがあります。
- TTLベース: シンプルにTTLを設定して自動失効させる。整合性が緩やかでもいいデータに向いています。
- イベント駆動の無効化(推奨):
after_commitで更新と同時にキャッシュを削除。次のリクエストで再構築されます。 - 2フェーズ削除: 書き込み前と書き込み後の2回キャッシュを削除する(レプリケーション遅延対策)。」
Q2: 「Redisが1台障害になったら?」
「ElastiCache のCluster Modeでは、各シャードにPrimaryとReplicaがあります。Primaryが落ちると、ElastiCacheが自動でReplicaをPrimaryに昇格させます(フェイルオーバー時間: 通常30秒〜1分)。この間のリクエストはキャッシュミスとして扱い、DBに直接問い合わせるフォールバック実装が重要です。
アプリ側にサーキットブレーカーも入れておきます。Redisがダウン中はDB直接参照モードに切り替え、Redisが復旧したらキャッシュウォームアップを実行します。」
Q3: 「キャッシュキーの設計で気をつけることは?」
「3つあります。
- 名前空間:
{サービス}:{エンティティ}:{ID}:{バージョン}の形式(例:shop:product:12345:v2) - バージョニング: スキーマ変更時にバージョンを上げて古いキャッシュを自然に失効させる
- 長さ制限: 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つのキーを
key:shard:0〜key:shard:9のように10個に分散させ、読み取り時にランダムにシャードを選択 - ローカルキャッシュ(L1): アプリサーバーのメモリにインメモリキャッシュを持ち、ホットなデータはRedisにすら問い合わせない
- 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を使えばいいだけ』から、ずいぶん遠くまで来たね。」
「キャッシュがこんなに奥深いとは思いませんでした。」
「それが本当の設計だ。『何を使うか』より『なぜ、どう使うか』。面接官が見たいのは、あなたがトレードオフを理解していること。完璧な答えはない。でも根拠のある答えはある。」