So verbessern Sie die Leistung von ASP.NET-Apps in Webfarmen mit Caching

Veröffentlicht: 2022-03-11

In der Informatik gibt es nur zwei schwierige Dinge: Cache-Invalidierung und Benennung von Dingen.

  • Autor: Phil Karlton

Eine kurze Einführung in das Caching

Caching ist eine leistungsstarke Technik zur Leistungssteigerung durch einen einfachen Trick: Anstatt jedes Mal, wenn wir ein Ergebnis benötigen, teure Arbeit (wie eine komplizierte Berechnung oder komplexe Datenbankabfrage) zu leisten, kann das System das Ergebnis dieser Arbeit einfach speichern – oder zwischenspeichern bei der nächsten Anforderung bereitstellen, ohne dass diese Arbeit erneut ausgeführt werden muss (und kann daher enorm schneller reagieren).

Natürlich funktioniert die ganze Idee hinter dem Caching nur, solange das Ergebnis, das wir zwischengespeichert haben, gültig bleibt. Und hier kommen wir zum eigentlich schwierigen Teil des Problems: Wie stellen wir fest, wann ein zwischengespeichertes Element ungültig geworden ist und neu erstellt werden muss?

Caching ist eine leistungsstarke Technik zur Leistungssteigerung

Der In-Memory-Cache von ASP.NET ist extrem schnell
und perfekt, um verteilte Webfarm-Caching-Probleme zu lösen.
Twittern

Normalerweise muss eine typische Webanwendung ein viel höheres Volumen an Leseanfragen bewältigen als Schreibanfragen. Aus diesem Grund ist eine typische Webanwendung, die für die Bewältigung hoher Lasten ausgelegt ist, so konzipiert, dass sie skalierbar und verteilt ist und als eine Reihe von Knoten auf der Webebene bereitgestellt wird, die normalerweise als Farm bezeichnet werden. All diese Fakten wirken sich auf die Anwendbarkeit von Caching aus.

In diesem Artikel konzentrieren wir uns auf die Rolle, die Caching bei der Sicherstellung eines hohen Durchsatzes und einer hohen Leistung von Webanwendungen spielen kann, die für die Bewältigung einer hohen Last ausgelegt sind, und ich werde die Erfahrung aus einem meiner Projekte nutzen und eine ASP.NET-basierte Lösung bereitstellen als Illustration.

Das Problem der Handhabung einer hohen Last

Das eigentliche Problem, das ich lösen musste, war kein originelles. Meine Aufgabe bestand darin, den Prototyp einer monolithischen ASP.NET MVC-Webanwendung hochlastfähig zu machen.

Die notwendigen Schritte zur Verbesserung der Durchsatzfähigkeiten einer monolithischen Webanwendung sind:

  • Aktivieren Sie es, um mehrere Kopien der Webanwendung parallel hinter einem Load Balancer auszuführen und alle gleichzeitigen Anforderungen effektiv zu bedienen (dh skalierbar zu machen).
  • Profilieren Sie die Anwendung, um aktuelle Leistungsengpässe aufzudecken und zu optimieren.
  • Verwenden Sie Caching, um den Durchsatz von Leseanforderungen zu erhöhen, da dies normalerweise einen erheblichen Teil der gesamten Anwendungslast ausmacht.

Caching-Strategien beinhalten häufig die Verwendung eines Middleware-Caching-Servers wie Memcached oder Redis, um die zwischengespeicherten Werte zu speichern. Trotz ihrer hohen Akzeptanz und nachgewiesenen Anwendbarkeit haben diese Ansätze einige Nachteile, darunter:

  • Netzwerklatenzen, die durch den Zugriff auf die separaten Cache-Server eingeführt werden, können mit den Latenzen beim Erreichen der Datenbank selbst vergleichbar sein.
  • Die Datenstrukturen der Webebene können für die standardmäßige Serialisierung und Deserialisierung ungeeignet sein. Um Cache-Server zu verwenden, sollten diese Datenstrukturen die Serialisierung und Deserialisierung unterstützen, was einen ständigen zusätzlichen Entwicklungsaufwand erfordert.
  • Serialisierung und Deserialisierung erhöhen den Laufzeitaufwand, was sich negativ auf die Leistung auswirkt.

All diese Probleme waren in meinem Fall relevant, also musste ich nach alternativen Optionen suchen.

So funktioniert Caching

