WordPress pod ruchem bez paniki: warstwowe cache, CDN i obserwowalność w praktyce

WordPress pod ruchem bez paniki: warstwowe cache, CDN i obserwowalność w praktyce

Kampania startuje o 9:00, ruch rośnie 10× w kilka minut, a Ty patrzysz na te same symptomy: rosnący TTFB, sporadyczne 502/504, CPU w kosmos, a edytorzy pytają „czemu strona muli, przecież to tylko landing?”. W WordPressie (zwłaszcza z Elementorem i kilkunastoma wtyczkami) to nie jest kwestia „jednego ustawienia”. To jest problem architektury: jak zbudować ścieżkę serwowania treści tak, żeby większość żądań nie dotykała PHP i bazy.

U nas (serwisy marketingowe i wizerunkowe) zderzyliśmy się z klasycznym paradoksem: WordPress jest świetny do publikowania, ale jeśli zostawisz go „w domyślnej konfiguracji”, to płacisz cenę za dynamikę nawet wtedy, gdy strona jest w 95% statyczna. A ruch kampanijny jest bezlitosny: nie interesuje go, że na backendzie jest „tylko kilka zapytań”.

Ten post pokazuje podejście, które działa w realnych warunkach: warstwowe cache (CDN → cache na originie → cache obiektowy) + proste SLO/SLI + minimalna obserwowalność. Bez cudów, bez „magicznych pluginów”, z jasnym kontraktem: co cache’ujemy, kiedy purge’ujemy i jak wykrywamy regresje.

Tło i problem

W typowym serwisie WordPress:

  • każda odsłona strony to wejście w PHP,
  • PHP robi kilka–kilkadziesiąt zapytań do bazy,
  • do tego dochodzą wtyczki (SEO, formularze, cache, analityka, builder),
  • a na koniec renderujesz HTML, który dla anonima jest identyczny przez godziny lub dni.

Jeśli ruch jest mały, „jakoś to działa”. Problem zaczyna się w momentach szczytowych:

  • kampanie płatne (nagły spike),
  • mailing (dużo wejść w krótkim czasie),
  • PR (link z dużego portalu),
  • roboty (crawl w złym momencie).

Najgorsze jest to, że ten sam zestaw symptomów ma wiele przyczyn:

  • brak cache albo zły cache key,
  • cache stampede (wiele requestów naraz generuje tę samą stronę),
  • dynamiczne cookies psują hit ratio,
  • purgowanie zbyt szerokie (wszystko się unieważnia naraz),
  • admin i anonim wchodzą tą samą ścieżką.

Tu nie wystarczy „włączyć wtyczkę cache”. Potrzebujesz modelu: jakie warstwy chronią WordPressa i kiedy request ma prawo dotknąć backendu.

Wymagania / ograniczenia

Zebraliśmy wymagania tak, żeby nie przegiąć w żadną stronę: ani w koszt, ani w złożoność.

Wymagania funkcjonalne

  • Strona ma być szybka dla anonima (większość ruchu).
  • Edytorzy muszą publikować bez czekania „aż cache się ułoży”.
  • Formularze i elementy dynamiczne mają działać (kontakt, leady, checkout redirecty, itp.).

Wymagania niefunkcjonalne

  • Stabilność w spike’ach: nie chcemy, żeby kampania była „testem obciążeniowym produkcji”.
  • Rozsądny koszt: to wciąż serwis marketingowy, nie system transakcyjny.
  • Prosta operacyjność: ma działać w zespole, gdzie nie każdy jest SRE.

Ograniczenia

  • WordPress + Elementor: część HTML bywa generowana w runtime.
  • Wtyczki potrafią dodawać cookies lub query parametry.
  • Część podstron jest „prawie statyczna”, ale są wyjątki: wyszukiwarka, koszyk, logowanie.

Co nie działało wcześniej

1) Cache tylko „w WordPressie”

Jeśli jedyną warstwą cache jest plugin, to:

  • i tak dotykasz PHP, zanim plugin zdecyduje, czy ma cache,
  • łatwo o konflikty wtyczek,
  • trudniej o ochronę przed stampede (szczególnie bez locków na poziomie serwera),
  • debugowanie jest cięższe (bo wszystko dzieje się „w środku” aplikacji).

2) Brak jasnego rozdzielenia „anonim vs zalogowany”

