Webアプリケーションの利用者が増えると、APIの応答速度やデータベースへの負荷が重要な課題になります。
特に、同じデータを何度も取得するAPIでは、毎回データベースへアクセスすることが必ずしも効率的とは限りません。
このような場合に役立つ技術が Redisを利用したキャッシュ です。
本記事では、Redisの基本的な仕組みから、Node.jsとExpressを使った実装、キャッシュの有効期限、データ更新時の注意点までを紹介します。
1. キャッシュとは?
キャッシュとは、取得や計算に時間がかかるデータを一時的に保存し、次回以降の処理で再利用する仕組みです。
例えば、記事一覧を取得するAPIを考えます。
キャッシュを利用しない場合:
Client
|
v
API Server
|
v
Database
|
v
API Response
毎回データベースへアクセスするため、アクセス数が増えると負荷が高くなる可能性があります。
一方、キャッシュを利用する場合は次のようになります。
Client
|
v
API Server
|
v
Redis Cache
|
+-- Cache Hit --> Response
|
+-- Cache Miss --> Database
|
v
Cache Update
|
v
Response
キャッシュにデータが存在する場合は、データベースへのアクセスを省略できます。
ただし、キャッシュの導入が常に高速化につながるとは限りません。データの更新頻度やキャッシュヒット率によって効果は異なります。
2. Redisとは?
Redisは、主にメモリ上でデータを扱うデータストアです。
Web開発では、次のような用途に利用されています。
- APIレスポンスのキャッシュ
- セッション情報の保存
- レート制限
- 一時的な状態管理
- ランキングなどのデータ管理
Redisは文字列だけでなく、ハッシュ、リスト、セットなど複数のデータ構造をサポートしています。
今回は、JSONデータを文字列として保存するシンプルなキャッシュを実装します。
3. 開発環境を準備する
今回使用する技術は以下のとおりです。
- Node.js 20以降
- Express
- Redis
- node-redis
まず、プロジェクトを作成します。
mkdir redis-cache-demo
cd redis-cache-demo
npm init -y
npm install express redis
次に、Dockerを利用してRedisを起動します。
docker run --name redis-demo \
-p 127.0.0.1:6379:6379 \
-d redis:7
この例では、Redisのポートをローカルホストに限定しています。
Redisをインターネットへ直接公開しないよう注意しましょう。
4. Node.jsからRedisへ接続する
server.jsを作成します。
const express = require("express");
const { createClient } = require("redis");
const app = express();
const redis = createClient({
url: process.env.REDIS_URL ||
"redis://127.0.0.1:6379"
});
redis.on("error", (error) => {
console.error("Redis error:", error.message);
});
async function startServer() {
await redis.connect();
app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
app.listen(3000, () => {
console.log("Server running on port 3000");
});
}
startServer().catch(console.error);
実行方法:
node server.js
この時点では、Redisへの接続と基本的なHTTPサーバーの起動を確認できます。
本番環境では、接続失敗時の再試行、タイムアウト、正常終了処理なども考慮する必要があります。
5. Cache-Asideパターンを理解する
キャッシュ設計でよく利用される方式の一つが Cache-Aside です。
基本的な処理は次のとおりです。
- Redisにデータがあるか確認する
- 存在すればキャッシュから返す
- 存在しなければデータベースから取得する
- 取得したデータをRedisに保存する
- クライアントへ結果を返す
この方式は、既存のAPIへ比較的段階的に導入しやすいことが特徴です。
6. APIレスポンスをキャッシュする
今回は、データベースの代わりにサンプルデータを返す関数を使用します。
以下のコードを、先ほどのstartServer()内のapp.listen()より前に追加します。
async function getArticlesFromDatabase() {
return [
{
id: 1,
title: "Node.js API Design",
category: "Backend"
},
{
id: 2,
title: "Redis Cache Basics",
category: "Performance"
},
{
id: 3,
title: "TypeScript Guide",
category: "Development"
}
];
}
app.get("/api/articles", async (req, res) => {
const cacheKey = "articles:list:v1";
try {
const cached = await redis.get(cacheKey);
if (cached !== null) {
return res.json({
source: "cache",
data: JSON.parse(cached)
});
}
const articles = await getArticlesFromDatabase();
await redis.set(
cacheKey,
JSON.stringify(articles),
{ EX: 60 }
);
return res.json({
source: "database",
data: articles
});
} catch (error) {
console.error("Cache error:", error.message);
return res.status(500).json({
error: "Unable to load articles"
});
}
});
初回アクセスでは、キャッシュが存在しないため、データ取得処理が実行されます。
2回目以降、キャッシュが有効な間はRedisに保存されたデータが返されます。
なお、この例ではRedisの障害時にエラーを返します。実際のサービスでは、要件に応じてデータベースへフォールバックする設計も検討できます。
7. TTLでキャッシュの有効期限を設定する
キャッシュを長期間保存すると、古い情報が表示される可能性があります。
そこで利用するのがTTL(Time To Live)です。
await redis.set(
"example:key",
JSON.stringify({ message: "Hello" }),
{ EX: 120 }
);
EX: 120は、有効期限を120秒に設定することを意味します。
TTLは、データの更新頻度や求められる鮮度に応じて決定します。
| データの種類 | TTLの考え方 |
|---|---|
| 更新頻度が低い記事情報 | 比較的長く設定できる |
| 頻繁に変わる一覧データ | 短めの設定を検討する |
| 個人情報や権限情報 | キャッシュの必要性から慎重に判断する |
| 正確な最新状態が必要なデータ | キャッシュを利用しない選択肢もある |
TTLに唯一の正解はありません。
実際の更新頻度とユーザー要件を確認しながら調整することが重要です。
8. データ更新時にキャッシュを削除する
キャッシュを利用する場合、データ更新後に古い情報が残る問題があります。
例えば、記事を更新した後にキャッシュを削除する方法があります。
async function invalidateArticlesCache() {
await redis.del("articles:list:v1");
}
データ更新処理が成功した後に、この関数を実行します。
async function updateArticle(article) {
await saveArticleToDatabase(article);
await invalidateArticlesCache();
}
saveArticleToDatabase()は、実際のデータベース更新処理を表す関数です。
ただし、データベース更新とキャッシュ削除は別々の操作です。
更新後にキャッシュ削除が失敗する場合や、同時リクエストによって古いデータが再び保存される場合もあるため、厳密な整合性が必要な用途では追加の設計が必要になります。
9. キャッシュキーの設計
キャッシュキーは、どのデータを保存しているか分かる形式にすると管理しやすくなります。
例えば:
articles:list:v1
articles:detail:123
categories:list:v1
public:settings:v1
キーを設計する際には、次の点を意識します。
- データの種類を明確にする
- 必要に応じてIDを含める
- バージョンを管理する
- キーの衝突を避ける
- ユーザーごとのデータを適切に分離する
特に、ユーザー固有のデータをキャッシュする場合は、異なるユーザー間で情報が共有されないよう注意が必要です。
10. キャッシュスタンピードへの対策
人気のあるデータのキャッシュが期限切れになると、多数のリクエストが同時にデータベースへアクセスすることがあります。
この現象は、Cache Stampedeと呼ばれます。
対策として、以下の方法が考えられます。
- TTLにランダムな変動を加える
- ロックを利用して再取得処理を制御する
- 古いキャッシュを一時的に返す
- 人気データを事前に更新する
例えば、TTLにランダムな変動を加える方法は次のとおりです。
const baseTTL = 60;
const jitter = Math.floor(Math.random() * 20);
await redis.set(
cacheKey,
JSON.stringify(data),
{ EX: baseTTL + jitter }
);
複数のキャッシュキーが同時に期限切れになる可能性を減らすために利用できます。
ただし、単一の人気キーに対する同時アクセスを完全に防ぐものではありません。
11. デジタルプラットフォームへの応用
記事一覧、公開コンテンツ、カテゴリ情報などを提供するWebサービスでは、キャッシュによってデータ取得処理の負荷を軽減できる場合があります。
例えば、WOW88 のようなデジタルプラットフォームを技術的な設計例として考える場合、頻繁に参照される公開コンテンツ情報をRedisへ一時保存する構成が考えられます。
ただし、キャッシュ対象は慎重に選択する必要があります。
公開情報とユーザー固有の情報を区別し、データの鮮度、アクセス権限、更新頻度を考慮することが重要です。
また、キャッシュ導入による効果は実測データで評価する必要があります。
12. キャッシュの効果を測定する
キャッシュを導入した後は、次の指標を確認します。
| 指標 | 確認する内容 |
|---|---|
| Cache Hit Rate | キャッシュから取得できた割合 |
| API Latency | APIの応答時間 |
| Database Queries | データベースへの問い合わせ数 |
| Redis Memory Usage | Redisのメモリ使用量 |
| Error Rate | エラーの発生割合 |
例えば、キャッシュヒット率は次のように計算できます。
Cache Hit Rate =
Cache Hits / (Cache Hits + Cache Misses)
キャッシュヒット率が高くても、API全体の応答時間が改善していない場合は、別の処理がボトルネックになっている可能性があります。
そのため、複数の指標を組み合わせて評価することが重要です。
13. 本番環境への導入チェックリスト
Redisキャッシュを本番環境へ導入する際には、次の項目を確認しましょう。
□ キャッシュ対象のデータを明確にしている
□ 適切なTTLを設定している
□ キャッシュキーの命名規則を統一している
□ データ更新時の無効化処理を設計している
□ Redisへのアクセスを適切に制限している
□ ユーザー固有データの分離を確認している
□ Redis障害時の動作を設計している
□ キャッシュヒット率を監視している
□ API応答時間を測定している
□ キャッシュスタンピードを考慮している
まとめ
Redisキャッシュは、Webアプリケーションのパフォーマンス改善に役立つ技術の一つです。
今回紹介した重要なポイントは次のとおりです。
- キャッシュの基本的な仕組みを理解する
- RedisとNode.jsを接続する
- Cache-Asideパターンを実装する
- TTLでデータの有効期限を管理する
- データ更新時のキャッシュ無効化を設計する
- キャッシュスタンピードへの対策を検討する
- 応答時間やヒット率を継続的に測定する
キャッシュは単にデータを保存する仕組みではなく、データの鮮度、整合性、障害時の動作を含めて設計する必要があります。
まずは更新頻度の低い公開データから導入し、実測結果を確認しながら適用範囲を広げることが、安定したWebアプリケーションを構築するための現実的な方法です。