Der eingebaute ASP.NET-In-Memory-Cache ( System.Web.Caching.Cache ) ist extrem schnell und kann ohne Serialisierungs- und Deserialisierungsaufwand sowohl während der Entwicklung als auch zur Laufzeit verwendet werden. Der In-Memory-Cache von ASP.NET hat jedoch auch seine eigenen Nachteile:

  • Jeder Webschichtknoten benötigt eine eigene Kopie der zwischengespeicherten Werte. Dies kann zu einem höheren Verbrauch der Datenbankebene beim Kaltstart oder Recycling des Knotens führen.
  • Jeder Knoten der Webebene sollte benachrichtigt werden, wenn ein anderer Knoten einen Teil des Cache ungültig macht, indem aktualisierte Werte geschrieben werden. Da der Cache verteilt und ohne ordnungsgemäße Synchronisierung ist, geben die meisten Knoten alte Werte zurück, was normalerweise nicht akzeptabel ist.

Wenn die zusätzliche Belastung der Datenbankebene nicht selbst zu einem Engpass führt, scheint die Implementierung eines ordnungsgemäß verteilten Caches eine einfach zu handhabende Aufgabe zu sein, oder? Nun, es ist keine leichte Aufgabe, aber es ist möglich . In meinem Fall haben Benchmarks gezeigt, dass die Datenbankschicht kein Problem sein sollte, da die meiste Arbeit auf der Webschicht stattfand. Also habe ich mich für den In-Memory-Cache von ASP.NET entschieden und mich auf die Implementierung der richtigen Synchronisierung konzentriert.

Einführung einer ASP.NET-basierten Lösung

Wie bereits erläutert, bestand meine Lösung darin, den In-Memory-Cache von ASP.NET anstelle des dedizierten Caching-Servers zu verwenden. Das bedeutet, dass jeder Knoten der Webfarm seinen eigenen Cache hat, die Datenbank direkt abfragt, alle notwendigen Berechnungen durchführt und die Ergebnisse in einem Cache speichert. Auf diese Weise werden alle Cache-Operationen dank der In-Memory-Natur des Caches blitzschnell ausgeführt. Typischerweise haben zwischengespeicherte Elemente eine klare Lebensdauer und veralten bei einer Änderung oder dem Schreiben neuer Daten. Aus der Webanwendungslogik ist es daher normalerweise klar, wann das Cache-Element ungültig gemacht werden sollte.

Das einzige Problem, das hier bleibt, besteht darin, dass, wenn einer der Knoten ein Cache-Element in seinem eigenen Cache ungültig macht, kein anderer Knoten von dieser Aktualisierung erfährt. Daher liefern nachfolgende Anfragen, die von anderen Knoten bedient werden, veraltete Ergebnisse. Um dies zu beheben, sollte jeder Knoten seine Cache-Invalidierungen mit den anderen Knoten teilen. Nach Erhalt einer solchen Ungültigkeitserklärung könnten andere Knoten einfach ihren zwischengespeicherten Wert löschen und bei der nächsten Anforderung einen neuen erhalten.

Hier kann Redis ins Spiel kommen. Die Stärke von Redis liegt im Vergleich zu anderen Lösungen in seinen Pub/Sub-Fähigkeiten. Jeder Client eines Redis-Servers kann einen Kanal erstellen und einige Daten darauf veröffentlichen. Jeder andere Client kann diesen Kanal abhören und die zugehörigen Daten empfangen, ähnlich wie bei jedem ereignisgesteuerten System. Diese Funktionalität kann verwendet werden, um Cache-Invalidierungsnachrichten zwischen den Knoten auszutauschen, sodass alle Knoten ihren Cache bei Bedarf ungültig machen können.

Eine Gruppe von ASP.NET-Webschichtknoten, die eine Redis-Backplane verwenden

Der In-Memory-Cache von ASP.NET ist in gewisser Weise unkompliziert und in anderer Hinsicht komplex. Insbesondere ist es unkompliziert, da es als Karte von Schlüssel/Wert-Paaren funktioniert, aber es gibt eine Menge Komplexität im Zusammenhang mit seinen Invalidierungsstrategien und Abhängigkeiten.

Glücklicherweise sind typische Anwendungsfälle einfach genug, und es ist möglich, eine Standard-Invalidierungsstrategie für alle Elemente zu verwenden, sodass jedes Cache-Element höchstens eine einzige Abhängigkeit aufweisen kann. In meinem Fall endete ich mit dem folgenden ASP.NET-Code für die Schnittstelle des Caching-Dienstes. (Beachten Sie, dass dies nicht der eigentliche Code ist, da ich einige Details der Einfachheit halber und der proprietären Lizenz weggelassen habe.)

 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); }

