Optymalizacja legacy-kodu: jak skrócić czas przetwarzania o 99% w rzeczywistym przypadku
Wdrożenie integracji REST z zewnętrznym systemem często napotyka problemy z wydajnością. W tym artykule omówimy, jak w 4 krokach przyspieszyliśmy przetwarzanie 75 tysięcy rekordów, zmniejszając czas wykonania o 99%. Przypadek oparty na rzeczywistym zadaniu integracji systemu bankowego z zewnętrznym partnerem przy sztywnych ograniczeniach.
Problem: integracja REST z ograniczeniami partnera
Warunki wyjściowe:
- Konieczność wysyłania danych o użytkownikach do systemu partnera przez REST API
- Partner akceptuje tylko pojedyncze zapytania (zakaz przetwarzania wsadowego)
- Ograniczenie czasowe: okno o 5 rano w celu minimalizacji obciążenia
- Wolumen danych: 75 000 rekordów w statusie NEW
- Zakaz przetwarzania asynchronicznego i skalowania horyzontalnego
Początkowy kod używał paginowanego ładowania przez Spring Data Pageable z rozmiarem strony 2000 rekordów. Krytyczny błąd polegał na akumulowaniu wszystkich zmienionych encji w pamięci do jednej operacji saveAll(), co prowadziło do:
- Automatycznego flush kontekstu przed każdym SELECT
- Generowania 2000 UPDATE-zapytań na każdą stronę
- Blokady transakcji na czas wywołania HTTP
Pierwsze pomiary pokazały czas przetwarzania ≈105 sekund na Apple M4 z 16 GB RAM. Przy emulacji opóźnienia zewnętrznego systemu w 1 ms to już krytyczny wskaźnik dla środowiska produkcyjnego.
Krok 1: Eliminacja nadmiarowych operacji z bazą danych
Analiza logów SQL ujawniła fatalny błąd: Hibernate generował 2000 oddzielnych UPDATE zamiast operacji batch. Przyczyna — trzymanie encji w persistence context między iteracjami pętli. Rozwiązanie obejmowało dwa kluczowe zmiany:
- Zamiana zapisywania na końcu procesu na pośrednie commity:
content.forEach(this::registerUser);
userRepository.saveAll(content);
- Wdrożenie native query dla masowego aktualizowania statusów:
@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);
Te zmiany dały pierwsze rezultaty — obniżenie czasu o 6% (do 98,4 sekund). Kluczowy wniosek: standardowe metody Spring Data JPA mogą generować ukryte koszty przy pracy z dużymi wolumenami danych.
Krok 2: Precyzyjne dostrojenie parametrów przetwarzania
Następna optymalizacja skupiła się na rozmiarze paczki (batch size). Testy pokazały:
- Przy rozmiarze 500: czas wzrósł o 8,41% (106,68 s)
- Przy rozmiarze 2000: wynik bazowy 98,4 s
- Przy rozmiarze 5000: poprawa o 1,86% (96,58 s)
Ważne zrozumieć, że nie istnieje „srebrna kula”. Optymalny rozmiar zależy od:
- Charakterystyk sprzętu (IOPS dysku, przepustowość sieci)
- Złożoności logiki biznesowej w pętli przetwarzania
- Konfiguracji puli połączeń z bazą danych
Zalecane podejście: przeprowadzić testy A/B na środowisku zbliżonym do produkcyjnego z profilowaniem zasobów (CPU, memory, disk I/O). W naszym przypadku optymalny okazał się rozmiar 5000 rekordów, co zmniejszyło liczbę commitów transakcji.
Krok 3: Bezpieczne skalowanie horyzontalne
Próba uruchomienia przetwarzania w dwóch wątkach doprowadziła do katastrofalnego wyniku — duplikacji wszystkich operacji (150 000 zamiast 75 000 zapytań). Rozwiązanie wymagało zmiany strategii dostępu do danych:
- Wdrożenie SELECT FOR UPDATE SKIP LOCKED:
SELECT * FROM users WHERE status = 'NEW' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 5000
- Realizacja transakcyjnego cyklu:
@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()
);
}
To podejście zapewniło:
- Gwarantowane jednokrotne wykonanie przetwarzania
- Minimalne blokady tabeli
- Możliwość uruchomienia N równoległych instancji
Po wdrożeniu czas przetwarzania spadł do 1,05 sekund — skrócenie o 99% w porównaniu z wariantem wyjściowym.
Kluczowe etapy optymalizacji
- Analiza ukrytych kosztów
- Włączenie show-sql do śledzenia generowanych zapytań
- Sprawdzenie zachowania persistence context w pętli
- Przejście na operacje batch
- Zamiana saveAll() na pośrednie commity
- Użycie native query dla masowych aktualizacji
- Eksperymenty z parametrami
- Testowanie różnych rozmiarów paczek
- Monitorowanie metryk systemowych podczas testów
- Przygotowanie do skalowania
- Wdrożenie SKIP LOCKED dla konkurencyjnego dostępu
- Dekompozycja transakcji na atomowe bloki
Co ważne
- Flush Hibernate przed SELECT — krytyczny problem przy pracy z dużymi wolumenami danych. Każde zapytanie do bazy danych powoduje automatyczny flush, co prowadzi do kaskady UPDATE.
- Rozmiar paczki — nie jest stałą. Wymaga empirycznego doboru z uwzględnieniem specyfiki obciążenia. W testach optymalny okazał się rozmiar 5000 rekordów.
- Izolacja transakcyjna przy skalowaniu. SELECT FOR UPDATE SKIP LOCKED zapewnia konkurencyjne przetwarzanie bez duplikacji, ale zwiększa czas trzymania blokad.
- Profilowanie — obowiązkowy etap. Bez pomiarów na środowisku zbliżonym do produkcyjnego optymalizacje mogą pogorszyć sytuację (jak przy zmniejszeniu rozmiaru paczki do 500).
- Zgodność z ograniczeniami legacy. Rozwiązanie musi uwzględniać zewnętrzne ograniczenia (np. zakaz partnera na zapytania wsadowe), co wyklucza proste rozwiązania typu przetwarzanie asynchroniczne.
Ostateczna implementacja pokazała stabilny czas przetwarzania 1,05±0,05 sekund przy testach obciążeniowych z 100 000 rekordami. Kluczowy czynnik sukcesu — połączenie optymalizacji technicznych z głębokim zrozumieniem umowy z zewnętrznym systemem. Podejście nadaje się do dowolnych scenariuszy integracji z REST API przy ograniczeniach rozmiaru zapytań i wymaganiach co do niezawodności.
— Editorial Team
Brak komentarzy.