Redisキャッシュ実践ガイド - 高速なデータアクセスの設計と実装

中級 | 45分 で読める | 2025.12.02

公式ドキュメント

今回やること

Redisは、高速なインメモリデータストアとして、キャッシュ、セッション管理、リアルタイム機能など幅広い用途で活用されています。本記事では、Redisを使った効果的なキャッシュ戦略を実践的に解説します。

キャッシュは速くするための道具ですが、古いデータ、削除漏れ、障害時の挙動まで含めて設計しないと不具合の原因になります。

Redisの基本

接続設定

// redis.ts
import Redis from 'ioredis';

// シングルインスタンス
const redis = new Redis({
 host: process.env.REDIS_HOST || 'localhost',
 port: parseInt(process.env.REDIS_PORT || '6379'),
 password: process.env.REDIS_PASSWORD,
 db: 0,
 retryStrategy: (times) => {
 const delay = Math.min(times * 50, 2000);
 return delay;
 },
 maxRetriesPerRequest: 3,
 enableReadyCheck: true,
 lazyConnect: true,
});

// クラスター構成
const cluster = new Redis.Cluster([
 { host: 'redis-node-1', port: 6379 },
 { host: 'redis-node-2', port: 6379 },
 { host: 'redis-node-3', port: 6379 },
], {
 redisOptions: {
 password: process.env.REDIS_PASSWORD,
 },
 scaleReads: 'slave', // 読み取りをスレーブに分散
 maxRedirections: 16,
});

// 接続イベント
redis.on('connect', () => console.log('Redis connected'));
redis.on('error', (err) => console.error('Redis error:', err));
redis.on('close', () => console.log('Redis connection closed'));

export { redis, cluster };

データ型の概要

データ型用途
Stringキャッシュ、カウンター、セッション
Hashオブジェクトの保存、ユーザープロファイル
Listキュー、タイムライン、ログ
Setタグ、ユニーク値、関係性
Sorted Setランキング、スコアボード、タイムライン
Streamイベントソーシング、メッセージング
JSON複雑なオブジェクト(RedisJSON)

キャッシュパターン

ポイント: Cache-Asideは最も基本的なキャッシュパターンです。「キャッシュを確認 → ミスならDBから取得 → キャッシュに保存」の3ステップで動作します

Cache-Aside パターン

// cache-aside.ts
import { redis } from './redis';

interface CacheOptions {
 ttl?: number; // 秒
 prefix?: string;
}

class CacheAside<T> {
 private prefix: string;
 private defaultTtl: number;

 constructor(prefix: string, defaultTtl: number = 3600) {
 this.prefix = prefix;
 this.defaultTtl = defaultTtl;
 }

 private getKey(id: string): string {
 return `${this.prefix}:${id}`;
 }

 async get(id: string): Promise<T | null> {
 const cached = await redis.get(this.getKey(id));
 if (cached) {
 return JSON.parse(cached);
 }
 return null;
 }

 async set(id: string, data: T, ttl?: number): Promise<void> {
 const key = this.getKey(id);
 const serialized = JSON.stringify(data);
 await redis.setex(key, ttl || this.defaultTtl, serialized);
 }

 async getOrFetch(
 id: string,
 fetcher: () => Promise<T | null>,
 ttl?: number
 ): Promise<T | null> {
 // 1. キャッシュを確認
 const cached = await this.get(id);
 if (cached) {
 return cached;
 }

 // 2. データソースから取得
 const data = await fetcher();
 if (data === null) {
 return null;
 }

 // 3. キャッシュに保存
 await this.set(id, data, ttl);

 return data;
 }

 async invalidate(id: string): Promise<void> {
 await redis.del(this.getKey(id));
 }

 async invalidatePattern(pattern: string): Promise<void> {
 const keys = await redis.keys(`${this.prefix}:${pattern}`);
 if (keys.length > 0) {
 await redis.del(...keys);
 }
 }
}

// 使用例
interface User {
 id: string;
 name: string;
 email: string;
}

