← ブログ一覧

Redis キャッシュ戦略の実務実装ガイド

Redis を使った効果的なキャッシュ設計を解説。TTL 設計・キャッシュパターン・無効化戦略・メモリ管理まで、受託開発で即使える実践ガイド。

#Redis#データベース#パフォーマンス#アーキテクチャ
Redis キャッシュ戦略の実務実装ガイド

Redis キャッシュ戦略の実務実装ガイド

「DB が重い」「API のレスポンスが遅い」という課題に対して、Redis を使ったキャッシュは効果的な解決策です。しかし、どこにキャッシュを入れるかいつ無効化するかどれくらいメモリを使うかといった設計判断を誤ると、かえって複雑性が増してしまいます。

この記事では、受託開発・自社開発の現場で即使える Redis キャッシュの実装パターンを、具体的なコード例とともに解説します。


1. Redis キャッシュを導入すべき場面の判断基準

導入を検討すべきケース

| ケース | 効果 | 優先度 | |--------|------|--------| | 頻繁に読まれる更新頻度が低いデータ(商品マスタ、カテゴリ一覧) | ◎ DB 負荷大幅削減 | 高 | | 外部 API 呼び出しのレスポンスをキャッシュ(為替レート、天気情報) | ◎ レイテンシ削減・コスト削減 | 高 | | 計算コストが高い集計結果(ダッシュボード、ランキング) | ◎ CPU 負荷削減 | 高 | | セッション情報の共有(マルチサーバー環境) | ○ スケーラビリティ向上 | 中 | | レート制限のカウンター | ○ メモリ効率が良い | 中 |

導入を避けるべきケース

  • リアルタイム性が必須:在庫数、決済状態など即座に最新が必要なデータ
  • データが頻繁に更新される:キャッシュ無効化のコストが読み取りコストを上回る
  • 一度しか読まれない:ユーザー固有の一時データなど

2. 基本的なキャッシュパターン 4 選

2.1 Cache-Aside(Lazy Loading)

最も一般的なパターン。アプリケーションがキャッシュと DB の両方を制御します。

import { Redis } from 'ioredis';

const redis = new Redis();

async function getProduct(productId: string) {
  const cacheKey = `product:${productId}`;
  
  // 1. キャッシュを確認
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }
  
  // 2. DB から取得
  const product = await db.products.findUnique({ where: { id: productId } });
  
  // 3. キャッシュに保存(TTL: 1時間)
  await redis.setex(cacheKey, 3600, JSON.stringify(product));
  
  return product;
}

メリット

  • シンプルで理解しやすい
  • 必要なデータだけがキャッシュされる

デメリット

  • 初回アクセス時は遅い(Cache Miss)
  • キャッシュ無効化のタイミングを自分で管理

2.2 Write-Through

データ更新時に同時にキャッシュも更新するパターン。

async function updateProduct(productId: string, data: ProductUpdate) {
  // 1. DB を更新
  const updated = await db.products.update({
    where: { id: productId },
    data,
  });
  
  // 2. キャッシュも同時に更新
  const cacheKey = `product:${productId}`;
  await redis.setex(cacheKey, 3600, JSON.stringify(updated));
  
  return updated;
}

メリット

  • キャッシュと DB の一貫性が高い
  • 読み取り時は常にキャッシュヒット

デメリット

  • 書き込みが遅くなる(Redis へも書き込むため)
  • 読まれないデータもキャッシュされる可能性

2.3 Write-Behind(Write-Back)

キャッシュに書き込み、非同期で DB に反映するパターン。高速化を最優先する場合に使用。

import Bull from 'bull';

const writeQueue = new Bull('db-write');

async function updateProductFast(productId: string, data: ProductUpdate) {
  const cacheKey = `product:${productId}`;
  
  // 1. キャッシュを即座に更新
  const updated = { id: productId, ...data };
  await redis.setex(cacheKey, 3600, JSON.stringify(updated));
  
  // 2. DB 更新はキューに追加(非同期)
  await writeQueue.add({ productId, data });
  
  return updated;
}

// Worker で DB に反映
writeQueue.process(async (job) => {
  await db.products.update({
    where: { id: job.data.productId },
    data: job.data.data,
  });
});

メリット

  • 書き込みが非常に高速
  • DB 負荷を平準化できる

デメリット

  • Redis 障害時にデータ損失のリスク
  • 実装が複雑

2.4 Refresh-Ahead

TTL 切れ前に自動で再取得するパターン。Cache Miss を防ぎます。

async function getProductWithRefresh(productId: string) {
  const cacheKey = `product:${productId}`;
  const ttl = await redis.ttl(cacheKey);
  
  // TTL が残り 10% 未満なら非同期で再取得
  if (ttl > 0 && ttl < 360) { // 3600秒の10%
    refreshProductCache(productId); // 非同期実行
  }
  
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }
  
  // 初回は通常通り取得
  const product = await db.products.findUnique({ where: { id: productId } });
  await redis.setex(cacheKey, 3600, JSON.stringify(product));
  return product;
}

