キャッシュを使用してWebファームでASP.NETアプリのパフォーマンスを向上させる方法

公開: 2022-03-11

コンピュータサイエンスには、キャッシュの無効化と名前の付け方という2つの難しいことがあります。

  • 著者:フィルカールトン

キャッシングの簡単な紹介

キャッシングは、単純なトリックでパフォーマンスを向上させるための強力な手法です。結果が必要になるたびに高価な作業(複雑な計算や複雑なデータベースクエリなど)を実行する代わりに、システムはその作業の結果を保存またはキャッシュできます。その作業を再実行する必要なしに、次に要求されたときにそれを供給します(したがって、非常に高速に応答できます)。

もちろん、キャッシュの背後にある考え方全体は、キャッシュした結果が有効である限り機能します。 そして、ここで問題の実際の難しい部分に到達します。キャッシュされたアイテムが無効になり、再作成する必要がある場合をどのように判断するのでしょうか。

キャッシングは、パフォーマンスを向上させるための強力な手法です

ASP.NETのメモリ内キャッシュは非常に高速です
分散型Webファームのキャッシュの問題を解決するのに最適です。
つぶやき

通常、一般的なWebアプリケーションは、書き込み要求よりもはるかに大量の読み取り要求を処理する必要があります。 そのため、高負荷を処理するように設計された一般的なWebアプリケーションは、スケーラブルで分散されるように設計され、通常はファームと呼ばれるWeb層ノードのセットとして展開されます。 これらすべての事実は、キャッシングの適用性に影響を与えます。

この記事では、高負荷を処理するように設計されたWebアプリケーションの高スループットとパフォーマンスを保証する上でキャッシングが果たす役割に焦点を当て、プロジェクトの1つからの経験を使用して、ASP.NETベースのソリューションを提供します。例として。

高負荷の処理の問題

私が解決しなければならなかった実際の問題は、元の問題ではありませんでした。 私の仕事は、ASP.NETMVCモノリシックWebアプリケーションのプロトタイプが高負荷を処理できるようにすることでした。

モノリシックWebアプリケーションのスループット機能を改善するために必要な手順は次のとおりです。

  • ロードバランサーの背後でWebアプリケーションの複数のコピーを並行して実行し、すべての同時リクエストを効果的に処理できるようにします(つまり、スケーラブルにします)。
  • アプリケーションのプロファイルを作成して、現在のパフォーマンスのボトルネックを明らかにし、それらを最適化します。
  • キャッシュを使用して読み取り要求のスループットを向上させます。これは通常、アプリケーション全体の負荷の重要な部分を構成するためです。

キャッシング戦略では、多くの場合、MemcachedやRedisなどのミドルウェアキャッシングサーバーを使用して、キャッシュされた値を保存します。 それらの高い採用と実証済みの適用性にもかかわらず、これらのアプローチには次のようないくつかの欠点があります。

  • 個別のキャッシュサーバーにアクセスすることによって導入されるネットワーク遅延は、データベース自体に到達するまでの遅延に匹敵する可能性があります。
  • Web層のデータ構造は、そのままの状態でのシリアル化と逆シリアル化には不適切な場合があります。 キャッシュサーバーを使用するには、これらのデータ構造でシリアル化と逆シリアル化をサポートする必要があります。これには、継続的な追加の開発作業が必要です。
  • シリアル化と逆シリアル化は実行時のオーバーヘッドを追加し、パフォーマンスに悪影響を及ぼします。

私の場合、これらの問題はすべて関連していたので、別のオプションを検討する必要がありました。

キャッシングの仕組み

組み込みのASP.NETメモリ内キャッシュ( System.Web.Caching.Cache )は非常に高速であり、開発中と実行時の両方で、シリアル化と逆シリアル化のオーバーヘッドなしで使用できます。 ただし、ASP.NETのメモリ内キャッシュには独自の欠点もあります。

  • 各Web層ノードには、キャッシュされた値の独自のコピーが必要です。 これにより、ノードのコールドスタートまたはリサイクル時にデータベース層の消費量が増える可能性があります。
  • 別のノードが更新された値を書き込んでキャッシュの一部を無効にした場合は、各Web層ノードに通知する必要があります。 キャッシュは分散されており、適切な同期が行われていないため、ほとんどのノードは古い値を返しますが、これは通常は受け入れられません。