const userCache = new CacheAside<User>('user', 3600);

async function getUser(id: string): Promise<User | null> {
 return userCache.getOrFetch(id, async () => {
 // データベースから取得
 const user = await db.user.findUnique({ where: { id } });
 return user;
 });
}

async function updateUser(id: string, data: Partial<User>): Promise<User> {
 // データベースを更新
 const user = await db.user.update({
 where: { id },
 data,
 });

 // キャッシュを無効化
 await userCache.invalidate(id);

 return user;
}

Write-Through パターン

// write-through.ts

class WriteThrough<T extends { id: string }> {
 private cache: CacheAside<T>;

 constructor(
 prefix: string,
 private repository: Repository<T>,
 ttl: number = 3600
 ) {
 this.cache = new CacheAside<T>(prefix, ttl);
 }

 async get(id: string): Promise<T | null> {
 return this.cache.getOrFetch(id, () => this.repository.findById(id));
 }

 async create(data: Omit<T, 'id'>): Promise<T> {
 // 1. データベースに書き込み
 const entity = await this.repository.create(data);

 // 2. 同時にキャッシュにも書き込み
 await this.cache.set(entity.id, entity);

 return entity;
 }

 async update(id: string, data: Partial<T>): Promise<T> {
 // 1. データベースを更新
 const entity = await this.repository.update(id, data);

 // 2. キャッシュも更新
 await this.cache.set(id, entity);

 return entity;
 }

 async delete(id: string): Promise<void> {
 // 1. データベースから削除
 await this.repository.delete(id);

 // 2. キャッシュも削除
 await this.cache.invalidate(id);
 }
}

注意: Write-Behindパターンはキャッシュに先に書き込みDBへの反映を遅延させるため、プロセス障害時にデータロスのリスクがあります。重要度の高いデータには使用を避けてください

Write-Behind パターン

// write-behind.ts
import { redis } from './redis';

class WriteBehind<T extends { id: string }> {
 private pendingWrites: Map<string, T> = new Map();
 private flushInterval: NodeJS.Timeout;

 constructor(
 private prefix: string,
 private repository: Repository<T>,
 private batchSize: number = 100,
 private flushIntervalMs: number = 5000
 ) {
 // 定期的にバッチ書き込み
 this.flushInterval = setInterval(
 () => this.flush(),
 flushIntervalMs
 );
 }

 async write(entity: T): Promise<void> {
 // 1. キャッシュに即座に書き込み
 await redis.setex(
 `${this.prefix}:${entity.id}`,
 3600,
 JSON.stringify(entity)
 );

 // 2. ペンディングキューに追加
 this.pendingWrites.set(entity.id, entity);

 // バッチサイズに達したらフラッシュ
 if (this.pendingWrites.size >= this.batchSize) {
 await this.flush();
 }
 }

 async get(id: string): Promise<T | null> {
 // ペンディングにあればそれを返す
 if (this.pendingWrites.has(id)) {
 return this.pendingWrites.get(id)!;
 }

 // キャッシュを確認
 const cached = await redis.get(`${this.prefix}:${id}`);
 if (cached) {
 return JSON.parse(cached);
 }

 // データベースから取得
 return this.repository.findById(id);
 }

 private async flush(): Promise<void> {
 if (this.pendingWrites.size === 0) return;

 const writes = Array.from(this.pendingWrites.values());
 this.pendingWrites.clear();

 try {
 // バッチでデータベースに書き込み
 await this.repository.upsertMany(writes);
 console.log(`Flushed ${writes.length} writes to database`);
 } catch (error) {
 // 失敗した場合はペンディングに戻す
 for (const entity of writes) {
 this.pendingWrites.set(entity.id, entity);
 }
 console.error('Flush failed:', error);
 }
 }

 async stop(): Promise<void> {
 clearInterval(this.flushInterval);
 await this.flush();
 }
}

TTL戦略

適応型TTL

// adaptive-ttl.ts

interface CacheMetrics {
 hits: number;
 misses: number;
 lastAccess: number;
}

