Optimization - when and how?

11 august 2022
Jakub Rojek Jakub Rojek
Photo by Aron Visuals on Unsplash (https://unsplash.com/photos/BXOXnQ26B7o)
Categories: For clients, Deployments, Programming

Wszyscy chcemy, aby nasze oprogramowanie działało szybko i sprawnie. Niektórzy powiedzą, że to żadna nowość oraz żadne wyzwanie. Przecież komputery są coraz bardziej wydajne, połączenia internetowe sprawne, a zatem automatycznie wszystko będzie działało prędzej. I tutaj się zatrzymajmy.

To prawda, że mamy coraz lepszą technologię i wyższą szybkość, która z niej wynika. Ale nie zapominajmy o tym, że:

  • rosną również wymagania użytkowników - kiedyś byli w stanie poczekać 8 sekund na stronie, a dzisiaj 3 to już "długo",
  • rośnie "lenistwo" programistów, także ze względu na natłok zadań,
  • bazy przechowują coraz więcej informacji,
  • bardzo dużo danych zawsze będzie kłopotem, niezależnie od środowiska.

Dlatego owszem - niekiedy polepszenie lub poszerzenie infrastruktury (korzystając ze zdolności skalowania oprogramowania) będzie wystarczające, ale może także być bardzo krótkowzroczne, szczególnie, jeśli ilość danych będzie ciągle rosnąć. W końcu nie będzie innego wyjścia, jak zabrać się za bohaterkę dzisiejszego tekstu - optymalizację.

Opowiemy sobie dzisiaj ogólnie o tym zagadnieniu, które z jednej strony może być fascynujące i stanowi wręcz pasjonującą zagadkę detektywistyczną dla niektórych programistów. Jednak należy pamiętać o drugiej stronie medalu - jest ono zwykle piekielnie trudne i nie zawsze kończy się sukcesem. Dlatego jednym z celów dzisiejszego artykułu jest nakreślenie tego tematu osobom nietechnicznym, aby mieli świadomość, iż działania optymalizacyjne bywają skomplikowane i wiążą się z dużymi nakładami czasowymi, a efekty nie są czasami zbyt widowiskowe.

Czym tak naprawdę jest optymalizacja?

Większości temat "optymalizacja" kojarzy się z przyspieszaniem działania oprogramowania i jest to słuszny tok myślenia. Formalnie jest to takie działanie, które pozwala osiągnąć jak największe efekty przy jak najmniejszej liczbie zasobów, a w dodatku w matematyce jest sposobem znalezienia rozwiązania przy zachowaniu zadanych ograniczeń (definicje ze Słownika Języka Polskiego PWN). My pozostaniemy przy pierwszej definicji.

Zazwyczaj optymalizacja jest potrzebna, gdy aplikacja działa wolno, zauważalnie przeszkadzając użytkownikom w wygodnym korzystaniu z udostępnionych funkcji. W skrajnych przypadkach odbiorcy oprogramowania nie mają wręcz możliwości używać wszystkich funkcji systemu z uwagi na problemy powiązane z powolnością (np. czasy przekroczenia żądania). Najczęściej dzieje się to w określonych momentach i w konkretnych warunkach, które niekiedy - niestety - są trudne do zidentyfikowania i odtworzenia. W takiej sytuacji działania optymalizacyjne mają na celu, oczywiście, przyspieszczenie działania aplikacji, gdyż niska szybkość staje się już nie tylko problemem technicznym, ale też biznesowym - praktycznie żaden użytkownik nie będzie chciał płacić za coś, z czego korzystaniem wiążą się duże pokłady cierpliwości.

Ale nie zawsze kryterium jest czas i tylko on. Bywają sytuacje, w których problemem jest nadmierne wykorzystanie pamięci, skutkujące zawieszaniem się programu. Przykładowo, generowanie raportu w postaci pliku XLSX z bardzo dużą ilością danych może wiązać się z wymaganiem zarejestrowania bardzo dużej ilości pamięci RAM, którego serwer może nie spełnić. Oczywiście, rozwiązaniem może być zwiększenie pamięci, ale tak, jak wcześniej pisałem - jest to działanie krótkowzroczne, gdyż góry danych ciągle rosną i prędzej czy później dołożona pamięć również przestanie być wystarczająca.

Zazwyczaj czas oraz pamięć są kryteriami przeciwwstawnymi - wiele technik optymalizacji czasu działania wykorzystuje większą ilość pamięci niż normalnie i odwrotnie. Z tych dwóch obszarów, to zazwyczaj ten drugi zasób jest dzisiaj bardziej dostępny i dlatego często kosztem miejsca na dysku lub RAM-u przyspiesza się aplikację, aby użytkownik krócej czekał na wyświetlenie wyników. Jednak należy pamiętać, że bardzo istotne znalezienie jest punktu kompromisowego.

Dlaczego nie optymalizować od samego początku?

To dobre pytanie, mimo że zarazem dziwne dla niektórych. Przecież optymalizacja od samego początku powoduje, że, co prawda, może w pierwszych etapach postęp będzie trochę mniejszy, ale za to później problem nie wystąpi, a w dodatku oszczędzimy wszystkich nerwów, prawda? Otóż, nie.

Wraz z rozwojem narzędzi do programowania oraz zespołów programistycznym, w cenie jest obecnie dobra architektura oraz czytelny kod, w którym odnajdą się wszyscy developerzy. A warto pamiętać, że zespół techniczny nie składa się tylko z weteranów, którzy w dodatku są z projektem od początku - bardzo często kolejni programiści dochodzą do projektu lub wracają do niego po dłuższym czasie i ich poziom zaawansowania jest różny. Zatem bardzo ważne jest utrzymywanie projektu w czystości i pisanie go w zrozumiały sposób. Z drugiej strony, nie zawsze idzie to w parze z szybkością działania - wiele sztuczek optymalizacyjnych sprawia, że kod staje się bardziej zawiły i zaistnieją w nim fragmenty, które bez dodatkowego wyjaśnienia będą niezrozumiałe dla nowych osób.

Kolejna sprawa to taka, że nie wszystko jesteśmy w stanie przewidzieć na początku - nawet przy wieloletnim doświadczeniu programistów cały czas może ich coś zaskoczyć. Aplikacje są bardzo różne, działają w różnych warunkach, a ludzie mają różne potrzeby, które nie zawsze umieją wyrazić zawczasu. Z tego powodu nie należy się nastawiać na to, że wszystkie potencjalne kłopoty zostaną zidentyfikowane na starcie i zostanie opracowane dla nich działanie prewencyjne.

Dodatkowo, prawda jest taka, że, o ile nie tworzymy systemu o krytycznym znaczeniu strategicznym bądź dynamicznej gry, nie ma sensu optymalizować każdego fragmentu. Przykładowo - załóżmy, że utrzymujemy aplikację webową, w której strona ładuje się 8 sekund i składa się z wielu żądań HTTP. Analizując te żądania, w jednym z nich widzimy niepotrzebne operacje, które mogłyby zostać skrócone. Oczywiście, jeśli to kwestia minuty pracy (wraz z weryfikacją), to zapewne można to zrobić bez zbędnej zwłoki. Jednak jeśli widzimy, że na zmianę oraz weryfikowanie, czy coś funkcjonalnie nie zostało zepsute zostaną poświęcone godziny, to należy spojrzeć na to, ile czasu wykonuje się dane żądanie. Jeśli stanowi ono sporą część ładowania strony i zmiana wydatnie wpłynie na ograniczenie tych 8 sekund - jak najbardziej jest sens. Ale jeśli to kwestia oszczędności 10 milisekund - nie ma potrzeby zmiany tego, przynajmniej w pierwszej kolejności. Oczywiście, o ile ta mała zmiana w tym miejscu nie wpłynie pozytywnie na inne, większe obszary w kolejnych miejscach, torując drogę do dalszych optymalizacji.

To wszystko sprawia, że prawdziwe jest stwierdzenie, którego źródło przypisuje się legendarnemu profesorowi, Donaldowi Knuthowi:

Premature optimization is the root of all evil
(Przedwczesna optymalizacja jest źródłem wszelkiego zła)

Brzmi to zabawnie i niejednokrotnie przywołanie tej frazy wywołało uśmiech na twarzach klientów, ale faktem jest, że to bardzo praktyczna zasada. Nie ma potrzeby zamartwiać się optymalizacją na bieżąco, szczególnie kosztem czytelności kodu oraz rozwoju architektury. Po pierwsze, "brudzenie" projektu od początku nie przysłuży się dbaniu o poprawność wyników i postępom, które przecież każdy chce widzieć, a szczególnie klient. Po drugie, problemy wydajnościowe zazwyczaj nie objawiają się w pierwszych fazach - musi upłynąć trochę czasu, a system musi zostać zapełniony danymi, aby możliwe było zaobserwowanie problemów wydajnościowych i wykrycie ich źródła. W końcu może się okazać, że te kłopoty nigdy nie wystąpią i wówczas przedwczesna optymalizacja okaże się po prostu stratą czasu.

Oczywiście, nie oznacza to, że od początku nie należy stosować różnych bezbolesnych konstrukcji, które wynikają z doświadczenia. Jeśli na bazie innych projektów odkryliśmy, że np. odpowiednie zarządzanie tłumaczeniami nie jest skomplikowane, a przyczynia się do wzrostu wydajności, to warto to od razu stosować, zwłaszcza że zmiany strukturalne są najgorszymi i najbardziej pracochłonnymi w późniejszych etapach projektów informatycznych. Jak zwykle, jest to kwestia wiedzy i przebytych projektów, które kształtują nasze myślenie, głównie - niestety - na podstawie porażek. Ale tak, jak wcześniej wspomniałem - nie liczmy, że przewidzimy wszystko.

Jako przykład tego, w jaki sposób drobne optymalizacje mogą być wątpliwe z punktu widzenia czytelności kodu, można podać znany fragment z gry Quake III Arena, dotyczący liczenia odwrotności pierwiastka. Oczywiście, w dynamicznej grze sieciowej opartej na szybkości i strzelaniu takie mikrooptymalizacje mają ogromne znaczenie (zwłaszcza, że dotyczą często zachodzących sytuacji), ale dotyczy to małej części projektów.

W jaki sposób szuka się miejsca do optymalizowania?

Najczęściej sprawa zaczyna się od stwierdzenia, że system działa powoli i trzeba coś z tym zrobić. Natomiast to nie wystarczy - trzeba wiedzieć co dokładnie spowalnia. Oczywiście, może zaistnieć sytuacja, w której problemem jest kluczowa funkcjonalność wykorzystywana na każdej stronie, więc tak naprawdę problem jest "wszędzie". Natomiast również wtedy, jak i w większości wypadków należy wyizolować miejsca, w których spadek wydajności jest zauważalny. Jak to zrobić?

Przede wszystkim, w przypadku aplikacji webowych przeglądarki udostępniają takie wspaniałe narzędzie jak narzędzia developerskie (ang. developer tools). To nakładka, za pomocą której można robić wiele rzeczy - podejrzeć dane przechowywane przez stronę, zobaczyć konsolę JavaScriptu, tymczasowo zmienić fragmenty kodu HTML, a także monitorować żądania HTTP zachodzące w tle. I to ostatnio jest niezwykle istotne podczas badania tematu optymalizacji, gdyż przy każdym żądaniu wyświetlany jest czas oraz moment, w którym zostało ono wykonane. To pozwala zobaczyć, czy na stronie wczytującej się 10 sekund, tylko jedno żądanie zajmuje tyle czasu, czy składa się na to kilka żądań. W ten sposób można znaleźć punkt startowy do dalszych, już bardziej konkretnych działań.

Drugą sprawą są dokładniejsze pomiary. Najczęściej jest bowiem tak, że nawet jeśli jedno żądanie trwa ok. 8 sekund, to większość tego czasu przypada na konkretny zbiór instrukcji. Nie da się ukryć, że najczęściej okazuje się, że winowajcą jest zapytanie do bazy danych. Można to potwierdzić poprzez narzędzia do profilowania, które pozwalają mierzyć poszczególne międzyczasy w kodzie. Cóż, szumnie powiedziane "narzędzia" - często ten efekt można osiągnąć poprzez dodanie odpowiedniego logowania, którego wyniki można później odczytać w plikach logu. Taką możliwość udostępnia wiele frameworków, a zawsze też istnieje możliwość napisania swojego kodu, który ułatwi ten proces.

Wreszcie, jeśli podczas analizy okaże się, że istnieje wiele obszarów wpływających na szybkość ładowania się strony, to należy uważnie przyjrzeć się temu, czy nie mają one jednego, wspólnego źródła. Łatwo można wyobrazić sobie sytuację, w której kolejne kroki pewnego procesu w aplikacji webowej zachodzą wolno, ale wynika to z tego, że na każdym etapie pobierany jest ten sam przetwarzany obiekt w identyczny sposób i to jedno żądanie jest wolne. W takim układzie może się okazać, że tak naprawdę do poprawy jest tylko jeden aspekt systemu i powinien on "odblokować" resztę. To, czy to łatwe zadanie, to już zupełnie inna sprawa.

Po wykryciu takich miejsc warto utworzyć sobie listę priorytetów i odpowiednio zaplanować pracę. Często bywa bowiem tak, że większe działania optymalizacyjne są tak związane z wewnętrznymi mechanizmami danej aplikacji, że utrudniają (a w skrajnych przypadkach uniemożliwiają) równoczesny rozwój funkcjonalności czy poprawki jakościowe. Dodatkowo, optymalizowanie dwóch miejsc naraz może być możliwe, ale istnieją też przypadki, w których działania w jednym fragmencie znacząco wpływają na ten drugi. Próba implementacji zmian w obu miejscach jednocześnie to gotowy przepis na katastrofę. Niestety - tutaj wszystko zależy od konkretnego przypadku i nie można podać jednej reguły.

Główne techniki i obszary optymalizacji

Tak naprawdę jest to temat na cały osobny artykuł (a nawet książkę), gdyż takich obszarów jest dużo i zarówno one, jak i wykorzystywane techniki są stricte związane z danym przypadkiem, technologią i samą aplikacją. Nie ma jednego uniwersalnego sposobu na zwiększenie wydajności, podobnie jak nie ma jednej recepty na udany projekt czy film. Oczywiście, są pewne ogólne reguły, których należy się trzymać lub opcje, których w(y)łączenie pomaga w osiągnięciu lepszych rezultatów, jednak nie znajdziecie nigdzie tutorialu, który wprost powie Wam, jak zoptymalizować Waszą aplikację i rozwiąże dokładnie taki problem, jaki z nią macie.

Najczęściej optymalizacja dotyczy bazy danych i zapytań. Samo połączenie się aplikacji z bazą zajmuje pewien czas (dlatego ogranicza się liczbę takich operacji), natomiast prawdziwymi zabójcami są skomplikowane zapytania w znormalizowanych schematach. Od razu może zaznaczę, że bazy normalizuje się ze względu na czytelność i spójność danych oraz jest to ogólnie dobra praktyka. Niestety, skutkiem ubocznym jest to, że prowadzi to do wielu połączeń (ang. joinów) lub podzapytań i w ten sposób można narazić się na trudności w kwestii wydajności. Na szczęście, technik jest mnóstwo - od poprawy samych zapytań, poprzez podzielenie ich na mniejsze (nie zawsze muszą być wykonywane dla każdej krotki), aż do indeksów czy ustawienia odpowiednich parametrów. Niestety, to nie oznacza, że jest łatwiej - przy określonej strukturze bazy pewne zmiany są często niezwykle trudne i czasochłonne do przeprowadzenia. Ciut łatwiej jest przy problemach wynikających z ogromnej ilości danych, gdyż w tym przypadku bardzo mocno może pomóc cache'owanie, co jednak też ma swoje konsekwencje.

Oczywiście, może się zdarzyć, że sam kod napisany jest niewydajnie i po bliższej analizie programista zidentyfikuje wielokrotnie wykonywane pętle, niepotrzebne instrukcje warunkowe, ciągłe pobieranie tych samych danych itd. Są to dla mnie zazwyczaj najprzyjemniejsze optymalizacje, gdyż dość klarowne i niewymagające dużych zmian w bazie danych. Niekiedy wiążą się z brzydkimi zapachami i przyspieszanie jest też okazją do refaktoryzacji, choć - jak już wspomniałem wcześniej - nierzadko to właśnie optymalizacja prowadzi do zabrudzonego kodu w imię szybkości jego działania.

Analizując żądania, można też wykryć miejsca, w których kilka żądań można połączyć w jedno. Przykładowo, jeśli frontend przesyła do backendu 6 żądań, z których każde zwraca inne informacje, ale wszystkie opierają się na wywołaniu jednego zapytania do bazy danych, to warto przemyśleć, czy nie można tych informacji pobrać raz i zwrócić je we wspólnej odpowiedzi, wykonując tym samym to zapytanie tylko jednokrotnie. Oczywiście, nie zawsze jest to możliwe, ale zazwyczaj wówczas okazuje się, że nagle system znacznie przyspieszył.

Może się też zdarzyć, że to nie backend jest problemem, tylko frontend. Kłopot może leżeć nie w danych czy złożonej logiki, ale np. w nieefektywnym wysyłania żądań HTTP czy czekaniu na ładowanie zasobów takich jak grafiki, skrypty javascriptowe czy style CSS. Na szczęście, w tym przypadku technik też jest bardzo dużo, a sama przeglądarka pomaga w tym poprzez swoje mechanizmy cache'owania już raz pobranych zasobów.

Wreszcie, być może z aplikacją jest wszystko w porządku, ale to sprzęt lub konfiguracja, na której działa, jest za słaba lub źle ustawiona. Oczywiście, inwestycję w mocniejsze parametry trudno wówczas nazwać optymalizacją, gdyż jest to po prostu zwiększenie mocy, które - pamiętajmy - nie zawsze jest rozwiązaniem. Ale już konfiguracją warto czasem się zainteresować, o ile jest do niej dostęp. Dotyczy to szczególnie systemów, w których ruch pochodzi z bardzo wielu źródeł. Tutaj trudnością może być obsługa i wiedza o parametrach, gdyż zwykle nie jest to obszar, w którym programiści są ekspertami, więc w takim wypadku przydatna może być konsultacja z administratorem.

Jak uniknąć optymalizacji?

Wspomniałem o tym, że wraz z doświadczeniem można nauczyć się tworzyć systemy w taki sposób, aby już na starcie "zachować" wydajność w pewnym stopniu i robić uniki przed problemami w przyszłości.

Przede wszystkim dotyczy to architektury i przygotowywania ją na ewentualne skalowanie. Natomiast pamiętajmy, że dotyczy to naprawdę dużych systemów, które będą obsługiwały wielu użytkowników naraz. Nie oznacza to, że dla oprogramowania o mniejszym rozmiarze zabrania się skalowania - po prostu jest ono tam mniej opłacalne. Należy przy tym poważnie rozważyć podzielenie systemu na frontend i backend, aby mieć większą elastyczność przy późniejszych wdrożeniach.

Także różne sztuki bazodanowe mogą pomóc w uniknięciu potrzeby optymalizacji. Złączenie pewnych tabel oznacza ograniczenie połączeń w zapytaniach, więc jeżeli nie wpłynie to negatywnie na spójność, to warto poświęcić trochę czasu na redukcję. Tak samo, w przypadku często powtarzających się podzapytań, można rozważyć dodatkowe kolumny przechowujące aktualne sumy lub średnie, ale w tym przypadku może się to wiązać już z potencjalnie niekorespondującymi ze sobą informacjami.

Wreszcie, warto podczas tworzenia projektu wykryć rzeczy, które mogą ulec zapisywaniu w pamięci podręcznej i odtwarzaniu z niej wyników. W ten sposób, paradoksalnie, możemy dotrzeć do momentu, w którym okaże się, że niektórych rzeczy w ogóle nie warto przechowywać w bazie, a tym samym sięgać do niej w poszukiwaniu informacji. Przy czym znowu - przy nieostrożnym działaniu może to grozić niespójnóścią.

Podsumowanie

Na pewno nie wyczerpaliśmy dzisiaj tematu optymalizacji - jest on zbyt szeroki, ale też zbyt indywidualny. Rozwinięcia wymaga choćby temat cache'owania, który często jest odpowiedzią na wiele problemów, jednak ma swoje skutki uboczne, które nie zawsze są akceptowalne. Tym niemniej, mam nadzieję, że nakreśliłem dzisiaj sam temat i wyjaśniłem, dlaczego nie jest on taki prosty, oczywisty i czasami po prostu potrzebny. Za to trzeba uczciwie powiedzieć, że jest on bardzo satysfakcjonujący i dobrze przeprowadzona optymalizacja daje niesamowite uczucie zwycięstwa każdemu programiści, a klientowi po prostu ulgę.

Pozdrawiam i dziękuję - Jakub Rojek.

We write not only blog articles, but also applications and documentation for our clients. See who we have worked with so far.

About author

Jakub Rojek

Lead programmer and co-owner of Wilda Software, with many years of experience in software creation and development, but also in writing texts for various blogs. A trained analyst and IT systems architect. At the same time he is a graduate of Poznan University of Technology and teaches at this university. In his free time, he enjoys playing video games (mainly card games), reading books, watching american football and e-sport, discovering heavier music, and pointing out other people's language mistakes.

Jakub Rojek