5. Performance‐Optimierung - Dxme98/Dokumentation GitHub Wiki
Anstatt Performance-Probleme nachträglich mit Caching zu "überdecken", war mein Ziel, sie von vornherein zu verhindern. Der Fokus lag darauf, die Datenbanklast so gering wie möglich zu halten.
In den meisten Webanwendungen ist die Datenbank der primäre Flaschenhals. Folgende Maßnahmen wurden konsequent umgesetzt, um diesen Flaschenhals zu minimieren.
Das N+1-Problem ist der häufigste Performance-Killer in JPA-Anwendungen und wird durch falsch konfiguriertes Lazy Loading verursacht.
-
Problem: Eine Query holt 1 Projekt. Beim anschließenden (Lazy) Zugriff auf die 50
membersin einer Java-Schleife werden 50 weitere Queries ausgelöst. Gesamt: 1 + 50 = 51 Queries.Konzept-Code (SCHLECHT):
// 1. Die ERSTE Query: Lade das Projekt // Hier wird die Standard-Methode projectRepository.findById(...) verwendet Project project = projectRepository.findById(1L).get(); // Löst 1 Query aus // 2. Die "N" QUERIES: // project.getMembers() ist LAZY. Der Zugriff in der Schleife // löst pro Mitglied eine EIGENE Query aus! for (ProjectMember member : project.getMembers()) { log.info(member.getUser().getUsername()); // Löst N Queries aus }
-
Lösung: Anweisung an JPA, alle benötigten Daten (Projekt + Mitglieder + deren User-Objekte) in einer einzigen, sauberen
JOIN-Query zu laden. Dies wird proaktiv im Repository mittels@EntityGraphdefiniert.
Code-Beispiel (Lösung im ProjectRepository.java):
@Repository
public interface ProjectRepository extends JpaRepository<Project, Long> {
// GUT (Löst EINE Query aus, die Projekt, Member + User per JOIN lädt)
// Wenn diese Methode im Service aufgerufen wird, tritt das N+1-Problem nicht auf,
// da die Daten bereits "eager" geladen wurden.
@EntityGraph(attributePaths = {"members", "members.user"})
Optional<Project> findProjectWithMembersAndUsersById(Long projectId);
}Die N+1-Lösung (@EntityGraph) kann ein weiteres Performance-Problem erzeugen, wenn man mehrere "One-to-Many"-Listen gleichzeitig lädt (z.B. tasks UND members).
-
Problem (Die "Monster-Query"): Die Datenbank muss ein Kartesisches Produkt bilden.
-
Beispiel:
1 Projekt * 50 Tasks * 10 Member = 5.000 Zeilen. - Ergebnis: Das Netzwerk wird mit Duplikaten überflutet, und der Java-Speicher wird überlastet, was die Anwendung extrem verlangsamt.
-
Beispiel:
-
Lösung (Batch Fetching): Statt einer "Monster-Query" werden mehrere kleine, gebündelte Queries ausgeführt. Dies löst das N+1-Problem, indem die
NAbfragen auf1 + (N / BatchSize)Abfragen reduziert werden.
-
Kartesisches Produkt - SCHLECHT:
-- Eine riesige "Monster-Query", die Sprints, Items, Assignees -- und Kommentare in einem gigantischen JOIN lädt. select s1_0.id, bi1_0.id, am1_0.id, c1_0.id ... from sprints s1_0 left join sprint_backlog_items bi1_0 on ... left join sprint_backlog_item_assignee am1_0 on ... left join comments c1_0 on ...
(Ergebnis: Extrem langsam, hohe Speicherlast)
-
Batch Fetching - GUT:
-- Query 1: Lade die Sprints select s1_0.id, s1_0.name ... from sprints s1_0 where ... -- Query 2: Lade die Items für einen "Batch" von Sprints select ... from sprint_backlog_items bi1_0 where bi1_0.sprint_id = any (?) -- (oder: ... WHERE sprint_id IN (1, 2, ... 25)) -- Query 3: Lade die Assignees für einen "Batch" von Items select ... from sprint_backlog_item_assignee am1_0 where am1_0.sprint_backlog_item_id = any (?)
(Ergebnis: 1 + M Queries, extrem schnell, geringe Speicherlast)
Ein weiterer Fehler ist das Laden riesiger Datenmengen in den Java-Speicher (In-Memory), nur um sie dort zu filtern oder zu zählen.
-
Problem: Um die Anzahl der "offenen" Tasks in einem Projekt zu zählen, werden alle 10.000 Tasks vom Projekt geladen (
List<Task> tasks = taskRepo.findAll()) und dann in Java verarbeitet (tasks.stream().filter(t -> t.isOpen()).count()). -
Lösung: Die Arbeit an die Datenbank delegieren. Sie ist darauf optimiert, Daten zu zählen und zu aggregieren.
Code-Beispiel (TaskRepository.java):
// SCHLECHT (Lädt 10.000+ Objekte in den Java-Speicher)
// long count = tasks.stream().filter(t -> t.isOpen()).count();
// GUT (Die DB macht die Arbeit, nur eine Zahl wird zurückgegeben)
@Query("SELECT COUNT(t) FROM Task t WHERE t.project.id = :projectId AND t.status = 'OPEN'")
long countOpenTasksByProject(@Param("projectId") Long projectId);JOIN- und WHERE-Klauseln auf Tabellen mit Tausenden von Einträgen können ohne Indizes extrem langsam sein (sogenannte Full Table Scans).
-
Problem: Eine häufige Query ist "Zeige mir alle meine offenen Einladungen". Dies erfordert eine Suche in der
project_invite-Tabelle anhand vonreceiver_idUNDinvite_status_id. - Lösung: Ein Multi-Column-Index, der in Flyway (siehe Datenbank-Doku) definiert wurde, um diese spezifische Abfrage massiv zu beschleunigen.
Code-Beispiel (Flyway-Migration):
-- Beschleunigt die Abfrage für "Meine offenen Einladungen"
CREATE INDEX idx_project_invite_receiver_id_status
ON project_invite(receiver_id, invite_status_id);