追加のデータベース層の負荷がそれ自体でボトルネックにならない場合は、適切に分散されたキャッシュを実装するのは簡単な作業のように思えますよね? まあ、それは簡単な作業ではありませんが、それは可能です。 私の場合、ベンチマークでは、ほとんどの作業がWeb層で行われたため、データベース層は問題にならないはずであることが示されました。 そこで、ASP.NETのメモリ内キャッシュを使用して、適切な同期の実装に集中することにしました。

ASP.NETベースのソリューションの紹介

説明したように、私の解決策は、専用のキャッシュサーバーの代わりにASP.NETのメモリ内キャッシュを使用することでした。 これには、Webファームの各ノードが独自のキャッシュを持ち、データベースに直接クエリを実行し、必要な計算を実行し、結果をキャッシュに保存する必要があります。 このように、キャッシュのメモリ内の性質のおかげで、すべてのキャッシュ操作が非常に高速になります。 通常、キャッシュされたアイテムには明確な存続期間があり、新しいデータの変更または書き込みによって古くなります。 したがって、Webアプリケーションロジックからは、通常、キャッシュアイテムをいつ無効にする必要があるかが明確になります。

ここに残っている唯一の問題は、ノードの1つが自身のキャッシュ内のキャッシュ項目を無効にすると、他のノードがこの更新について認識しないことです。 したがって、他のノードによって処理される後続の要求は、古い結果を提供します。 これに対処するには、各ノードがキャッシュの無効化を他のノードと共有する必要があります。 このような無効化を受信すると、他のノードは単にキャッシュされた値を削除し、次のリクエストで新しい値を取得する可能性があります。

ここで、Redisが機能します。 他のソリューションと比較したRedisのパワーは、Pub/Sub機能にあります。 Redisサーバーのすべてのクライアントは、チャネルを作成し、それにいくつかのデータを公開できます。 他のクライアントは、イベント駆動型システムと非常によく似て、そのチャネルをリッスンして関連データを受信できます。 この機能を使用して、ノード間でキャッシュ無効化メッセージを交換できるため、すべてのノードが必要なときにキャッシュを無効化できます。

Redisバックプレーンを使用するASP.NETWeb層ノードのグループ

ASP.NETのメモリ内キャッシュは、ある意味では単純で、他の意味では複雑です。 特に、キーと値のペアのマップとして機能するという点で単純ですが、無効化戦略と依存関係に関連する複雑さがたくさんあります。

幸い、一般的なユースケースは非常に単純であり、すべてのアイテムにデフォルトの無効化戦略を使用して、各キャッシュアイテムに最大で1つの依存関係のみを持たせることができます。 私の場合、キャッシングサービスのインターフェイス用に次のASP.NETコードで終了しました。 (簡単にするために一部の詳細を省略し、プロプライエタリライセンスを使用しているため、これは実際のコードではないことに注意してください。)

 public interface ICacheKey { string Value { get; } } public interface IDataCacheKey : ICacheKey { } public interface ITouchableCacheKey : ICacheKey { } public interface ICacheService { int ItemsCount { get; } T Get<T>(IDataCacheKey key, Func<T> valueGetter); T Get<T>(IDataCacheKey key, Func<T> valueGetter, ICacheKey dependencyKey); }

ここで、キャッシュサービスは基本的に2つのことを許可します。 まず、スレッドセーフな方法で値ゲッター関数の結果を保存できるようにします。 次に、要求されたときにその時点での値が常に返されるようにします。 キャッシュアイテムが古くなるか、キャッシュから明示的に削除されると、値ゲッターが再度呼び出されて現在の値が取得されます。 キャッシュキーは、主にアプリケーション全体でのキャッシュキー文字列のハードコーディングを回避するために、 ICacheKeyインターフェイスによって抽象化されました。

キャッシュアイテムを無効にするために、次のような別のサービスを導入しました。

 public interface ICacheInvalidator { bool IsSessionOpen { get; } void OpenSession(); void CloseSession(); void Drop(IDataCacheKey key); void Touch(ITouchableCacheKey key); void Purge(); }

