#java #performance #benchmarks #saga #distributed-systems #jvm

Co mierzysz zależy od tego, gdzie wyznaczasz granicę

Większość benchmarków frameworków saga mierzy czas dispatchu, a nie ukończonej pracy. Oto co się zmienia gdy mierzysz wszystko — i dlaczego dwa niespokrewnione stosy oparte o Axon dzielą tę samą wadę poprawności pod skonfigurowaną iniekcją błędów.

scenario e2e-shop-order-saga hardware dev-laptop jdk 26 date 2026-05-05 status descriptive reproducibility complete

Poprzedni artykuł w tej serii — Gdzie kończy się StructuredTaskScope — argumentował architektoniczny case na rzecz budowy natywnego silnika Flow zamiast adopcji istniejącego frameworka saga. To jest empiryczna kontynuacja: co te frameworki faktycznie kosztują, gdy mierzysz wszystkie trzy gwarancje sagi, nie tylko forward progress.

Uruchomiłem benchmark porównawczy spodziewając się różnic w latencji. Spring + Axon faktycznie uzyskał najszybsze p95 — ale to zaskoczyło mnie mniej niż coś innego w tej samej tabeli. Pod dopasowaną iniekcją 3% błędów oba stosy oparte o Axon zaraportowały zero kompensacji. Nie “mniej niż oczekiwano”. Zero. To kazało mi się zatrzymać i przyjrzeć dokładniej.

Frameworki saga są oceniane pod kątem przepustowości i latencji. To jest niekompletne, a czasem wprowadzające w błąd. Saga ma trzy gwarancje: forward progress, kompensację pod awarią i terminację. Większość benchmarków mierzy tylko forward progress — i potem nazywa framework “poprawnym” jeśli happy path działa.

Oto co się dzieje, gdy mierzysz wszystkie trzy.

Konfiguracja

Trzy frameworki saga. Jeden identyczny kontrakt scenariusza. Ten sam payload, ta sama liczba VU, ten sam think time, ten sam payment_fail_rate (3% skonfigurowanej iniekcji błędów), to samo JDK 26, ten sam sprzęt dev-laptop, ten sam wire protocol (HTTP/1.1, loopback — wszystkie trzy aplikacje były skonfigurowane na h2c, ale k6 negocjowało HTTP/1.1 w każdym przebiegu; protokół był identyczny pomiędzy stosami niezależnie).

StosModel orkiestracji
Exeris (open-core)Natywny silnik Flow — synchroniczne wykonanie sagi, in-process maszyna stanów
Quarkus 3 + Axon Framework + Neo4jAsynchroniczna event-sourced saga przez Axon Server (osobny proces)
Spring Boot 3 + Axon Framework + Neo4jTen sam model Axon, inny host runtime

Różnica: model orkiestracji, nie domena problemu. Ten sam kontrakt scenariusza po każdej stronie.

Dwa terminy używane w artykule wymagają jawnych definicji. dev-laptop to tutaj AMD Ryzen 5 5600 (6 rdzeni / 12 wątków), 32 GB DDR4, na Linuksie z pełnym graficznym desktop environment, ze wszystkimi komponentami benchmarka — k6, trzema aplikacjami docelowymi i Axon Server gdzie ma zastosowanie — współzlokalizowanymi na loopback. perf-box-amd64, następny milestone dla claimów porównawczych, to baremetal z realnym WAN-em między load generatorem a aplikacją docelową. CPU może, ale nie musi być szybsze od dev-laptopa — nie o to chodzi. Sensem re-runa perf-boxa jest usunięcie skrótu loopback i hałasu desktop environmentu, i wystawienie ścieżki sagi na uczciwą latencję sieciową i operacyjny kształt.

Kontrakt benchmarka — e2e-shop-order-saga — wykonuje pięciokrokową sagę zamówienia: rejestracja klienta → rekomendacja produktów → dodanie do koszyka → pobranie koszyka → utworzenie zamówienia. Błąd płatności jest wstrzykiwany z rate 3%. Kompensacja musi wycofać rezerwację stocku i zwolnić zamówienie.

Metadane reprodukcyjności żyją w opublikowanym result.json dla każdego przebiegu:

{
  "scenario_id": "e2e-shop-order-saga",
  "contract_id": "exeris_community_h2c_v1",
  "hardware_profile": "dev-laptop",
  "jdk_version": "26",
  "claim_scope": "exploratory",
  "reproducibility_status": "complete"
}

