Czy Twój user nie czeka za długo? Jak przyspieszyć działanie strony
W poniższej notce omówię kilka prostych do zaimplementowania tricków, które pozwolą niewielkim kosztem przyspieszyć działanie strony internetowej, a przez to zwiększyć satysfakcję odwiedzających ją użytkowników. Zaczniemy od określenia czy pewnych części serwisu nie da się (w sposób zautomatyzowany) przenieść na statyczne odpowiedniki.
Następnie omówię pokrótce mechanizm cache’owania i jak można wykorzystać go w celu zapewnienia sobie szybszego czasu odpowiedzi (a także odciążenia baz danych). Na koniec pokażę miejsca w serwisie internetowym, w których możemy troszkę internautę oszukać informując go, że jakaś czynność została zakończona zanim tak naprawdę zakończyliśmy ją robić.
Patryk Węgrzynek. Backend Developer oraz Site Reliability Engineer w firmie QuarticOn, która specjalizuje się w automatycznych rekomendacjach produktowych. Fascynuje się optymalizacją prędkości serwisów internetowych, a także zwiększaniem ich niezawodności. Jeśli aktualnie nie programuje, ani nie spaceruje po biurze, to zapewne zaczytuje się w kolejny artykuł o tematyce devops’owej.
Spis treści
Dynamicznie, czy… statycznie?
Jak często zmienia się zawartość na Twojej stronie internetowej? Czy na 100% serwer każdorazowo musi łączyć się z bazą danych, po to aby wyświetlić użytkownikowi stronę główną serwisu, albo jeden z dodanych artykułów? Czy menu naprawdę zmienia się co kilka minut? Dla przykładu samo wczytanie strony głównej bloga wordpress w wersji 4.9.8 (po czystej instalacji, bez dodatkowych pluginów) wymaga (każdorazowo!) wykonania 17 SQLek, wejście na stronę posta już 22 (per każdy użytkownik).
Każde zapytanie do bazy danych dokłada nam latency (serwer www musi poczekać aż serwer bazodanowy zwróci mu wynik zapytania), a ta powoduje, że serwis ładuje się wolniej. Byłbym oczywiście bardzo niesprawiedliwy obwiniając o „całe zło” serwer baz danych. Współczesny system zarządzania treścią składa się z wielu współpracujących ze sobą komponentów (translacje, wyszukiwarka, bloczki odpowiadające fragmentom witryny). Żeby ukazać problem utworzyłem testowego bloga na WordPressie (czysta instalacja wersji 4.9.8, domyślny szablon).
Dynamiczna wersja strony ładowała się aż 1100 ms(!). Odpowiadają za to zarówno zapytania do bazy danych, jak i cała generacja strony (odpytanie translacji, sprawdzenie jakie sekcje zostały umieszczone na stronie głównej, rendering każdej z nich osobno).
Podczas gdy jej statyczny odpowiednik zaledwie 380 ms:
Mało tego, poleganie na tym, że serwer bazodanowy musi zwrócić nam odpowiedź powoduje zwiększenie awaryjności witryny (musi działać nie tylko serwer www, ale również serwer bazodanowy). W większości przypadków baza danych nie jest nam niezbędna do prawidłowego działania strony. Dla przykładu stronę główną wystarczy odświeżyć po dodaniu nowej notki, a post tylko w przypadku jego edycji (chyba, że chcemy samodzielnie obsługiwać komentarze).
Oczywiście, statycznie generowana wersja serwisu powinna powstawać w sposób zautomatyzowany z dynamicznej wersji (redaktorzy serwisu powinni mieć dostęp np. do edytora typu WYSIWYG, a my powinniśmy mieć co najmniej jakieś strukturalne źródło danych, dzięki czemu będziemy w stanie szybko wygenerować całość serwisu w przypadku np. zmiany layoutu). Dobrym przykładem jest tutaj WP Static Site Generator, który w kilka minut jest w stanie scrawlować wordpressa, a następnie utworzyć paczkę i zdeployować ją na m.in. githuba, netlify, Amazon S3).
Dzięki temu redakcja może korzystać z dobrodziejstw WordPressa, my z bezpieczeństwa (bo przecież jeszcze nikt do pliku html się nie włamał 😉 ), a nasi czytelnicy z szybkości działania serwisu.
Mechanizm konta użytkownika vs statyczna strona
O ile stronę główną i posty jesteśmy w stanie zamienić na treść statyczną, to co w przypadku edycji profilu użytkownika? Albo mechanizmu rejestracji lub logowania? Są to specyficzne strony, których z oczywistych względów nie powinniśmy zapisywać jako statystyczne pliki HTML. W takim razie jak z nich w ogóle skorzystać, skoro nasz serwis został zapisany jako statyczny serwis?
Istnieją trzy rozwiązania:
1. Rozdzielenie statycznej zawartości od dynamicznej
2. Składanie strony internetowej ze statycznego i dynamicznego bloczka
3. Przełożenie mechanizmu wymagającego dynamizmu na klienta (JavaScript)
Rozdzielenie statycznej zawartości od dynamicznej
W większości przypadków możemy w łatwy sposób wydzielić strony, które powinny być dostępne po zalogowaniu od tych, które tego działania nie wymagają. W takiej sytuacji wystarczy poinformować serwer www (używając np. .htaccess
, albo nginx.conf
) o tym, na które ścieżki powinien kierować plik bootstrap’ujący aplikację.
Np: Kieruj /user/* do pliku app/index.php, natomiast /* do odpowiadającemu mu pliku .html.
Składanie strony internetowej ze statycznego i dynamicznego bloczka
Czasem jednak „powyższe dwa światy” się w jakiś sposób ze sobą mieszają: Wyobraźmy sobie na chwilę, że na naszej stronie internetowej chcemy w zależności od tego czy użytkownik jest zalogowany, wyświetlić w menu albo jego avatar, albo przycisk „zaloguj się”.
W takim przypadku możemy naszą statycznie wygenerowaną stronę skonfigurować w taki sposób, aby serwer www ładował plik index.php
, który załaduje odpowiedni plik statyczny, a następnie sparsuje pojawiające się w nim makro (lub sprawdzi czy użytkownik posiada odpowiednie uprawnienia, a następnie zaincluduje statyczny, niedostępny w inny sposób plik zawierający płatną zawartość).
Przełożenie mechanizmu wymagającego dynamizmu na klienta (JavaScript)
Osobiście jednak, w sytuacji gdy „świat plików statycznych przenika się z działaniami zależnymi od sesji” najczęściej korzystam z przerzucenia mechanizmu wymagającego dynamizmu na klienta. W takiej sytuacji konfiguruję serwer zgodnie z „Rozdzielenie statycznej zawartości od dynamicznej”, a następnie wraz z zapytaniem zwracam statyczny kod strony, która zawiera plik JavaScriptowy pytający serwer o to jaki awatar wyświetlić (serwer www zwraca wtedy albo obrazek profilowy użytkownika, albo błąd informujący go o tym, że nie jest zalogowany).
Dzięki temu oszczędzam serwer, który zamiast parsować dla każdego użytkownika makro, po prostu zwraca krótką JSONową odpowiedź.
O cache’owaniu słów kilka
Ilekroć nie możemy skorzystać z wcześniej wygenerowanych statycznych elementów witryny, należy rozważyć użycie cache. Jest to rodzaj bardzo szybkiej pamięci (zarówno na serwerze, jak i po stronie klienta), który umożliwia nam przyspieszenie ładowania się stron internetowych.
Cachowanie po stronie serwera
Jaka zawartość Twojej strony internetowej musi zostać zoptymalizowana? Co stanowi wąskie gardło? Jeżeli nie jesteś w stanie od razu wskazać takiego komponentu polecam użycie profilera, czyli programu komputerowego, który potrafi zobrazować m.in. szybkość działania oraz zużywane zasoby w rozbiciu na pojedyncze metody kodu. Dla PHP osobiście polecam Tideways (fork xhprof’a dopasowany do php7), płatny blackfire, albo xdebug.
Co powinniśmy cache’ować?
- często używane elementy serwisu (dla strony z wydarzeniami będą to na przykład wydarzenia, a dla bloga tytuł wpisu, obrazek oraz zajawka),
- translacje (często nawet nie zdajemy sobie sprawy jak dużo czasu zajmuje znalezienie wszystkich translacji wymaganych do prawidłowego wyświetlenia strony!),
- informacje, których uzyskanie wymaga długiego czasu działania kodu.
Po stronie serwera do dyspozycji mamy kilka sposób na cache’owanie informacji:
- APC / APCu — najszybszy znany mi sposób cache’owania danych. Służy raczej do cachowania niewielkich elementów, które będą najczęściej odwiedzane w serwisie. Dzięki temu, że APC znajduje się na tym samym serwerze co serwis internetowy minimalizujemy latency. Warto zauważyć, że sam fakt włączenia APC w PHP 5.*, a OPcache w PHP 7.* wpływa pozytywnie na szybkość działania serwisu.
- Memcached / Redis — wolniejszy (głównie ze względu na konieczność przesyłu danych z zewnętrznego serwera*) sposób cache’owania danych. W Memcached bardzo ważny jest również rozmiar cache’owanych danych.Domyślnie jeden chunk może przyjąć do 1mb danych. Prośba o scache’owanie większej danej zostanie odrzucona. Maksymalny możliwy do ustawienia rozmiar chunka to 128mb (warto jednak zauważyć, że alokacja jednej 128 mb danej spowoduje zwiększenie pozostałych chunków również na 128mb). Innymi słowy im większe dane cache’ujemy, tym więcej pamięci tracimy.
* zazwyczaj memcached instalujemy na wydzielonym serwerze, który zawiera dużą ilość pamięci RAM
- JSON / Array — tak, dobrze widzisz! Duże rozmiarowo dane (np. translacje/spaginowane posty) warto zapisać lokalnie na serwerze jako „półfabrykat”. Dzięki temu każde kolejne zapytanie zamiast wydobywać te dane z zewnętrznego serwera spowoduje otwarcie wcześniej utworzonego pliku. W przypadku korzystania z tej metody warto napisać kod ładujący taki plik w sposób, który zakłada jakiś okres życia scache’owanego pliku (np. piętnaście minut), lub usunie/zaktualizuje plik w przypadku wystąpienia jakiegoś zdarzenia (np. dodanie postu/edycja translacji).
Cache z wykorzystaniem dostawcy CDN
Na rynku istnieje wiele podmiotów zajmujących się dostarczaniem rozwiązań cache’ujących serwis internetowy. Dla przykładu wymienię tutaj darmowy CloudFlare oraz płatny (w przypadku wykorzystania darmowych $50) Fastly. Tutaj większość rzeczy dzieje się w sposób zautomatyzowany (dostawca cache sam stara się „zapamiętać” jak największą część witryny oraz zwracać ją ze swoich serwerów odciążając tym samym twoje).
Ale po co korzystać z zewnętrznego dostawcy?
Na pierwszy rzut oka brzmi to świetnie, ale… jest pewien haczyk. Musisz samemu zadbać o to, żeby poufne informacje (np. dostępne po zalogowaniu, dane osobowe itd) nie zostały scache’owane oraz pokazane innemu użytkownikowi!
Służą do tego nagłówki Cache Control. Najważniejsze z nich to:
- Cache-Control: no-cache
Informujący dostawcę o tym, aby nie używał zapisanej odpowiedzi bez walidacji po stronie serwera
- Cache-Control: no-store
Zakazujący zapisanie jakiejkolwiek części zarówno pytania jak i odpowiedzi. Oraz nakazujący jej jak najszybsze usunięcie.
- Cache-Control: must-revalidate
Nakazujący dostawcy wstrzymanie się z odpowiedzią do momentu, aż serwer nie potwierdzi wykonania zapytania. W przeciwnym wypadku provider musi odpowiedzieć takim samym statusem jak serwer (np. 504).
- Cache-Control: max-age
Informuje przez jak długi okres czasu (w sekundach) dostawca może przechować zawartość. Warto zauważyć, że wartość parametru nie może zostać wzięta w cudzysłowy. max-age=10, nie max-age=”10″.
- Cache-Control: private
Informuje, że odpowiedź jest przeznaczona tylko dla pojedynczego użytkownika i nie może zostać pokazana innemu.
Natomiast dostawca może wykorzystać ją przy identycznym zapytaniu wysłanym przez tego samego użytkownika.
- Cache-Control: public
Pozwala na cache’owanie serwisu dla wszystkich użytkowników. Raz wyświetlona strona może zostać zapamiętana i pokazana dowolnemu użytkownikowi. Wiele dostawców CDN traktuje to jako wartość domyślną.
Powyżej wymienione parametry można ze sobą oczywiście dowolnie łączyć, na przykład:
cache-control: no-cache, no-store, max-age=0, must-revalidate
cache-control: public, max-age=31536000
Niektórzy dostawcy CDN są w stanie automatycznie zinwalidować (oznaczać jako nieważne) dane, które zostały zmodyfikowane od czasu ich scache’owania. Wykorzystują do tego między innymi ETag’i lub nagłówki Last-Modified.
Cachowanie po stronie klienta
Przeglądarka kliencka korzysta z tych samych nagłówków, co zewnętrzny dostawca CDN. Oznacza to, że poprawnie skonfigurowane nagłówki są w stanie zmniejszyć obciążenie serwera internetowego, oraz jeszcze bardziej zminimalizować. Większość przeglądarek, tak samo jak dostawcy CDN, okresowo sprawdza czy zawartość strony nie została zmieniona od ostatniego jej obejrzenia (oraz wyrzuca z pamięci podręcznej dawno oglądane strony).
W przypadku implementacji JavaScript’owego klienta pośredniczącego w renderowaniu widoków strony www warto również zainteresować się Progressive Web Apps, dzięki czemu będziemy mogli część informacji zapisać bezpośrednio na komputerze internauty, a nawet zarządzać tym czy zapytanie powinno w ogóle zostać wysłane na serwer oraz w jaki sposób nasza aplikacja powinna się zachować w trakcie oczekiwania na odpowiedź.
Przykładowo:
1. W przypadku wpisania adresu URL serwisu wyświetl ostatnio pobraną (i zapisaną zawartość).
2. Następnie odpytaj serwer o to czy zawartość nie uległa zmianie.
3. Jeżeli nie możesz nawiązać połączenia z serwerem poinformuj użytkownika o tym, że przebywa aktualnie w trybie offline.
4. Pomimo tego, że użytkownik jest aktualnie rozłączony z internetem, pozwól mu przeczytać ostatnio zapisane artykuły, a nawet…
5. Napisać mu komentarz (który zostanie dodany do lokalnej bazy danych, a w przypadku przejścia w tryb „online” automatycznie opublikowany.
Takie podejście do projektowania serwisów internetowych nazywa się offline first, a wprowadzenie go w życie jest naprawdę łatwe. Wystarczy tylko przygotować odpowiedni Service Worker, który niczym lokalny serwer CDN będzie decydować o tym czy zapisać wybrany url, oraz kiedy go zaktualizować.
Kolejkowanie, czyli praca, którą możemy zrobić potem
Ostatnim zagadnieniem jakie chcę poruszyć jest kolejkowanie ruchu. Takie podejście zastosowałem m.in. planując serwis, który musiał obsłużyć równoczesne zapisy dużej ilości osób na workshopy zgodnie z zasadą „kto pierwszy ten lepszy”.
Spowodowało to ogromny ruch w pierwszych minutach „uruchomienia zapisów”, co ze względu na niewielkie zasoby serwerowe w przypadku braku kolejkowania mogłoby doprowadzić do niedostępności.
Proces działał w następujący sposób:
Po tzw. lazy validation, czyli szybkim sprawdzeniu tego, czy użytkownik wypełnił formularz prawidłowo (np. wpisał ciąg znaków, który wyglądał na imię, numer telefonu miał dziewięć znaków, e-mail zawierał „@” itd) wyświetlona została użytkownikowi informacja o tym, że zarejestrował się poprawnie.
W rzeczywistości jednak, endpoint zaraz po sprawdzeniu poprawności wypełnienia formularza propagował w cache informację, że wybrany numer telefonu oraz e-mail został zajęty, następnie wysyłał użytkownikowi informację o prawidłowej rejestracji, żeby finalnie… wysłać element do kolejki, gdzie drugi serwer (niedostępny dla ruchu) w tle rejestrował konta. Użytkownik w tym czasie czekał na „maila potwierdzającego rejestrację”.
Dlaczego zastosowałem powyższą sztuczkę?
Ponieważ w trakcie betatestów systemu znaczna część użytkowników w przypadku dłuższej odpowiedzi niż 300-400 ms ponawiała przesłanie formularza(!). W sumie całkiem naturalne rozwiązanie, gdy właśnie trwa „wyścig z czasem”, prawda? ;).
Powodowało to wystąpienie efektu „kuli śnieżnej”, który w konsekwencji blokował kolejne dostępne child process’y, co powodowało niedostępność usługi dla coraz większej ilości użytkowników.
Stosując tę technikę pamiętaj jednak o tym, że… Twój kod musi mieć bardzo dobrą obsługę błędów (!) O ile w standardowym procesie rejestracji możesz zwrócić użytkownikowi komunikat w stylu „Something went wrong”, albo „Spróbuj ponownie później”, o tyle w przypadku „zamiecenia operacji pod dywan” musisz mieć pewność, że serwer prawidłowo wykona zadanie, a w przypadku losowego błędu (np. chwilowego przeciążenia bazy) samodzielnie odczeka i ponowi operację (w „rozsądnym” dla użytkownika terminie).
Magia pierwszego wyniku wyszukiwania
Ostatnim bardzo ciekawym rozwiązaniem optymalizacyjnym jest…
<link href="https://www.onet.pl/" rel="prerender">
Jest to potężne narzędzie wykorzystywane między innymi w wyszukiwarce Google do załadowania pierwszej strony wyników wyszukiwania w tle. Wyobraź sobie jego działanie jako otwarcie nowej niewidocznej dla użytkownika karty przeglądarki w tle i całkowite jej wyrenderowanie zanim jeszcze użytkownik kliknie w link. Dzięki temu, w momencie kliknięcia, przeglądarka po prostu podmienia widoczną kartę na tę niewidoczną (jest to spore uproszczenie, ale opisanie w tym miejscu jak każdy silnik przeglądarek traktuje ten tag zajęłoby stanowczo zbyt dużo miejsca).
Jest to jednak miecz obosieczny — jeżeli masz niemal pewność, że użytkownik kliknie w wybrany link (np. Google wie, że statystyczny internauta szukający “onet” kliknie w onet.pl) znacząco zwiększysz odczuwalną szybkość działania strony. Jeżeli natomiast wybierze inny odnośnik… zmarnujesz mu transfer i w skrajnym przypadku (prerender nie skończyło renderingu zanim użytkownik kliknął w link) spowolnisz działanie serwisu.
Dlatego też Google używa prerender tylko w przypadku znalezienia (jak sam informuje w swoim źródle) “Wynik z internetu z linkami do witryny”.
Podsumowując
W powyższej notce omówiłem pokrótce najważniejsze oraz najciekawsze metody optymalizacji serwisu internetowego. Oczywiście, jest to temat rzeka (na podstawie którego można napisać niejedną książkę), dlatego zachęcam czytelnika do dalszego eksperymentowania i poszukiwania informacji. Należy pamiętać, że nie istnieje coś takiego jak “idealna optymalizacja” — każdy serwis się rozwija, a wraz z rozwojem… wymaga nowego myślenia. Stare pomysły przestają wystarczać, lub nie spełniają najnowszych założeń biznesowych. Dlatego zachęcam do myślenia o optymalizacji jak o niekończącym się procesie, który ma po prostu dawać wielką frajdę.
Zdjęcie główne artykułu pochodzi z pexels.com.