Redis
 Computer >> コンピューター >  >> プログラミング >> Redis

Upstash for RedisでDenoアプリのパフォーマンスを向上させる:応答時間を大幅に短縮するサーバーレスキャッシュの実装

本記事では、Upstash for RedisとJavaScriptのPerformance APIを活用して、Denoアプリのパフォーマンスを最大限に引き出す方法を解説します。Upstash for Redisは、サーバーサイドキャッシュに最適なサーバーレスデータベースです。筆者が開発していたWebアプリは、初期サーバー応答時間(Initial Server Response Time)のスコアが低く、Lighthouseでは500ミリ秒と報告されていました。そこへUpstashのキャッシュを導入したところ、この数値を150ミリ秒未満まで削減し、監査にも合格できるようになりました。

実は難しかったのはキャッシュの追加作業そのものではなく、「どこにキャッシュを使うべきか」を見極めることでした。パフォーマンスを実際に計測することで初めてボトルネックを特定でき、最小限の工数でパフォーマンスを改善できたのです。本記事では、そのパフォーマンス計測の手法についても詳しく掘り下げていきます。

筆者のプロジェクトはDeno Fresh製のWebアプリでした。Denoはビルドとデプロイが即時に行えるため、最適化作業には理想的な環境です。フィードバックループが非常に短く、ローカルで書いた最適化コードをプッシュすれば、すぐにリモートサイトでテストできます。

使用するスタック

ここでは、スケルトンアプリを使ってパフォーマンス改善の手順を説明します。使用するツールは以下の通りです。

  • upstash_redis:Upstash for Redisを操作するためのDenoモジュール
  • Deno Fresh:Denoでサーバーサイドレンダリング(SSR)アプリを構築するための、本番運用可能な新しいフレームワーク
  • サーバーレスロギング:検証ではコンソールを使用しますが、デプロイ済みアプリでリアルタイムの計測値にアクセスするには、Logtailのようなサービスが必要です

セットアップ

Node.jsとDenoの大きな違いの一つは、サードパーティモジュールへのアクセス方法です。Denoはpackage.jsonファイルの代わりに、URLとインポートマップを使用します。例えばupstash_redisの完全なURLはhttps://deno.land/x/upstash_redis@v1.20.0です。まずは新しいDeno Freshアプリを作成しましょう。

deno run -A -r https://fresh.deno.dev upstash-redis-deno-perf

Deno自体を初めて使う場合は、いくつかのターミナルコマンドでシステムにセットアップできます。

次に、プロジェクトのルートディレクトリにあるimport_map.jsonにUpstashを追加します。

{
 "imports": {
 "@/": "./",
 "$fresh/": "https://deno.land/x/fresh@1.1.2/",
 // ...省略
 "$std/": "https://deno.land/std@0.177.0/",
 "upstash/": "https://deno.land/x/upstash_redis@v1.20.0/"
 }
}
  • @/:利便性のためのインポートエイリアスです。これにより、ソースファイルがどのフォルダにあっても、@/componentsという形式で(プロジェクトルート直下の)componentsをインポートできます。
  • $std/:Deno標準ライブラリのエイリアスです。.env形式の環境変数ファイルを読み込むユーティリティ関数が含まれています。
  • upstash/:プロジェクト内の任意のTypeScript・JavaScriptファイルからUpstash for Redisライブラリにアクセスできるようにします。

Upstash for RedisとPerformance API:スケルトンアプリ

スケルトンアプリは以下のデータを取得します。

  • Tinybird(サーバーレスClickHouse)のWebアナリティクスによる、過去28日間のページビュー数
  • Webmentionによるページの「いいね」数

これらのデータはfetchリクエストで取得するため、サーバー側のタスクとして現実的なビジネスアプリに近い構成になっています。likesviewsの各変数がそれぞれのAPIからのレスポンスを保持し、フロントエンドに表示されます。

Upstash for RedisでDenoアプリのパフォーマンスを向上させる:応答時間を大幅に短縮するサーバーレスキャッシュの実装