Największy hit ratio jest na anonimach. Jeśli przypadkowo cache’ujesz zalogowanych albo miksujesz te światy:

  • ryzykujesz correctness (złe treści dla złej osoby),
  • albo wyłączasz cache „dla bezpieczeństwa” i wracasz do punktu wyjścia.

3) CDN tylko do statycznych assetów

CDN wyłącznie dla obrazków i JS/CSS to półśrodek. Największy koszt generowania strony to HTML (PHP + DB). Bez cache’owania HTML na brzegu dalej obciążasz origin.

4) Purge „na pałę”

Częsty pattern: publikacja posta → purge „wszystkiego”. Efekt:

  • hit ratio spada,
  • origin dostaje nagle lawinę requestów,
  • rosną opóźnienia w najgorszym możliwym momencie (po publikacji).

5) Brak metryk, tylko „czujemy, że wolno”

Bez minimalnych SLI:

  • nie wiesz, czy poprawiłeś czas odpowiedzi, czy tylko przeniosłeś problem,
  • nie masz alertów na realne awarie (np. wzrost 5xx),
  • regresje wydajności wchodzą bokiem (np. nowa wtyczka).

Nowe podejście / architektura

Zrobiliśmy to warstwowo, bo to najprostszy sposób na odporność bez heroizmu.

Założenie nadrzędne:

90–99% requestów anonima ma zakończyć się na CDN albo na cache originu, bez uruchamiania PHP.

Architektura logiczna:

  1. CDN jako pierwsza linia (cache HTML + assety, origin shield, WAF opcjonalnie).
  2. Nginx FastCGI cache na originie jako druga linia (tania i skuteczna ochrona PHP).
  3. Redis Object Cache jako trzecia linia (odciążenie bazy i przyspieszenie generowania, gdy PHP jednak musi odpalić).
  4. Purge z kontraktem: publikacja/aktualizacja unieważnia tylko to, co trzeba.
  5. Obserwowalność: kilka SLI i proste alerty.

Ważne: to nie jest „jedyna słuszna” architektura. To jest zestaw kompromisów, który często wygrywa w serwisach contentowych: szybki efekt, niski koszt i przewidywalna operacyjność.

Jak to działa

Warstwa 1: CDN jako domyślna ścieżka

Na CDN cache’ujemy:

  • HTML dla anonima (GET/HEAD),
  • assety statyczne,
  • czasem obrazy w wariantach (image resizing, jeśli macie).

Praktyczne zasady cache’owania HTML:

  • cache tylko dla requestów bez „dynamicznych” cookies (np. sesja, koszyk),
  • ignoruj parametry śledzące (utm_*, fbclid, gclid) w cache key,
  • ustaw rozsądne TTL (np. 5–30 min) i opieraj świeżość na purge (nie tylko na TTL).

Wnioski (konkretne):

  • CDN dla HTML daje największy skok wydajności i kosztowo jest zwykle „tani” w porównaniu z dokładaniem serwerów.
  • Najwięcej pracy to cache correctness: wykluczenia, cookies, query params.

Warstwa 2: cache na originie (Nginx/FastCGI)

CDN nie zawsze trafi w cache (cold start, purge, edge case). Dlatego origin musi umieć:

  • szybko serwować HTML z cache,
  • chronić PHP i DB przed stampede,
  • rozdzielać anonim/zalogowany.

Poniżej przykład konfiguracji Nginx (fragment), pokazujący kluczowe elementy: cache, lock, stale, bypass.

# FastCGI cache storage
fastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2
  keys_zone=WP:100m inactive=60m max_size=2g use_temp_path=off;

map $http_cookie $skip_cache {
  default 0;
  ~*wordpress_logged_in 1;
  ~*wp-postpass_       1;
  ~*comment_author     1;
  ~*woocommerce_items_in_cart 1;
  ~*woocommerce_cart_hash     1;
}

# Ignorujemy typowe tracking query params w cache key (przykład)
map $args $clean_args {
  default $args;
  "~*(^|&)utm_[^&]*"   "";
  "~*(^|&)gclid=[^&]*" "";
  "~*(^|&)fbclid=[^&]*" "";
}

