Caluga - Java blog

Morphium 6.2.5: Wie batchSize=1 20 Sekunden Latenz versteckte

Morphium 6.2.5: Wie batchSize=1 20 Sekunden Latenz versteckte

Morphium 6.2.5 ist auf Maven Central. Auf dem Papier ein unauffälliges Patch-Release — ein paar Fixes, zwei kleine Features. Aber einer dieser Fixes hat eine Geschichte, die das Erzählen wert ist: die Sorte Bug, die völlig unsichtbar bleibt, bis genau die falschen Bedingungen zusammenkommen.

Das 20-Sekunden-Rätsel

Das Symptom war zermürbend sporadisch: ab und zu brauchte ein sendAndAwaitAnswers() in einem messaging-lastigen Service plötzlich zwanzig Sekunden statt der üblichen paar Millisekunden. Kein Fehler, kein Timeout, kein Stacktrace — nur ein Request, der gelegentlich von der Klippe fiel und sich dann von selbst wieder fing.

Lokal ließ es sich nie reproduzieren. Auf localhost war alles instant. Auftauchen tat es ausschließlich über eine Verbindung mit hoher Latenz — hier ein Service, der seine MongoDB durch einen SSH/SOCKS-Tunnel mit ein paar Dutzend Millisekunden Round-Trip-Time erreichte.

Am Ende haben wir es live an der Produktion gemessen — und die Ursache war eine einzige hartcodierte Zahl.

batchSize = 1

Morphiums Messaging und watch() basieren auf MongoDB Change Streams. Der Change-Stream-Cursor wurde mit einer getMore-Batch-Größe von 1 angelegt — genau ein Event pro Round-Trip zum Server.

Bei niedriger Latenz ist das unsichtbar. Bei wenig Traffic ebenso. Aber kombiniert man einen ausgelasteten Stream mit einer langsamen Leitung, wird die Rechnung hässlich: mit einem Event pro Round-Trip kann der Stream höchstens ein Event pro RTT liefern. Bei 30 ms RTT sind das rund 33 Events pro Sekunde — kommen Nachrichten schneller rein, baut der Cursor einen Rückstau auf, den er nur mit 1/RTT abbauen kann. Die Events kommen weiterhin an, nur immer später, bis der Traffic abebbt und der Cursor endlich aufholt. Die von sendAndAwaitAnswers() erwarteten Antworten lagen genau in diesem Rückstau. Daher: zwanzig Sekunden, dann wieder gut.

Der Grund, warum batchSize=1 überhaupt dort stand, ist der beste Teil. Es war ein bewusster Workaround für einen Multi-Document-Batch-Hänger in der alten watch()-Implementierung — damals load-bearing. Nach dem Change-Stream-Rewrite in 6.x reproduziert dieser Hänger nicht mehr. Der Workaround hatte das Problem, das er löste, stillschweigend überlebt.

Der Fix

Die Batch-Größe ist jetzt konfigurierbar und steht per Default auf 100 statt 1:

// globaler Default für alle Change-Stream-Cursor (Messaging, watch)
cfg.driverSettings().setChangeStreamBatchSize(500);   // Default ist 100
// oder pro Monitor, vor start()
ChangeStreamMonitor mon = new ChangeStreamMonitor(morphium);
mon.setBatchSize(500);
mon.start();

Da awaitData zurückkehrt, sobald das erste Event verfügbar ist, fügt eine größere Batch bei wenig Traffic keinerlei Latenz hinzu — ein einzelner Round-Trip darf bei viel Traffic einfach viele aufgestaute Events auf einmal abräumen. Die effektive Batch bleibt unabhängig vom konfigurierten Wert durch MongoDBs ~16-MB-Limit pro Antwort begrenzt.

Die Lehre, wenn es eine gibt: Vorsicht vor load-bearing defaults. Ein Wert, der mal gewählt wurde, um einen längst behobenen Bug zu umgehen, kann jahrelang unberührt liegen — und sich in dem Moment in einen Produktionsvorfall verwandeln, in dem sich die Umgebung drumherum ändert.

Alles andere in 6.2.5

Der Rest des Releases ist eine solide Runde Fixes plus zwei Features:

Atlas / DNS

  • mongodb+srv://-URLs lösen jetzt auch den begleitenden TXT-Seedlist-Record auf, sodass authSource und replicaSet von Atlas automatisch ĂĽbernommen werden, statt sie von Hand setzen zu mĂĽssen (#169). Explizite Konfiguration gewinnt immer.
  • Der SRV-Resolver fällt nur noch als echter letzter Ausweg auf öffentliches DNS (8.8.8.8 / 1.1.1.1) zurĂĽck — sind System-Nameserver vorhanden, gelten sie als autoritativ. Das behebt falsche Ergebnisse und Per-Server-Timeouts in Split-DNS-/Private-Atlas-/Firewall-Szenarien (#170).

Quarkus & Native Images

  • Neu: ClassGraphCache.preRegisterClassesWithAnnotation() erlaubt Frameworks, die ihre annotierten Klassen schon zur Build-Zeit kennen (z. B. die quarkus-morphium-Extension via Jandex), diese zu injizieren und den Runtime-ClassGraph-Scan komplett zu ĂĽberspringen — essenziell fĂĽr Native Images, wo ein Live-Scan nichts findet (#200).

InMemoryDriver

  • Upsert seedet das neue Dokument jetzt auch aus Equality-Predicates, die in $and verschachtelt sind — wie echtes MongoDB. Vorher bekam ein Filter wie {$and:[{_id:"lock"},{expires_at:{$lte:now}}]} eine generierte ObjectId, sodass ein späteres delete({_id:"lock"}) nie matchte — ein echtes Lock-Leak, das wir in einem Migration-Runner hatten (#201).
  • $expr-Queries mit Aggregations-Expression-Operatoren (z. B. $dateFromString) werden nicht mehr fälschlich als „unbekannter Operator" abgewiesen.

Aggregation & Mapping

  • Die Feldnamen-Ăśbersetzung deckt jetzt auch unset(Enum...) und das lookup-foreignField ab und schlieĂźt die letzten camelCase→snake_case-LĂĽcken in den Aggregation-Buildern (#198).
  • BigDecimalMapper toleriert jetzt Werte, die MongoDB als Integer/Long zurĂĽckgibt, statt eine ClassCastException zu werfen.

Upgrade

<dependency>
    <groupId>de.caluga</groupId>
    <artifactId>morphium</artifactId>
    <version>6.2.5</version>
</dependency>

6.2.5 ist ein Drop-in-Upgrade von 6.2.x — keine API-Änderungen. Die vollständigen Notes liegen im GitHub-Release.

Wie immer: Feedback, Issues und PRs sind auf GitHub willkommen. Happy coding!