Die Anforderungen an Stabilität von Software sind in den letzten Jahren stetig gestiegen. Globalisierung, Dynamisierung von Arbeitszeit und auch die Verbreitung von Mobilgeräten haben dafür gesorgt, dass von vielen Systemen erwartet wird, dass diese dauerhaft verfüg- und nutzbar sind.

Gleichzeitig haben wir es mit immer komplexeren Systemen zu tun. Neben unserem eigenen Code setzen wir Fremdbibliotheken und Frameworks ein. Zudem ist die Anbindung von anderen Systemen über das Netzwerk fast immer gegeben. Wir haben es also mit einem verteilten System zu tun.

Wo wir vor einem Jahrzehnt das Netzwerk noch häufig als stabil und immer vorhanden betrachtet haben, erkennen wir nun, auch durch Cloudumgebungen, an, dass wir uns darauf nicht verlassen können. Diese Kombination ermöglicht eine Menge an Fehlern, auf die unsere Anwendung vorbereitet sein sollte, um die im ersten Absatz geforderten Qualitäten zu erfüllen.

Der Fachbegriff lautet hier Resilience. Mir ist der Begriff das erste Mal im Buch „Release It” von Michael T. Nygard begegnet. Obwohl das Buch in seiner ersten Edition bereits 2007 erschienen ist, ist es heute aktueller denn je. Richtig eingesetzt, erhöhen die dort bereits beschriebenen Muster die Stabilität unseres Systems deutlich.

Auf die JVM haben es die Muster vor allem durch die bekannte Bibliothek Hystrix geschafft. Da diese sich jedoch bereits seit zwei Jahren im Maintenance-Modus befindet, wollen wir uns in diesem Artikel mit Resilience4j eine aktuellere Alternative anschauen und wie die aus Hystrix bekannten Muster dort umgesetzt werden.

Danke

Danke

Nach mittlerweile fünf Jahren Kolumne möchte ich hier einmal allen danken, die mich unterstützen und dies möglich machen.

Zuerst möchte ich mich bei meiner Frau Nadine bedanken, die mich auch dann unterstützt, wenn ich rund um die Deadline alle zwei Monate schlechte Laune bekomme und mich quäle, um die Kolumne fertig zu bekommen.

Auch Frau Weinert, meine Lektorin, muss darunter leiden, wenn ich dann verspätet meinen Text einreiche und sie anschließend sämtliche Kommas noch einmal verschieben muss. Danke dafür.

Weiterhin möchte ich den vielen Kolleginnen und Kollegen von INNOQ danken, die immer ein offenes Ohr für mich haben, mich motivieren oder mit mir über Themen diskutieren. Ohne euch wüsste ich nicht, wie ich bereits 30 Themen zum Schreiben gefunden hätte. Danke Phillip, Stefan, Lisa, Torsten, Martin, Joy, Thomas, Till und allen anderen.

Danke auch an Emanuel und Michael, die mir die Kolumne anvertraut haben und seitdem darauf vertrauen, dass ich es alle zwei Monate schaffe, einen sinnvollen Text einzureichen.

Und zuletzt möchte ich mich noch bei Ihnen bedanken. Danke, dass Sie meine Texte lesen und diese zumindest nicht offensichtlich verreißen. Sollten Sie doch einmal einen Fehler finden oder nicht einverstanden sein, freue ich mich, wie gehabt, über Ihr Feedback in jeglicher Form.

Danke!

Hystrix

Mit Hystrix hat Netflix bereits Ende 2012 die vermutlich erste und wohl bekannteste Bibliothek auf der JVM für Widerstandsfähigkeit zur Verfügung gestellt. Im Kern stellt Hystrix uns dabei die Klasse HystrixCommand zur Verfügung, welche von uns implementiert wird und anschließend unseren Code durch eine Kombination der Muster Fallback, Timeout, Circuit Breaker und Bulkhead absichert (s. Listing 1).

public class HystrixExample {

