API, które sprawnie działa w środowisku stagingowym, może lec w gruzy w momencie, gdy uderzy realny ruch. Awaria rzadko jest spektakularna. To raczej śmierć przez tysiąc cięć. Zablokowany wątek tutaj. Zbędne zapytanie tam. Przy małym obciążeniu te drobne nieefektywności pozostają niewidoczne. Pod presją środowiska produkcyjnego kumulują się, prowadząc do zagłodzenia puli wątków, zapchania bazy danych i opóźnień, które niszczą zaufanie użytkowników. Optymalizacja wydajności nie polega na znalezieniu jednej magicznej poprawki. Chodzi o zbudowanie systemu, w którym każda warstwa szanuje ograniczoność wątków, pamięci i połączeń z bazą danych. Gdy traktujesz te zasoby jako skończone, przestajesz reagować na awarie, a zaczynasz im zapobiegać.
Wyeliminuj sync-over-async, zanim on zniszczy Twoją aplikację
Najbardziej niszczycielskim wzorcem w aplikacjach ASP.NET Core o dużym natężeniu ruchu jest sync-over-async. Widzisz to, gdy ktoś wywołuje .Result lub .Wait() na metodzie asynchronicznej, ponieważ potrzebuje wartości natychmiast i nie chce refaktoryzować stosu wywołań. Taka decyzja blokuje wątek wywołujący. Wątek pozostaje bezczynny, czekając na pracę, która już dzieje się gdzie indziej, ale runtime nie może go wykorzystać do obsługi innego żądania.
Gdy wystąpi to w wystarczającej liczbie żądań, dochodzi do zagłodzenia puli wątków. Wykres CPU wygląda zdrowo, ponieważ procesory nie są zajęte, a mimo to opóźnienia (latency) gwałtownie rosną. Żądania ustawiają się w kolejce, czekając na wątki, które nigdy się nie zwolnią. Rozwiązanie jest mechaniczne, ale wymaga dyscypliny: używaj await na każdym poziomie stosu wywołań. Jeśli metoda wywołuje asynchroniczne API, sama również musi być asynchroniczna. Nie ma tu dróg na skróty. Każde usunięte blokowanie synchroniczne odzyskuje zapas zasobów dla realnego ruchu.
Przestań marnować zasoby na rozłączonych klientów
Klienci się rozłączają. Przeglądarki zamykają karty. Aplikacje mobilne tracą sygnał. Jeśli Twój serwer nie wie, że klient odszedł, nadal wykonuje zapytania do bazy danych, parsuje JSON i marnuje wątki na odpowiedź, której nikt nie odbierze. Przekazuj CancellationToken do każdej operacji asynchronicznej, która to wspiera. Dotyczy to zapytań Entity Framework, wywołań HTTP za pomocą HttpClient oraz wszelkich długotrwałych zadań w tle.
Gdy połączenie zostanie przerwane, token wyzwala anulowanie, a praca zostaje natychmiast przerwana. Pozwala to zaoszczędzić cykle procesora bazy danych i szybciej zwraca wątki do puli. To mała zmiana w sygnaturach metod, która przynosi ogromne korzyści pod obciążeniem.
Cache'uj to, co się nie zmienia
Twój katalog produktów prawdopodobnie nie zmienia się przy każdym żądaniu. Flagi konfiguracyjne na pewno nie. Mimo to wiele API wielokrotnie odpytuje bazę danych o te same statyczne dane. Output caching w ASP.NET Core pozwala przechowywać wygenerowane odpowiedzi i serwować je bezpośrednio z pamięci, bez ponownego angażowania kontrolerów czy bazy danych.
Używaj unieważniania opartego na tagach (tag-based invalidation) z rozwagą. Gdy aktualizujesz produkt, unieważnij tylko tag powiązany z tą kategorią lub tym elementem. Nie musisz czyścić całego cache'u. Dzięki temu utrzymasz wysoki wskaźnik trafień w cache (cache hit ratio) i niską liczbę zapytań do bazy danych.
Najpierw napraw dostęp do danych
W większości API wywołania bazy danych pochłaniają większość czasu trwania żądania. Zanim zoptymalizujesz cokolwiek innego, przyjrzyj się właśnie temu miejscu.
Jeśli odpytujesz dane tylko po to, aby je wyświetlić i nie planujesz ich nigdy aktualizować, dodaj AsNoTracking() do swoich zapytań Entity Framework. EF Core pominie śledzenie zmian i tworzenie migawek (snapshots).