このページのサーバー側handlerコードは、おおよそ次のようになります。

export const handler: Handlers<Data> = {
 async GET(request, context) {
 const { url } = request;
 const { pathname } = new URL(url);
 
 const likes = await getWebmentionLikes(pathname);
 const views = await getTinybirdViews({ days: 28 });
 
 return context.render({ likes, views });
 },
};

受信したrequestオブジェクトからpathnameを取り出し、それをデータ取得用のヘルパー関数に渡し、最後にリモートから取得した値を返しています。

効率化のため、これらのヘルパー関数呼び出しは次のように並列化できます。

const [likes, views] = await Promise.all([
 getWebmentionLikes(pathname),
 getTinybirdViews({ days: 28 }),
]);

JavaScript Performance Web APIとは

パフォーマンス計測は、通常、最適化の最初のステップであるべきです。律速となっている処理は、必ずしも予想どおりの場所とは限りません。計測せずに進めると、結果的に非効率な解決策に時間とリソースを費やしてしまう恐れがあります。そんなときPerformance APIは絶大な威力を発揮します。このセクションでは、Performance APIを使って、アプリ内のどこにUpstash for Redisを適用すべきかを判断する方法を見ていきます。

window.performanceを使うと、クライアントブラウザからPerformance Web APIにアクセスできます。そしてDenoはサーバーサイドでもWeb APIをサポートしているため、performanceはDenoのサーバーコードでもグローバルに利用可能です。特に覚えておきたいメソッドは次の2つです。

  • performance.mark('your-mark-name'):時点を表すPerformanceMarkオブジェクトを作成します。名前パラメータは、後ほどそのマークを使ってmeasureを作成する際に使用します。
  • performance.measure('your description', startMarkName, finishMarkName):開始と終了の時刻マークをラベルに関連付けたPerformanceMeasureオブジェクトを作成します。ログ出力やイベント所要時間の算出に役立ちます。

timeEvent:計測用ヘルパー関数

基本がわかったところで、timeEvent関数を作成しましょう。この関数は、PerformanceMeasureオブジェクトの配列と、計測対象の関数を引数として受け取ります。timeEventは開始マークを作成し、渡された関数を実行した直後に終了マークを作成します。最後に、入力として受け取ったPerformanceMeasure配列に新しい計測結果を追加します。以下はutils/performance.tsのコードです。

export async function timeEvent<EventReturnType>(
 eventFunction: () => Promise<EventReturnType>,
 {
 description,
 performanceMeasures,
 }: { description: string; performanceMeasures: PerformanceMeasure[] },
): Promise<EventReturnType> {
 // 準備
 const startName = `${description}-started`;
 const finishName = `${description}-finished`;
 
 // 計測
 performance.mark(startName);
 const result = await eventFunction();
 performance.mark(finishName);
 
 // 記録
 performanceMeasures.push(
 performance.measure(description, startName, finishName),
 );
 
 return result;
}

この関数はジェネリックですが、スケルトンアプリではEventReturnTypeは常に数値型になります。

計測機能を組み込んだサーバーコードへの更新

作成したtimeEvent関数をハンドラーで使い、比較計測を始めましょう。更新後のサーバーハンドラーコードは以下の通りです。

import type { Handlers, PageProps } from "$fresh/server.ts";
 
import "$std/dotenv/load.ts"; /* ここでは可視性のため記載していますが、
 通常は `dev.ts` などでプロジェクト全体に対して一度だけインポートすれば十分です */
 
import { timeEvent } from "@/utils/performance.ts";
 
// ...省略
 