server {
  # ...

  location / {
    set $no_cache 0;
    if ($request_method !~ ^(GET|HEAD)$) { set $no_cache 1; }
    if ($skip_cache = 1)                { set $no_cache 1; }

    fastcgi_cache WP;
    fastcgi_cache_key "$scheme$request_method$host$request_uri?$clean_args";
    fastcgi_cache_valid 200 301 302 10m;

    # ochrona przed stampede
    fastcgi_cache_lock on;
    fastcgi_cache_lock_timeout 10s;

    # serwuj stare, gdy origin ma problem
    fastcgi_cache_use_stale error timeout invalid_header updating http_500 http_503;

    add_header X-Cache $upstream_cache_status always;

    fastcgi_no_cache $no_cache;
    fastcgi_cache_bypass $no_cache;

    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root/index.php;
    fastcgi_pass php-fpm;
  }
}

Co jest tu ważne:

  • map $http_cookie $skip_cache – twardy rozdział anonima od zalogowanych/dynamicznych przypadków.
  • fastcgi_cache_lock – minimalizuje stampede: jeden request generuje, reszta czeka.
  • fastcgi_cache_use_stale – jeśli backend ma chwilowy problem, serwujesz „stare” (lepsze niż 502 w kampanii).
  • Nagłówek X-Cache – szybki debug: HIT/MISS/BYPASS.

To daje nam dwa efekty biznesowe:

  • kampanie nie „piorą” PHP-FPM i MySQL/PostgreSQL,
  • awarie są łagodniejsze: użytkownik widzi stronę, nawet jeśli backend ma czkawkę.

Warstwa 3: Redis Object Cache

Kiedy request omija cache (np. edytor, preview, panel admina, dynamiczna strona), WordPress dalej może być szybki, jeśli:

  • nie robi ciągle tych samych zapytań do bazy,
  • cache’uje obiekty i wyniki query.

Redis Object Cache w praktyce:

  • zmniejsza liczbę zapytań do DB,
  • stabilizuje czasy odpowiedzi,
  • pomaga szczególnie przy ciężkich builderach i wielu wtyczkach.

Dobre praktyki:

  • osobna instancja Redis (lub dobrze wydzielona baza/logiczne DB),
  • limity pamięci i polityka usuwania (żeby Redis nie zjadał całego hosta),
  • monitorowanie: hit ratio Redis, evictions, latency.

Purge i „cache correctness”

Cache bez purge to albo:

  • zbyt krótki TTL (ciągle MISS),
  • albo ryzyko nieświeżych treści.

Działające podejście:

  • publikacja/aktualizacja → purge tylko:
    • URL wpisu/strony,
    • stron list (np. blog, kategorie, tagi),
    • czasem homepage (jeśli wyświetla ostatnie wpisy).
  • zmiana globalnych komponentów (nagłówek/stopka) → purge szersze, ale nadal kontrolowane.

Konkretny proces (checklista):

  • Zdefiniuj listę URL-i „zależnych” od publikacji (homepage, listing, kategorie).
  • Ignoruj tracking params w cache key.
  • Ustal zasady dla preview i draftów (zwykle bypass cache).
  • Dodaj nagłówki debug (X-Cache, CF-Cache-Status lub odpowiednik).
  • Miej możliwość purge per-URL i per-tag (jeśli CDN wspiera).

W praktyce purge robimy przez:

  • webhook z WordPressa (publish/update) → endpoint, który woła API CDN,
  • albo integrację z pluginem, ale z naciskiem na kontrolę zakresu.

Obserwowalność: SLI/SLO i sygnały awarii

Nie potrzebujesz od razu pełnego Prometheusa + Grafany, żeby mieć kontrolę. Minimalny zestaw, który realnie pomaga:

SLI (co mierzymy):

  • dostępność: % odpowiedzi 2xx/3xx vs 5xx,
  • latency p95/p99 dla HTML (osobno dla /wp-admin i dla anonima),
  • cache hit ratio (CDN i origin),
  • liczba requestów do originu (czy CDN działa),
  • błędy PHP-FPM (timeout, max children reached),
  • DB: liczba połączeń, slow queries.

SLO (jakie cele): (przykład, dostosuj do biznesu)

  • 99.9% dostępności miesięcznie dla publicznego HTML,
  • p95 TTFB < 200–400 ms dla anonima (zależnie od regionu; szacunek sensowny dla stron z CDN),
  • cache hit ratio CDN > 85% dla HTML w dniach bez publikacji (cel orientacyjny).

