Powrót do strony głównej

Optymalizacja legacy-kodu: zmniejszenie czasu przetwarzania o 99%

Analiza rzeczywistego case'u optymalizacji przetwarzania 75 tysięcy rekordów w systemie legacy. Pokazano, jak w 4 kroki skrócić czas wykonania z 105 sekund do 1,05 sekundy poprzez eliminację nadmiarowych operacji, konfigurację przetwarzania batch i bezpieczne skalowanie.

Rzeczywisty case: optymalizacja legacy-kodu z 105 s do 1,05 s
Advertisement 728x90

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:

Google AdInline article slot
  • 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.

Google AdInline article slot

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.

Google AdInline article slot

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

Advertisement 728x90

Czytaj dalej