export const handler: Handlers<Data> = {
 async GET(request, context) {
 // ...省略
 
 const performanceMeasures: PerformanceMeasure[] = [];
 
 const [likes, views] = await Promise.all([
 timeEvent<number>(() => getWebmentionLikes(pathname), {
 description: "web-mention-likes",
 performanceMeasures,
 }),
 timeEvent<number>(() => getTinybirdViews({ days: 28 }), {
 description: "analytics-views",
 performanceMeasures,
 }),
 ]);
 
 // 本番環境ではサーバーレスロギングサービスに置き換えてください
 console.log({ performanceMeasures });
 
 return context.render({ likes, views });
 },
};

timeEvent関数は、引数として渡された関数の実行結果をそのまま返します。この特性のおかげで、既存の2つのデータヘルパー関数をtimeEvent呼び出しで包むだけで済みます。ここではローカルで実行していますが、本番アプリではライブサイト上で計測を行う必要があります。サーバーのバックボーン接続はローカル環境とは性能が異なるためです。サーバー上で実行する際は、Logtailのようなロギングサービスで計測値を記録しましょう。

Upstash for RedisでDenoアプリのパフォーマンスを向上させる:応答時間を大幅に短縮するサーバーレスキャッシュの実装

コンソールログのキャプチャを見ると、PerformanceMeasureオブジェクトが以下の計測値を提供していることがわかります(前述の通り)。

  • name(名前)
  • start time(開始時刻)
  • duration(所要時間・ミリ秒)

理想的には、アプリの描画には両方のデータ(いいね数と閲覧数)が必要です。今回のケースでは、両者ともほぼ同じ時間(約2秒)かかりました。もし片方の関数がもう片方より大幅に遅い場合は、最も遅い値にのみUpstash for Redisのキャッシュを追加すればよいでしょう。今回は両方に追加します。なお、本番環境では少なくとも数百件程度のデータポイントを収集し、平均値やP90などの集計指標で比較することが望ましいでしょう。

アナリティクスヘルパー関数へのUpstash for Redisの追加

次に、アナリティクスヘルパー関数にUpstash for Redisを追加するコードを見てみましょう。Webmentions用のヘルパー関数も同様の構成で、GitHubリポジトリ(下記リンク)で全文を確認できます。

import { Redis } from "upstash/mod.ts";
 
const UPSTASH_REDIS_REST_TOKEN = Deno.env.get("UPSTASH_REDIS_REST_TOKEN");
if (typeof UPSTASH_REDIS_REST_TOKEN === "undefined") {
 console.error("env `UPSTASH_REDIS_REST_TOKEN` must be set");
}
const UPSTASH_REDIS_REST_URL = Deno.env.get("UPSTASH_REDIS_REST_URL");
if (typeof UPSTASH_REDIS_REST_URL === "undefined") {
 console.error("env `UPSTASH_REDIS_REST_URL` must be set");
}
 
const redis = new Redis({
 token: UPSTASH_REDIS_REST_TOKEN,
 url: UPSTASH_REDIS_REST_URL,
});

まず、Upstash for Redisオブジェクトを初期化する必要があります。必要なのは、Upstashコンソールから取得できるUPSTASH_REDIS_REST_TOKENUPSTASH_REDIS_REST_URLの2つの値です。まだUpstashアカウントをお持ちでない場合も、登録はすぐに完了します。両方の値をプロジェクトルートディレクトリの.envファイルに追加してください。

先頭のインポート文では、先ほど設定したインポートマップのupstashキーを使用している点に注目してください。各TypeScriptファイルに完全なインポートURLを書くよりもはるかに便利です。upstash_redisの次のリリースが出たときも、バージョン番号を更新する箇所は一箇所だけで済みます。

続いて、getTinybirdViews関数のコードです。

export async function getTinybirdViews({
 days,
}: {
 days: number;
}): Promise<number> {
 try {
 // ...省略
 
 const cachedCount = (await redis.get("view-count")) as number | null;
 
 if (cachedCount != null) {
 return cachedCount;
 }
 
 // ...省略
 const response = await fetch(
 `https://api.tinybird.co/v0/pipes/${TINYBIRD_PIPE_NAME}.json?${params.toString()}`,
 {
 headers: {
 Authorization: `Bearer ${TINYBIRD_TOKEN}`,
 },
 },
 );
 
 const {
 data: [{ count_sessions: count = -1 }],
 } = await response.json();
 
 if (typeof count === "number" && count > 0) {
 const CACHE_TTL_SECONDS = 14_400;
 await redis.set("view-count", count);
 await redis.expire("view-count", CACHE_TTL_SECONDS);
 }
 
 return count;
 } catch (error: unknown) {
 // ...省略
 }
}