    public static void main(String[] args) {
        String result = new MyCommand().execute();
        System.out.println("Result: " + result);
    }

    static class MyCommand extends HystrixCommand<String> {

        private MyCommand() {
            super(Setter
                .withGroupKey(HystrixCommandGroupKey.Factory.asKey("MyGroup"))
                .andCommandKey(HystrixCommandKey.Factory.asKey("MyCommand"))
                .andCommandPropertiesDefaults(
                    HystrixCommandProperties.Setter()
                        .withExecutionTimeoutInMilliseconds(3000)));
        }

        @Override
        protected String run() {
            return "Success";
        }

        @Override
        protected String getFallback() {
            return "Fallback";
        }
    }
}
Listing 1: Verwendung von Hystrix

Für eine detailliertere Ausführung zu Hystrix empfehle ich den Artikel „Hystrix – damit Ihnen rechtzeitig die Sicherung durchbrennt” von zwei meiner Kollegen.

Seit Ende 2018 befindet sich Hystrix jedoch im Maintenance-Modus, wird also nicht mehr weiterentwickelt. Für neue Projekte wird der Einsatz von Resilience4j empfohlen.

Im Gegensatz zu Hystrix, das mit Archaius immer die Netflix eigene Konfigurationsbibliothek mitbrachte, kommt Resilience4j bis auf VAVR ohne externe Abhängigkeiten aus und hat somit einen deutlich kleineren Fußabdruck. Zudem modelliert Resilience4j die Stabilitätsmuster einzeln und erlaubt es uns somit, eine beliebige Kombination zusammenzustellen, die genau zum Anwendungsfall passt.

Im Folgenden wollen wir uns anschauen, wie wir mit Resilience4j die vier von Hystrix kombinierten Muster implementieren können.

Fallback

Immer wenn ein Fehler passiert, müssen wir uns die Frage stellen, wie unsere Anwendung darauf reagieren soll.

Eine Antwort, die vermutlich einfachste, hierauf ist es, den Fehler den Anwendern anzuzeigen und diese entscheiden zu lassen, was sie tun wollen. Vielfach ist es jedoch besser, sich eine Alternative, einen Fallback, zu überlegen. Können wir beispielsweise zur Bonitätsprüfung das externe System von Anbieter A nicht erreichen, kann es sinnvoll sein, das teurere System von Anbieterin B anzufragen. Auch wäre es hier möglich, anhand der uns zur Verfügung stehenden Daten eine eigene, dafür jedoch simplere und vermutlich schlechtere Entscheidung zu treffen.

Wichtig ist dabei, dass wir bei der Betrachtung stets die Fachlichkeit und den Fachbereich einbeziehen. Nur dieser kann in unserem Beispiel oben entscheiden, ob es sich lohnt, eine teurere oder schlechtere Überprüfung durchzuführen oder die Bestellung abzulehnen, da gerade keine Überprüfung der Bonität durchführbar ist.

Fallbacks werden von Resilience4j nicht durch ein eigenes Konzept abgebildet, sondern durch das Fangen von Exceptions oder die Nutzung von VAVRs Try in unserem Code umgesetzt.

Timeout

Jeder Aufruf, der länger dauert, bindet innerhalb unserer Anwendung Ressourcen in Form von Prozessen oder Threads. Da diese, schon hardwarebedingt, nur endlich verfügbar sind, kann eine Anwendung, bei der alle Ressourcen ausgelastet sind, nicht mehr reagieren oder neue Anfragen entgegennehmen.

Deshalb ist es sinnvoll, für Aufrufe, die lange dauern können, Timeouts zu definieren. Der Aufruf wird somit spätestens nach einer definierten Zeitspanne mit einem Fehler abgebrochen. Damit verhindern wir, dass unsere Anwendung nicht mehr reagieren kann, müssen uns jedoch anschließend Gedanken machen, wie wir fachlich auf diese Fälle reagieren, also einen Fallback einbauen.

