database

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

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 パターン

// 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 パターン

// 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実装

// 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 };

レート制限

固定ウィンドウ

// 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は、高速なデータアクセスを実現する強力なツールです。

キャッシュ戦略の選択

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

ベストプラクティス

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

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

参考リンク

← 一覧に戻る