LESSON 40分

「Redis は単なるキーバリューストアじゃない」と佐藤CTOは強調した。「データ構造サーバーであり、キャッシュ、セッションストア、メッセージブローカー、ランキングエンジンとしても使える。ただし、その特性を正しく理解しないと痛い目に遭う。」

1. Redis アーキテクチャ

シングルスレッドモデル

Redis はメインの処理がシングルスレッドで動作する。

特性説明
イベントループepoll/kqueue ベースの I/O 多重化
アトミック性単一コマンドはアトミックに実行
ロックフリー競合が発生しない
スループット単一ノードで 100K+ ops/s
制限重い処理が全体をブロックする

デプロイメントパターン

// 1. Standalone(単一ノード)
import Redis from 'ioredis';

const standalone = new Redis({
  host: 'redis-primary',
  port: 6379,
  password: process.env.REDIS_PASSWORD,
  db: 0,
  retryStrategy: (times) => Math.min(times * 50, 2000),
});

// 2. Sentinel(高可用性)
const sentinel = new Redis({
  sentinels: [
    { host: 'sentinel-1', port: 26379 },
    { host: 'sentinel-2', port: 26379 },
    { host: 'sentinel-3', port: 26379 },
  ],
  name: 'mymaster',
  password: process.env.REDIS_PASSWORD,
  sentinelPassword: process.env.SENTINEL_PASSWORD,
});

// 3. Cluster(水平スケーリング)
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',
  natMap: {}, // NAT環境用のマッピング
});

2. Redis データ構造の活用

class RedisPerformancePatterns {
  constructor(private redis: Redis) {}

  // String: シンプルなキャッシュ
  async cacheApiResponse(endpoint: string, response: object, ttl: number): Promise<void> {
    await this.redis.set(
      `api:${endpoint}`,
      JSON.stringify(response),
      'EX', ttl
    );
  }

  // Hash: オブジェクトの部分更新
  async updateUserProfile(userId: string, fields: Record<string, string>): Promise<void> {
    const key = `user:${userId}`;
    await this.redis.hmset(key, fields);
    await this.redis.expire(key, 900); // 15分
  }

  async getUserField(userId: string, field: string): Promise<string | null> {
    return this.redis.hget(`user:${userId}`, field);
  }

  // Sorted Set: リアルタイムランキング
  async updateLeaderboard(boardId: string, userId: string, score: number): Promise<void> {
    await this.redis.zadd(`leaderboard:${boardId}`, score, userId);
  }

  async getTopN(boardId: string, n: number): Promise<Array<{ userId: string; score: number }>> {
    const results = await this.redis.zrevrange(
      `leaderboard:${boardId}`, 0, n - 1, 'WITHSCORES'
    );

    const entries: Array<{ userId: string; score: number }> = [];
    for (let i = 0; i < results.length; i += 2) {
      entries.push({ userId: results[i], score: parseFloat(results[i + 1]) });
    }
    return entries;
  }

  // HyperLogLog: ユニークカウント(近似)
  async trackUniqueVisitor(page: string, visitorId: string): Promise<void> {
    await this.redis.pfadd(`uv:${page}:${this.todayKey()}`, visitorId);
  }

  async getUniqueVisitorCount(page: string): Promise<number> {
    return this.redis.pfcount(`uv:${page}:${this.todayKey()}`);
  }

  // Stream: イベントログ
  async publishEvent(stream: string, event: Record<string, string>): Promise<string> {
    return this.redis.xadd(stream, '*', ...Object.entries(event).flat());
  }

  // Lua スクリプト: アトミックな複合操作
  async rateLimit(key: string, limit: number, windowSeconds: number): Promise<boolean> {
    const script = `
      local current = redis.call('INCR', KEYS[1])
      if current == 1 then
        redis.call('EXPIRE', KEYS[1], ARGV[1])
      end
      return current <= tonumber(ARGV[2])
    `;

    const result = await this.redis.eval(
      script, 1,
      `ratelimit:${key}`,
      windowSeconds.toString(),
      limit.toString()
    );

    return result === 1;
  }

  private todayKey(): string {
    return new Date().toISOString().split('T')[0];
  }
}

3. Eviction ポリシー

