RedisのキャッシュでAPIが別次元に速くなった話!キャッシュの消し方とCache Stampede対策
🙌 結論から
同じデータへの問い合わせがDBに何度も飛んでいて応答が遅くなっていたAPIに、Redisのキャッシュ層を追加してみました。
結果として、対象エンドポイントのレスポンスタイムが150ms前後から数msまで縮み、DBへのクエリ数もかなり減りました!
「DBのインデックスは見直したのに、まだ遅い」という状況だったので、この改善の幅は正直かなり助かりました(・∀・)
ボトルネックがDB側ではなく「同じ問い合わせの繰り返し」そのものだった、という気づきが今回の一番の収穫です。
💡 なぜRedisを入れることにしたか
問題になっていたのは、同じユーザー情報や設定値を何度もSELECTしているだけのエンドポイントでした。
クエリ自体は軽いのですが、リクエスト数が増えるとDBへの接続数がじわじわ増え、ピーク時間帯だけレスポンスが不安定になるという状態が続いていました。
データの更新頻度がそこまで高くない箇所だったので、「毎回DBを見に行かなくても、少しの間だけ結果を覚えておけば十分では」と考えて、インメモリのキャッシュ層としてRedisを選びました。
👀 導入してみた
Node.jsからRedisを扱うライブラリにはioredisを使いました。
npm install ioredis
キャッシュの読み書きは、こんな形のシンプルな関数にまとめています。
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function getUserProfile(userId: string) {
const cacheKey = `user:profile:${userId}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const profile = await db.user.findUnique({ where: { id: userId } });
await redis.set(cacheKey, JSON.stringify(profile), 'EX', 300);
return profile;
}
EX 300で有効期限を5分に設定し、キャッシュが残っていればDBを見に行かない、という単純なパターンです。
最初はこの「Cache-Aside」パターンだけ入れてみたのですが、それだけでも効果はかなり大きかったです。
🔧 キャッシュ戦略で工夫した点
キャッシュを入れてすぐにぶつかったのが、更新系APIを叩いたあとに古いデータが返ってくる問題でした。
これは、更新処理の中で該当キーをdelするだけで解決しました。
async function updateUserProfile(userId: string, data: ProfileInput) {
const profile = await db.user.update({ where: { id: userId }, data });
await redis.del(`user:profile:${userId}`);
return profile;
}
もう一つ工夫したのが、アクセスが集中するタイミングでキャッシュが同時に切れて、DBに問い合わせが殺到する「Cache Stampede」への対策です。
ここは有効期限に多少のランダムな幅(例えば300秒±30秒)を持たせるだけでも、同時切れのリスクをかなり減らせました。
「キャッシュを入れれば終わり」ではなく、更新時の整合性と切れるタイミングの分散、この2点まで見ておくと安心して運用できると感じました。
✨ 使ってみた感想
導入してから一番わかりやすかったのは、ピーク時間帯でもレスポンスが安定するようになったことです。
DBのCPU使用率にも余裕が出て、他の重いクエリを実行しても全体への影響が出にくくなりました!
ただ、キャッシュの有効期限をどこまで長くするかは、データの性質ごとに調整が必要で、最初は少し試行錯誤しました。
更新頻度が高いデータにまで安易にキャッシュを広げると、逆に「表示が古い」という問い合わせが増えるので、対象は慎重に選んだほうがよさそうです。
🔧 Cache Stampede対策をもう一段深く入れた
有効期限に多少のランダムな幅を持たせる対策だけでもかなり効果があったのですが、もう少し調べてみると、アクセスがかなり集中するキーではランダム幅だけでは足りないケースがあると知りました。
いわゆる「Cache Stampede」(Thundering Herd)と呼ばれる問題で、人気のキーが切れた瞬間に、そのキーを待っていた大量のリクエストが同時にDBへなだれ込む現象です。
今回のエンドポイントはそこまでアクセスが集中していなかったのですが、将来的にアクセスが増えたときのために、ミューテックスロック(排他ロック)を使ったパターンも試してみました。
async function getUserProfileWithLock(userId: string) {
const cacheKey = `user:profile:${userId}`;
const lockKey = `lock:${cacheKey}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// NX: キーが存在しない場合だけセットできる = ロック取得
const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 10);
if (!acquired) {
// 他のリクエストが再生成中なので少し待ってから再取得
await new Promise((resolve) => setTimeout(resolve, 100));
return getUserProfileWithLock(userId);
}
try {
const profile = await db.user.findUnique({ where: { id: userId } });
await redis.set(cacheKey, JSON.stringify(profile), 'EX', 300);
return profile;
} finally {
await redis.del(lockKey);
}
}
ポイントは SET NX EX で**「ロックを取れたリクエストだけがDBに問い合わせにいき、それ以外は待って再取得する」**形にしているところです。
ロックの有効期限(この例だと10秒)は短すぎると処理中に他のリクエストがロックを取ってしまい、長すぎるとDB側で障害が起きたときに全リクエストが待たされ続けるので、再生成処理にかかる時間の目安から逆算して決めるのがよさそうでした。
正直、今回のアクセス量ではランダム幅の対策だけで十分だったのですが、「アクセスが急に伸びたときの保険」として仕込んでおくと安心感が違います🤔
🔧 キャッシュのキーと消し方を決めるチェックリスト
Redis のキャッシュで後から困るのは、速さよりも「古いデータが出続ける」問題です(・_・;)
入れる前に、この4つを決めておくと事故が減ります。
- キーに何を含めるか: ユーザーごとに違うデータなら、キーにユーザーIDを入れる。検索条件があるなら条件も入れる
- いつ消すか: データを更新したときに消すのか、有効期限まで待つのか
- 有効期限をどれくらいにするか: 数分ずれても困らないデータだけ長めにする
- キーの名前のルール:
products:list:{page}のように、先頭で種類が分かる形に揃える
Laravel なら、更新のタイミングで消すのはモデルのイベントに書くとまとまります👀
// app/Models/Product.php
protected static function booted(): void
{
static::saved(fn (Product $p) => Cache::forget("products:show:{$p->id}"));
}
一覧のようにキーがたくさんあって消しきれないものは、キャッシュのタグ(Redis なら使える)でまとめて消すか、有効期限を短めにして割り切ります。
一番まずいのは、ユーザーごとのデータを、ユーザーIDなしのキーでキャッシュしてしまうことです。
他の人のデータが見えてしまうので、キーの設計だけは必ずレビューしてもらうようにしています✨
パパ系Webエンジニア〜愛用ガジェット一覧 🛍️
この記事を書いている作業環境のガジェットを楽天ROOMにまとめています ✨ 在宅エンジニアのデスク作りの参考にどうぞ 🙌
👍 まとめ
Redisのキャッシュ層は、更新頻度が低く読み取りが多いエンドポイントに対してかなり効果の大きい改善策です。
Cache-Asideパターンと期限切れの分散、この2点さえ押さえておけば、実際に仕事で使ってみて大きなトラブルなく運用できています。
DBへの問い合わせが多くて応答が不安定になっているAPIがあれば、まず読み取り中心のエンドポイントから試してみる価値がありますよ🙌
※ キャッシュ戦略やCache Stampede対策は、データの更新頻度や整合性要件によって最適解が変わるので、導入前に対象データの性質を見極めてください👀