Każda opublikowana liczba niesie claim_scope, hardware_profile, transport_mode i reproducibility_status. Twarde fairness gates odrzucają przebiegi które nie zdają testów równoważności. Claimy porównawcze wymagają dopasowanego kontraktu scenariusza po obu stronach — wszystko inne jest oznaczone jako descriptive_only lub exploratory. Tak był oceniany każdy claim poniżej, włączając w to mój własny.

→ kontrakt scenariusza → surowe artefakty

Liczby, które wyglądają rozsądnie, dopóki ich nie przeczytasz

Metric Exeris Quarkus Spring
http_req_duration p50 4.59 ms 4.18 ms 3.10 ms
http_req_duration p95 16.6 ms 30.3 ms 14.6 ms
iteration_duration p95 9.18 s 8.49 s 9.28 s
Saga success rate 96.7% 98.2% 98.8%
Compensation rate (cfg: 3%) 3.32% ✓ 0% ✗ 0% ✗
Saga unresolved rate 0% ✓ 1.82% ✗ 1.22% ✗
App peak RSS 459 MB 752 MB 1,312 MB
App peak threads 66 94 81
App CPU time 24.7 s 22.3 s 33.3 s
Axon Server RSS (separate proc) ~787 MB ~850 MB
Axon Server PIDs ~120-126 ~133-148

Spójrz na górę tej tabeli. Spring Boot 3 + Axon pokazuje najszybsze p50 i p95. Quarkus i Exeris są w tym samym paśmie. Według standardowego modelu mentalnego — tego, w którym pisana jest większość postów o wydajności — Spring wygrywa.

Teraz spójrz na dół.

Oba stosy oparte o Axon wykonywały tę samą skonfigurowaną 3% stopę błędu płatności co Exeris. Exeris skompensował 3.32% sag, zgodnie ze skonfigurowaną stopą w granicach szumu statystycznego. Oba Quarkus i Spring Boot zaraportowały 0% kompensacji.

Gdzie poszły te błędy? W “saga unresolved” — sagi, które nigdy nie osiągnęły stanu terminalnego przed zamknięciem okna testowego. 1.82% dla Quarkusa, 1.22% dla Springa.

To nie jest luka wydajnościowa. To luka poprawności. A dwa stosy oparte o Axon dzielą ją z tego samego powodu.

Co mierzysz zależy od tego, gdzie wyznaczasz granicę

Oto co dzieje się strukturalnie.

Exeris-native Flow uruchamia sagę inline. Endpoint zamówienia wraca, gdy maszyna stanów sagi posunęła się do przodu — gdy kompensacja faktycznie się wykonała, odpowiedź niesie ten fakt. 16.6 ms p95, które widzisz w tabeli, mierzy rzeczywisty postęp sagi, włączając w to pracę kompensacyjną na ścieżkach błędu.

Spring + Axon dispatchuje komendę zamówienia do Axon Server asynchronicznie. Endpoint zwraca 202 Accepted, gdy tylko zdarzenie zostanie opublikowane. Maszyna stanów sagi działa dalej w osobnym procesie, bez nikogo, kto czeka na nią na ścieżce requestu. 14.6 ms p95 mierzy czas publikacji do Axon Server. Nie czas wykonania pracy.

Quarkus + Axon ma tę samą architekturę. Ten sam asynchroniczny dispatch. Tę samą iluzję.

To jest też dokładnie powód, dla którego oba stosy oparte o Axon gubią kompensacje. Saga nie skończyła się do momentu zamknięcia okna testowego k6. Wciąż działa w Axon Server, asynchronicznie, bez nikogo kto by ją odpytywał. Benchmark obserwuje “0% kompensacji”, bo praca kompensacyjna jeszcze nie pobiegła, nie dlatego, że nie było błędów. 1.2–1.8% sag w stanie nieterminalnym to ślad tej dynamiki.

Ten sam problem granicy odnosi się do każdego innego zasobu w tej tabeli:

Pamięć:
  Spring app 1.3 GB     +  Axon Server ~850 MB  =  ~2.16 GB
  Quarkus app 752 MB    +  Axon Server ~787 MB  =  ~1.54 GB
  Exeris all-in 459 MB                          =     459 MB

Wątki:
  Spring app 81         +  Axon Server ~140    =  ~221
  Quarkus app 94        +  Axon Server ~123    =  ~217
  Exeris all-in                                 =    66

Czas CPU:
  Spring app 33 s       +  Axon Server ~52 s   =  ~85 s
  Quarkus app 22 s      +  Axon Server ~26 s   =  ~48 s
  Exeris all-in                                 =    25 s

