
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 キャッシュは正しく使えば劇的な高速化を実現できますが、設計を誤ると複雑性だけが増します。
重要なポイント:
- キャッシュパターンを適切に選ぶ:まずは Cache-Aside から始める
- TTL は必ず設定:メモリリークを防ぐ
- 無効化戦略を明確にする:即座 or TTL 任せを判断
- 監視を怠らない:ヒット率・メモリ使用量をモニタリング
- 障害時の挙動を設計:Redis が落ちても DB にフォールバックできる構成に
受託開発では、クライアントの予算・スケジュールと技術的最適解のバランスが重要です。まずは Cache-Aside + 適切な TTL でシンプルに始め、ボトルネックが明確になってから最適化する段階的アプローチをお勧めします。
Yureate へのお問い合わせ
Redis を含むインフラ設計・パフォーマンス改善・技術選定でお困りの際は、Yureate にご相談ください。受託開発の実績を活かし、ビジネス要件に最適な技術構成をご提案します。