ポリシー説明適用場面
noevictionメモリ上限でエラーデータ損失不可
allkeys-lru全キーから LRU 削除汎用キャッシュ
allkeys-lfu全キーから LFU 削除アクセス頻度に偏りあり
volatile-lruTTL付きキーから LRU永続キーと一時キーの混在
volatile-lfuTTL付きキーから LFU同上 + 頻度考慮
volatile-ttlTTL が短いキーから削除TTL に意味がある場合
allkeys-randomランダム削除アクセスパターンが均等
// Redis メモリ管理の監視
class RedisMemoryMonitor {
  constructor(private redis: Redis) {}

  async getMemoryStats(): Promise<{
    usedMemory: number;
    maxMemory: number;
    utilizationPercent: number;
    evictionPolicy: string;
    evictedKeys: number;
    fragmentationRatio: number;
  }> {
    const info = await this.redis.info('memory');
    const stats = await this.redis.info('stats');

    const parse = (text: string, key: string): string => {
      const match = text.match(new RegExp(`${key}:(.+)`));
      return match ? match[1].trim() : '0';
    };

    const usedMemory = parseInt(parse(info, 'used_memory'));
    const maxMemory = parseInt(parse(info, 'maxmemory'));
    const fragmentationRatio = parseFloat(parse(info, 'mem_fragmentation_ratio'));
    const evictionPolicy = parse(info, 'maxmemory-policy');
    const evictedKeys = parseInt(parse(stats, 'evicted_keys'));

    return {
      usedMemory,
      maxMemory,
      utilizationPercent: maxMemory > 0 ? (usedMemory / maxMemory) * 100 : 0,
      evictionPolicy,
      evictedKeys,
      fragmentationRatio,
    };
  }

  // キーの分析
  async analyzeKeyDistribution(): Promise<Record<string, { count: number; memory: number }>> {
    const distribution: Record<string, { count: number; memory: number }> = {};
    let cursor = '0';

    do {
      const [nextCursor, keys] = await this.redis.scan(
        cursor, 'COUNT', 1000
      );
      cursor = nextCursor;

      for (const key of keys) {
        const prefix = key.split(':')[0];
        if (!distribution[prefix]) {
          distribution[prefix] = { count: 0, memory: 0 };
        }
        distribution[prefix].count++;

        const memUsage = await this.redis.memory('USAGE', key);
        distribution[prefix].memory += memUsage as number;
      }
    } while (cursor !== '0');

    return distribution;
  }
}

4. Redis vs Memcached

項目RedisMemcached
データ構造String/Hash/List/Set/SortedSet 等String のみ
永続化RDB/AOFなし
レプリケーション対応(Sentinel/Cluster)なし(クライアント側)
スレッドモデルシングルスレッド(I/O はマルチ)マルチスレッド
メモリ効率中程度高い(slab allocator)
最大値サイズ512MB1MB(デフォルト)
Pub/Sub対応非対応
Lua スクリプト対応非対応
適用場面多機能キャッシュ、データストア単純な KV キャッシュ
コラム: Redis Cluster のハッシュスロット

Redis Cluster は 16,384 個のハッシュスロットをノードに分配する。

CRC16(key) mod 16384 → スロット番号 → 担当ノード

例:
- user:1001 → CRC16("user:1001") mod 16384 = 5649 → Node A
- user:1002 → CRC16("user:1002") mod 16384 = 12832 → Node C

ハッシュタグ: {user}:1001{user}:1002 は同じスロットに配置される({} 内がハッシュ対象)。これにより、関連キーを同一ノードに配置してマルチキー操作が可能になる。

// ハッシュタグを使った関連キーの配置
await redis.set('{order:123}:details', '...');
await redis.set('{order:123}:items', '...');
// 同じノードに配置されるため、MULTI/EXEC が使える

まとめ

トピック要点
Redis アーキテクチャシングルスレッド + I/O 多重化、100K+ ops/s
デプロイパターンStandalone/Sentinel/Cluster
データ構造String/Hash/Set/SortedSet/HyperLogLog/Stream
Evictionallkeys-lru/lfu が一般的、volatile-* はTTL付き限定
Redis vs MemcachedRedis は多機能、Memcached はシンプル高効率

チェックリスト

  • Redis のシングルスレッドモデルの利点と制約を説明できる
  • Sentinel と Cluster の違いを理解した
  • ユースケースに応じたデータ構造の選択ができる
  • Eviction ポリシーの選択基準を知っている
  • Redis と Memcached の使い分けを説明できる

次のステップへ

分散キャッシュの基盤を理解した。次は CDN とエッジキャッシング を学び、ユーザーに最も近い場所でのキャッシュ戦略を身につけよう。

推定読了時間: 40分