Im Idealfall sollten wir mindestens unsere Netzwerkaufrufe mit einem Timeout versehen und das idealerweise innerhalb der von uns verwendeten Bibliothek. Sollte das nicht möglich sein, können wir auf den Support von Resilience4j und der dort vorhandenen TimeLimiter-Komponente (s. Listing 2) zurückgreifen.

public class TimeLimiterExample {

    public static void main(String[] args) throws Exception {
        TimeLimiterConfig config = TimeLimiterConfig.custom()
            .timeoutDuration(Duration.ofSeconds(3))
            .build();
        TimeLimiterRegistry registry = TimeLimiterRegistry.of(config);

        TimeLimiter timeLimiter = registry.timeLimiter("someMethod");

        Callable<String> method = timeLimiter.decorateFutureSupplier(
            () -> CompletableFuture.supplyAsync(
                () -> someMethod("Success")));

        try {
            String result = method.call();
            System.out.println("Result: " + result);
        } catch (TimeoutException e) {
            System.out.println("Timeout: " + e);
        }
    }

    static String someMethod(String result) {
        sleep(4);
        return result;
    }
}
Listing 2: Verwendung von TimeLimiter

Um eine Instanz von TimeLimiter zu erzeugen, müssen wir eine TimeLimiterRegistry verwenden. Diese wiederum erhält eine vorher erzeugte Konfiguration, in diesem Falle mit einem Timeout von drei Sekunden. Auf dem so erzeugten TimeLimiter haben wir nun Methoden zur Verfügung, um eine übergebene Methode mit dem Timeout abgesichert auszuführen. Häufig ist es jedoch sinnvoller, wie in Listing 2 zu sehen, unseren Code über eine statische Methode zu dekorieren und eine Methodenreferenz zu erhalten, die wir dann später aufrufen können. In diesem Fall wird dabei eine TimeoutException geworfen, da unser Code länger als der definierte Timeout braucht.

Diese Art der Programmierschnittstelle verwendet Resilience4j auch bei allen weiteren Mustern. Erst erzeugen wir eine Registry mit einer Konfiguration. Mit dieser können wir anschließend eine benannte Musterinstanz erzeugen. Diese enthält Methoden, die mit dem Wort execute anfangen und unseren Code direkt, durch das Muster abgesichert, ausführen. Alternativ gibt es auf dem Interface des Musters noch statische Methoden, die mit dem Wort decorate beginnen und uns eine abgesicherte Methodenreferenz zurückgeben.

Circuit Breaker

Die beiden vorherigen Muster helfen uns zwar dabei, nicht selber lange zu warten und auch ein kurzzeitig nicht erreichbares anderes System auszuhalten, das Gesamtsystem stabilisieren sie jedoch nicht.

Nehmen wir an, das andere System hat gerade Probleme, weil es überlastet ist, und kann deswegen nicht mehr schnell genug antworten. Gerade in solchen Fällen führt unsere Strategie dazu, dieses System noch mehr zu befeuern und damit das Gesamtproblem noch zu verschärfen.

Um dies zu verhindern, können wir einen Circuit Breaker einsetzen. Dieser funktioniert dabei wie eine Sicherung im Sicherungskasten. Solange die Aufrufe fehlerfrei funktionieren, bleibt diese im geschlossenen Zustand. Sobald jedoch ein vorher definierter Schwellwert überschritten wird, wechselt die Sicherung in den offenen Zustand. In diesem wird jeder neue Aufruf direkt abgewiesen und gar nicht erst versucht. Somit entlasten wir das Ziel des Aufrufs und geben ihm Zeit, sich zu erholen.

Im Gegensatz zu einer physischen besitzt eine solche digitale Sicherung jedoch noch einen dritten, den halb-offenen Zustand. In diesen wechselt eine vorher geöffnete Sicherung nach einer definierten Zeitdauer oder Anzahl von Aufrufen und lässt anschließend eine kleine Menge von Aufrufen durch, lehnt den Großteil jedoch weiterhin ab. Abhängig vom Ergebnis dieser durchgelassenen Aufrufe entscheidet die Sicherung anschließend, ob sie erneut in den offenen oder wieder in den geschlossenen Zustand wechselt.