class AdaptiveTTLCache<T> {
 private metrics: Map<string, CacheMetrics> = new Map();
 private readonly minTtl: number;
 private readonly maxTtl: number;
 private readonly baseTtl: number;

 constructor(
 private prefix: string,
 options: { minTtl?: number; maxTtl?: number; baseTtl?: number } = {}
 ) {
 this.minTtl = options.minTtl || 60; // 1分
 this.maxTtl = options.maxTtl || 86400; // 24時間
 this.baseTtl = options.baseTtl || 3600; // 1時間
 }

 private calculateTtl(key: string): number {
 const metric = this.metrics.get(key);

 if (!metric) {
 return this.baseTtl;
 }

 const hitRate = metric.hits / (metric.hits + metric.misses + 1);
 const timeSinceLastAccess = Date.now() - metric.lastAccess;

 // ヒット率が高いほどTTLを長く
 let ttl = this.baseTtl * (1 + hitRate);

 // アクセス頻度が高いほどTTLを長く
 if (timeSinceLastAccess < 60000) { // 1分以内
 ttl *= 1.5;
 }

 return Math.min(Math.max(ttl, this.minTtl), this.maxTtl);
 }

 async get(key: string): Promise<T | null> {
 const fullKey = `${this.prefix}:${key}`;
 const cached = await redis.get(fullKey);

 const metric = this.metrics.get(key) || { hits: 0, misses: 0, lastAccess: 0 };

 if (cached) {
 metric.hits++;
 metric.lastAccess = Date.now();
 this.metrics.set(key, metric);
 return JSON.parse(cached);
 }

 metric.misses++;
 this.metrics.set(key, metric);
 return null;
 }

 async set(key: string, data: T): Promise<void> {
 const fullKey = `${this.prefix}:${key}`;
 const ttl = Math.round(this.calculateTtl(key));

 await redis.setex(fullKey, ttl, JSON.stringify(data));
 }
}

階層型キャッシュ

// tiered-cache.ts

class TieredCache<T> {
 private l1: Map<string, { data: T; expiry: number }> = new Map();
 private l1MaxSize: number;
 private l1Ttl: number;

 constructor(
 private prefix: string,
 private l2Ttl: number = 3600,
 l1Options: { maxSize?: number; ttl?: number } = {}
 ) {
 this.l1MaxSize = l1Options.maxSize || 1000;
 this.l1Ttl = l1Options.ttl || 60;
 }

 async get(key: string): Promise<T | null> {
 // L1 (インメモリ) チェック
 const l1Entry = this.l1.get(key);
 if (l1Entry && l1Entry.expiry > Date.now()) {
 return l1Entry.data;
 }

 // L2 (Redis) チェック
 const l2Data = await redis.get(`${this.prefix}:${key}`);
 if (l2Data) {
 const data = JSON.parse(l2Data);
 // L1に昇格
 this.setL1(key, data);
 return data;
 }

 return null;
 }

 async set(key: string, data: T): Promise<void> {
 // L1に保存
 this.setL1(key, data);

 // L2に保存
 await redis.setex(
 `${this.prefix}:${key}`,
 this.l2Ttl,
 JSON.stringify(data)
 );
 }

 private setL1(key: string, data: T): void {
 // LRUエビクション
 if (this.l1.size >= this.l1MaxSize) {
 const oldestKey = this.l1.keys().next().value;
 this.l1.delete(oldestKey);
 }

 this.l1.set(key, {
 data,
 expiry: Date.now() + this.l1Ttl * 1000,
 });
 }

 async invalidate(key: string): Promise<void> {
 this.l1.delete(key);
 await redis.del(`${this.prefix}:${key}`);
 }
}

分散ロック

実践メモ: 分散ロックはRedlockアルゴリズムが推奨です。単一Redis障害時でも安全にロックが動作するため、決済処理や在庫管理などのクリティカルな処理に最適です

Redlock実装

// distributed-lock.ts
import Redlock from 'redlock';

