LESSON 30分

ストーリー

「APIのレスポンスが遅い。毎回同じデータをDBから取得してる」

高橋アーキテクトがAPMのグラフを見せる。同じクエリが秒間1000回実行されている。

「Redisを使おう。インメモリだから桁違いに速い。しかもRedisは単なるキャッシュじゃない。データ構造が豊富で、キュー、ランキング、Pub/Subまでカバーできる万能ツールだ」


Redisの基本

特徴説明
インメモリデータをメモリに格納(超高速)
データ構造String, Hash, List, Set, Sorted Set, Stream
TTLキーごとに有効期限を設定
永続化RDB/AOFでディスクにも保存可能
シングルスレッドコマンドはアトミックに実行

パターン1: キャッシュ

最も基本的な用途。DBクエリ結果をキャッシュしてレスポンスを高速化。

import Redis from 'ioredis';

const redis = new Redis();

// Cache-Aside パターン
async function getUserById(userId: number): Promise<User | null> {
  const cacheKey = `user:${userId}`;

  // 1. キャッシュを確認
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }

  // 2. キャッシュミス → DBから取得
  const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
  if (!user) return null;

  // 3. キャッシュに格納(TTL: 5分)
  await redis.set(cacheKey, JSON.stringify(user), 'EX', 300);

  return user;
}

// キャッシュ無効化
async function updateUser(userId: number, data: Partial<User>): Promise<void> {
  await db.query('UPDATE users SET ... WHERE id = $1', [userId]);
  await redis.del(`user:${userId}`);  // キャッシュを削除
}

パターン2: セッション管理

// セッション格納(Hash型)
async function createSession(sessionId: string, userId: number): Promise<void> {
  const key = `session:${sessionId}`;
  await redis.hset(key, {
    userId: userId.toString(),
    loginAt: new Date().toISOString(),
    lastAccess: new Date().toISOString(),
  });
  await redis.expire(key, 3600); // 1時間で期限切れ
}

// セッション取得
async function getSession(sessionId: string): Promise<SessionData | null> {
  const key = `session:${sessionId}`;
  const data = await redis.hgetall(key);
  if (!data.userId) return null;

  // 最終アクセス時刻を更新(スライディングウィンドウ)
  await redis.hset(key, 'lastAccess', new Date().toISOString());
  await redis.expire(key, 3600); // TTLをリセット

  return {
    userId: parseInt(data.userId),
    loginAt: new Date(data.loginAt),
    lastAccess: new Date(data.lastAccess),
  };
}

パターン3: ランキング(Sorted Set)

// ランキング(Sorted Set)
async function addScore(userId: string, score: number): Promise<void> {
  await redis.zadd('leaderboard', score, userId);
}

// トップ10を取得(スコア降順)
async function getTopRankers(limit: number = 10): Promise<RankEntry[]> {
  const results = await redis.zrevrange('leaderboard', 0, limit - 1, 'WITHSCORES');

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

// 特定ユーザーの順位
async function getUserRank(userId: string): Promise<number | null> {
  const rank = await redis.zrevrank('leaderboard', userId);
  return rank !== null ? rank + 1 : null;
}

パターン4: レートリミット

// スライディングウィンドウ方式のレートリミット
async function checkRateLimit(
  userId: string,
  maxRequests: number = 100,
  windowSeconds: number = 60
): Promise<{ allowed: boolean; remaining: number }> {
  const key = `ratelimit:${userId}`;
  const now = Date.now();
  const windowStart = now - windowSeconds * 1000;

  // トランザクションで原子的に実行
  const pipeline = redis.pipeline();
  pipeline.zremrangebyscore(key, 0, windowStart); // 古いエントリを削除
  pipeline.zadd(key, now, `${now}`);               // 新しいリクエストを追加
  pipeline.zcard(key);                              // カウント取得
  pipeline.expire(key, windowSeconds);              // TTL設定

  const results = await pipeline.exec();
  const currentCount = results?.[2]?.[1] as number ?? 0;

  return {
    allowed: currentCount <= maxRequests,
    remaining: Math.max(0, maxRequests - currentCount),
  };
}

パターン5: Pub/Sub(リアルタイム通知)

// Publisher
async function publishNotification(channel: string, message: object): Promise<void> {
  await redis.publish(channel, JSON.stringify(message));
}

// Subscriber
const subscriber = new Redis();
subscriber.subscribe('notifications:user:1');

subscriber.on('message', (channel, message) => {
  const data = JSON.parse(message);
  console.log(`Channel: ${channel}, Message:`, data);
});

// 使用例: 新しいメッセージ通知
await publishNotification('notifications:user:1', {
  type: 'new_message',
  from: 'user:2',
  preview: 'こんにちは!',
});

パターン6: 分散ロック

// 分散ロック(Redlock パターンの簡易版)
async function acquireLock(
  lockKey: string,
  ttlMs: number = 5000
): Promise<string | null> {
  const lockValue = crypto.randomUUID();
  const result = await redis.set(
    `lock:${lockKey}`,
    lockValue,
    'PX', ttlMs,
    'NX'  // キーが存在しない場合のみ設定
  );

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

async function releaseLock(lockKey: string, lockValue: string): Promise<boolean> {
  // Luaスクリプトで原子的にチェック&削除
  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:${lockKey}`, lockValue);
  return result === 1;
}

// 使用例
const lockValue = await acquireLock('inventory:product:123');
if (lockValue) {
  try {
    // クリティカルセクション: 在庫更新
    await updateInventory(123, -1);
  } finally {
    await releaseLock('inventory:product:123', lockValue);
  }
}

Redisのデータ構造早見表

データ構造用途主要コマンド
Stringキャッシュ、カウンターGET, SET, INCR
Hashオブジェクト格納HGET, HSET, HGETALL
Listキュー、タイムラインLPUSH, RPOP, LRANGE
Setユニーク集合、タグSADD, SMEMBERS, SINTER
Sorted Setランキング、スコアZADD, ZRANGE, ZRANK
StreamイベントログXADD, XREAD, XRANGE

まとめ

パターンデータ構造用途
キャッシュStringDB負荷軽減、高速化
セッションHashユーザー状態管理
ランキングSorted Setスコアベースの順位付け
レートリミットSorted SetAPI呼び出し制限
Pub/Sub-リアルタイム通知
分散ロックString (NX)排他制御

理解度チェックリスト

  • Cache-Asideパターンを実装できる
  • Redisの6つのデータ構造の用途を説明できる
  • Sorted Setを使ったランキング機能を実装できる
  • 分散ロックの仕組みを理解している

次のステップ

次のレッスンではRDB vs NoSQLの選択の判断基準を学ぶ。実際のプロジェクトでどちらを選ぶべきか、体系的な判断フレームワークを身につけよう。


推定読了時間: 30分