Listing 3 zeigt uns die Verwendung dieses Musters in Resilience4j. Hierbei betrachtet die Sicherung immer das Fenster der letzten vier Aufrufe, um zu entscheiden, wie ihr Zustand ist. Sind dabei mindestens zwei Aufrufe vorhanden und mehr als 75 Prozent dieser Aufrufe sind fehlerhaft, wird in den offenen Zustand gewechselt. Von diesem wechselt sie nach einer Sekunde in den halb-offenen Zustand und lässt hier zwei Aufrufe durch.

public class CircuitBreakerExample {

    public static void main(String[] args) {
        CircuitBreakerConfig config = CircuitBreakerConfig.custom()
            .slidingWindow(4, 2, COUNT_BASED)
            .failureRateThreshold(75)
            .enableAutomaticTransitionFromOpenToHalfOpen()
            .permittedNumberOfCallsInHalfOpenState(2)
            .waitDurationInOpenState(Duration.ofSeconds(1))
            .build();
        CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(config);

        CircuitBreaker circuitBreaker = registry.circuitBreaker("breaker1");

        CheckedFunction1<String, String> function =
            CircuitBreaker.decorateCheckedFunction(
                circuitBreaker, CircuitBreakerExample::someMethod);

        execute(function, "Hallo");   // ERROR
        execute(function, "Michael"); // SUCCESS, 50% errors -> CLOSED
        execute(function, "Hallo");   // ERROR, 66% errors -> CLOSED
        execute(function, "Hallo");   // ERROR, 75% errors -> OPEN
        execute(function, "Michael"); // Not permitted
        sleep(2);                     // -> HALF_OPEN
        execute(function, "Michael"); // SUCCESS -> HALF_OPEN
        execute(function, "Michael"); // SUCCESS -> CLOSED
    }

    static String someMethod(String input) throws Exception {
        if ("Hallo".equals(input)) {
            throw new Exception("'Hallo' is not working");
        }
        return "'" + input + "' is working";
    }

    static void execute(CheckedFunction1<String, String> function,
            String input) {
        try {
            String result = function.apply(input);
            System.out.println(result);
        } catch (Throwable e) {
            System.out.println(e);
        }
    }
}
Listing 3: Verwendung von CircuitBreaker

Bulkhead

Wie bereits bei den Timeouts beschrieben, ist eines der häufigsten Probleme, wieso ein System nicht reagiert, darin begründet, dass alle Prozesse/Threads ausgelastet sind und wir keine weitere Abarbeitung durchführen können.

In vielen Anwendung gibt es jedoch kritischere und weniger kritischere Anwendungsfälle. Mit Bulkheads können wir diese nun voneinander isolieren und jedem eine gewisse Menge an Ressourcen zuweisen. Somit sollte eine Vollauslastung von Anwendungsfall A nicht mehr dazu führen, dass auch Anwendungsfall B nicht mehr aufgerufen werden kann. Das System funktioniert somit nur noch in Teilen, ist aber in seiner Summe widerstandsfähiger, da es nicht komplett ausgefallen ist.

Das Pendant in der realen Welt sind die Schotts auf Schiffen. Diese befinden sich im Rumpf und sind durch Wände voneinander getrennt. Sollte das Schiff ein Leck haben, füllen sich nur Teile und nicht direkt der gesamte Rumpf mit Wasser und das Schiff sinkt nicht sofort.

Wie in Listing 4 zu sehen, greift Resilience4j auch hier wieder das bekannte Muster auf. Hier erlauben wir in der Konfiguration lediglich zwei gleichzeitige Aufrufe und sorgen dafür, dass im Falle von Vollauslastung neue Aufrufe maximal vier Sekunden lang warten, bis diese abgewiesen werden.

public class BulkheadExample {

