RedisでWeb APIを強化する:実践的なパフォーマンス最適化ガイド
著者:Tarique Ejaz
パフォーマンスは、あらゆるソフトウェアを設計する際に考慮すべき本質的な要素です。とりわけ、目に見えないバックエンド処理においてはその重要性が増します。
私たち開発者は、パフォーマンスを向上させるためにさまざまな工夫や実装を取り入れています。そこで活躍するのがキャッシュです。
キャッシュとは、データやファイルを一時的な保存領域に格納しておき、必要なときに即座にアクセスできるようにする仕組みのことです。
今日のWebアプリケーションにおいて、キャッシュはもはや必須の存在となっています。本記事では、Node.jsとMongoDBで構築したWeb APIを、Redisによって大幅に高速化する方法を紹介します。
Redisとは?初心者向けに解説
公式ドキュメントによると、Redisはインメモリのデータ構造ストアであり、データベース、メッセージブローカー、キャッシュストレージとして利用できます。文字列(string)、ハッシュ(hash)、リスト(list)、セット(set)、範囲クエリに対応したソート済みセット(sorted set)、ビットマップ、HyperLogLog、半径クエリに対応した地理空間インデックス、ストリームなど、多様なデータ構造をサポートしています。
「かなり多くのデータ構造がある」と感じたかもしれません。しかしシンプルに捉えれば、サポートされているデータ構造のほとんどは、何らかの形式の文字列に集約できます。実際の実装を進めるうちに、理解が深まっていくでしょう。
確かなのは一点だけです。Redisは非常に強力であり、正しく使えばアプリケーションを高速化するだけでなく、驚くほど効率的にできるということです。理論はここまでにして、早速実装に取り掛かりましょう。
コードを見ていこう
作業を始める前に、ローカル環境にRedisをセットアップする必要があります。公式の手順に沿って、Redisをインストールし起動しておいてください。
準備はできましたか?それでは始めましょう。今回は、MongoDB Atlasのインスタンスに対してデータの読み書きを行う、シンプルなExpressアプリケーションを題材にします。
/blogsルートファイルには、主に2つのAPIを作成しています。
// GET - 指定ユーザーのブログ投稿をすべて取得
blogsRouter.route('/:user')
.get(async (req, res, next) => {
const blogs = await Blog.find({ user: req.params.user });
res.status(200).json({
blogs,
});
});
// POST - 新しいブログ投稿を作成
blogsRouter.route('/')
.post(async (req, res, next) => {
const existingBlog = await Blog.findOne({ title: req.body.title });
if (!existingBlog) {
let newBlog = new Blog(req.body);
const result = await newBlog.save();
return res.status(200).json({
message: `Blog ${result.id} is successfully created`,
result,
});
}
res.status(200).json({
message: 'Blog with same title exists',
});
});
Redisを導入する
まず、ローカルのRedisサーバーに接続するため、npmパッケージredisをインストールします。
const mongoose = require('mongoose');
const redis = require('redis');
const util = require('util');
const redisUrl = 'redis://127.0.0.1:6379';
const client = redis.createClient(redisUrl);
client.hget = util.promisify(client.hget);
util.promisify関数を使うことで、コールバック形式のclient.hgetを、Promiseを返す関数へと変換しています。「プロミス化(Promisification)」という手法です。
Redisとの接続が整いました。キャッシュのコードを書き進める前に、一度立ち止まって、達成すべき要件と予想される課題を整理しましょう。
今回のキャッシュ戦略では、次の2点を実現できるようにします。
- 特定ユーザーのブログ投稿一覧リクエストをキャッシュする
- 新しいブログ投稿が作成されるたびにキャッシュをクリアする
また、戦略を進める上で注意すべき課題は以下のとおりです。
- キャッシュデータを保存するキーの適切な設計
- キャッシュの鮮度を保つための有効期限管理と強制失効
- キャッシュロジックの再利用可能な実装
要件を整理し、Redisへの接続も完了しました。次のステップに進みます。
Mongooseのデフォルトexec関数をオーバーライドする
キャッシュロジックは再利用可能であるべきです。さらに言えば、データベースへのあらゆるクエリの前に、最初のチェックポイントとして機能させたいものです。これは、Mongooseのexec関数に処理を差し込むというシンプルなテクニックで実現できます。
const exec = mongoose.Query.prototype.exec;
mongoose.Query.prototype.exec = async function() {
const result = await exec.apply(this, arguments);
console.log('Data Source: Database');
return result;
}
Mongooseのprototypeオブジェクトを利用することで、独自のキャッシュロジックをクエリ実行の最初のステップとして組み込んでいます。
キャッシュをクエリとして追加する
どのクエリをキャッシュ対象にするかを示すため、Mongooseのクエリを拡張します。optionsオブジェクトを通じて、ハッシュキーとして使うuserを渡せるようにしましょう。
補足:ハッシュキーとは、ハッシュ型データ構造を識別するためのIDです。平たく言えば、複数のキー・バリューのペアをまとめる「親キー」のようなものです。これにより、大量のクエリ結果をまとめてキャッシュできます。
mongoose.Query.prototype.cache = function(options = {}) {
this.enableCache = true;
this.hashKey = JSON.stringify(options.key || 'default');
return this;
};
これで、キャッシュしたいクエリに対して、次のようにcache()をチェーンするだけでよくなりました。
const blogs = await Blog
.find({ user: req.params.user })
.cache({ key: req.params.user });
キャッシュロジック本体を作る
キャッシュ対象を指定する共通クエリが用意できました。続いて、中心となるキャッシュロジックを記述していきます。
mongoose.Query.prototype.exec = async function() {
if (!this.enableCache) {
console.log('Data Source: Database');
return exec.apply(this, arguments);
}
const key = JSON.stringify(Object.assign({}, this.getQuery(), {
collection: this.mongooseCollection.name,
}));
const cachedValue = await client.hget(this.hashKey, key);
if (cachedValue) {
const parsedCache = JSON.parse(cachedValue);
console.log('Data Source: Cache');
return Array.isArray(parsedCache)
? parsedCache.map(doc => new this.model(doc))
: new this.model(parsedCache);
}
const result = await exec.apply(this, arguments);
client.hmset(this.hashKey, key, JSON.stringify(result), 'EX', 300);
console.log('Data Source: Database');
return result;
};
メインのクエリと一緒にcache()が呼ばれると、enableCacheフラグがtrueに設定されます。
フラグがfalseの場合は、通常どおりオリジナルのexecを実行して返します。trueの場合は、まずキャッシュの取得・保存(更新)に使うキーを生成します。
キーの一意性を確保するため、collection名とクエリ条件を組み合わせてキー名としています。一方、ハッシュキーには、先ほどcache()関数で指定したuser名が使われます。
キャッシュデータの取り出しにはclient.hget()関数を使います。この関数はハッシュキーとキーの2つを引数に取ります。
補足:Redisからデータを取得する際は必ずJSON.parse()を使い、逆に保存する前にはキーとデータにJSON.stringify()を適用します。これは、RedisがJSON形式のデータ構造を直接サポートしていないためです。
キャッシュからデータを取得したら、各オブジェクトをMongooseモデルのインスタンスへと変換する必要があります。これはnew this.model(<object>)とするだけで簡単に行えます。
キャッシュに必要なデータが存在しなかった場合は、データベースへクエリを実行します。そして結果をAPIに返した後、client.hmset()でキャッシュを更新します。あわせて、デフォルトの有効期限として300秒を設定しています。この値は、あなたのキャッシュ戦略に合わせて自由に変更可能です。
これでキャッシュロジックが完成し、デフォルトの有効期限も設定できました。次は、新しいブログ投稿が作成されたときにキャッシュを強制的に失効させる仕組みを見ていきます。
キャッシュの強制失効
ユーザーが新しいブログ投稿を作成した場合、その投稿がすぐに投稿一覧に表示されることを、ユーザーは当然期待します。
そのためには、該当ユーザーに関連するキャッシュをクリアし、最新データで更新する必要があります。つまり「強制失効」です。これは、Redisが提供するdel()関数を呼び出すことで実現できます。
module.exports = {
clearCache(hashKey) {
console.log('Cache cleaned');
client.del(JSON.stringify(hashKey));
}
}
また、強制失効は複数のルートで発生することも想定しておきましょう。拡張性の高い方法としては、このclearCache()をミドルウェアとして切り出し、ルートの処理が完了したタイミングで呼び出すやり方が挙げられます。
const { clearCache } = require('../services/cache');
module.exports = async (req, res, next) => {
// ルートハンドラの処理完了を待機
await next();
clearCache(req.body.user);
}
このミドルウェアは、次のように対象のルートに簡単に組み込めます。
blogsRouter.route('/')
.post(cleanCache, async (req, res, next) => {
...
}
以上で完成です。かなり長いコードでしたが、これでアプリケーションへのRedis統合が完了し、想定される課題にもほぼすべて対応できました。それでは、キャッシュ戦略が実際に動く姿を見てみましょう。
Redisの動作を検証する
APIクライアントとしてPostmanを使い、キャッシュ戦略の効果を確認します。APIの操作を順番に見ていきましょう。
/blogsルートで新しいブログ投稿を作成します。- ユーザー
tejazのブログ投稿をすべて取得します。 - 同じくユーザー
tejazのブログ投稿を、もう一度取得します。
結果は歴然です。キャッシュから取得した場合、レスポンスタイムが409msから24msへと短縮されました。これは所要時間がおよそ95%削減されたことを意味し、APIが劇的に高速化されています。
さらに、キャッシュの失効と更新処理も期待どおりに動作していることが確認できました。
完全なソースコードは、GitHubリポジトリ内のredis-expressフォルダで公開されています。
まとめ
キャッシュは、高性能でデータ集約的なアプリケーションを構築するうえで欠かせない施策です。Redisを活用すれば、Webアプリケーションにこの仕組みを簡単に導入できます。非常に強力なツールであり、正しく使えば開発者とユーザーの双方に優れた体験を提供できるでしょう。
Redisコマンドの全リストは公式ドキュメントで確認できます。redis-cliと併用すれば、キャッシュデータやアプリケーションの動作をモニタリングすることも可能です。
ひとつの技術が秘める可能性は無限大です。ご質問がある方は、筆者のLinkedInまでお気軽にご連絡ください。
それでは、Happy Coding!
無料でプログラミングを学べるfreeCodeCampのオープンソースカリキュラムは、これまでに40,000人以上の方々の開発者就職を支援してきました。ぜひあなたも始めてみませんか?
-
Firebase・Upstash・SvelteKitで作るオープンソースJIRAクローン開発ガイド
本記事では、Upstash、SvelteKit、Firebase Storageを活用して、Jiraのカンバンボードに代わるオープンソースアプリケーションを構築した方法について詳しく解説します。 使用する技術スタック SvelteKit(UIおよびAPIルート) Upstash(CRUD操作) Tailwind CSS(スタイリング) Firebase Storage(画像やPDFなどのアセット保存) Auth.jsによるSvelteKit Auth 事前に必要なもの データベース作成用のUpstashアカウント ストレージコンテナ作成用のFirebaseアカウント OAuthクレデン
-
Redis ZREMコマンドの使い方 – ソート済みセットから要素を削除する方法
このチュートリアルでは、Redisに保存されたソート済みセット(Sorted Set)の値から、指定した1つ以上の要素を削除する方法を解説します。この操作には、redis-cli上でZREMコマンドを使用します。 ZREMコマンドは、指定したキーに格納されているソート済みセットから、1つ以上の指定されたメンバーを削除します。ソート済みセット内に存在しないメンバーが指定された場合は、そのメンバーは単純に無視されます。また、キーは存在するものの、そのキーに格納されている値がソート済みセット型ではない場合には、エラーが返されます。 構文 redis host:port> ZREM <ke