Skip to content
Lektion undefined von undefined0/undefined abgeschlossen
Modul 12 — Reactive Spring: WebFlux & RSocket

Reactive Spring

0 pts

Reactive Spring — WebFlux & RSocket

Modul 11 hat gezeigt, wie Virtual Threads blocking Code skalieren. Modul 12 dreht den Spieß um: kein Thread pro Request, sondern wenige Event-Loop-Threads, die mit nicht-blockierendem I/O tausende Verbindungen gleichzeitig bedienen. Werkzeuge: Project Reactor (Flux/Mono), WebFlux, RSocket und R2DBC — plus die ehrliche Frage, wann sich der Stilwechsel lohnt und wann Virtual Threads die bessere Wahl bleiben.

Lektion 56: Reactive Paradigma & Project Reactor (Flux/Mono)

Reactive heißt: Der Datenfluss wird deklarativ als Pipeline beschrieben — nicht "rufe auf und warte", sondern "wenn Daten ankommen, tu dies damit". Grundlage ist der Reactive-Streams-Vertrag: Ein Publisher erzeugt Items, ein Subscriber meldet sich an und gibt über Demand an, wie viel er verarbeiten kann — das ist Backpressure. Project Reactor ist die Spring-Implementierung dieses Vertrags mit zwei Typen:

// Mono<T>: 0..1 Element — Ergebnis oder Fehler
Mono<Policy> policy = policyRepository.findById(insuredId);

// Flux<T>: 0..N Elemente — Stream, Liste, SSE-Feed
Flux<Claim> claims = claimRepository.findAllByInsured(insuredId);

// Nichts läuft bis hierhin — nur die Pipeline ist "zusammengebaut" (Assembly)
claims.filter(Claim::isOpen)
    .map(ClaimMapper::toDto)
    .subscribe(dto -> notifications.send(dto)); // erst jetzt fließen Daten

Der entscheidende Unterschied zu einer Liste oder Stream: Die Pipeline ist lazy. findById() liefert kein Policy-Objekt, sondern eine Beschreibung, wie man eines bekommen würde. Erst subscribe() startet den Datenfluss, und der Subscriber kann mit request(n) steuern, wie viele Items er sofort abnehmen will — der Publisher darf dadurch nicht schneller liefern, als der Konsument verarbeitet.

Und die ehrliche Ansage: Reactive ist nicht automatisch schneller. Ein einzelner Request wird nicht kürzer — dieselbe DB-Abfrage dauert gleich lange. Der Gewinn liegt im Durchsatz bei vielen gleichzeitigen Verbindungen: Statt einem wartenden Thread (1 MB Stack, Context-Switch-Kosten) pro Request bedienen wenige Event-Loop-Threads alle Verbindungen und nutzen die I/O-Wartezeit für andere Requests. CPU-gebundene Arbeit wird durch Reactive nicht schneller — nie. Der Unterschied zu Virtual Threads (Modul 11): VTs machen blockierenden Code billig, Reactive macht Warten strukturiert nicht-blockierend — mit deutlich mehr kognitiver Last im Code.

Assembly vs. Subscription — Lazy bis zum Ende

Mono und Flux sind nur Beschreibungen. Jeder Aufruf von filter(), map() oder flatMap() hängt an die Beschreibung an — es passiert nichts. Erst subscribe() (oder ein WebFlux-Framework, das für dich subscribed) startet den Fluss. Wer ein Mono baut und das Ergebnis nie subscribed, hat Code geschrieben, der still steht — der häufigste Anfängerfehler in Reactive.

Lektion 57: WebFlux — annotierte Controller & funktionales Routing

spring-boot-starter-webflux statt spring-boot-starter-web tauscht die Laufzeit aus: Netty statt Tomcat, Event-Loop-Threads (etwa so viele wie CPU-Kerne) statt Thread-per-Request. Die annotated-Controller-Welt kennt man weiterhin — nur die Rückgabetypen sind jetzt Mono/Flux:

@RestController
public class ClaimController {

    @GetMapping("/claims/{id}")
    public Mono<ClaimDto> claim(@PathVariable String id) {
        return claimService.find(id).map(ClaimMapper::toDto);
    }