    public static void main(String[] args) {
        BulkheadConfig config = BulkheadConfig.custom()
            .maxConcurrentCalls(2)
            .maxWaitDuration(Duration.ofSeconds(4))
            .build();
        BulkheadRegistry registry = BulkheadRegistry.of(config);

        Bulkhead bulkhead = registry.bulkhead("bulkhead");

        for (int i=0; i < 6; i++) {
            final int j = i;

            Supplier<String> bulkheadedMethod =
                Bulkhead.decorateSupplier(bulkhead, () -> someMethod(j));

            System.out.println("[" + j + "]: Executing @ " + now());
            new Thread(() -> {
                try {
                    System.out.println(bulkheadedMethod.get());
                } catch (BulkheadFullException e) {
                    System.out.println("[" + j + "]: Not permitted @ "
                        + now()); }
            }).start();
        }
    }

    static String someMethod(int j) {
        LocalTime start = now();
        System.out.println("[" + j + "]: Starting @ " + start);
        sleep(3);
        LocalTime finish = now();
        System.out.println("[" + j + "]: Finished @ " + finish +
            " -> took " + duration(start, finish) + "s");
        return "SomeMethod #" + j;
    }
}
Listing 4: Verwendung von Bulkhead

Mehr Resilience4j

Neben den vier von Hystrix unterstützten Mustern bietet uns Resilience4j darüber hinausgehend noch Unterstützung für Retries, Rate Limits und Caching. Aus Platzgründen werden diese hier nicht behandelt, befinden sich aber als Beispiele, wie auch der hier gezeigte Code, unter https://github.com/mvitz/javaspektrum-resilience.

Zudem bietet uns Resilience4j für gängige Frameworks wie Spring Boot oder Micronaut auch tiefere Integrationsmöglichkeiten als den hier gezeigten programmatischen Zugriff an. Hier kommen dann häufig Annotationen zum Einsatz, mit denen die Methoden unseres Codes annotiert und damit um die Stabilitätsaspekte ergänzt werden. Außerdem können wir die von Resilience4j während der Laufzeit zur Verfügung gestellten Metriken mit einem zusätzlichen Modul in Micrometer und damit in nahezu alle gängigen Monitoring-Systeme integrieren.

Conclusion

Wir haben uns in diesem Artikel mit Resilience, auf Deutsch Widerstandsfähigkeit, beschäftigt. Hierzu haben wir die vier Stabilitätsmuster Fallback, Timeout, Circuit Breaker und Bulkhead kennengelernt. Deren Einsatz wurde lange Zeit primär durch die Bibliothek Hystrix populär. Da diese seit Ende 2018 nicht mehr weiterentwickelt wird, ist es in neuen Projekten sinnvoll, auf eine modernere Bibliothek zu setzen. Auch diese haben wir mit Resilience4j in diesem Artikel kennengelernt.

Resilience4j ermöglicht es uns, die vier Muster gezielt einzusetzen und beliebig zu kombinieren. Daneben bietet sie mit Retry, Rate Limiting und Caching auch noch weitere Muster an und kommt, mit Ausnahme von VAVR, ohne weitere Abhängigkeiten daher. Zudem gibt es Integrationsmöglichkeiten in Spring Boot und Micronaut und auch das Abgreifen von Metriken ist mit Micrometer möglich.

Wie immer bei Mustern geht es nicht darum, stets alle einzubauen, sondern diese an den passenden Stellen anzuwenden. Geschickt eingesetzt und kombiniert, können diese jedoch den Unterschied zwischen einer Anwendung, die nicht mehr erreichbar ist oder nicht mehr reagiert, und einer, die noch benutzbar ist, ausmachen.

Der Einsatz bietet sich vor allem an, wenn wir mit einem anderen System über das Netzwerk interagieren. Aber auch innerhalb eines Systems an Modulgrenzen kann ein Einsatz sinnvoll sein.

Alle Code-Listings aus diesem Artikel und weitere Beispiele können unter https://github.com/mvitz/javaspektrum-resilience gefunden werden.

TAGS

Comments