Dabei erlaubt der Cache-Dienst grundsätzlich zwei Dinge. Erstens ermöglicht es das Speichern des Ergebnisses einer Wert-Getter-Funktion auf Thread-sichere Weise. Zweitens stellt es sicher, dass immer der aktuelle Wert zurückgegeben wird, wenn er angefordert wird. Sobald das Cache-Element veraltet ist oder explizit aus dem Cache entfernt wurde, wird der Werte-Getter erneut aufgerufen, um einen aktuellen Wert abzurufen. Der Cache-Schlüssel wurde von der ICacheKey -Schnittstelle abstrahiert, hauptsächlich um die Hartcodierung von Cache-Schlüsselzeichenfolgen in der gesamten Anwendung zu vermeiden.

Um Cache-Elemente ungültig zu machen, habe ich einen separaten Dienst eingeführt, der so aussah:

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

Neben grundlegenden Methoden zum Ablegen von Elementen mit Daten und Berühren von Tasten, die nur abhängige Datenelemente hatten, gibt es einige Methoden, die sich auf eine Art „Sitzung“ beziehen.

Unsere Webanwendung verwendete Autofac für die Abhängigkeitsinjektion, eine Implementierung des IoC-Entwurfsmusters (Inversion of Control) für das Abhängigkeitsmanagement. Mit dieser Funktion können Entwickler ihre Klassen erstellen, ohne sich Gedanken über Abhängigkeiten machen zu müssen, da der IoC-Container diese Last für sie verwaltet.

Der Cache-Dienst und der Cache-Invalidator haben drastisch unterschiedliche Lebenszyklen in Bezug auf IoC. Der Cache-Dienst wurde als Singleton registriert (eine Instanz, die von allen Clients gemeinsam genutzt wird), während der Cache-Invalidator als Instanz pro Anfrage registriert wurde (für jede eingehende Anfrage wurde eine separate Instanz erstellt). Warum?

Die Antwort hat mit einer zusätzlichen Subtilität zu tun, mit der wir umgehen mussten. Die Webanwendung verwendet eine Model-View-Controller (MVC)-Architektur, die hauptsächlich bei der Trennung von UI- und Logikbelangen hilft. Eine typische Controller-Aktion wird also in eine Unterklasse eines ActionFilterAttribute . Im ASP.NET MVC-Framework werden solche C#-Attribute verwendet, um die Aktionslogik des Controllers auf irgendeine Weise zu dekorieren. Dieses spezielle Attribut war für das Öffnen einer neuen Datenbankverbindung und das Starten einer Transaktion zu Beginn der Aktion verantwortlich. Außerdem war am Ende der Aktion die Unterklasse des Filterattributs dafür verantwortlich, die Transaktion im Erfolgsfall festzuschreiben und im Fehlerfall rückgängig zu machen.

Wenn die Cache-Invalidierung mitten in der Transaktion stattfand, könnte es zu einer Race-Bedingung kommen, bei der die nächste Anforderung an diesen Knoten den alten (noch für andere Transaktionen sichtbaren) Wert erfolgreich zurück in den Cache legen würde. Um dies zu vermeiden, werden alle Invalidierungen verschoben, bis die Transaktion festgeschrieben ist. Danach können Cache-Elemente sicher entfernt werden, und im Falle eines Transaktionsfehlers ist überhaupt keine Cache-Änderung erforderlich.

Das war der genaue Zweck der „Session“-bezogenen Teile im Cache-Invalidator. Das ist auch der Zweck seiner Lebensdauer, die an die Anfrage gebunden ist. Der ASP.NET-Code sah folgendermaßen aus:

 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; } ... }

Die PublishRedisMessageSafe Methode ist hier für das Senden der Nachricht (zweites Argument) an einen bestimmten Kanal (erstes Argument) verantwortlich. Tatsächlich gibt es getrennte Kanäle für das Ablegen und Berühren, sodass der Nachrichtenverarbeiter für jeden von ihnen genau wusste, was zu tun war – die Taste fallen lassen/berühren, die der Nutzlast der empfangenen Nachricht entspricht.