const redlock = new Redlock([redis], {
 driftFactor: 0.01,
 retryCount: 10,
 retryDelay: 200,
 retryJitter: 200,
 automaticExtensionThreshold: 500,
});

// 使用例
async function processOrderWithLock(orderId: string) {
 let lock;

 try {
 // ロック取得
 lock = await redlock.acquire([`lock:order:${orderId}`], 30000);

 // クリティカルセクション
 const order = await db.order.findUnique({ where: { id: orderId } });

 if (order?.status !== 'pending') {
 throw new Error('Order already processed');
 }

 await db.order.update({
 where: { id: orderId },
 data: { status: 'processing' },
 });

 // 処理実行...

 await db.order.update({
 where: { id: orderId },
 data: { status: 'completed' },
 });
 } catch (error) {
 if (error instanceof Redlock.LockError) {
 console.log('Could not acquire lock, order is being processed');
 }
 throw error;
 } finally {
 // ロック解放
 if (lock) {
 await lock.release();
 }
 }
}

// シンプルなロック実装
class SimpleLock {
 async acquire(key: string, ttl: number = 30000): Promise<string | null> {
 const token = crypto.randomUUID();
 const result = await redis.set(
 `lock:${key}`,
 token,
 'PX',
 ttl,
 'NX'
 );

 return result === 'OK' ? token : null;
 }

 async release(key: string, token: string): Promise<boolean> {
 const script = `
 if redis.call("get", KEYS[1]) == ARGV[1] then
 return redis.call("del", KEYS[1])
 else
 return 0
 end
 `;

 const result = await redis.eval(script, 1, `lock:${key}`, token);
 return result === 1;
 }

 async withLock<T>(
 key: string,
 fn: () => Promise<T>,
 ttl: number = 30000
 ): Promise<T> {
 const token = await this.acquire(key, ttl);

 if (!token) {
 throw new Error(`Could not acquire lock for ${key}`);
 }

 try {
 return await fn();
 } finally {
 await this.release(key, token);
 }
 }
}

セッション管理

// session.ts
import { randomBytes } from 'crypto';

interface Session {
 userId: string;
 email: string;
 role: string;
 createdAt: number;
 lastAccessedAt: number;
}

class RedisSessionStore {
 private readonly prefix = 'session';
 private readonly ttl = 24 * 60 * 60; // 24時間

 async create(userId: string, userData: Partial<Session>): Promise<string> {
 const sessionId = randomBytes(32).toString('hex');
 const session: Session = {
 userId,
 email: userData.email || '',
 role: userData.role || 'user',
 createdAt: Date.now(),
 lastAccessedAt: Date.now(),
 };

 const key = `${this.prefix}:${sessionId}`;
 await redis.setex(key, this.ttl, JSON.stringify(session));

 // ユーザーのセッション一覧を管理
 await redis.sadd(`user_sessions:${userId}`, sessionId);
 await redis.expire(`user_sessions:${userId}`, this.ttl);

 return sessionId;
 }

 async get(sessionId: string): Promise<Session | null> {
 const key = `${this.prefix}:${sessionId}`;
 const data = await redis.get(key);

 if (!data) return null;

 const session: Session = JSON.parse(data);

 // スライディングウィンドウ: アクセス時にTTLを延長
 session.lastAccessedAt = Date.now();
 await redis.setex(key, this.ttl, JSON.stringify(session));

 return session;
 }

 async destroy(sessionId: string): Promise<void> {
 const session = await this.get(sessionId);
 if (session) {
 await redis.srem(`user_sessions:${session.userId}`, sessionId);
 }
 await redis.del(`${this.prefix}:${sessionId}`);
 }

 async destroyAllUserSessions(userId: string): Promise<void> {
 const sessionIds = await redis.smembers(`user_sessions:${userId}`);

 if (sessionIds.length > 0) {
 const keys = sessionIds.map(id => `${this.prefix}:${id}`);
 await redis.del(...keys);
 }

 await redis.del(`user_sessions:${userId}`);
 }