    @GetMapping(value = "/claims/stream",
        produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ClaimDto> openClaims() {
        return claimService.openClaims(); // SSE: jedes neue Element = Event
    }
}

Wer es expliziter mag, definiert Routen funktional — RouterFunctions trennen Routing (wohin?) vom Handling (was passiert?):

@Bean
RouterFunction<ServerResponse> routes(ClaimHandler handler) {
    return RouterFunctions.route()
        .GET("/claims/{id}", handler::getById)
        .GET("/claims", handler::findAll)
        .build();
}

Das Threading-Modell ist der Kernunterschied zu Spring MVC: Kommt ein Request an, übernimmt ein Event-Loop-Thread die Bearbeitung bis zum ersten nicht-blockierenden Punkt und springt dann zum nächsten Request. Läuft auf diesem Event-Loop-Thread dennoch blocking Code (JDBC, block(), Thread.sleep), stehen alle anderen Requests — der Event-Loop ist eine gemeinsam genutzte Ressource. Getestet wird idiomatisch mit WebTestClient, dem reaktiven Geschwister zu TestRestTemplate/MockMvc:

// Slice-Test für einen WebFlux-Controller
WebTestClient client = WebTestClient.bindToController(new ClaimController()).build();

client.get().uri("/claims/C-1").exchange()
    .expectStatus().isOk()
    .expectBody(ClaimDto.class)
    .consumeWith(r -> assertThat(r.getResponseBody().id()).isEqualTo("C-1"));

Lektion 58: RSocket — bidirektionale Streams & Backpressure

HTTP kennt nur ein Muster: Request kommt, Response geht — Client fragt, Server antwortet. RSocket ist ein binäres Protokoll (über TCP oder WebSocket) mit vier Interaktionsmodellen, und Spring bildet jedes direkt auf Reactor-Typen ab:

ModellRichtungReactor-TypBeispiel
Request-Response1 Anfrage, 1 AntwortMono<T>Klassischer RPC
Fire-and-Forget1 Anfrage, keine AntwortMono<Void>Telemetrie-Ping
Request-Stream1 Anfrage, Strom zurückFlux<T>Live-Kursdaten
ChannelStrom hin und zurückFlux<T> reaktivChat, interaktive Sessions

Serverseitig genügt eine @MessageMapping-Methode — das Pendant zu @GetMapping, nur über das RSocket-Routing:

@Controller
public class DwdRsocketController {

    @MessageMapping("dwd.trockenindex")
    public Flux<DwdIndex> stream(String region) {
        return dwdService.liveIndex(region); // Flux mit Backpressure
    }
}
# application.yml — RSocket-Server über WebSocket exponieren
spring:
  rsocket:
    server:
      transport: websocket
      mapping-path: /rsocket

Zwei Protokoll-Features heben RSocket über rohe WebSockets: Leasing lässt den Server die Anfragenrate pro Client begrenzen (Flow Control, nicht der Client limitiert sich selbst), und Resumption stellt eine unterbrochene Session wieder her — Streams laufen nach dem Reconnect weiter, ohne Daten zu verlieren. Wann RSocket statt HTTP/REST? Wenn es wirklich bidirektional wird: Service-zu-Service-Streaming mit Backpressure, Clients auf mobilen oder instabilen Verbindungen, Live-Feeds in beide Richtungen. Für klassisches CRUD-HTTP bleibt REST die einfachere Wahl — Browser-Support für RSocket ist umständlich, und REST kann man mit curl debuggen.

Backpressure gehört ins Protokoll — nicht in den Code

Bei HTTP-SSE muss der Server überflüssige Events wegwerfen, wenn der Client nicht mitkommt — die Rückmeldung fehlt im Protokoll. RSocket trägt Demand protokollseitig: Der Client sagt vor jedem Batch, wie viele Items er nimmt. Für Streams, die schneller produzieren als konsumiert werden kann, ist das der strukturelle Unterschied.

Lektion 59: Reactive Data mit R2DBC & Transaktionen

Eine WebFlux-Anwendung mit JDBC ist halb reactive: Der Controller gibt Mono/Flux zurück, aber die DB-Abfrage blockiert den Event-Loop-Thread. R2DBC (Reactive Relational Database Connectivity) ist die konsequente Antwort — eine Reactive-Streams-API für relationale Datenbanken mit nicht-blockierenden Treibern (PostgreSQL, H2, MySQL, MariaDB). Statt JPA- Repositories arbeitet man mit DatabaseClient:

@Service
public class DwdService {

    private final DatabaseClient database;

    DwdService(DatabaseClient database) {
        this.database = database;
    }

    public Flux<DwdIndex> criticalRegions(double minIndex) {
        return database.sql("""
                SELECT region, value FROM dwd_index WHERE value > :min
                """)
            .bind("min", minIndex)
            .map((row, meta) -> new DwdIndex(
                row.get("region", String.class),
                row.get("value", Double.class)))
            .all(); // Flux<DwdIndex>, lazily aus der DB gestreamt
    }
}

Transaktionen funktionieren weiterhin deklarativ — aber der ReactiveTransactionManager führt die Transaktion durch die Pipeline: Beginn beim Subscription-Start, Commit nach onComplete, Rollback bei Fehler. Dazu muss die Methode selbst Mono/Flux zurückgeben; eine blocking Methode mit @Transactional bekommt keinen reaktiven Transaction-Manager:

@Service
public class ClaimService {

