Modernizacja legacy .NET: jak przyspieszyliśmy generowanie umów 40×
Duży czeski bank generował umowy ponad 2 godziny, CPU na 95%. Po refaktorze: 3 minuty, CPU 15%. Jak znaleźliśmy wąskie gardło i co zmieniliśmy.
Generowanie umów trwało ponad dwie godziny. CPU serwera pracowało na 95%. Kolejka rosła, pracownicy banku czekali, a w weekendy nocny batch blokował wszystkie pozostałe systemy. Taki był punkt wyjścia, gdy duży czeski bank poprosił nas o pomoc.
To nie jest wyjątek. Starsze systemy .NET w bankach, ubezpieczalniach i produkcji podążają tym samym wzorcem: wszystko działa, dopóki wolumen nie urośnie. Potem zaczyna się zacierać — a każda poprawka dodaje kolejną łatę zamiast rozwiązywać przyczynę.
Objawy, które mówią „to nie jest normalne"
Rozpoznanie problemu jest łatwe. Trudniejsza jest odporność na pierwsze wytłumaczenie. Typowe objawy wydajnościowe legacy:
- Batch joby, które rosną wraz z danymi. Rok temu zajmowały 20 minut, dziś 3 godziny — a za rok? Nikt tego nie mierzył.
- CPU skaczący przy jednej konkretnej operacji. Nie stale, ale jeden typ żądania dusi serwer.
- Timeout, które „czasem się zdarzają". Aplikacja działa, ale od czasu do czasu coś nie wyrabia w terminie — i nikt nie wie dokładnie dlaczego.
- Kod, którego nikt nie chce otwierać. Kluczowa metoda ma 800 linii, komentarze z 2011 roku i wszyscy jej nie ruszają, żeby „nie zepsuć produkcji".
W przypadku banku było to jasne: 2+ godziny na batch umów, CPU nigdy poniżej 80% podczas przetwarzania. Ale dlaczego?
Nie zgadywać — profilować
Najczęstszy błąd przy optymalizacji to wdrażanie rozwiązania zanim wiesz, gdzie jest prawdziwe wąskie gardło. Doświadczenie prowadzi do hipotez — ale hipotezy to zły fundament dla refaktoryzacji produkcyjnego kodu.
Właściwe podejście:
- Profiler, nie intuicja. dotnet-trace, PerfView lub Application Insights pokazują, gdzie faktycznie spędza się czas. Nie tam, gdzie myślisz.
- Mierz konkretne metryki. Liczba zapytań SQL na żądanie, alokacje pamięci, czasy odpowiedzi per operacja. Liczby, nie odczucia.
- Szukaj anomalii, nie tylko wolnych części. Metoda, która trwa 50 ms, ale wywołana jest 80 000 razy podczas batcha, to większy problem niż metoda 2-sekundowa wywoływana raz.
W przypadku banku profiler był jednoznaczny: kod iterował po tej samej liście tysiące razy. Nie raz — tysiące razy. Dla każdego rekordu ponownie przeszukiwał całą kolekcję, żeby znaleźć dopasowanie. Klasyczny problem O(n²) ukryty w pozornie zwykłym kodzie.
Konkretne techniki, które zadecydowały
HashSet i Dictionary zamiast List dla wyszukiwania O(1)
Podstawowy problem w banku: kod używał List<T>.Contains() do przeszukiwania kolekcji tysięcy rekordów. Każde Contains przechodzi przez całą listę — O(n). Rób to 50 000 razy i otrzymujesz O(n²).
Rozwiązanie: zamiana kolekcji wyszukiwanych na HashSet<T> lub Dictionary<TKey, TValue>. Lookup wynosi O(1) niezależnie od rozmiaru kolekcji. Ta jedna zmiana skróciła czas przetwarzania o ponad połowę.
Cache Dictionary dla powtarzalnych obliczeń
Drugi duże źródło marnotrawstwa: te same obliczenia były wykonywane wielokrotnie dla tych samych danych wejściowych. Każdy rekord ładował i przeliczał dane, które dla danego typu umowy w ogóle się nie zmieniają.
Rozwiązanie: memoizacja przez Dictionary. Wynik obliczany jest raz, przechowywany pod kluczem i zwracany natychmiast przy następnym trafieniu. Dla dłuższej żywotności lub współdzielenia między instancjami — Redis.
Batch processing zamiast rekord po rekordzie
Kod legacy przetwarzał rekordy jeden po drugim, każdy z własnym zapytaniem SQL. 10 000 rekordów = 10 000 round tripów do bazy danych.
Rozwiązanie: ładowanie danych paczką (WHERE id IN (...)), przetwarzanie w pamięci, zapis paczką. Liczba zapytań do bazy spadła z tysięcy do dziesiątek.
Indeksy i przepisanie zapytań SQL
Profiler SQL ujawnił full table scany na tabelach z milionami wierszy. Brakowało indeksów na kolumnach używanych w klauzulach WHERE. Dodanie właściwych indeksów i przepisanie kilku zapytań (usunięcie zbędnych SELECT *, użycie projekcji) skróciło czasy zapytań 10–20×.
Wynik: 40× szybciej
Po wdrożeniu wszystkich zmian:
- Generowanie umów: 2+ godziny → 3 minuty
- CPU podczas przetwarzania: 95% → 15%
- Nocny batch przestał blokować pozostałe systemy
Żadnej wymiany platformy. Żadnego przepisania na mikroserwisy. Ta sama codebase .NET, ta sama baza danych. Tylko naprawienie prawdziwych przyczyn zamiast dokupowania sprzętu.
Zasada: naprawić przyczynę, nie objaw
To jest kluczowe: każdy z powyższych problemów miał „szybką naprawę". Dodać serwer. Zwiększyć timeout. Uruchomić batch w nocy, kiedy mniejsze obciążenie. Te łatki by zadziałały — na kilka miesięcy. Potem problem byłby dwa razy większy.
Algorytm O(n²) nie zmienia się, gdy dodajesz RAM. Tysiące zbędnych zapytań SQL nie przyspieszają wraz z lepszym sprzętem. Wąskie gardło zawsze przenosi się gdzie indziej.
Atlas Copco potwierdza to z innej strony: zredukowaliśmy ich pipeline integracji SAP z 15 000 do 3 000 linii SQL i przeszliśmy na architekturę event-driven. Wynik: 80% mniej kodu, system który można faktycznie utrzymywać.
Jak sprawdzić, czy masz ten sam problem
Przejdź przez te pytania:
- Ile zapytań SQL wykonuje Twój główny batch na jeden przetworzony rekord?
- Czy Twoje kluczowe kolekcje to
List<T>czyHashSet/Dictionary? - Kiedy ostatnio uruchomiłeś profiler na produkcyjnym obciążeniu?
- Czy czas przetwarzania rośnie liniowo z danymi, czy szybciej?
Jeśli nie znasz odpowiedzi — albo odpowiedzi są nieprzyjemne — czas to zmienić.
Mamy praktyczne doświadczenie w modernizacji systemów legacy .NET w regulowanych branżach. Zobacz naszą usługę modernizacji lub skontaktuj się z nami bezpośrednio — pokażemy Ci, gdzie Twój system jest narażony.