async function refreshProductCache(productId: string) {
  const product = await db.products.findUnique({ where: { id: productId } });
  await redis.setex(`product:${productId}`, 3600, JSON.stringify(product));
}

3. TTL(有効期限)設計の実務ルール

3.1 データ特性別の推奨 TTL

| データ種別 | 推奨 TTL | 理由 | |-----------|----------|------| | 静的マスタ(国リスト、カテゴリ) | 24時間〜1週間 | ほぼ変更されない | | 準静的データ(商品情報、記事) | 1〜6時間 | 更新頻度が低い | | 動的データ(在庫数、価格) | 1〜10分 | リアルタイム性が必要 | | 外部 API 結果(天気、為替) | API の更新頻度に合わせる | 無駄な API 呼び出し削減 | | セッション情報 | セッションタイムアウトと同じ | 通常 30分〜2時間 | | レート制限カウンター | 制限期間と同じ | 例:1分あたり100回なら60秒 |

3.2 TTL 設定のコード例

// パターン1: シンプルな固定 TTL
await redis.setex('user:123', 3600, JSON.stringify(user));

// パターン2: ランダムな TTL でキャッシュ雪崩を防ぐ
const baseTTL = 3600;
const randomTTL = baseTTL + Math.floor(Math.random() * 600); // ±5分
await redis.setex('product:456', randomTTL, JSON.stringify(product));

// パターン3: 時刻ベースの TTL(毎日0時にリセット)
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
const ttl = Math.floor((tomorrow.getTime() - now.getTime()) / 1000);
await redis.setex('daily:ranking', ttl, JSON.stringify(ranking));

4. キャッシュ無効化戦略

4.1 無効化のタイミング判断

| 戦略 | 実装方法 | 使い分け | |------|----------|----------| | TTL 任せ | 何もしない | 多少古くても問題ないデータ | | 即座に削除 | DEL コマンド | リアルタイム性が必要 | | タグベース削除 | Set で関連キーを管理 | 関連データをまとめて無効化 | | イベント駆動 | Pub/Sub で通知 | マルチサーバー環境 |

4.2 即座に削除するパターン

async function updateProduct(productId: string, data: ProductUpdate) {
  // DB を更新
  const updated = await db.products.update({
    where: { id: productId },
    data,
  });
  
  // キャッシュを削除(次回アクセス時に再取得される)
  await redis.del(`product:${productId}`);
  
  return updated;
}

4.3 タグベース削除(関連データをまとめて無効化)

// キャッシュ作成時に関連タグを登録
async function cacheProductWithTag(product: Product) {
  const productKey = `product:${product.id}`;
  const categoryTag = `tag:category:${product.categoryId}`;
  
  // データをキャッシュ
  await redis.setex(productKey, 3600, JSON.stringify(product));
  
  // タグに紐づけ
  await redis.sadd(categoryTag, productKey);
  await redis.expire(categoryTag, 3600);
}

// カテゴリ配下の全商品キャッシュを削除
async function invalidateCategory(categoryId: string) {
  const categoryTag = `tag:category:${categoryId}`;
  const keys = await redis.smembers(categoryTag);
  
  if (keys.length > 0) {
    await redis.del(...keys);
  }
  await redis.del(categoryTag);
}

5. メモリ管理とエビクションポリシー

5.1 Redis のメモリ上限設定

# redis.conf または起動オプション
maxmemory 2gb
maxmemory-policy allkeys-lru

5.2 エビクションポリシーの選び方

| ポリシー | 説明 | 推奨ケース | |---------|------|------------| | noeviction | メモリ満杯時に書き込みエラー | 本番では非推奨 | | allkeys-lru | 全キーから LRU で削除 | 汎用的。迷ったらこれ | | volatile-lru | TTL 付きキーから LRU で削除 | セッションと永続キャッシュ混在時 | | allkeys-lfu | アクセス頻度が低いものを削除 | アクセスパターンが偏っている | | volatile-ttl | TTL が短いものから削除 | TTL 設計が明確な場合 |

5.3 メモリ使用量の監視

// メモリ使用状況を取得
const info = await redis.info('memory');
console.log(info);

// 特定の値を取得
const usedMemory = await redis.call('INFO', 'memory')
  .then(info => {
    const match = info.match(/used_memory:(\d+)/);
    return match ? parseInt(match[1]) : 0;
  });

// 使用量が閾値を超えたらアラート
if (usedMemory > 1.8 * 1024 * 1024 * 1024) { // 1.8GB
  console.error('Redis memory usage is high!');
}

6. パフォーマンス最適化テクニック

6.1 Pipeline でコマンドをまとめて送信

// ❌ 悪い例:ループで個別に実行
for (const userId of userIds) {
  await redis.get(`user:${userId}`);
}

// ✅ 良い例:Pipeline で一括実行
const pipeline = redis.pipeline();
for (const userId of userIds) {
  pipeline.get(`user:${userId}`);
}
const results = await pipeline.exec();