 async getUserSessionCount(userId: string): Promise<number> {
 return redis.scard(`user_sessions:${userId}`);
 }
}

const sessionStore = new RedisSessionStore();
export { sessionStore };

レート制限

レート制限はAPI保護の基本です。固定ウィンドウ、スライディングウィンドウ、トークンバケットの3つのアルゴリズムを用途に応じて使い分けます。

固定ウィンドウ

// rate-limiter.ts

class FixedWindowRateLimiter {
 constructor(
 private windowSizeSeconds: number,
 private maxRequests: number
 ) {}

 async isAllowed(key: string): Promise<{ allowed: boolean; remaining: number }> {
 const windowKey = `ratelimit:${key}:${Math.floor(Date.now() / 1000 / this.windowSizeSeconds)}`;

 const current = await redis.incr(windowKey);

 if (current === 1) {
 await redis.expire(windowKey, this.windowSizeSeconds);
 }

 const allowed = current <= this.maxRequests;
 const remaining = Math.max(0, this.maxRequests - current);

 return { allowed, remaining };
 }
}

スライディングウィンドウログ

class SlidingWindowLogRateLimiter {
 constructor(
 private windowSizeMs: number,
 private maxRequests: number
 ) {}

 async isAllowed(key: string): Promise<{ allowed: boolean; remaining: number; retryAfter?: number }> {
 const now = Date.now();
 const windowStart = now - this.windowSizeMs;
 const redisKey = `ratelimit:sliding:${key}`;

 // トランザクションで実行
 const multi = redis.multi();

 // 古いエントリを削除
 multi.zremrangebyscore(redisKey, 0, windowStart);

 // 現在のウィンドウ内のリクエスト数をカウント
 multi.zcard(redisKey);

 // 結果を取得
 const results = await multi.exec();
 const currentCount = results![1][1] as number;

 if (currentCount >= this.maxRequests) {
 // 最も古いリクエストの時間を取得してリトライ時間を計算
 const oldestRequest = await redis.zrange(redisKey, 0, 0, 'WITHSCORES');
 const retryAfter = oldestRequest.length > 0
 ? Math.ceil((parseInt(oldestRequest[1]) + this.windowSizeMs - now) / 1000)
 : 1;

 return {
 allowed: false,
 remaining: 0,
 retryAfter,
 };
 }

 // 新しいリクエストを追加
 await redis.zadd(redisKey, now, `${now}:${Math.random()}`);
 await redis.expire(redisKey, Math.ceil(this.windowSizeMs / 1000));

 return {
 allowed: true,
 remaining: this.maxRequests - currentCount - 1,
 };
 }
}

トークンバケット

class TokenBucketRateLimiter {
 constructor(
 private bucketSize: number, // バケットの最大容量
 private refillRate: number, // 1秒あたりの補充トークン数
 private refillIntervalMs: number = 1000
 ) {}

 async isAllowed(key: string, tokensRequired: number = 1): Promise<{
 allowed: boolean;
 tokens: number;
 retryAfter?: number;
 }> {
 const redisKey = `ratelimit:bucket:${key}`;

 // Luaスクリプトでアトミックに処理
 const script = `
 local bucket_key = KEYS[1]
 local bucket_size = tonumber(ARGV[1])
 local refill_rate = tonumber(ARGV[2])
 local refill_interval = tonumber(ARGV[3])
 local tokens_required = tonumber(ARGV[4])
 local now = tonumber(ARGV[5])

 local bucket = redis.call('HMGET', bucket_key, 'tokens', 'last_refill')
 local tokens = tonumber(bucket[1]) or bucket_size
 local last_refill = tonumber(bucket[2]) or now

 -- トークンを補充
 local time_passed = now - last_refill
 local refills = math.floor(time_passed / refill_interval)
 tokens = math.min(bucket_size, tokens + refills * refill_rate)
 last_refill = last_refill + refills * refill_interval

 if tokens >= tokens_required then
 tokens = tokens - tokens_required
 redis.call('HMSET', bucket_key, 'tokens', tokens, 'last_refill', last_refill)
 redis.call('EXPIRE', bucket_key, 3600)
 return {1, tokens}
 else
 redis.call('HMSET', bucket_key, 'tokens', tokens, 'last_refill', last_refill)
 redis.call('EXPIRE', bucket_key, 3600)
 local tokens_needed = tokens_required - tokens
 local wait_time = math.ceil(tokens_needed / refill_rate * refill_interval)
 return {0, tokens, wait_time}
 end
 `;

 const result = await redis.eval(
 script,
 1,
 redisKey,
 this.bucketSize,
 this.refillRate,
 this.refillIntervalMs,
 tokensRequired,
 Date.now()
 ) as [number, number, number?];

 return {
 allowed: result[0] === 1,
 tokens: result[1],
 retryAfter: result[2] ? Math.ceil(result[2] / 1000) : undefined,
 };
 }
}