データを含むアイテムをドロップし、依存するデータアイテムしか持たないキーに触れる基本的な方法の他に、ある種の「セッション」に関連するいくつかの方法があります。

私たちのWebアプリケーションは、依存性注入にAutofacを使用しました。これは、依存性管理のための制御の反転(IoC)デザインパターンの実装です。 この機能により、開発者は依存関係を気にすることなくクラスを作成できます。これは、IoCコンテナーがクラスの負担を管理するためです。

キャッシュサービスとキャッシュ無効化機能は、IoCに関して大幅に異なるライフサイクルを持っています。 キャッシュサービスはシングルトン(1つのインスタンス、すべてのクライアント間で共有)として登録され、キャッシュ無効化ツールはリクエストごとにインスタンスとして登録されました(着信リクエストごとに個別のインスタンスが作成されました)。 なんで?

答えは、私たちが処理する必要のある追加の微妙さと関係があります。 WebアプリケーションはModel-View-Controller(MVC)アーキテクチャーを使用しており、これは主にUIとロジックの問題の分離に役立ちます。 したがって、一般的なコントローラーアクションは、 ActionFilterAttributeのサブクラスにラップされます。 ASP.NET MVCフレームワークでは、このようなC#属性は、コントローラーのアクションロジックを何らかの方法で装飾するために使用されます。 その特定の属性は、新しいデータベース接続を開き、アクションの開始時にトランザクションを開始する役割を果たしました。 また、アクションの最後に、フィルター属性サブクラスは、成功した場合はトランザクションをコミットし、失敗した場合はロールバックする役割を果たしました。

トランザクションの途中でキャッシュの無効化が発生した場合、そのノードへの次の要求が古い(他のトランザクションからはまだ表示されている)値をキャッシュに正常に戻す競合状態が発生する可能性があります。 これを回避するために、トランザクションがコミットされるまで、すべての無効化は延期されます。 その後、キャッシュアイテムは安全に削除でき、トランザクションが失敗した場合でも、キャッシュを変更する必要はまったくありません。

これが、キャッシュ無効化機能の「セッション」関連部分の正確な目的でした。 また、それはその存続期間が要求にバインドされる目的です。 ASP.NETコードは次のようになりました。

 class HybridCacheInvalidator : ICacheInvalidator { ... public void Drop(IDataCacheKey key) { if (key == null) throw new ArgumentNullException("key"); if (!IsSessionOpen) throw new InvalidOperationException("Session must be opened first."); _postponedRedisMessages.Add(new Tuple<string, string>("drop", key.Value)); } ... public void CloseSession() { if (!IsSessionOpen) return; _postponedRedisMessages.ForEach(m => PublishRedisMessageSafe(m.Item1, m.Item2)); _postponedRedisMessages = null; } ... }

ここでのPublishRedisMessageSafeメソッドは、特定のチャネル(最初の引数)にメッセージ(2番目の引数)を送信する役割を果たします。 実際、ドロップとタッチには別々のチャネルがあるため、それぞれのメッセージハンドラーは、受信したメッセージペイロードに等しいキーをドロップ/タッチして何をすべきかを正確に知っていました。

トリッキーな部分の1つは、Redisサーバーへの接続を適切に管理することでした。 何らかの理由でサーバーがダウンした場合でも、アプリケーションは引き続き正常に機能するはずです。 Redisが再びオンラインになると、アプリケーションはシームレスにRedisの使用を開始し、他のノードとメッセージを交換する必要があります。 これを実現するために、StackExchange.Redisライブラリを使用し、結果の接続管理ロジックを次のように実装しました。

 class HybridCacheService : ... { ... public void Initialize() { try { Multiplexer = ConnectionMultiplexer.Connect(_configService.Caching.BackendServerAddress); ... Multiplexer.ConnectionFailed += (sender, args) => UpdateConnectedState(); Multiplexer.ConnectionRestored += (sender, args) => UpdateConnectedState(); ... } catch (Exception ex) { ... } } private void UpdateConnectedState() { if (Multiplexer.IsConnected && _currentCacheService is NoCacheServiceStub) { _inProcCacheInvalidator.Purge(); _currentCacheService = _inProcCacheService; _logger.Debug("Connection to remote Redis server restored, switched to in-proc mode."); } else if (!Multiplexer.IsConnected && _currentCacheService is InProcCacheService) { _currentCacheService = _noCacheStub; _logger.Debug("Connection to remote Redis server lost, switched to no-cache mode."); } } }