const users = results.map(([err, data]) => {
  if (err) return null;
  return data ? JSON.parse(data) : null;
});

6.2 MGET で複数キーを一度に取得

// ❌ 悪い例
const user1 = await redis.get('user:1');
const user2 = await redis.get('user:2');
const user3 = await redis.get('user:3');

// ✅ 良い例
const [user1, user2, user3] = await redis.mget('user:1', 'user:2', 'user:3');

6.3 Lua スクリプトでアトミックな操作

// レート制限の実装例(1分間に100回まで)
const rateLimitScript = `
  local key = KEYS[1]
  local limit = tonumber(ARGV[1])
  local ttl = tonumber(ARGV[2])
  
  local current = redis.call('GET', key)
  if current and tonumber(current) >= limit then
    return 0
  end
  
  current = redis.call('INCR', key)
  if current == 1 then
    redis.call('EXPIRE', key, ttl)
  end
  
  return 1
`;

const allowed = await redis.eval(
  rateLimitScript,
  1, // キー数
  `ratelimit:${userId}`,
  100, // limit
  60   // TTL(秒)
);

if (!allowed) {
  throw new Error('Rate limit exceeded');
}

7. 実装チェックリスト

必須項目

  • [ ] 接続プールの設定maxRetriesPerRequest, retryStrategy を設定
  • [ ] エラーハンドリング:Redis 障害時に DB フォールバックする
  • [ ] TTL の設定:全キャッシュに TTL を設定(メモリリーク防止)
  • [ ] キー命名規則:プレフィックスで名前空間を分ける(例:app:user:123
  • [ ] JSON の安全な扱いJSON.parse() のエラー処理

推奨項目

  • [ ] 監視:メモリ使用量・ヒット率・レイテンシを CloudWatch / Datadog で監視
  • [ ] キャッシュウォーミング:起動時に頻繁にアクセスされるデータを事前ロード
  • [ ] ランダム TTL:キャッシュ雪崩(Thundering Herd)を防ぐ
  • [ ] バージョニング:データ構造変更時のキー競合を防ぐ(例:v2:user:123

本番運用項目

  • [ ] Redis の冗長化:Redis Cluster または Sentinel で HA 構成
  • [ ] バックアップ:RDB / AOF の設定
  • [ ] スケーリング計画:メモリ使用量が 70% を超えたらスケールアップ

8. よくある失敗パターンと対策

8.1 キャッシュ雪崩(Cache Avalanche)

問題:大量のキャッシュが同時に期限切れ → DB に負荷集中

対策:TTL にランダム性を持たせる

const baseTTL = 3600;
const jitter = Math.floor(Math.random() * 600); // ±5分
await redis.setex(key, baseTTL + jitter, value);

8.2 キャッシュ貫通(Cache Penetration)

問題:存在しないデータへのアクセスが繰り返され、キャッシュが効かない

対策:存在しないことをキャッシュする(Null Object パターン)

const product = await db.products.findUnique({ where: { id } });

if (!product) {
  // 「存在しない」を短時間キャッシュ
  await redis.setex(`product:${id}`, 60, 'null');
  return null;
}

await redis.setex(`product:${id}`, 3600, JSON.stringify(product));
return product;

8.3 ホットキー問題

問題:特定のキーに極端にアクセスが集中し、Redis の単一スレッド性能がボトルネック

対策:アプリケーション層でローカルキャッシュを併用

import NodeCache from 'node-cache';

const localCache = new NodeCache({ stdTTL: 10 }); // 10秒

async function getHotData(key: string) {
  // L1: ローカルメモリ
  const local = localCache.get(key);
  if (local) return local;
  
  // L2: Redis
  const cached = await redis.get(key);
  if (cached) {
    const data = JSON.parse(cached);
    localCache.set(key, data);
    return data;
  }
  
  // L3: DB
  const data = await db.getData(key);
  await redis.setex(key, 300, JSON.stringify(data));
  localCache.set(key, data);
  return data;
}

まとめ

Redis キャッシュは正しく使えば劇的な高速化を実現できますが、設計を誤ると複雑性だけが増します。

重要なポイント

  1. キャッシュパターンを適切に選ぶ:まずは Cache-Aside から始める
  2. TTL は必ず設定:メモリリークを防ぐ
  3. 無効化戦略を明確にする:即座 or TTL 任せを判断
  4. 監視を怠らない:ヒット率・メモリ使用量をモニタリング
  5. 障害時の挙動を設計:Redis が落ちても DB にフォールバックできる構成に

受託開発では、クライアントの予算・スケジュールと技術的最適解のバランスが重要です。まずは Cache-Aside + 適切な TTL でシンプルに始め、ボトルネックが明確になってから最適化する段階的アプローチをお勧めします。


Yureate へのお問い合わせ

Redis を含むインフラ設計・パフォーマンス改善・技術選定でお困りの際は、Yureate にご相談ください。受託開発の実績を活かし、ビジネス要件に最適な技術構成をご提案します。

この内容について相談する他の記事を見る