Każda z tych metryk opowiada tę samą historię. Wyznaczenie granicy ciasno wokół procesu aplikacji sprawia, że wszystko wygląda rozsądnie. Wyznaczenie jej wokół “co potrzeba, żeby uruchomić sagę end-to-end?” — każda metryka jest zdominowana przez to, co jest poza procesem aplikacji.

To jest prawdziwy architektoniczny koszt asynchronicznego dispatchu: nie tylko utracone kompensacje, ale drugi proces, który musisz karmić CPU, RAM-em, wątkami, siecią i uwagą operacyjną. Exeris-native Flow wybrał ścieżkę in-process. Ten wybór jest też powodem, dla którego poprawnie kompensuje.

Dwa hosty runtime, ta sama wada

Dwa niespokrewnione hosty runtime (Quarkus 3 i Spring Boot 3) dzielą tę samą wadę poprawności. To wyklucza dziwactwa konkretnego host runtime. Wspólna przyczyna to event-sourced async dispatch model Axona zderzający się z oknem benchmarka o skończonej długości.

SubscribingEventProcessor Axona (wariant użyty tutaj) przetwarza zdarzenia na wątku różnym od dispatchera. Przejścia stanów sagi lądują na tym wątku procesora. Odczyty statusu — to, co k6 odpytuje, żeby określić “czy ta saga się zakończyła?” — idą przez in-memory projekcję ConcurrentHashMap Axona, która jest aktualizowana dopiero po wykonaniu event handlera.

Gdy okno benchmarka się kończy, in-flight zdarzenia, które nie zostały przetworzone, dalej są w kolejce. Stan sagi nigdy nie osiąga COMPLETED ani COMPENSATED z punktu widzenia testu. k6 widzi stan nieterminalny i zalicza go jako saga_unresolved.

Chcę, żeby było jasne, czym to jest, a czym nie jest. To nie jest bug w Axonie — to jest design. Asynchroniczne event-sourced sagi są zaprojektowane tak, żeby ostatecznie osiągnąć stan terminalny, mając wystarczająco czasu. Okno benchmarka — 180 sekund — jest skończone. “Ostatecznie” sagi nie mieści się w tym oknie dla pewnego procentu nieudanych sag.

Exeris-native Flow nie ma takiej luki, bo maszyna stanów sagi działa synchronicznie na VT requesta, ze stanem off-heap utrwalonym przed odpowiedzią HTTP. Gdy odpowiedź mówi “saga complete”, saga jest kompletna. Gdy odpowiedź mówi “saga compensated”, kompensacja się wykonała.

Możesz to zweryfikować, inspekcjonując snapshoty JFR, które towarzyszą każdemu przebiegowi benchmarka. JFR Exerisa pokazuje zdarzenia kompensacji na timeline’ie VT requesta, kończące się w obrębie requestu. JFR-y Axona pokazują wywołania event handlerów, które nie zawsze kończą się przed wyjściem VT requesta.

Gdzie strukturalna współbieżność pomaga — i gdzie się zatrzymuje

Saga nie jest problemem fork-join. STS (Structured Task Scope) daje ci ograniczony fork-and-join, strukturalne anulowanie, jawną propagację błędów. Przydatne prymitywy, ale niewystarczające same w sobie:

  • STS nie daje ci persystencji stanu między wywołaniami handlerów
  • STS nie daje ci trwałości kolejki kompensacji przez crash
  • STS nie daje ci idempotencji dla retry-after-restart

Exeris-native Flow łączy STS do orkiestracji in-flight z off-heap maszyną stanów flow i outboxem dla crash-safe kompensacji. STS to połowa historii sagi, nie całość.

Model Axona jest odwrotnością: rozwiązuje persystencję i trwałość (Axon Server trzyma zdarzenia), ale wymienia synchroniczną poprawność na asynchroniczną przepustowość. Ten trade-off jest w porządku — dopóki nie benchmarkujesz ze skończonymi oknami i skonfigurowaną iniekcją błędów.

Gdzie to dalej się nie uogólnia

