Beim Skalieren eines ungetesteten Monolithen bricht selten zuerst die CPU ein. Zuerst verliert das Team die Fähigkeit zu erkennen, ob eine Bestellung einmal oder zweimal ausgeführt wurde. Für QA ist deshalb die erste Migrationsfrage nicht, welchen Service man herausschneidet, sondern welche fachliche Zusage unter parallelen Aufrufen überprüfbar bleibt. Ohne diese Zusage macht zusätzliche Kapazität Fehler nur schneller.
Unter Last versagt zuerst das Testorakel, nicht der Lastgenerator
Ein einzelner erfolgreicher HTTP-Request beweist wenig, wenn niemand weiß, ob seine Antwort zur richtigen Änderung in der Datenbank gehört. Im geerbten Monolithen kann ein Endpunkt einen Auftrag anlegen, einen Bestand reduzieren und eine Benachrichtigung auslösen. Solange diese Schritte im selben Prozess verborgen bleiben, sieht ein Integrationstest meist nur den Statuscode. Bei parallelen Aufrufen treten dagegen Reihenfolgen auf, die der Test nie beschrieben hat: Eine Antwort kommt vor der Benachrichtigung, ein zweiter Aufruf sieht einen Zwischenstand, oder ein Timeout verdeckt eine bereits gespeicherte Bestellung.
Ich würde nicht mit einem großen Satz neuer Unit-Tests für interne Klassen beginnen, weil deren erwartetes Verhalten aus ungetestetem Code abgeleitet würde. Stattdessen würde ich drei bis fünf Geschäftsoperationen auswählen, deren doppelte oder verlorene Ausführung auffällt, und für jede eine von außen prüfbare Invariante formulieren. Für eine Bestellung könnte sie lauten: Derselbe Idempotenzschlüssel erzeugt höchstens eine Bestell-ID und höchstens eine Bestandsänderung. Der genaue Statuscode beim zweiten Aufruf ist dagegen eine Vertragsentscheidung, keine universelle Wahrheit.
Als Ausgangspunkt dienen reale Abläufe, nicht ausgedachte Idealantworten. Ein Mitschnitt an der HTTP-Grenze liefert Anfrageform, Status und Antwort; PostgreSQL-Abfragen vor und nach dem Versuch zeigen die persistierten Effekte. pytest und Testcontainers können daraus wiederholbare Charakterisierungstests machen, sofern die Testdaten isoliert sind. WireMock hilft bei einem instabilen Fremdsystem, kostet aber Aussagekraft über dessen tatsächliches Timeout-Verhalten, weil der Stub nur die von QA modellierten Antworten liefert. OpenAPI beschreibt Felder und Statuscodes; eine fachliche Invariante über zwei Aufrufe ersetzt es nicht.
Eine beispielhafte Baseline sollte ausdrücklich als solche im Bericht stehen: In einem lokalen Messlauf benötigte der ungeänderte Endpunkt am p95 420 Millisekunden bei 20 gleichzeitig aktiven Clients. Beide Werte sind Beobachtungen dieses Teststands, keine Zielwerte für die Migration. Wichtiger als die isolierte Latenz ist die Frage, ob Antwort, Datenbankzustand und Folgeeffekt beim selben Testlauf zusammenpassen. Fehlt diese Zuordnung, kann ein schnellerer Microservice denselben Defekt mit besseren Antwortzeiten ausliefern.
Den Text Monolith zu Microservices: Risiken erkennen, Migration planen würde ich als Gesprächsgrundlage nutzen, aber nicht als Freigabesignal, weil eine Risikoliste keine überprüfte Aussage über genau diese Bestelloperation ersetzt.
Wiederholte Requests brechen die Fachlogik vor der Datenbankkapazität
Sobald ein Client wegen eines Timeouts erneut sendet, muss das System zwischen „nicht angekommen“ und „ausgeführt, Antwort verloren“ unterscheiden. HTTP löst diese Mehrdeutigkeit nicht für einen POST. Ein API-Gateway kann einen Versuch ebenfalls wiederholen, während der erste noch arbeitet. Wird eine Operation aus dem Monolithen in einen eigenen Service verlagert, entstehen zusätzliche Netzgrenzen; dadurch nimmt die Zahl möglicher unklarer Ergebnisse zu, selbst wenn jede einzelne Komponente korrekt arbeitet.
Mein erster Lasttest wäre deshalb kein maximaler Durchsatztest, sondern ein Wiederholungsversuch mit kontrollierten Daten. Das folgende k6-Skript setzt einen Beispielvertrag voraus: POST /orders akzeptiert einen gültigen Test-SKU, der erste Aufruf liefert 201 und beide Antworten enthalten dieselbe id. Endpunkt, SKU und erlaubte Statuscodes müssen an den tatsächlichen Vertrag angepasst werden.
import http from 'k6/http';
import { check } from 'k6';
export const options = { vus: 20, duration: '30s' };
export default function () {
const key = `qa-${__VU}-${__ITER}`;
const data = JSON.stringify({ sku: 'test', quantity: 1 });
const params = { headers: { 'Content-Type': 'application/json', 'Idempotency-Key': key } };
const first = http.post(`${__ENV.BASE_URL}/orders`, data, params);
const retry = http.post(`${__ENV.BASE_URL}/orders`, data, params);
check(first, { 'created': r => r.status === 201 });
check(retry, { 'same id': r => [200, 201].includes(r.status) && r.json('id') === first.json('id') });
}
Mit BASE_URL=http://localhost:8080 k6 run retry.js ist der Versuch ausführbar, sobald eine passende Testinstanz bereitsteht. Die 20 virtuellen Nutzer und 30 Sekunden sind hier frei zu justierende Testparameter, keine veröffentlichten Belastbarkeitswerte. Das Skript sendet je Nutzer den zweiten Request erst nach dem ersten; es prüft damit wiederholte Aufrufe unter konkurrierenden Bestellungen, aber noch nicht zwei gleichzeitig eintreffende Aufrufe mit demselben Schlüssel. Diesen zweiten Fall würde ich separat mit parallel gestarteten Requests testen, weil nur er ein Rennen beim erstmaligen Anlegen des Idempotenzdatensatzes sichtbar macht.
Auch ein grüner k6-Check genügt nicht: Zwei verschiedene Datensätze könnten versehentlich dieselbe Antwort-ID tragen, oder ein Folgeeffekt könnte doppelt ausgelöst worden sein. Deshalb prüfe ich nach dem Lauf per Datenbankabfrage die Anzahl der Bestellungen und Bestandsbuchungen je Schlüssel. Als zu vereinbarende Schwelle setze ich für diesen Test null doppelte Buchungen; anders als ein Latenzziel ist das eine fachliche Entscheidung. Für die technische Durchsetzung eignet sich etwa ein eindeutiger PostgreSQL-Index auf dem Idempotenzschlüssel zusammen mit einer Transaktion. Eine Prüfung allein im Anwendungscode reicht bei konkurrierenden Requests nicht, weil beide Prozesse „noch nicht vorhanden“ lesen können, bevor einer schreibt.
Erst danach lohnt sich Toxiproxy zwischen Anwendung und Abhängigkeit: Ein abgebrochener Verbindungsaufbau oder eine verzögerte Antwort zeigt, ob der Retry dieselbe Operation erneut ausführt. Der Fehler muss an einer benannten Grenze injiziert werden, weil ein beliebiger Timeout sonst nicht erkennen lässt, welcher Schritt bereits erfolgreich war.
Ohne durchgehende Kennung bleibt der erste Skalierungsfehler unsichtbar
Mehr Instanzen erzeugen mehr Logs, aber nicht automatisch mehr Beweise. Wenn Gateway, Monolith und neuer Service dieselbe Operation unterschiedlich benennen, lässt sich ein doppelter Effekt nachträglich kaum einem Request zuordnen. Ich würde deshalb vor der Extraktion eine Korrelationskennung an der Eingangsgrenze übernehmen oder erzeugen und sie durch synchrone Aufrufe weiterreichen. W3C Trace Context mit traceparent und OpenTelemetry bieten dafür ein gemeinsames Format; eine fachliche Bestell-ID gehört zusätzlich in kontrollierte Logfelder, weil ein technischer Trace mehrere Versuche derselben Bestellung nicht automatisch zusammenführt.
Für die Diagnose reichen wenige gezielte Messgrößen. Prometheus kann die Fehlerrate und die Dauer einer Operation erfassen, während Grafana die zeitliche Korrelation sichtbar macht. Die Anzahl doppelter fachlicher Effekte muss aus einer vertrauenswürdigen Quelle kommen, etwa einer Abfrage der Buchungstabelle, weil HTTP-Metriken erfolgreiche Antworten zählen und keine Bestandsbuchungen. Bei einer asynchronen Verarbeitung gehört außerdem das Alter des ältesten unverarbeiteten Ereignisses ins Bild; eine kleine Queue ist nicht gesund, wenn ein einzelnes Ereignis dauerhaft festhängt.
Die Kubernetes-Dokumentation nennt für terminationGracePeriodSeconds standardmäßig 30 Sekunden. Das ist eine veröffentlichte Voreinstellung, keine Garantie für sauberes Herunterfahren: Dauert eine Operation länger, kann ein Pod während der Bearbeitung beendet werden. Für die eigene Anwendung würde ich die Frist anhand gemessener Bearbeitungszeit und tatsächlichen Shutdown-Verhaltens einstellen. Ein Test sollte während laufender Bestellungen eine Instanz beenden und danach dieselben Datenbankinvarianten prüfen. So wird ein Infrastrukturereignis zum reproduzierbaren QA-Fall statt zur Vermutung aus Logs.
Bei Traces würde ich zudem keine Vollständigkeit aus Stichproben ableiten. Sampling senkt Kosten, kann aber gerade einen seltenen doppelten Effekt auslassen; deshalb ist die Zählung persistierter Verstöße das Freigabekriterium und der Trace ein Werkzeug zur Ursachenanalyse. Diese Trennung schützt das Team davor, eine leere Grafana-Ansicht mit einem fehlerfreien System zu verwechseln.
Ein Service-Schnitt ist erst sicher, wenn sein Fehlermodus geprüft ist
Für die Kommunikation zwischen Monolith und neuem Service gibt es keine kostenlose Standardwahl. Synchroner HTTP-Aufruf gewinnt, wenn der Nutzer sofort ein verbindliches Ergebnis braucht und ein Fehler ohne Teilwirkung zurückgegeben werden kann; er kostet zusätzliche Latenz und koppelt die Verfügbarkeit beider Prozesse. Transactional Outbox gewinnt, wenn eine lokal gespeicherte Änderung zuverlässig später weitergegeben werden darf; sie kostet einen Dispatcher, Überwachung des Rückstands und idempotente Verarbeitung beim Empfänger. Ein direkter Schreibversuch in zwei Datenbanken ohne gemeinsames Transaktionskonzept wäre für diese Operation meine schlechteste Wahl, weil ein erfolgreicher erster und fehlgeschlagener zweiter Schreibvorgang keinen eindeutigen Zustand hinterlassen.
Die Outbox ist ebenfalls kein Beweis für „genau einmal“. Ein Dispatcher kann nach dem Senden abstürzen und dieselbe Nachricht erneut zustellen; der Empfänger benötigt daher einen stabilen Ereignisschlüssel und eine deduplizierende Schreiboperation. Kafka kann den Transport übernehmen, Debezium Änderungen aus einer Outbox-Tabelle lesen, doch QA muss weiterhin prüfen, ob nach Neustart, Verzögerung und doppelter Zustellung nur eine fachliche Buchung existiert. Ein Test, der allein auf die Anzahl veröffentlichter Nachrichten schaut, bewertet den Transport und nicht die Zusage an den Nutzer.
Den Beitrag Monolith Modernisierung: Schrittweise zur Microservices Architektur würde ich deshalb nicht als feste Reihenfolge für Service-Schnitte übernehmen, weil die zuerst prüfbare Fehlergrenze im geerbten System wichtiger ist als die optisch sauberste Domänengrenze. Manchmal bleibt die Schreiboperation vorerst im Monolithen, während nur eine lesende Funktion ausgelagert wird: Das gewinnt Zeit für Vergleichstests, kostet aber vorübergehend zwei Implementierungen derselben Ansicht.
Bei einer solchen lesenden Auslagerung kann Schattenverkehr helfen: Der Monolith beantwortet die Anfrage, der neue Service erhält eine Kopie, und ein Vergleichsprozess meldet Abweichungen. Pact-Vertragstests sind die andere Option, wenn vor allem Versionen von Request und Response unabhängig deploybar sein müssen; sie sind schneller und deterministischer, kosten aber eigene Beispiele für fachliche Randfälle. Schattenverkehr findet Abweichungen unter echten Anfrageformen, benötigt jedoch Normalisierung für Zeitstempel und IDs und darf keine schreibenden Nebenwirkungen auslösen. Welche Option gewinnt, entscheidet also der gesuchte Fehler, nicht die Beliebtheit des Werkzeugs.
Die erste Freigabe braucht einen nachweisbaren Gegenbeweis
Nimm morgen eine einzige schreibende Operation und formuliere ihren unerlaubten Zustand als Datenbankabfrage, etwa „mehr als eine Buchung pro Idempotenzschlüssel“. Führe dann den Wiederholungsversuch gegen eine isolierte Testinstanz aus und bewahre Anfrage, Antwort und Abfrageergebnis gemeinsam auf. Liefert der Test keinen eindeutigen Befund, ist nicht mehr Last der nächste Schritt, sondern ein besseres Testorakel.