監視とメトリクス

// redis-metrics.ts

class RedisMetrics {
 async getInfo(): Promise<Record<string, string>> {
 const info = await redis.info();
 const lines = info.split('\r\n');
 const result: Record<string, string> = {};

 for (const line of lines) {
 if (line.includes(':')) {
 const [key, value] = line.split(':');
 result[key] = value;
 }
 }

 return result;
 }

 async getKeyCount(): Promise<number> {
 const info = await this.getInfo();
 return parseInt(info['db0']?.split(',')[0]?.split('=')[1] || '0');
 }

 async getMemoryUsage(): Promise<{
 used: number;
 peak: number;
 fragmentation: number;
 }> {
 const info = await this.getInfo();
 return {
 used: parseInt(info['used_memory'] || '0'),
 peak: parseInt(info['used_memory_peak'] || '0'),
 fragmentation: parseFloat(info['mem_fragmentation_ratio'] || '1'),
 };
 }

 async getHitRate(): Promise<number> {
 const info = await this.getInfo();
 const hits = parseInt(info['keyspace_hits'] || '0');
 const misses = parseInt(info['keyspace_misses'] || '0');
 const total = hits + misses;

 return total > 0 ? hits / total : 0;
 }

 async getSlowLogs(count: number = 10): Promise<any[]> {
 return redis.slowlog('GET', count);
 }
}

const redisMetrics = new RedisMetrics();
export { redisMetrics };

動作確認

Redisを使う処理では、次を確認します。

確認項目コマンド・方法見ること
接続redis-cli pingPONG が返る
TTLTTL key期限切れが設定されている
ヒット率INFO statskeyspace_hitskeyspace_misses
メモリINFO memory上限に近づいていない
障害時Redis停止テストアプリ全体が落ちない

キャッシュが原因か迷ったら、一度キャッシュを使わない経路で同じ処理を確認します。

練習

次のCache-Asideパターンを実装してください。

  1. DBから記事を取得する関数を用意する
  2. 先にRedisから post:{id} を読む
  3. なければDBから取得してRedisへ保存する
  4. EX でTTLを設定する
  5. 記事更新時に該当キーを削除する

次のステップ

Redisの基本コマンドを先に確認したい場合は Redisチートシート を参照してください。キャッシュ設計の考え方は キャッシュ戦略Cache-Control も合わせて読むと整理できます。

まとめ

Redisは、高速なデータアクセスを実現する強力なツールです。

キャッシュ戦略の選択

パターンユースケース
Cache-Aside読み取り多、更新少
Write-Through一貫性重視
Write-Behind書き込み性能重視

ベストプラクティス

  1. 適切なTTL設計: データの特性に応じた設定
  2. キー設計: 名前空間とバージョニング
  3. 接続プール: コネクション再利用
  4. 監視: ヒット率とメモリ使用量
  5. フェイルオーバー: Redis Cluster/Sentinel

Redisを適切に活用することで、アプリケーションのパフォーマンスを大幅に向上させることができます。

参考リンク

参考リソース

← 一覧に戻る
PR
PR
PR
PR