In einem aktuellen Projekt habe ich einen in die Jahre gekommenen monolithischen Java-Webdienst modernisiert, der in Dropwizard geschrieben wurde. Dieser Dienst handhabte eine Reihe von Abhängigkeiten von Drittanbietern (3P) über AWS Lambda-Funktionen, die Leistung war jedoch aufgrund der synchronen, blockierenden Natur der Architektur zurückgeblieben. Das Setup hatte eine P99-Latenz von 20 Sekunden und blockierte Anforderungsthreads, während auf den Abschluss der serverlosen Funktionen gewartet wurde. Diese Blockierung führte zu einer Sättigung des Thread-Pools, was zu häufigen Anforderungsfehlern während des Spitzenverkehrs führte.
Der Kern des Problems bestand darin, dass jede Anfrage an eine Lambda-Funktion einen Anfragethread im Java-Dienst belegte. Da die Ausführung dieser 3P-Funktionen oft viel Zeit in Anspruch nahm, blieben die sie verarbeitenden Threads blockiert, was Ressourcen verbrauchte und die Skalierbarkeit einschränkte. Hier ist ein Beispiel dafür, wie dieses Blockierungsverhalten im Code aussieht:
// Blocking code example public String callLambdaService(String payload) { String response = externalLambdaService.invoke(payload); return response; }
In diesem Beispiel wartet die callLambdaService-Methode, bis externalLambdaService.invoke() eine Antwort zurückgibt. In der Zwischenzeit können keine anderen Aufgaben den Thread nutzen.
Um diese Engpässe zu beheben, habe ich den Dienst mithilfe asynchroner und nicht blockierender Methoden neu strukturiert. Diese Änderung umfasste die Verwendung eines HTTP-Clients, der die Lambda-Funktionen aufrief, um AsyncHttpClient aus der Bibliothek org.asynchttpclient zu verwenden, die intern eine EventLoopGroup verwendet, um Anforderungen asynchron zu verarbeiten.
Die Verwendung von AsyncHttpClient hat dazu beigetragen, Blockierungsvorgänge auszulagern, ohne Threads aus dem Pool zu verbrauchen. Hier ist ein Beispiel dafür, wie der aktualisierte nicht blockierende Anruf aussieht:
// Non-blocking code example public CompletableFuture<String> callLambdaServiceAsync(String payload) { return CompletableFuture.supplyAsync(() -> { return asyncHttpClient.invoke(payload); }); }
Zusätzlich dazu, dass einzelne Anrufe nicht blockiert werden, habe ich mithilfe von CompletableFuture mehrere Abhängigkeitsaufrufe verkettet. Mit Methoden wie thenCombine und thenApply konnte ich Daten aus mehreren Quellen asynchron abrufen und kombinieren und so den Durchsatz erheblich steigern.
CompletableFuture<String> future1 = callLambdaServiceAsync(payload1); CompletableFuture<String> future2 = callLambdaServiceAsync(payload2); CompletableFuture<String> combinedResult = future1.thenCombine(future2, (result1, result2) -> { return processResults(result1, result2); });
Während der Implementierung habe ich festgestellt, dass es dem standardmäßigen AsyncResponse-Objekt von Java an Typsicherheit mangelte, sodass beliebige Java-Objekte weitergegeben werden konnten. Um dieses Problem zu beheben, habe ich eine SafeAsyncResponse-Klasse mit Generika erstellt, die sicherstellte, dass nur der angegebene Antworttyp zurückgegeben werden konnte, was die Wartbarkeit fördert und das Risiko von Laufzeitfehlern verringert. Diese Klasse protokolliert auch Fehler, wenn eine Antwort mehr als einmal geschrieben wird.
// Blocking code example public String callLambdaService(String payload) { String response = externalLambdaService.invoke(payload); return response; }
// Non-blocking code example public CompletableFuture<String> callLambdaServiceAsync(String payload) { return CompletableFuture.supplyAsync(() -> { return asyncHttpClient.invoke(payload); }); }
Um die Wirksamkeit dieser Änderungen zu überprüfen, habe ich Lasttests mit virtuellen Threads geschrieben, um den maximalen Durchsatz auf einer einzelnen Maschine zu simulieren. Ich habe verschiedene Ebenen der Ausführungszeiten serverloser Funktionen generiert (im Bereich von 1 bis 20 Sekunden) und festgestellt, dass die neue asynchrone, nicht blockierende Implementierung den Durchsatz bei kürzeren Ausführungszeiten um das Achtfache und bei längeren Ausführungszeiten um etwa das Vierfache erhöhte.
Beim Einrichten dieser Lasttests habe ich darauf geachtet, die Verbindungslimits auf Client-Ebene anzupassen, um den Durchsatz zu maximieren, was wichtig ist, um Engpässe in asynchronen Systemen zu vermeiden.
Während der Durchführung dieser Stresstests habe ich einen versteckten Fehler in unserem benutzerdefinierten HTTP-Client entdeckt. Der Client verwendete ein Semaphor mit einem auf Integer.MAX_VALUE eingestellten Verbindungszeitlimit. Das heißt, wenn dem Client keine verfügbaren Verbindungen mehr zur Verfügung standen, würde er den Thread auf unbestimmte Zeit blockieren. Die Behebung dieses Fehlers war von entscheidender Bedeutung, um potenzielle Deadlocks in Hochlastszenarien zu verhindern.
Man könnte sich fragen, warum wir nicht einfach auf virtuelle Threads umgestiegen sind, die den Bedarf an asynchronem Code reduzieren können, indem Threads ohne nennenswerte Ressourcenkosten blockiert werden können. Derzeit gibt es jedoch eine Einschränkung bei virtuellen Threads: Sie werden während synchronisierter Vorgänge fixiert. Das bedeutet, dass ein virtueller Thread, wenn er in einen synchronisierten Block eintritt, die Bereitstellung nicht aufheben kann, wodurch möglicherweise Betriebssystemressourcen blockiert werden, bis der Vorgang abgeschlossen ist.
Zum Beispiel:
CompletableFuture<String> future1 = callLambdaServiceAsync(payload1); CompletableFuture<String> future2 = callLambdaServiceAsync(payload2); CompletableFuture<String> combinedResult = future1.thenCombine(future2, (result1, result2) -> { return processResults(result1, result2); });
Wenn in diesem Code das Lesen blockiert wird, weil keine Daten verfügbar sind, wird der virtuelle Thread an einen Betriebssystem-Thread angeheftet, wodurch verhindert wird, dass er ausgehängt wird und auch der Betriebssystem-Thread blockiert wird.
Glücklicherweise können sich Java-Entwickler mit JEP 491 am Horizont auf ein verbessertes Verhalten für virtuelle Threads freuen, bei dem Blockierungsvorgänge in synchronisiertem Code effizienter gehandhabt werden können, ohne die Plattform-Threads zu erschöpfen.
Durch die Umgestaltung unseres Dienstes auf eine asynchrone, nicht blockierende Architektur haben wir erhebliche Leistungsverbesserungen erzielt. Durch die Implementierung von AsyncHttpClient, die Einführung von SafeAsyncResponse zur Typsicherheit und die Durchführung von Lasttests konnten wir unseren Java-Dienst optimieren und den Durchsatz erheblich verbessern. Dieses Projekt war eine wertvolle Übung zur Modernisierung monolithischer Anwendungen und zeigte die Bedeutung geeigneter asynchroner Praktiken für die Skalierbarkeit.
Im Zuge der Weiterentwicklung von Java können wir möglicherweise in Zukunft virtuelle Threads effektiver nutzen, aber vorerst bleibt die asynchrone und nicht blockierende Architektur ein wesentlicher Ansatz für die Leistungsoptimierung in von Drittanbietern abhängigen Diensten mit hoher Latenz.
Das obige ist der detaillierte Inhalt vonModernisierung von Java-Monolithen für bessere Leistung mit asynchronen und nicht blockierenden Architekturen. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!