## Legacy-Code optimieren: So reduzieren Sie die Verarbeitungszeit um 99 % in einem Praxisbeispiel
Die Implementierung einer REST-Integration mit einem externen System stößt oft auf Performance-Probleme. In diesem Artikel zerlegen wir, wie wir die Verarbeitung von 75.000 Datensätzen in nur 4 Schritten beschleunigt haben, indem wir die Ausführungszeit um 99 % gekürzt haben. Der Fall basiert auf einer realen Aufgabe, bei der ein Banksystem mit einem externen Partner unter strengen Einschränkungen integriert wurde.
Problem: REST-Integration mit Partnerbeschränkungen
Ausgangsbedingungen:
- Muss Benutzerdaten an das Partnersystem über REST API senden
- Partner akzeptiert nur einzelne Anfragen (kein Batch-Verarbeitung erlaubt)
- Ausführungszeitfenster: ein Slot um 5 Uhr morgens, um die Belastung zu minimieren
- Datenvolumen: 75.000 Datensätze mit NEW-Status
- Keine asynchrone Verarbeitung oder horizontale Skalierung erlaubt
Der ursprüngliche Code nutzte paginierte Ladevorgänge über Spring Data Pageable mit einer Seitengröße von 2000 Datensätzen. Der kritische Fehler bestand darin, alle geänderten Entitäten im Speicher anzuhäufen, bis zu einer einzigen saveAll()-Operation, was zu Folgendem führte:
- Automatischer Kontext-Flush vor jedem SELECT
- Generierung von 2000 UPDATE-Abfragen pro Seite
- Transaktionssperre während HTTP-Aufrufen
Erste Benchmarks zeigten eine Verarbeitungszeit von ≈105 Sekunden auf einem Apple M4 mit 16 GB RAM. Die Emulation einer 1-ms-Verzögerung des externen Systems machte dies zu einer kritischen Metrik für die Produktion.
Schritt 1: Eliminierung redundanter DB-Operationen
Die Analyse der SQL-Logs deckte den fatalen Fehler auf: Hibernate generierte 2000 separate UPDATEs statt Batch-Operationen. Die Ursache war, dass Entitäten über Schleifeniterationen hinweg im Persistenzkontext gehalten wurden. Die Lösung umfasste zwei Schlüsseländerungen:
- Ersetzen der Saves am Ende des Prozesses durch Zwischensaves:
content.forEach(this::registerUser);
userRepository.saveAll(content);
- Implementierung einer nativen Abfrage für Bulk-Status-Updates:
@Modifying
@Query(value = """
UPDATE users SET status = CAST(:status AS text)
WHERE id IN (:ids)
""", nativeQuery = true)
int batchUpdateStatus(@Param("ids") Collection<Long> ids, @Param("status") String status);
Diese Änderungen lieferten erste Ergebnisse – eine Zeitreduktion um 6 % (auf 98,4 Sekunden). Wichtige Erkenntnis: Standardmethoden von Spring Data JPA können bei großen Datenmengen versteckten Overhead verursachen.
Schritt 2: Feinabstimmung der Verarbeitungsparameter
Die nächste Optimierung konzentrierte sich auf die Batch-Größe. Tests zeigten:
- Bei Größe 500: Zeit um 8,41 % gestiegen (106,68 Sek.)
- Bei Größe 2000: Basis 98,4 Sek.
- Bei Größe 5000: 1,86 % Verbesserung (96,58 Sek.)
Es gibt keine Allheilmittel. Die optimale Größe hängt ab von:
- Hardware-Spezifikationen (Festplatten-IOPS, Netzwerkbandbreite)
- Komplexität der Geschäftslogik in der Verarbeitungsschleife
- Einstellungen des DB-Verbindungs-Pools
Empfohlener Ansatz: Führen Sie A/B-Tests in einer produktionsähnlichen Umgebung mit Ressourcen-Profiling (CPU, Speicher, Festplatten-I/O) durch. In unserem Fall erwies sich 5000 Datensätze als optimal, was die Anzahl der Transaktions-Commits reduzierte.
Schritt 3: Sichere horizontale Skalierung
Der Versuch, mit zwei Threads zu verarbeiten, führte zu einer Katastrophe – alle Operationen wurden dupliziert (150.000 statt 75.000 Abfragen). Die Lösung erforderte eine Änderung der Datenbankzugriffsstrategie:
- Implementierung von SELECT FOR UPDATE SKIP LOCKED:
SELECT * FROM users WHERE status = 'NEW' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 5000
- Implementierung einer transaktionalen Schleife:
@Transactional
public void processBatch() {
List<User> batch = userRepository.lockAndSelectBatch(5000);
batch.forEach(this::registerUser);
userRepository.batchUpdateStatus(
batch.stream().map(User::getId).collect(Collectors.toList()),
UserStatus.PROCESSED.name()
);
}
Dieser Ansatz gewährleistete:
- Garantierte einmalige Verarbeitung
- Minimale Tabellensperren
- Möglichkeit, N parallele Instanzen auszuführen
Nach der Implementierung sank die Verarbeitungszeit auf 1,05 Sekunden – eine Reduktion um 99 % gegenüber dem Original.
Wichtige Optimierungsphasen
- Analyse versteckten Overheads
- Aktivieren von show-sql, um generierte Abfragen zu verfolgen
- Überprüfen des Persistenzkontext-Verhaltens in Schleifen
- Wechsel zu Batch-Operationen
- Ersetzen von saveAll() durch Zwischensaves
- Nutzung nativer Abfragen für Bulk-Updates
- Experimentieren mit Parametern
- Testen verschiedener Batch-Größen
- Überwachen von Systemmetriken während der Tests
- Vorbereitung auf Skalierung
- Implementierung von SKIP LOCKED für gleichzeitigen Zugriff
- Zerlegung von Transaktionen in atomare Blöcke
Wichtige Erkenntnisse
- Hibernate-Flush vor SELECT – ein kritisches Problem bei großen Datenmengen. Jede DB-Abfrage löst einen automatischen Flush aus, der eine Kaskade von UPDATEs verursacht.
- Batch-Größe ist nicht konstant. Erfordert empirische Abstimmung basierend auf Workload-Spezifika. Tests zeigten 5000 Datensätze als optimal.
- Transaktionsisolation für Skalierung. SELECT FOR UPDATE SKIP LOCKED ermöglicht gleichzeitige Verarbeitung ohne Duplikate, erhöht aber die Sperrzeiten.
- Profiling ist essenziell. Ohne produktionsähnliche Benchmarks können Optimierungen nach hinten losgehen (z. B. Batch-Größe auf 500 verkleinern).
- Kompatibilität mit Legacy-Einschränkungen. Lösungen müssen externe Limits berücksichtigen (z. B. Verbot von Batch-Anfragen durch den Partner), was einfache asynchrone Verarbeitung ausschließt.
Die finale Implementierung lieferte stabile Verarbeitungszeiten von 1,05 ± 0,05 Sekunden unter Lasttests mit 100.000 Datensätzen. Der Schlüssel zum Erfolg war die Kombination technischer Optimierungen mit tiefem Verständnis des Vertrags des externen Systems. Dieser Ansatz funktioniert für alle REST-API-Integrationsszenarien mit Anfragengrößenlimits und Zuverlässigkeitsanforderungen.
— Editorial Team
Noch keine Kommentare.