    @Transactional
    public Mono<Claim> fileClaim(ClaimDto dto) {
        return claimRepository.save(Claim.from(dto))
            .flatMap(claim -> auditLog.record(claim).thenReturn(claim));
    } // Commit erst, wenn der Mono-Subscriber das onComplete bekommt
}

Die Migration von JPA zu R2DBC ist eine bewusste Entscheidung: Hibernate ist durch und durch sync — Lazy Loading, Dirty Tracking, Persistence Context brauchen blocking I/O, und für R2DBC gibt es kein JPA. Pragmatische Strategie: Repositories schichtweise migrieren, dabei die mächtigen JPA-Features (Assoziationen, Locking) gegen eigene, flache SQL-Queries tauschen. Einfache Queries überleben das trivial; wer Relationsgraphen lazily lädt, muss das Datenmodell denormalisieren oder aggressive Joins schreiben.

Lektion 60: Migration — Blocking zu Reactive, und wann NICHT

Blocking Code schleicht sich gern zurück in reaktive Pipelines — ein block() hier, ein JDBC-Call dort. BlockHound macht das sichtbar: Es instrumentiert die JVM und wirft eine Exception, sobald blocking Code auf einem Event-Loop-Thread landet — ideal als Test-Wächter:

// build.gradle: testImplementation "io.projectreactor.tools:blockhound"
@TestConfiguration
static class BlockhoundConfig {
    @BeforeAll
    static void install() {
        BlockHound.install(); // block() auf dem Event-Loop → Test schlägt fehl
    }
}

Die klassischen Anti-Patterns und ihre Fixes:

// ANTI-PATTERN 1: block() im reaktiven Fluss
Policy p = policyRepository.findById(id).block(); // Event-Loop lahmgelegt

// FIX: Pipeline durchreichen; blocking Code auf boundedElastic kanalisieren
Mono<Policy> policy = Mono.fromCallable(() -> legacyClient.load(id))
    .subscribeOn(Schedulers.boundedElastic()); // eigener Pool, Event-Loop bleibt frei

// ANTI-PATTERN 2: ThreadLocal für Request-Kontext (Security, Tracing)
// Auf Event-Loops wechselt der Thread ständig — ThreadLocal ist unzuverlässig.
// FIX: Reactor Context — der Kontext fließt mit den Signalen, nicht mit dem Thread
Mono<UUID> userId = Mono.deferContextual(ctx ->
    Mono.just(ctx.get("userId")));

Und die ehrliche Entscheidungsmatrix zum Abschluss des Kurses:

SituationEmpfehlung
Klassisches request/response, JDBC, blocking ClientsVirtual Threads (Modul 11)
Viele gleichzeitige, langlebige I/O-VerbindungenWebFlux
Streaming mit Backpressure, SSE, Live-FeedsWebFlux/RSocket
CPU-gebundene ArbeitPlatform-Thread-Pool
Team neu in Reactive, Legacy-JDBC-DomäneVirtual Threads

Reactive lohnt sich, wenn I/O-Wartezeit der Engpass ist und viele Verbindungen gleichzeitig bedient werden müssen — oder wenn Streams mit Backpressure das Fachliche sind. Für den klassischen request/response-Stack mit JDBC bleiben Virtual Threads der einfachere Weg: gleiche Skalierung, lesbarer Code, normale Stacktraces. Beide Modelle koexistieren — blocking Teile via boundedElastic einkapseln, den Rest reaktiv halten.

Java Playground — Flux-Pipeline simuliertBereit
Editor wird geladen…

Zwei Wege zur Skalierung — kenne den Preis

Virtual Threads kaufen Skalierung mit blocking Code (einfach zu schreiben, schwer zu debuggen bei Pinning). Reactive kauft Skalierung mit nicht-blockierendem Code (schwerer zu schreiben, präzise kontrollierbar). Keins ist "schneller" — die Frage ist, ob dein Engpass Wartezeit bei vielen Verbindungen ist und ob dein Team die Pipeline-Kognitive-Last dauerhaft tragen will.

Praxis-Szenario: CropGuard: DWD-Live-Trockenindex über RSocket

Die CropGuard-App zeigt Versicherten den Trockenindex ihrer Felder live. Mit Polling über REST fragen 50.000 Clients alle 10 Sekunden an — 5.000 Requests/Sekunden, die meisten Antworten unverändert. Mit RSocket-Request-Stream abonniert jeder Client den Flux für seine Region genau einmal, der Server pusht nur echte Änderungen, und Leasing begrenzt Anfragenrate pro Client.

✅ Wichtige Erkenntnisse

Haken setzen, um deinen Lernfortschritt zu markieren:

    This site uses essential cookies for Stripe payments. No tracking cookies.