「Redis への1回のラウンドトリップが1msだとしても、100回呼べば100msだ」佐藤CTOは指摘した。「インプロセスキャッシュを組み合わせることで、さらにレイテンシを削減できる。ただし、マルチインスタンス環境での整合性には要注意だ。」
1. インプロセスキャッシュ
LRU キャッシュの実装
// 高性能なLRUキャッシュの実装
class LRUCache<K, V> {
private cache = new Map<K, V>();
constructor(private maxSize: number) {}
get(key: K): V | undefined {
if (!this.cache.has(key)) return undefined;
// アクセスしたキーを末尾に移動(LRU更新)
const value = this.cache.get(key)!;
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
set(key: K, value: V): void {
// 既存キーの場合は削除して再挿入(LRU更新)
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.maxSize) {
// 最も古いエントリ(Map の最初の要素)を削除
const oldestKey = this.cache.keys().next().value;
if (oldestKey !== undefined) {
this.cache.delete(oldestKey);
}
}
this.cache.set(key, value);
}
has(key: K): boolean {
return this.cache.has(key);
}
delete(key: K): boolean {
return this.cache.delete(key);
}
clear(): void {
this.cache.clear();
}
get size(): number {
return this.cache.size;
}
}
// TTL 付きのインプロセスキャッシュ
class TTLCache<K, V> {
private cache = new Map<K, { value: V; expiresAt: number }>();
private cleanupInterval: NodeJS.Timeout;
constructor(
private maxSize: number,
private defaultTtlMs: number,
cleanupIntervalMs: number = 60000
) {
this.cleanupInterval = setInterval(() => this.cleanup(), cleanupIntervalMs);
}
get(key: K): V | undefined {
const entry = this.cache.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return undefined;
}
return entry.value;
}
set(key: K, value: V, ttlMs?: number): void {
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
// 最も古いエントリを削除
const oldestKey = this.cache.keys().next().value;
if (oldestKey !== undefined) {
this.cache.delete(oldestKey);
}
}
this.cache.set(key, {
value,
expiresAt: Date.now() + (ttlMs ?? this.defaultTtlMs),
});
}
private cleanup(): void {
const now = Date.now();
for (const [key, entry] of this.cache) {
if (now > entry.expiresAt) {
this.cache.delete(key);
}
}
}
destroy(): void {
clearInterval(this.cleanupInterval);
this.cache.clear();
}
}
2. メモ化(Memoization)
// 関数メモ化のユーティリティ
function memoize<TArgs extends unknown[], TResult>(
fn: (...args: TArgs) => TResult,
options: {
maxSize?: number;
ttlMs?: number;
keyFn?: (...args: TArgs) => string;
} = {}
): (...args: TArgs) => TResult {
const {
maxSize = 1000,
ttlMs = 300000, // 5分
keyFn = (...args: TArgs) => JSON.stringify(args),
} = options;
const cache = new TTLCache<string, TResult>(maxSize, ttlMs);
return (...args: TArgs): TResult => {
const key = keyFn(...args);
const cached = cache.get(key);
if (cached !== undefined) return cached;
const result = fn(...args);
cache.set(key, result);
return result;
};
}
// 非同期メモ化(重複リクエストの抑制付き)
function memoizeAsync<TArgs extends unknown[], TResult>(
fn: (...args: TArgs) => Promise<TResult>,
options: {
maxSize?: number;
ttlMs?: number;
keyFn?: (...args: TArgs) => string;
} = {}
): (...args: TArgs) => Promise<TResult> {
const {
maxSize = 1000,
ttlMs = 300000,
keyFn = (...args: TArgs) => JSON.stringify(args),
} = options;
const cache = new TTLCache<string, TResult>(maxSize, ttlMs);
const inflight = new Map<string, Promise<TResult>>();
return async (...args: TArgs): Promise<TResult> => {
const key = keyFn(...args);
// キャッシュヒット
const cached = cache.get(key);
if (cached !== undefined) return cached;
// 同じキーのリクエストが進行中なら合流(deduplication)
if (inflight.has(key)) {
return inflight.get(key)!;
}
// 新規リクエスト
const promise = fn(...args).then(result => {
cache.set(key, result);
inflight.delete(key);
return result;
}).catch(error => {
inflight.delete(key);
throw error;
});
inflight.set(key, promise);
return promise;
};
}
// 使用例
const getProductPrice = memoizeAsync(
async (productId: string) => {
const product = await db.products.findById(productId);
return product?.price ?? 0;
},
{ ttlMs: 60000, keyFn: (id) => `price:${id}` }
);
3. HTTP レスポンスキャッシュ
// Express ミドルウェアによるレスポンスキャッシュ
import { Request, Response, NextFunction } from 'express';
interface ResponseCacheOptions {
ttlSeconds: number;
keyGenerator?: (req: Request) => string;
condition?: (req: Request) => boolean;
vary?: string[];
}
function responseCacheMiddleware(
cache: CacheClient,
options: ResponseCacheOptions
) {
const {
ttlSeconds,
keyGenerator = (req) => `res:${req.method}:${req.originalUrl}`,
condition = (req) => req.method === 'GET',
vary = [],
} = options;
return async (req: Request, res: Response, next: NextFunction) => {
if (!condition(req)) return next();
// Vary ヘッダーを考慮したキャッシュキー生成
let cacheKey = keyGenerator(req);
for (const header of vary) {
cacheKey += `:${req.get(header) || ''}`;
}
// キャッシュ確認
const cached = await cache.get(cacheKey);
if (cached) {
const { statusCode, headers, body } = JSON.parse(cached);
res.set('X-Cache', 'HIT');
res.set(headers);
return res.status(statusCode).send(body);
}
// レスポンスをキャプチャ
const originalJson = res.json;
res.json = function (this: Response, body: any) {
const entry = {
statusCode: this.statusCode,
headers: {
'Content-Type': this.get('Content-Type'),
},
body,
};
// 成功レスポンスのみキャッシュ
if (this.statusCode >= 200 && this.statusCode < 300) {
cache.set(cacheKey, JSON.stringify(entry), 'EX', ttlSeconds);
}
res.set('X-Cache', 'MISS');
return originalJson.call(this, body);
} as any;
next();
};
}
// 使用例
// app.get('/api/products', responseCacheMiddleware(redis, {
// ttlSeconds: 60,
// vary: ['Accept-Language'],
// }), productController.list);
4. Cache Stampede(サンダリングハード問題)の防止
// Cache Stampede: キャッシュ失効時に大量のリクエストが同時にDBに殺到する問題
// 対策1: ロックベース(Mutex)
class StampedeProtectedCache {
constructor(
private cache: CacheClient,
private lockTtlMs: number = 5000
) {}
async getOrSet<T>(
key: string,
loader: () => Promise<T>,
ttlSeconds: number
): Promise<T> {
// 1. キャッシュ確認
const cached = await this.cache.get(key);
if (cached) return JSON.parse(cached);
// 2. ロックを取得して排他的にロード
const lockKey = `lock:${key}`;
const lockAcquired = await this.cache.set(
lockKey, '1', 'PX', this.lockTtlMs, 'NX'
);
if (lockAcquired) {
try {
// ロック取得成功 → ロードして保存
const value = await loader();
await this.cache.set(key, JSON.stringify(value), 'EX', ttlSeconds);
return value;
} finally {
await this.cache.del(lockKey);
}
} else {
// ロック取得失敗 → 少し待ってリトライ
await new Promise(resolve => setTimeout(resolve, 100));
return this.getOrSet(key, loader, ttlSeconds);
}
}
}
// 対策2: 確率的早期再検証(PER: Probabilistic Early Recomputation)
class PERCache {
constructor(private cache: CacheClient) {}
async getOrSet<T>(
key: string,
loader: () => Promise<T>,
ttlSeconds: number,
beta: number = 1.0 // 再計算の積極性(大きいほど早期に再計算)
): Promise<T> {
const entry = await this.cache.get(key);
if (entry) {
const parsed = JSON.parse(entry) as {
value: T;
computeTime: number;
createdAt: number;
};
const ttlRemaining = ttlSeconds - (Date.now() - parsed.createdAt) / 1000;
// 確率的に早期再計算を実行
// TTL残りが少ないほど、computeTimeが長いほど再計算確率が上がる
const shouldRecompute =
ttlRemaining < beta * parsed.computeTime * Math.log(Math.random()) * -1;
if (!shouldRecompute) {
return parsed.value;
}
}
// キャッシュミスまたは早期再計算
const startTime = Date.now();
const value = await loader();
const computeTime = (Date.now() - startTime) / 1000;
await this.cache.set(key, JSON.stringify({
value,
computeTime,
createdAt: Date.now(),
}), 'EX', ttlSeconds);
return value;
}
}
// 対策3: stale キャッシュ + バックグラウンド更新
class StaleBackgroundCache {
private refreshing = new Set<string>();
constructor(private cache: CacheClient) {}
async getOrSet<T>(
key: string,
loader: () => Promise<T>,
freshTtlSeconds: number,
staleTtlSeconds: number
): Promise<T | null> {
const entry = await this.cache.get(`data:${key}`);
const isFresh = await this.cache.get(`fresh:${key}`);
if (entry) {
const value = JSON.parse(entry) as T;
if (!isFresh && !this.refreshing.has(key)) {
// stale データ → バックグラウンドで更新
this.refreshInBackground(key, loader, freshTtlSeconds, staleTtlSeconds);
}
return value;
}
// 完全にキャッシュなし
const value = await loader();
await this.setWithStale(key, value, freshTtlSeconds, staleTtlSeconds);
return value;
}
private async setWithStale<T>(
key: string, value: T, freshTtl: number, staleTtl: number
): Promise<void> {
await this.cache.set(`data:${key}`, JSON.stringify(value), 'EX', staleTtl);
await this.cache.set(`fresh:${key}`, '1', 'EX', freshTtl);
}
private async refreshInBackground<T>(
key: string, loader: () => Promise<T>,
freshTtl: number, staleTtl: number
): Promise<void> {
this.refreshing.add(key);
try {
const value = await loader();
await this.setWithStale(key, value, freshTtl, staleTtl);
} finally {
this.refreshing.delete(key);
}
}
}
// 型定義
declare class CacheClient {
get(key: string): Promise<string | null>;
set(key: string, value: string, ...args: any[]): Promise<any>;
del(...keys: string[]): Promise<number>;
keys(pattern: string): Promise<string[]>;
sadd(key: string, ...members: string[]): Promise<number>;
smembers(key: string): Promise<string[]>;
}
declare class DatabaseClient {
findById(id: string): Promise<any>;
update(id: string, data: any): Promise<any>;
save(entity: any): Promise<any>;
bulkUpsert(entities: any[]): Promise<void>;
}
コラム: 多層キャッシュの設計原則
L1: インプロセスキャッシュ → レイテンシ: ~0.01ms, サイズ: ~100MB
L2: Redis (ローカルリージョン) → レイテンシ: ~1ms, サイズ: ~10GB
L3: CDN エッジ → レイテンシ: ~5ms, サイズ: ~100GB
L4: オリジンサーバー → レイテンシ: ~50ms
設計原則:
- L1 は小さく短く: ホットデータのみ、TTL短め(秒〜分)
- L2 は共有: 全インスタンスで共有、整合性を担保
- L3 は公開データ: パーソナライズされていないコンテンツ
- 無効化は L1→L2→L3 の順: Redis Pub/Sub でL1を無効化通知
まとめ
| トピック | 要点 |
|---|---|
| インプロセスキャッシュ | LRU/TTL、レイテンシ最小、サイズに注意 |
| メモ化 | 関数レベルのキャッシュ、重複リクエスト抑制 |
| HTTP レスポンスキャッシュ | Vary ヘッダー考慮、ミドルウェアで実装 |
| Cache Stampede 防止 | ロック/PER/Stale+背景更新の3パターン |
チェックリスト
- LRU キャッシュの仕組みを実装レベルで理解した
- 非同期メモ化と重複リクエスト抑制を説明できる
- Cache Stampede の3つの防止策を比較できる
- 多層キャッシュの設計原則を理解した
次のステップへ
アプリケーションレベルのキャッシュ戦略を学んだ。次は演習で 多層キャッシュ戦略の設計 に挑戦しよう。
推定読了時間: 40分