Farbschema
Ansicht
Hero-Effekt
Alle Einträge
2025 · #java

Alte Suche: von 50 Sekunden auf unter 4

Eine langsame Suche in einem Firmensystem 14× schneller gemacht und den Datenfehler dahinter gefunden.

Ergebnis
49,6 s → 3,5 s
Stack
Spring Boot 2.7 · Hibernate 5.6 · PostgreSQL · Next.js
Gemessen an
Bücher, 100 Zeilen

Das Problem

In dem Projekt, an dem ich damals gearbeitet habe, brauchte die Listenansicht, um die herum fast die ganze Anwendung gebaut war, 17 bis 50 Sekunden und lief bei größeren Seitengrößen in einen Timeout. Der ursprüngliche Code stammte nicht von uns, das Projekt war an uns übergeben worden, und mehrere Queries nutzten dieselbe Struktur. Alles umzubauen oder zu refaktorieren, oder auch nur für diesen einen Fall eine neue Struktur zu bauen, hätte massive Änderungen in Frontend und Backend bedeutet. Diesen Luxus hatten wir damals nicht. Dazu kam, dass die Performance über zwei Jahre langsam schlechter geworden war, weil die Datenmenge wuchs. Am Anfang gab es das Problem wegen des kleineren Datenbestands noch nicht. Leider war das Projekt nur noch im Wartungsmodus, große Änderungen oder eigene Sprints waren also gar nicht eingeplant.

Warum keine Filter pro Spalte

Die Nutzer konnten nach jedem Feld filtern. Die Datenmodelle und ihre verknüpften Objekte waren sechs Ebenen tief. Wir brauchten also etwas Dynamisches und Flexibles.

Gelöst hatten wir das so: Ein eigener Deserializer übersetzte die JSON-Filter in dynamische LEFT JOINs und gab vollständige Entities zurück. Mit DTOs hätten wir die Filter-Engine neu schreiben und dazu das Datenmodell und die Requests im Frontend ändern müssen. Projections habe ich ausprobiert, sie brachten keinen nennenswerten Geschwindigkeitsgewinn. Entity Graphs hätten auch nicht geholfen, weil unser eigener Query Builder das verschachtelte Fetching schon übernahm.

Was ich gefunden habe

Es war kein N+1, auch wenn alle das vermutet hatten. Die SQL-Logs zeigten eine einzige große Query. Jede Beziehung war EAGER, also zog jede Zeile ihren kompletten Graphen über sechs Ebenen mit, und durch zirkuläre Referenzen serialisierte Jackson dieselben Daten immer wieder.

Was ich gemacht habe

Ich habe für den PO fünf Optionen aufgeschrieben, vom gezielten Laden einzelner Spalten bis zum kompletten Redesign. Wir haben uns auf einen Kompromiss geeinigt und die umfangreichste Variante gewählt, die wir uns leisten konnten: EAGER zu LAZY auf jeder Ebene, explizite Unterbrechungen der zirkulären Referenzen, neue Indizes und nebenbei einige Optimierungen am Query Builder.

Ein unerwarteter Fund

Nach der Umstellung auf LAZY warfen einige Endpunkte eine NonUniqueResultException. Datensätze, die im Java-Code eins zu eins waren, hatten Duplikate in der Datenbank, und auf Datenbankebene sorgte leider nichts für diese Regel. Die Properties mit FetchType.EAGER haben das irgendwie verdeckt, deshalb blieb es lange unbemerkt.

Die Anwendung hatte einen zweiten Kind-Datensatz angelegt, statt den ersten zu aktualisieren. Ich habe die Duplikate bereinigt und Unique Constraints hinzugefügt. Die Bereinigung war natürlich ein Zusammenführen und nicht einfach das Löschen eines Eintrags. Dabei ist mir aufgefallen, dass der erste Eintrag in fast allen Fällen so gut wie leer war, was Properties und Unter-Properties angeht. Ich hätte gern genauer untersucht, wie und warum das passiert ist, aber die Performance-Verbesserung musste so schnell wie möglich live gehen.

Im Nachhinein glaube ich nicht, dass der Wechsel von FetchType.EAGER auf LAZY den Effekt hatte, den ich erwartet hatte. Die Query, die später gebaut wurde, lud trotzdem alle Properties und Unterobjekte eager und fast dieselbe Datenmenge wie vorher. Ich denke, @JsonIgnoreProperties, das Entfernen der Duplikate und die kleinen Optimierungen am Query Builder haben den größeren Unterschied gemacht.

Ergebnis

Endpunkt Vorher Nachher
Bücher, 100 Zeilen 49,6 s 3,5 s
Bücher, 25 Zeilen 17,3 s 1,7 s
Kategorie-Lookup, 25 Zeilen 46,5 s 409 ms
Kategorie-Lookup, 100 Zeilen Timeout 448 ms

Was ich heute anders machen würde

Ich habe nur die gesamte Antwortzeit gemessen, vorher und nachher. Viel sinnvoller wäre es gewesen, jede Verbesserung einzeln zu messen und erst dann zu beurteilen, welcher Teil den größten Effekt hatte.

Nächste Fallstudie →90 GB Google-Fotos-Videos auf 8,5 GB verkleinert