Einer der kniffligen Teile war die ordnungsgemäße Verwaltung der Verbindung zum Redis-Server. Falls der Server aus irgendeinem Grund ausfällt, sollte die Anwendung weiterhin ordnungsgemäß funktionieren. Wenn Redis wieder online ist, sollte die Anwendung nahtlos damit beginnen, es wieder zu verwenden und wieder Nachrichten mit anderen Knoten auszutauschen. Um dies zu erreichen, habe ich die StackExchange.Redis-Bibliothek verwendet und die resultierende Verbindungsverwaltungslogik wurde wie folgt implementiert:

 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."); } } }

Dabei ist ConnectionMultiplexer ein Typ aus der StackExchange.Redis-Bibliothek, die für transparentes Arbeiten mit zugrundeliegendem Redis zuständig ist. Der wichtige Teil hier ist, dass, wenn ein bestimmter Knoten die Verbindung zu Redis verliert, er in den No-Cache-Modus zurückfällt, um sicherzustellen, dass keine Anfrage veraltete Daten erhält. Nachdem die Verbindung wiederhergestellt wurde, beginnt der Knoten wieder mit der Verwendung des In-Memory-Cache.

Hier sind Beispiele für Aktionen ohne Verwendung des Cache-Dienstes ( SomeActionWithoutCaching ) und eine identische Operation, die ihn verwendet ( 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() ); ); } }

Ein Code-Snippet aus einer ISomeService -Implementierung könnte so aussehen:

 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 */); } }

Benchmarking und Ergebnisse

Nachdem der Caching-ASP.NET-Code vollständig eingerichtet war, war es an der Zeit, ihn in der vorhandenen Webanwendungslogik zu verwenden, und Benchmarking kann hilfreich sein, um zu entscheiden, wo der größte Aufwand beim Umschreiben des Codes für die Verwendung des Cachings betrieben werden sollte. Es ist von entscheidender Bedeutung, einige der betrieblich häufigsten oder kritischsten Anwendungsfälle für einen Benchmark auszuwählen. Danach könnte ein Tool wie Apache jMeter für zwei Dinge verwendet werden:

  • Benchmarking dieser wichtigen Anwendungsfälle über HTTP-Anforderungen.
  • Um eine hohe Last für den zu testenden Webknoten zu simulieren.

Um ein Leistungsprofil zu erhalten, kann jeder Profiler verwendet werden, der sich an den IIS-Arbeitsprozess anhängen kann. In meinem Fall habe ich JetBrains dotTrace Performance verwendet. Nachdem Sie einige Zeit damit verbracht haben, die richtigen jMeter-Parameter zu bestimmen (z. B. Anzahl gleichzeitiger Anfragen und Anfragen), können Sie mit dem Sammeln von Leistungsmomentaufnahmen beginnen, die beim Identifizieren von Hotspots und Engpässen sehr hilfreich sind.

In meinem Fall zeigten einige Anwendungsfälle, dass etwa 15 % bis 45 % der gesamten Codeausführungszeit in den Datenbanklesevorgängen mit den offensichtlichen Engpässen verbracht wurden. Nachdem ich Caching angewendet hatte, verdoppelte sich die Leistung für die meisten von ihnen fast (dh war doppelt so schnell).

Verwandte: Acht Gründe, warum Microsoft Stack immer noch eine gute Wahl ist

Fazit

Wie Sie vielleicht sehen, könnte mein Fall wie ein Beispiel dafür erscheinen, was üblicherweise als „das Rad neu erfinden“ bezeichnet wird: Warum sich die Mühe machen, etwas Neues zu schaffen, wenn es bereits weit verbreitete Best Practices gibt? Richten Sie einfach ein Memcached oder Redis ein und lassen Sie es los.

Ich stimme definitiv zu, dass die Verwendung von Best Practices normalerweise die beste Option ist. Aber bevor man irgendwelche Best Practices blind anwendet, sollte man sich fragen: Wie anwendbar ist diese „Best Practice“? Passt es gut zu meinem Fall?

So wie ich es sehe, sind richtige Optionen und Tradeoff-Analysen ein Muss bei jeder wichtigen Entscheidung, und das war der Ansatz, den ich gewählt habe, weil das Problem nicht so einfach war. In meinem Fall mussten viele Faktoren berücksichtigt werden, und ich wollte keine Einheitslösung wählen, wenn dies möglicherweise nicht der richtige Ansatz für das vorliegende Problem ist.

Am Ende habe ich mit dem richtigen Caching eine Leistungssteigerung von fast 50 % gegenüber der ursprünglichen Lösung erzielt.