RedisでCache-Asideを実装する - TTL・更新時削除・障害時フォールバック

中級 | 20分 で読める | 2025.12.02

公式ドキュメント

今回やること

Redisを使って、ユーザー情報を読むCache-Asideを実装します。

Redisを確認
  ├─ hit  → cacheの値を返す
  └─ miss → DBから読み、TTL付きでcacheへ保存する

さらに、DB更新後のキャッシュ削除と、Redisが停止していてもDBから結果を返すフォールバックまで確認します。分散ロック、セッション、レート制限は別の目的なので扱いません。

前提条件

  • Node.js 20以上
  • Docker
  • TypeScriptのasync / awaitが読める

プロジェクトを準備する

Redisをローカルで起動します。

docker run --name cache-practice -p 6379:6379 -d redis:8-alpine

TypeScriptプロジェクトを作ります。

mkdir redis-cache-aside
cd redis-cache-aside
npm init -y
npm install ioredis
npm install --save-dev typescript tsx @types/node
mkdir src

package.jsonへ次の項目を追加します。

{
  "type": "module",
  "scripts": {
    "dev": "tsx src/index.ts"
  }
}

Cache-Asideを実装する

src/index.tsを作成します。

import Redis from "ioredis";

type User = {
  id: string;
  name: string;
};

const database = new Map<string, User>([
  ["1", { id: "1", name: "Ada" }],
]);

const redis = new Redis({
  host: "127.0.0.1",
  port: 6379,
  lazyConnect: true,
  connectTimeout: 1_000,
  maxRetriesPerRequest: 1,
  enableOfflineQueue: false,
  retryStrategy: () => null,
});

redis.on("error", (error) => {
  console.warn("Redis unavailable:", error.message);
});

const keyOf = (id: string): string => `user:${id}`;
const ttlSeconds = 30;

async function readCache(id: string): Promise<User | null> {
  try {
    if (redis.status === "wait") {
      await redis.connect();
    }

    const cached = await redis.get(keyOf(id));
    if (cached === null) {
      return null;
    }

    try {
      return JSON.parse(cached) as User;
    } catch {
      await redis.del(keyOf(id));
      return null;
    }
  } catch {
    // cache障害はDB読み取りを止めない
    return null;
  }
}

async function writeCache(user: User): Promise<void> {
  try {
    await redis.set(keyOf(user.id), JSON.stringify(user), "EX", ttlSeconds);
  } catch {
    // DBから取得した値はそのまま呼び出し元へ返す
  }
}

async function invalidateCache(id: string): Promise<void> {
  try {
    await redis.del(keyOf(id));
  } catch {
    // DB更新は成功済み。cache障害を更新失敗にしない
  }
}

async function findUserInDatabase(id: string): Promise<User | null> {
  console.log("DB read");
  return database.get(id) ?? null;
}

async function getUser(id: string): Promise<User | null> {
  const cached = await readCache(id);
  if (cached !== null) {
    console.log("cache hit");
    return cached;
  }

  console.log("cache miss");
  const user = await findUserInDatabase(id);
  if (user !== null) {
    await writeCache(user);
  }
  return user;
}

async function updateUserName(id: string, name: string): Promise<User> {
  const user = database.get(id);
  if (user === undefined) {
    throw new Error("User not found");
  }

  const updated = { ...user, name };
  database.set(id, updated);

  // DB更新後に削除する。次の読み取りで新しい値をcacheする
  await invalidateCache(id);
  return updated;
}

console.log("first:", await getUser("1"));
console.log("second:", await getUser("1"));

await updateUserName("1", "Grace");
console.log("after update:", await getUser("1"));

redis.disconnect();

ここではキャッシュを高速化の補助として扱い、データの正本はDBとします。重要な境界は次の2つです。

  • GETまたはSETに失敗しても、DB読み取りの結果を返す
  • DB更新後のDELに失敗しても、成功したDB更新を失敗扱いにしない

ただしDEL失敗時には、TTLが切れるまで古い値を読む可能性があります。本番ではエラーを監視し、要件に応じて再試行やバージョン付きキーを検討します。

本番では同時実行の競合も考える

この最小例には、cache missの読み取りとDB更新が同時に起きる競合が残ります。

  1. リクエストAがcache missになり、DBから古い値を読む
  2. リクエストBがDBを更新し、cache keyをDELする
  3. リクエストAが、先ほど読んだ古い値をRedisへSETする

この順番では、削除後に古い値が再投入され、TTLが切れるまでstale(更新前)の値を返す可能性があります。TTLは古さの上限を作りますが、競合そのものは防ぎません。

厳しい整合性が必要な本番システムでは、データのversionをcache keyまたは値へ含めて古い書き戻しを拒否する、更新と無効化の順序を整合制御する、といった設計を検討します。同じkeyへの大量の同時missにはsingle-flightでDB取得を1回へまとめる方法もあります。ただしsingle-flightだけで更新との競合が解消するわけではありません。

retryStrategy: () => nullmaxRetriesPerRequest: 1enableOfflineQueue: falseは、学習例でRedis停止時に長く待ち続けないための設定です。本番の再接続方針は可用性要件に合わせて設計してください。

成功を確認する

実行します。

npm run dev

次の順番で表示されれば成功です。

cache miss
DB read
first: { id: '1', name: 'Ada' }
cache hit
second: { id: '1', name: 'Ada' }
cache miss
DB read
after update: { id: '1', name: 'Grace' }

TTLも確認できます。実行直後、30秒以内に次のコマンドを使ってください。

docker exec cache-practice redis-cli TTL user:1

1から30までの値なら、期限が設定されています。

Redis停止時のフォールバック

Redisを停止してから再実行します。

docker stop cache-practice
npm run dev

Redisの警告が出ても、DB readとユーザー情報が表示されればフォールバック成功です。

よくあるエラー

enableOfflineQueueにより最初のコマンドが失敗する

この例は、readCacheの最初にredis.connect()を呼んでからGETします。接続処理を消すと、接続準備前のコマンドが失敗します。

更新後も古い値が返る

DB更新後にinvalidateCache(id)を呼んでいるか確認します。TTLだけに任せると、期限切れまで古い値が残ります。

Redis停止時に処理が終わらない

接続設定のretryStrategymaxRetriesPerRequestenableOfflineQueueを確認します。無期限の再試行やキュー待ちは、フォールバックを遅らせます。

練習

ttlSecondsを5へ変え、最初の取得から5秒以上待った後にもう一度getUser("1")を呼び、cache missになることを確認してください。

次のステップ

マネージドRedisを使った接続とTTLを試す場合は、Upstash Redis実践へ進んでください。セッション用途はRedisセッションストアの設計で別に扱います。

参考リソース

(最終確認: 2026年7月25日)

← 一覧に戻る
PR
PR
PR
PR