ここで、 ConnectionMultiplexerはStackExchange.Redisライブラリのタイプであり、基盤となるRedisとの透過的な作業を担当します。 ここで重要なのは、特定のノードがRedisへの接続を失うと、キャッシュモードにフォールバックして、要求が古いデータを受け取らないようにすることです。 接続が復元された後、ノードはメモリ内キャッシュの使用を再開します。

キャッシュサービスを使用しないアクション( SomeActionWithoutCaching )とそれを使用する同一の操作( SomeActionUsingCache )の例を次に示します。

 class SomeController : Controller { public ISomeService SomeService { get; set; } public ICacheService CacheService { get; set; } ... public ActionResult SomeActionWithoutCaching() { return View( SomeService.GetModelData() ); } ... public ActionResult SomeActionUsingCache() { return View( CacheService.Get( /* Cache key creation omitted */, () => SomeService.GetModelData() ); ); } }

ISomeService実装のコードスニペットは次のようになります。

 class DefaultSomeService : ISomeService { public ICacheInvalidator _cacheInvalidator; ... public SomeModel GetModelData() { return /* Do something to get model data. */; } ... public void SetModelData(SomeModel model) { /* Do something to set model data. */ _cacheInvalidator.Drop(/* Cache key creation omitted */); } }

ベンチマークと結果

キャッシングASP.NETコードがすべて設定されたら、既存のWebアプリケーションロジックでそれを使用するときが来ました。ベンチマークは、キャッシングを使用するためにコードを書き直すために最も努力する場所を決定するのに便利です。 ベンチマークする最も運用上一般的または重要なユースケースをいくつか選択することが重要です。 その後、ApachejMeterのようなツールを次の2つの目的に使用できます。

  • HTTPリクエストを介してこれらの主要なユースケースをベンチマークします。
  • テスト対象のWebノードの高負荷をシミュレートします。

パフォーマンスプロファイルを取得するには、IISワーカープロセスに接続できる任意のプロファイラーを使用できます。 私の場合、JetBrainsdotTracePerformanceを使用しました。 正しいjMeterパラメーター(同時実行数や要求数など)を決定するための実験に時間を費やした後、パフォーマンススナップショットの収集を開始できるようになります。これは、ホットスポットとボトルネックを特定するのに非常に役立ちます。

私の場合、いくつかのユースケースでは、明らかなボトルネックがあり、全体のコード実行時間がデータベースの読み取りに約15%〜45%費やされていることが示されました。 キャッシングを適用した後、ほとんどの場合、パフォーマンスはほぼ2倍になりました(つまり、2倍の速さでした)。

関連: MicrosoftStackが依然として実行可能な選択肢である8つの理由

結論

ご覧のとおり、私のケースは、通常「車輪の再発明」と呼ばれるものの例のように見えるかもしれません。すでに広く適用されているベストプラクティスがあるのに、なぜわざわざ新しいものを作成しようとするのでしょうか。 MemcachedまたはRedisをセットアップして、手放すだけです。

私は、ベストプラクティスの使用が通常最良の選択肢であることに間違いなく同意します。 しかし、やみくもにベストプラクティスを適用する前に、自分自身に問いかける必要があります。この「ベストプラクティス」はどの程度適用可能ですか。 それは私のケースによく合いますか?

私の見方、適切なオプション、およびトレードオフ分析は、重要な決定を行う際に必須であり、問​​題がそれほど簡単ではなかったため、それが私が選択したアプローチでした。 私の場合、考慮すべき要素はたくさんあり、目前の問題に対する適切なアプローチではない可能性がある場合は、万能の解決策を採用したくありませんでした。

最終的に、適切なキャッシュを設定すると、最初のソリューションよりもパフォーマンスがほぼ50%向上しました。