この関数の処理の流れは以下の通りです。

  1. redis.get('view-count')を呼び出し、view-countのUpstash for Redisキャッシュ済みの値が存在するかどうかを確認します。
  2. キャッシュがあればその値を返し、なければTinybirdから最新の値を取得します。
  3. 取得した新しい値をredis.set('view-count', value)で保存し、redis.expire(TTL)で有効期限を設定します。これにより、値にTTL(Time To Live:生存時間)が設定され、期限切れになったデータは古いものとみなされます。ここではTTLを4時間に設定しており、それ以降のgetTinybirdViews呼び出しでは、再びTinybirdに最新値を問い合わせることになります。

Upstash for RedisでDenoアプリのパフォーマンスを向上させる:応答時間を大幅に短縮するサーバーレスキャッシュの実装

両方のデータクエリにUpstash for Redisを組み込んでページを(何度か)再読み込みすると、処理速度が明らかに向上していることがわかります。約2秒強から約0.29秒まで短縮されました。ただし、これはローカル実行であり、データポイントも多くないため、数字そのものを深読みしないでください。ぜひご自身のアプリで試して、どの程度の改善が得られるか確認してみてください。

まとめ:Upstash for RedisとPerformance APIで実現する高速化

本記事では、JavaScriptのPerformance APIを活用して、最適化の努力をどこに注ぐべきかの意思決定を支援する方法を紹介しました。さらに、Deno FreshアプリにUpstash for Redisを実装する具体的な手順も解説しました。そして最後に、DenoがサーバーサイドでもWeb APIをサポートしていることを確認しました。これにより学習曲線は大きくなだらかになります。また、Denoのもう一つの大きな利点は即時デプロイです。フィードバックループが短いため、パフォーマンスの微調整を素早く繰り返せます。

本記事が皆さんの参考になれば幸いです。DenoやDeno Freshが初めての方は、筆者が作成したDeno入門コンテンツもぜひご覧ください。アプリの完全なコードはGitHubで公開しています。

  1. SevallaでRedisキャッシュを導入してNext.js APIのパフォーマンスを大幅に向上させる方法

    Next.jsと聞くと、静的なウェブサイトやReact駆動のフロントエンドを思い浮かべる方が多いかもしれません。しかし、それはNext.jsの能力の一部にすぎません。実はNext.jsは、他のバックエンドサービスと同じようにホスティングやスケーリングが可能な、本格的なバックエンドAPIの構築にも活用できます。 以前の記事では、Next.jsでAPIを構築し、Sevallaにデプロイする手順を解説しました。そのサンプルではPostgreSQLデータベースにデータを保存し、リクエストを直接処理していました。小規模なうちは問題なく動作しますが、トラフィックが増加すると、リクエストごとにデータベース

  2. Redis ZINTERSTOREコマンド徹底解説 – ソート済みセットの積集合を求める方法

    このチュートリアルでは、Redisデータストアに保存された2つ以上のソート済みセット(ZSET)に対して、ZINTERSTOREコマンドを使って積集合(共通部分)の演算を行う方法を詳しく解説します。 集合の積集合とは? 集合論において、2つ以上の集合の積集合(intersection)とは、すべての集合に共通して含まれる要素だけを集めた集合のことです。以下の例を見てみましょう。 A = {1, 2, 3, 4, 5} B = {4, 5, 6, 7, 8, 9} A と B の積集合: A ∩ B = {4, 5} このように、AとBの両方に存在する「4」と「5」だけが積集合の結果となります