今回やること
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更新が同時に起きる競合が残ります。
- リクエストAがcache missになり、DBから古い値を読む
- リクエストBがDBを更新し、cache keyをDELする
- リクエストAが、先ほど読んだ古い値をRedisへSETする
この順番では、削除後に古い値が再投入され、TTLが切れるまでstale(更新前)の値を返す可能性があります。TTLは古さの上限を作りますが、競合そのものは防ぎません。
厳しい整合性が必要な本番システムでは、データのversionをcache keyまたは値へ含めて古い書き戻しを拒否する、更新と無効化の順序を整合制御する、といった設計を検討します。同じkeyへの大量の同時missにはsingle-flightでDB取得を1回へまとめる方法もあります。ただしsingle-flightだけで更新との競合が解消するわけではありません。
retryStrategy: () => null、maxRetriesPerRequest: 1、enableOfflineQueue: 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停止時に処理が終わらない
接続設定のretryStrategy、maxRetriesPerRequest、enableOfflineQueueを確認します。無期限の再試行やキュー待ちは、フォールバックを遅らせます。
練習
ttlSecondsを5へ変え、最初の取得から5秒以上待った後にもう一度getUser("1")を呼び、cache missになることを確認してください。
次のステップ
マネージドRedisを使った接続とTTLを試す場合は、Upstash Redis実践へ進んでください。セッション用途はRedisセッションストアの設計で別に扱います。
参考リソース
(最終確認: 2026年7月25日)
← 一覧に戻る