5. Performance‐Optimierung - Dxme98/Dokumentation GitHub Wiki

5. Performance-Optimierung

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.

1. Datenbank-Performance (Der Flaschenhals)

In den meisten Webanwendungen ist die Datenbank der primäre Flaschenhals. Folgende Maßnahmen wurden konsequent umgesetzt, um diesen Flaschenhals zu minimieren.

Maßnahme 1: Vermeidung des N+1-Query-Problems

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 members in 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 @EntityGraph definiert.

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);
}

Maßnahme 2: Vermeidung des Kartesischen Produkts

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.
  • 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 N Abfragen auf 1 + (N / BatchSize) Abfragen reduziert werden.

Konzept-Vergleich

  • 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)


Maßnahme 3: Vermeidung von In-Memory-Verarbeitung

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);

Maßnahme 4: Gezielte Indizierung

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 von receiver_id UND invite_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);
⚠️ **GitHub.com Fallback** ⚠️