Bo rygor ma większe znaczenie niż masowa dystrybucja, oto co dane powyżej wspierają, a czego nie:

  • Sprzęt: dev-laptop, nie perf-box. Re-run na perf-box-amd64 jest w roadmapie przed jakimkolwiek claimem comparison_eligible.
  • Stopa błędów: tylko skonfigurowane 3%. Skrajne stopy błędów (10–30%) i long-tail rzadkie błędy jeszcze nie zwalidowane.
  • Wariant Axona: architektura Axon Server, nie embedded EventStore. Wariant embedded może zachowywać się inaczej.
  • Backing grafu: Neo4j Bolt driver w ścieżce; wariant PG-only jeszcze nie zaimplementowany.
  • Okno: 180s. Niewystarczające dla obserwacji long-tail kompensacji. Okno 1800s prawdopodobnie przesunęłoby liczby dla obu stosów Axon ku lepszym stopom kompensacji — ale to jest osobny eksperyment.

Każde z tych ograniczeń jest znanym limitem zakresu, opublikowanym jako następne milestone’y w roadmapie publicznego benchmark suite’a.

Reprodukcja

Wszystko jest reprodukowalne. Opublikowane artefakty zawierają:

scenarios/e2e-shop-order-saga/
  scenario.json                          ← kontrakt protokołu
  comparative-pair-manifest.json         ← co się porównuje
  seed/
    seed-manifest.json                   ← deterministyczne dane seedowe
    verify-seed.sh                       ← skrypt weryfikacji seeda

results/raw/e2e-shop-order-saga/
  20260505T115008Z-baseline/             ← przebieg Quarkus + Axon
    result.json
    target-quarkus-app-axon-*.jfr
    logs/axonserver-docker-stats.csv
  20260505T115722Z-baseline/             ← przebieg Exeris
    result.json
    target-exeris-community-app-*.jfr
  20260505T120906Z-baseline/             ← przebieg Spring + Axon
    result.json
    target-spring-app-axon-*.jfr
    logs/axonserver-docker-stats.csv

scripts/
  run-e2e-shop-order-saga-campaign.sh    ← reprodukcja wielocelowa (pełne porównanie)
  run-e2e-shop-order-saga-baseline.sh    ← pojedynczy cel (wywoływany przez campaign)

Pełna reprodukcja trzech powyższych przebiegów to jedno wywołanie skryptu campaign:

./scripts/run-e2e-shop-order-saga-campaign.sh \
  --targets exeris-community-app,quarkus-app-axon,spring-app-axon \
  --graph-track neo4j \
  --repeats 1

Każdy może to uruchomić na własnym sprzęcie i zweryfikować asymetrię poprawności. To jedyna rzecz, która ma znaczenie dla tego typu claimu.

Co tu uznaję za solidne, a czego nie

Ufam asymetrii poprawności kompensacji. Reprodukuje się. Ma mechaniczne wyjaśnienie. Pojawia się w dwóch niespokrewnionych hostach runtime dla tego samego modelu Axon. Claim jest strukturalnie obronny: asynchroniczno-dispatchowe frameworki saga wracają przed ukończeniem pracy; dlatego latencja wygląda szybko, a kompensacje przepadają.

Ufam porównaniu footprintu całego systemu (pamięć, wątki, CPU). Proces Axon Server jest realny, ma mierzalny koszt, i ten koszt należy do porównania. To nie jest krytyka Axona — Axon Server wykonuje swoją pracę. To jest krytyka benchmarków, które wyznaczają granicę ciasno wokół aplikacji JVM i zapominają o backendzie orkiestracji.

Nie ufam surowym liczbom latencji jako dowodowi porównawczemu. Są tylko descriptive. Różnice p50 i p95 w zakresie 3–10 ms na dev-laptopie, single-tenant loopback, to szum relatywny do zakresu 1.5–4× który miałby znaczenie dla decyzji o deploymencie produkcyjnym. Ciekawa historia to nie “Exeris jest szybszy od Springa” — bo 14.6 ms Springa to czas dispatchu, nie czas pracy, więc porównanie nie domyka się nawet typowo.

Liczby, którym ufam najbardziej, to te, których najbardziej boję się opublikować — bo zawierają to, co jeszcze nie jest zwalidowane. To też dlatego są oznaczone jako descriptive i exploratory, nie comparison_eligible.

Następny milestone zamyka lukę sprzętową (re-run na perf-box-amd64) i eksploruje szersze stopy błędów. Powyższe dane albo się utrzymają, albo się rozsypią. Każde z dwóch jest w porządku — dlatego są publiczne.



Silnik Flow produkujący poprawność kompensacji powyżej znajduje się w exeris-kernel-core i exeris-kernel-spi. TCK pokrywający kompensację pod crash-recovery jest w exeris-kernel-tck: 🔗 exeris-systems/exeris-kernel