Objaw i przyczyna mogą dotyczyć każdego sklepu z zalogowanymi klientami. Ceny przypisane do konta i katalog dostępny tylko po zalogowaniu są niewidoczne, dopóki pamięć stron nie działa, i pojawiają się dopiero po naprawie. Dlatego sekcje 05 i 06 są częścią poprawki, nie dodatkiem.
| Konfiguracja | Magento Open Source 2.4.8-p3, tryb produkcyjny, wbudowana pamięć stron w Redisie, katalog tylko po zalogowaniu, rabaty przypisane do kont, wszyscy klienci w jednej grupie |
|---|---|
| Przyczyna | odczyt z pamięci liczy klucz klasą IdentifierForSave z kontekstu, który przy odczycie jest jeszcze pusty |
| Wynik | listing dla zalogowanego klienta 1,6–2,3 s → 0,06 s |
| Ograniczenia | wygenerowanie strony, której nie ma jeszcze w pamięci (zimny wpis), dalej trwa ok. 1,8 s przy 12 produktach; naprawa wymagała też poprawek kluczy cen i powiązania strony z pamięci z sesją klienta |
Czasy w tym wpisie to czas odpowiedzi na żądanie samej strony HTML, mierzony narzędziem curl z zachowaniem ciastek klienta, bez zdjęć i skryptów.
Objaw: 2 sekundy przy każdym wejściu, a winne wydają się zdjęcia
Zgłoszenie brzmiało „sklep bardzo wolno ładuje zdjęcia na listingu”. Pomiar pokazał coś innego: średnie zdjęcie miało 9 kB, cały listing pobierał około 50 kB grafik po kilkanaście milisekund każda, a pamięć miniatur była kompletna. Wolny był HTML listingu: 2,12, 2,12 i 2,08 s w trzech kolejnych wejściach na ten sam adres. Zdjęcia ładują się leniwie, czyli dopiero po HTML-u, więc z perspektywy klienta to one „stały”.
Przy działającej pamięci stron drugie wejście na ten sam adres trwa ułamek sekundy. Tutaj każde wejście kosztowało tyle samo, a strona logowania, oglądana bez sesji klienta, odpowiadała w 0,05 s. To mocny sygnał, ale sam w sobie nie dowodzi awarii pamięci stron: strona może też być niecacheowalna albo ciągle unieważniana.
Dlatego najpierw wykluczyliśmy te przyczyny: cacheowalność układu strony (wszystkie warianty kategorii cacheowalne), bloki oznaczone jako niecacheowalne, tryb serwisowy, wyłączoną pamięć stron i unieważnianie pamięci przez synchronizację z systemem zewnętrznym (zatrzymana na dwie minuty, listing dalej 2 s).
Dlaczego klucz odczytu nie trafia w klucz zapisu
Magento zapisuje stronę w pamięci pod kluczem liczonym z adresu, protokołu i odcisku klienta (tzw. vary string, czyli skrót kontekstu: grupa klienta, waluta, sklep i to, co moduły dodadzą do kontekstu HTTP). W klasie Magento\Framework\App\PageCache\Kernel odczyt korzysta z obiektu $identifier, a zapis z $identifierForSave.
| Wersja | Klucz odczytu | Klucz zapisu |
|---|---|---|
| 2.4.7 | Identifier: ciastko X-Magento-Vary, a gdy go brak, kontekst | IdentifierForSave: kontekst |
| 2.4.8 do 2.4.8-p5 | IdentifierForSave: tylko kontekst | IdentifierForSave: kontekst |
| 2.4.9 | IdentifierForSave: najpierw ciastko vary, potem kontekst | IdentifierForSave |
W 2.4.8 moduł PageCache w swoim etc/di.xml dodał preferencję, która kieruje interfejs IdentifierInterface, a więc i obiekt odczytu, na IdentifierForSave. Klucz odczytu i zapisu liczy więc ta sama klasa, ale w innym momencie. Przy zapisie kontekst zawiera już dane klienta, bo trafiają tam przy obsłudze akcji. Przy odczycie, który dzieje się wcześniej, kontekst jest jeszcze pusty, a ta klasa w 2.4.8 nie zagląda do ciastka vary. Pełne nazwy klas to Magento\Framework\App\PageCache\Identifier i Magento\PageCache\Model\App\Request\Http\IdentifierForSave.
Dla zalogowanego klienta klucz odczytu wychodzi więc zawsze jak dla gościa. Co z tego wynika, zależy od sklepu:
- jeśli strony gości są w pamięci, zalogowany klient dostaje stronę gościa, np. z cenami grupy gości zamiast swojej grupy. Ten objaw opisuje zgłoszenie #40474 w repozytorium Magento
- jeśli katalog jest tylko po zalogowaniu, stron gości w pamięci nie ma, więc odczyt nie trafia nigdy, a pamięć stron w praktyce nie działa. Tak było w opisanym sklepie
Zakres sprawdziliśmy w publicznym repozytorium: preferencji nie ma w 2.4.7-p8, jest od 2.4.8 do 2.4.8-p5, a w 2.4.9 klasa IdentifierForSave czyta już najpierw ciastko X-Magento-Vary. W informacjach o wydaniu Magento Open Source 2.4.9 ta poprawka figuruje jako AC-14999 („FPC not work when login”). Opis dotyczy wbudowanej pamięci stron, np. w Redisie albo Valkey. Przy Varnishu stronę z pamięci wydaje Varnish, a nie ta klasa.
Jak to potwierdzić, zanim cokolwiek zmienisz
Pomiar powtórek daje podejrzenie. Potwierdzenie dają dwie kolejne obserwacje:
- sygnał: powtórne wejście zalogowanego klienta na ten sam adres nie jest szybsze, a strona logowania jest
- sygnał: wpis strony jest w Redisie albo Valkey i daje się odczytać, ale liczba wpisów nie rośnie przy kolejnych wejściach, bo zapis trafia ciągle w ten sam klucz
- potwierdzenie: dla tego samego żądania policz klucz odczytu i klucz zapisu klasami Magento, przy pustym kontekście i przy kontekście klienta; jeśli się różnią, to ten przypadek
Licząc klucze ręcznie, łatwo się pomylić: adres w kluczu to pełny URL z protokołem i domeną, nie sama ścieżka. Przy pomiarach curl musi i zapisywać, i odsyłać ciastka. Bez ciastka X-Magento-Vary każde żądanie wygląda jak pierwsze i pomiar fałszywie pokazuje, że pamięć nie działa.
Cache-Control: no-store w odpowiedzi nie oznacza, że pamięć stron nie działa. Przy wbudowanej pamięci Magento strona leży w pamięci na serwerze, a przeglądarce mówi się, żeby jej nie trzymała. Wniosek z tego nagłówka to najczęstszy fałszywy trop.Kierunek poprawki i zmierzony efekt
Poprawka nie wymaga zmian w rdzeniu. We własnym module, w konfiguracji DI dla obszaru frontend, przywraca się dla Magento\Framework\App\PageCache\Kernel argument identifier wskazujący na Magento\Framework\App\PageCache\Identifier. Odczyt znowu bierze odcisk klienta z ciastka X-Magento-Vary, jak w 2.4.7, a zapis zostaje bez zmian. Przy aktualizacji do 2.4.9 tę zmianę warto przejrzeć, bo rdzeń rozwiązuje to już inaczej.
| Środowisko | Przed | Po |
|---|---|---|
| kopia testowa | 1,2 s | 0,05 s |
| produkcja, listing kategorii | 1,6–2,3 s | 0,06 s |
Na kopii testowej sprawdziliśmy też, że dodanie do koszyka ze strony wydanej z pamięci działa. Przy wdrożeniu dwie rzeczy potrafią zmylić pomiar. Po podmianie pliku PHP zmiana może wejść dopiero po minucie, jeśli OPcache sprawdza pliki co 60 s. Poza tym zaraz po wyczyszczeniu pamięci każda strona jest zimna. Porównywać trzeba pamięć rozgrzaną kilkunastoma wejściami.
Sklep z cenami per konto: izolacja cen klientów
Magento zakłada, że ceny różnicuje się grupami klientów. W sklepie B2B często jest inaczej: wszyscy klienci firmowi są w jednej grupie, a rabat jest przypisany do konta, czasem z wyjątkami dla wybranych marek. Problem nie dotyczy każdego takiego sklepu. Pojawia się tam, gdzie cena zależy od danych, których nie ma w kluczu danej pamięci. Wtedy pamięć podaje wszystkim cenę klienta, który jako pierwszy wygenerował stronę. Takie miejsca są trzy:
- pamięć stron: kontekst HTTP musi zawierać cały profil cenowy konta, a nie samą stawkę rabatu. Dwóch klientów z tą samą stawką ogólną, ale innymi wyjątkami dla marek, inaczej dostanie ten sam klucz. Profil trzeba zapisywać w stałej kolejności, bo ten sam profil zapisany w innej kolejności daje inny klucz
- pamięć bloków z ceną na listingu: klucz bloku ceny kończy się identyfikatorem grupy klienta, więc przy jednej grupie wszystkie konta dzielą ten sam wpis
- widżety produktów z cenami, np. na stronie głównej: blok listy produktów zapisuje cały HTML z cenami, a jego klucz też rozróżnia tylko grupę
W opisanym sklepie konto z wyższym rabatem widziało na listingu cenę konta z niższym rabatem, a koszyk liczył poprawnie. Przykład umowny: przy cenie katalogowej 100 zł konto z rabatem 5% widzi 95,00 zł, a konto z rabatem 30% powinno widzieć 70,00 zł, lecz widzi 95,00 zł, bo stronę jako pierwsze wygenerowało konto z rabatem 5%. Błąd dotyczy więc ceny, którą klient widzi przy wyborze towaru. Zasadę liczenia rabatu per konto z wyjątkami dla marek opisuje kolejny wpis z serii: rabat klienta na markę w Magento Open Source.
Do kontekstu powinien trafiać profil cenowy, a nie identyfikator konta. Dokumentacja Adobe o treściach publicznych w pamięci ostrzega, że zmienna kontekstu przypisana do pojedynczego użytkownika oznacza osobną kopię strony dla każdego użytkownika. Przy profilu konta o identycznych warunkach dzielą wpisy, a liczba wariantów rośnie z liczbą różnych profili, nie kont. Po zmianie warunków konto dostaje strony liczone pod nowym kluczem, pod warunkiem że jego kontekst i ciastko X-Magento-Vary w trwającej sesji zostaną zaktualizowane. Kiedy to następuje, zależy od wdrożenia, dlatego po zmianie warunków handlowych trzeba sprawdzić ceny na koncie z aktywną sesją, a nie tylko po ponownym zalogowaniu.
Sklep z katalogiem po zalogowaniu: pamięć odpowiada przed kontrolą logowania
W sklepach, w których katalog jest tylko dla zalogowanych, kontrola logowania zwykle działa przy obsłudze akcji. Pamięć stron odpowiada wcześniej, a o tym, którą stronę wydać, decyduje ciastko X-Magento-Vary. Dopóki pamięć nie działała dla zalogowanych, nie miało to znaczenia. Po poprawce z sekcji 04 strona z cenami może zostać wydana z pamięci każdemu, kto przyśle pasujące ciastko vary, także bez sesji klienta.
Test po wdrożeniu: żądanie bez ciastka sesji, ale z ciastkiem vary skopiowanym z zalogowanej przeglądarki, powinno skończyć się przekierowaniem do logowania, a nie stroną z cenami. Jeśli dostajesz stronę, wydanie strony z pamięci trzeba powiązać z sesją klienta i sprawdzać to powiązanie, zanim pamięć odpowie. W razie wątpliwości pamięć ma zwrócić „brak wpisu”, a nie stronę.
Stan po zakończeniu prac: testy odbioru
W opisanym sklepie prace zakończyły się poniższymi testami. Wszystkie przeszły:
| Test | Wynik |
|---|---|
| listing dla zalogowanego klienta, pamięć rozgrzana | 0,05–0,06 s |
| żądanie bez sesji z cudzym ciastkiem vary | przekierowanie do logowania |
| to samo z cudzym znacznikiem powiązania z sesją albo ze zmyśloną sesją | przekierowanie do logowania |
| dwa konta z różnymi rabatami, w obu kolejnościach, od pustej pamięci: strona główna, kategoria (str. 1 i 2), podkategoria, szybkie zamawianie, wyszukiwanie, karta produktu | każde konto widzi swoje ceny |
| dane osobowe w stronach z pamięci (imię, firma, e-mail) | brak; przychodzą osobnym, prywatnym zapytaniem |
| konto, koszyk i zamówienia w pamięci stron | nie trafiają do pamięci |
Pamięć przyspiesza tylko strony, które już w niej są
Nawet działająca pamięć stron nie przyspiesza strony, której jeszcze w niej nie ma, czyli zimnego wpisu. Pierwsza wizyta klienta może trafić we wpis przygotowany wcześniej dla innego klienta o tym samym profilu. Ale w sklepie z profilami cenowymi każda kombinacja kategorii, liczby produktów na stronie, numeru strony i profilu to osobny wpis, więc klienci regularnie trafiają na wpisy zimne. Przy decyzji o wariantach „pokaż na stronie” liczy się czas generowania strony bez wpisu w pamięci:
| Produktów na stronie | Zimny wpis | Z pamięci | HTML |
|---|---|---|---|
| 12 | 1,8 s | 0,06 s | 334 kB |
| 36 | 5,7 s | 0,09 s | 533 kB |
| 60 | 7,6 s | 0,06 s | 731 kB |
| 120 | 14,7 s | 0,08 s | 1,2 MB |
| 240 | 28,5 s | 0,13 s | 2,2 MB |
W tym sklepie wychodzi około 0,11 s na każdy produkt przy generowaniu strony bez wpisu w pamięci. Nie profilowaliśmy, ile z tego przypada na rdzeń Magento, a ile na szablon i moduły, więc to zależność zmierzona w tym środowisku, a nie norma dla Magento. Wariant 240 produktów na stronie odrzuciliśmy właśnie z powodu czasu generowania zimnego wpisu.
Kiedy naprawisz to sam, a kiedy potrzebny jest przegląd cen
Diagnoza z sekcji 03: pomiar powtórek, liczba wpisów w Redisie, porównanie kluczy. Jeśli sklep nie ma katalogu za logowaniem ani cen przypisanych do kont, a ceny różnicujesz grupami klientów, poprawka z sekcji 04 z testem na kopii zwykle wystarcza.
Sklep z rabatami przypisanymi do kont, wyjątkami dla marek albo katalogiem za logowaniem. Tam poprawka wydajności otwiera kwestie z sekcji 05 i 06, a każde miejsce z ceną i własną pamięcią trzeba sprawdzić osobno, z prawdziwymi sesjami kilku klientów.
Gdy zlecasz nam taki przegląd, dostajesz diagnozę mechanizmu pamięci stron w Twoim sklepie (pomiary, porównanie kluczy, liczba wpisów), porównanie cen na kontach testowych w obu kolejnościach, weryfikację dostępu do katalogu bez sesji i raport z testów odbioru w układzie z sekcji 07. Więcej o pracy przy sklepach na Magento i przy indywidualnych rabatach i cennikach B2B.