Alerty, które mają sens:

  • 5xx > X/min przez 5 min,
  • nagły spadek cache hit ratio (np. purge „za szeroko” albo cookies się zmieniły),
  • PHP-FPM „max children reached”,
  • wzrost p95 latency 2× względem baseline.

To są sygnały operacyjne, które pozwalają złapać problem zanim złapie go klient albo kampania.

Wyniki / metryki / lekcje

Bez twardego RUM nie będę udawał precyzji. Poniżej to typowe efekty po wdrożeniu warstwowego cache w serwisach contentowych; traktuj jako realistyczny zakres (szacunek, zależny od treści i wtyczek):

  • TTFB dla anonima: spadek z ~600–1200 ms do ~80–250 ms przy HIT na CDN (szacunek).
  • Origin load: spadek liczby requestów trafiających do PHP o 70–95% w godzinach szczytu (szacunek).
  • Stabilność kampanii: mniej 5xx, mniej timeoutów, mniej „panic scaling”.
  • Koszt: często niższy niż dokładanie większych instancji, bo płacisz za cache i transfer, a nie za CPU w kółko.

Lekcje, które wyciągnęliśmy (konkretnie)

  • Cache correctness > cache size. Najwięcej roboty jest w zasadach: cookies, query params, wyjątki.
  • Purge musi być selektywny. Globalny purge po każdej publikacji niszczy hit ratio i przenosi koszt na backend.
  • Origin cache to nie „przeżytek”. CDN nie zawsze jest HIT; druga linia obrony daje spokój operacyjny.
  • Debug headers są złotem. X-Cache, status CDN i prosta tabela w logach często zastępują „zgadywanie”.

Dwie krótkie listy wniosków do wdrożenia „od jutra”:

  • Ustal kontrakt: które cookies wykluczają cache i dlaczego.
  • Ignoruj tracking params w cache key.
  • Dodaj cache_lock / ochronę stampede na originie.
  • Rozdziel admin i public (choćby logicznie w konfiguracji).
  • Mierz hit ratio i 5xx – bez tego nie wiesz, czy działa.

Co dalej / roadmapa

Jeśli podstawy działają, kolejne kroki wybieramy pod ryzyko i ROI:

  1. Edge Side Includes (ESI) / fragment caching (jeśli macie realnie dynamiczne kawałki)
    Przykład: header z licznikiem lub elementy personalizowane. Zamiast wyłączać cache dla całej strony, cache’ujesz HTML, a fragment dociągasz osobno.
  2. Multi-region / origin failover (jeśli biznesowo krytyczne)
    Dla serwisów marketingowych często wystarczy dobre „stale on error”. Dla krytycznych – warto mieć plan przełączenia originu.
  3. Lepszy RUM
    Zbieranie realnych metryk użytkowników (TTFB, INP, LCP) pozwala odróżnić „serwer szybki” od „strona szybka”.
  4. Kontrola wtyczek i budżet wydajności
    Najczęstszy regres to „wtyczka dodała cookie” i nagle cache przestał działać. Budżet + testy (np. Lighthouse CI / WebPageTest) ograniczają przypadki „w piątek w nocy”.

Wnioski

WordPress da się skalować bez przepisywania na headless i bez mnożenia serwerów – ale trzeba przestać traktować go jak aplikację, która ma generować HTML za każdym razem. Dla większości serwisów contentowych prawidłowy model jest prosty: cache’uj HTML wszędzie, gdzie się da, a WordPress niech zajmuje się publikacją, nie obsługą każdego requestu.

Jeśli miałbym zostawić jedną myśl: w kampanii nie wygrywa „mocniejszy serwer”, tylko architektura, w której kampania nie dotyka serwera.

Jeśli chcesz to wdrożyć u siebie, zacznij od:

  • CDN z cache HTML + sensowny cache key,
  • FastCGI cache na originie z lockiem,
  • minimalnych SLI i nagłówków debug.

A jeśli chcesz, możemy przejść przez to na Twoim konkretnym setupie: domeny, cookies, wyjątki (formularze, koszyk), i ułożyć reguły cache/purge tak, żeby było szybko i poprawnie

Na ile ten artykuł był dla Ciebie pomocny?

Powiązane wpisy