Mission 1: キャッシュパターンの選択
以下の4つのシナリオに対して、最適なキャッシュパターン(Cache-Aside / Read-Through / Write-Through / Write-Behind)を選択し、理由を説明せよ。
- 商品カタログ API: 読み取り 95%、書き込み 5%、強い整合性不要
- ユーザーセッション管理: 読み書き同頻度、強い整合性必要
- IoT センサーデータ集約: 秒間10万件の書き込み、リアルタイム集計
- ニュースフィード生成: 読み取り多、計算コスト高、数分の遅延許容
解答例
// 1. 商品カタログ API → Cache-Aside
// 理由: 読み取り主体で最もシンプル。TTLベースの失効で十分。
// Write-Through の同期書き込みオーバーヘッドが不要。
class ProductCatalogCache {
async getProduct(id: string): Promise<Product | null> {
const cached = await redis.get(`product:${id}`);
if (cached) return JSON.parse(cached);
const product = await db.products.findById(id);
if (product) {
await redis.set(`product:${id}`, JSON.stringify(product), 'EX', 3600);
}
return product;
}
}
// 2. セッション管理 → Write-Through
// 理由: 読み書き同頻度で整合性重要。キャッシュとDBを同期更新。
// セッション更新が即座にキャッシュに反映される必要がある。
class SessionStore {
async updateSession(sessionId: string, data: SessionData): Promise<void> {
await db.sessions.upsert(sessionId, data);
await redis.set(`session:${sessionId}`, JSON.stringify(data), 'EX', 7200);
}
}
// 3. IoT データ集約 → Write-Behind
// 理由: 秒間10万件をDBに直接書くのは不可能。
// キャッシュにバッファし、バッチでDBに書き込む。
class IoTDataBuffer {
private buffer: SensorReading[] = [];
// 1秒ごとにバッチ書き込み
async flush(): Promise<void> {
const batch = this.buffer.splice(0, 10000);
await db.sensorReadings.bulkInsert(batch);
}
}
// 4. ニュースフィード → Read-Through + PER
// 理由: 計算コストが高く、数分の遅延が許容できる。
// PER で確率的に早期再計算し、Cache Stampede を防止。
class NewsFeedCache {
async getFeed(userId: string): Promise<FeedItem[]> {
return perCache.getOrSet(
`feed:${userId}`,
() => this.generateFeed(userId),
300, // 5分 TTL
1.5 // beta(やや積極的に再計算)
);
}
}
Mission 2: Redis キャッシュ設計
EC サイトの商品検索機能に対して、Redis を使ったキャッシュ設計を行え。
要件:
- 商品数: 100万件
- 検索 QPS: 5,000
- 平均レスポンスサイズ: 2KB
- 検索条件: カテゴリ、価格帯、キーワード、ソート順
- 商品情報の更新頻度: 平均1分に1件
- キャッシュ予算: Redis 8GB
設計すべき項目:
- キャッシュキーの設計
- TTL とエビクション戦略
- メモリ使用量の推定
- キャッシュヒット率の向上策
解答例
// 1. キャッシュキー設計
function buildSearchCacheKey(params: SearchParams): string {
// 正規化してキーの一意性を保証
const normalized = {
cat: params.category || 'all',
pmin: params.priceMin || 0,
pmax: params.priceMax || 999999,
q: (params.keyword || '').toLowerCase().trim(),
sort: params.sortBy || 'relevance',
page: params.page || 1,
size: params.pageSize || 20,
};
// キーは短く、でも衝突しないように
return `search:${JSON.stringify(normalized)}`;
// もしくはハッシュ化: `search:${md5(JSON.stringify(normalized))}`
}
// 2. TTL とエビクション
// - 検索結果TTL: 60秒(1分に1件更新、許容範囲)
// - エビクション: allkeys-lfu(人気検索条件を優先保持)
// - 商品更新時はタグベースで関連キャッシュを無効化
// 3. メモリ使用量推定
// 検索結果キャッシュ:
// - ユニーク検索条件/分: 5000QPS × 60s = 300,000リクエスト
// - ユニーク率 30% → 90,000 ユニークキー
// - キーサイズ: ~100B、値サイズ: ~2KB
// - 90,000 × (100 + 2048) = ~193MB
// - Redis オーバーヘッド 2x → ~400MB
// → 8GBの5%、余裕あり
// 商品個別キャッシュ:
// - 人気商品 10万件 × 1KB = 100MB
// - Redis オーバーヘッド 2x → 200MB
// 合計: ~600MB / 8GB → 十分な余裕
// 4. キャッシュヒット率向上策
class SearchCacheOptimizer {
// 4a. 検索条件の正規化
normalizeQuery(raw: string): string {
return raw.toLowerCase().trim()
.replace(/\s+/g, ' ') // 連続スペースを1つに
.replace(/[^\w\s]/g, ''); // 特殊文字除去
}
// 4b. 価格帯のバケット化(連続値をグループ化)
bucketizePrice(price: number): number {
if (price <= 1000) return Math.ceil(price / 100) * 100;
if (price <= 10000) return Math.ceil(price / 500) * 500;
return Math.ceil(price / 1000) * 1000;
}
// 4c. 人気検索のプリウォーム
async prewarmPopularSearches(): Promise<void> {
const popularSearches = await db.searchLogs
.groupBy('normalized_query')
.orderBy('count', 'desc')
.limit(1000);
for (const search of popularSearches) {
await this.executeAndCache(search.params);
}
}
// 4d. 関連キーワードの統合
// "iPhone 15" と "iphone15" を同じキャッシュに
}
Mission 3: Cache-Control ヘッダー設計
以下の API エンドポイントに対して、適切な Cache-Control ヘッダーを設計せよ。
| エンドポイント | 特性 |
|---|---|
GET /api/products/:id | 商品情報、更新頻度低 |
GET /api/users/me | ログインユーザー情報 |
GET /api/feed | パーソナライズフィード |
GET /api/categories | マスタデータ、ほぼ不変 |
GET /api/products/:id/reviews | ユーザーレビュー、頻繁に追加 |
POST /api/orders | 注文作成 |
解答例
const cacheHeaders: Record<string, string> = {
// 商品情報: CDNでキャッシュ、ブラウザはSWRで更新
'GET /api/products/:id':
'public, max-age=60, s-maxage=300, stale-while-revalidate=600, stale-if-error=86400',
// ログインユーザー情報: プライベート、短めTTL
'GET /api/users/me':
'private, max-age=60, must-revalidate',
// パーソナライズフィード: CDNキャッシュ不可、ブラウザのみ短期キャッシュ
'GET /api/feed':
'private, no-cache, max-age=0',
// Vary: Authorization, Accept-Language
// マスタデータ: 長期キャッシュ、SWRで更新
'GET /api/categories':
'public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400',
// レビュー: CDNで短期キャッシュ、SWRで鮮度と速度のバランス
'GET /api/products/:id/reviews':
'public, max-age=0, s-maxage=30, stale-while-revalidate=120',
// 注文作成: キャッシュ禁止
'POST /api/orders':
'no-store',
};
// Vary ヘッダーも重要
const varyHeaders: Record<string, string[]> = {
'GET /api/products/:id': ['Accept', 'Accept-Encoding'],
'GET /api/users/me': ['Authorization'],
'GET /api/feed': ['Authorization', 'Accept-Language'],
'GET /api/categories': ['Accept-Language', 'Accept-Encoding'],
'GET /api/products/:id/reviews': ['Accept-Encoding'],
};
Mission 4: Cache Stampede 対策
以下のシナリオで Cache Stampede が発生する状況を分析し、最適な防止策を実装せよ。
シナリオ:
- 人気商品ページのアクセス: 1,000 req/s
- 商品情報の計算コスト: 500ms(DB + 外部API + 集計)
- キャッシュ TTL: 60秒
- Redis の SETNX レイテンシ: 1ms
- 問題: TTL 失効の瞬間に 1,000 リクエストが同時に DB に殺到
解答例
// 最適解: Stale + バックグラウンド更新 + ロック
// 理由:
// - 500ms の計算コストは長い → stale データを返すのが最善
// - 1000 req/s → ロックだけでは待ちが発生
// - PER は確率的なので、1000req/sでは複数が同時に再計算してしまう可能性
class HotProductCache {
private refreshing = new Map<string, Promise<ProductDetail>>();
constructor(
private redis: CacheClient,
private lockTtlMs: number = 10000
) {}
async getProduct(productId: string): Promise<ProductDetail | null> {
const key = `product_detail:${productId}`;
// 1. データ取得(fresh でも stale でも返す)
const cached = await this.redis.get(key);
if (cached) {
const entry = JSON.parse(cached) as {
data: ProductDetail;
expiresAt: number;
};
// fresh ならそのまま返す
if (Date.now() < entry.expiresAt) {
return entry.data;
}
// stale だがデータはある → バックグラウンドで更新
this.triggerBackgroundRefresh(productId, key);
return entry.data; // stale データを即時返却
}
// 2. 完全にキャッシュなし → ロック付きで取得
return this.loadWithLock(productId, key);
}
private async triggerBackgroundRefresh(
productId: string, key: string
): Promise<void> {
// 既に更新中なら何もしない
if (this.refreshing.has(key)) return;
// 分散ロックを取得(他のインスタンスの重複更新を防止)
const lockKey = `lock:refresh:${key}`;
const locked = await this.redis.set(lockKey, '1', 'PX', this.lockTtlMs, 'NX');
if (!locked) return;
const promise = this.computeProductDetail(productId)
.then(async (data) => {
await this.redis.set(key, JSON.stringify({
data,
expiresAt: Date.now() + 60000, // 60秒後にstaleに
}), 'EX', 300); // 300秒後にデータ自体が消える
})
.catch(err => console.error('Background refresh failed:', err))
.finally(() => {
this.refreshing.delete(key);
this.redis.del(lockKey);
});
this.refreshing.set(key, promise);
}
private async loadWithLock(
productId: string, key: string
): Promise<ProductDetail | null> {
const lockKey = `lock:load:${key}`;
const locked = await this.redis.set(lockKey, '1', 'PX', this.lockTtlMs, 'NX');
if (locked) {
try {
const data = await this.computeProductDetail(productId);
await this.redis.set(key, JSON.stringify({
data,
expiresAt: Date.now() + 60000,
}), 'EX', 300);
return data;
} finally {
await this.redis.del(lockKey);
}
}
// ロック取得失敗 → 待機してリトライ
await new Promise(r => setTimeout(r, 100));
const cached = await this.redis.get(key);
if (cached) return JSON.parse(cached).data;
return this.loadWithLock(productId, key); // リトライ
}
private async computeProductDetail(productId: string): Promise<ProductDetail> {
// 500ms かかる重い処理
const [product, reviews, inventory] = await Promise.all([
db.products.findById(productId),
reviewApi.getReviews(productId),
inventoryService.getStock(productId),
]);
return { ...product, reviews, inventory };
}
}
interface ProductDetail {
reviews: any;
inventory: any;
[key: string]: any;
}