eRIZ’s weblog

PHP, webdesign, Linux, Windows i inne, bo nie samym chlebem człowiek żyje
Serdecznie zapraszam do udziału w ANKIECIE

Cache danych - czym, jak, gdzie i kiedy - cz. I: teoria

PHP (zresztą nie tylko ten język) jako server-side do nauczenia trudny nie jest. Po jakim czasie seito twierdzi, że potrafi pisać całkiem niezłe skrypty, CMS, czy aplikacje. Ale nawet i wielu sensei często miewa problemy, gdy chodzi o mocno obciążone serwisy, w których stopniowo zaczynają pojawiać się wąskie gardła - strony wczytują się coraz wolniej, aż do momentu, gdy baza danych odmawia posłuszeństwa z powodu przeciążenia.

Na domiar złego, użytkownicy odświeżają wielokrotnie te same strony - albo z powodu wielu prób otwarcia, albo w celu aktualizacji często zmieniających się treści. I tak mamy pewien procent żądań, które tylko niepotrzebnie obciążają serwer, nieraz będące błędnym kołem - skrypt oczekuje na połączenie z bazą, a zniecierpliwiony gość albo opuści stronę, albo będzie wciskał CTRL+R do oporu…

Owszem, można postawić farmę serwerów z load-balancingiem/proxy-cache, ale nieraz jest to strzelaniem do muchy z armaty, poza tym - w przypadku mniejszych serwisów jest to poza ich zasięgiem finansowym. Jednak są sposoby… ;]

Osobiście wolę projektować rozwiązania dedykowane - w których koder ma największą kontrolę nad tym, co i w jaki sposób jest wykonywane przez oprogramowanie serwera. Czemu o tym wspominam? Gdyż właściwie co framework/ORM, to inny sposób cache’owania zawartości. Jednak sterowniki są zazwyczaj bardzo podobne, jeśli chodzi o ideę. Niestety, gotowe biblioteki stanowią utrudnienie, jeśli chodzi o wprowadzanie własnej logiki obsługi bufora, dlatego może się okazać konieczne edytowanie źródeł, czy też napisanie od zera własnego helpera.

Ja opiszę ogólne metody cache danych, niezależne od wykorzystywanych bibliotek, ale najpierw parę słów o tym, co cache’ować.

co cache’ować

  1. wyniki zapytań do bazy danych

    Najprostszym do skonstruowania i - z tego powodu - najpopularniejszym sposobem na cache danych jest buforowanie wyników zapytań. Jeśli są już pobrane i posortowane rekordy, to po co je znowu wyciągać? Problemem może być - w tym przypadku - nadmiarowość danych. Z czego to wynika? Głównie z samej składni zapytań - należy bowiem w jakiś sposób rozróżniać zapisywane dane w buforze. Najczęściej jest to hash treści danego zapytania. Pozornie nie ma nadmiarowości, ale wystarczy np. skorzystać z tych samych warunków, zmodyfikować jedynie klauzulę LIMIT. Inny przykład - jedno zapytanie wyciąga wszystkie pola, inne potrzebuje tylko jednego z tego samego zestawu rekordów. W przypadku stosowania konwencji bazującej na hashu zapytania, nie ma możliwości rozróżnienia, z którego obiektu korzystać. Wystarczyłoby chociaż wprowadzić pewien schemat nazewnictwa cache, aby wiedzieć, z którego korzystać. Oczywiście, przy większych systemach będzie to nawet kilka tysięcy różnych buforów, nad którymi również trzeba zapanować. Co wówczas? Wystarczy zbudować tokenizer analizujący skadnię zapytań pod kątem warunków, złączeń, pól i zwracający odpowiedni zasób.

  2. dane z zewnętrznych źródeł

    Wiele serwisów wykorzystuje również dane z zewnętrznych źródeł, np. kursy walut, RSS, pogodę, pozycje z katalogu partnera. Dlaczego cache?

    • transfer z zewnętrznego serwera jest zawsze wolniejszy niż z systemu plików
      dostępnego lokalnie
    • zestawianie połączeń TCP jest zasobożerne (vide: TCP/IP)
    • na czas przetwarzania żądania wstrzymywane jest wykonywanie całego skryptu
    • w przypadku awarii serwera zewnętrznego skrypt wykonuje się bardzo długo (patrz: punkt poprzedni; również ze względu na tzw. timeout dla połączeń) każdorazowe pobieranie zawartości z zewnątrz uszczupla limity transferowe - tu chyba nie ma czego wyjaśniać ;)

    Zawartość taką cache’owac można bezpośrednio, nie jest to tak problematyczne jak dane pochodzące z bazy.

  3. bloki strony/widoki

    Do skomplikowanych również nie należy, polega na zbuforowaniu pewnego fragmentu strony przez funkcje buforowania wyjścia i zapisywaniu tych danych w cache. Jeden z ciekawszych - moim zdaniem - sposobów, gdyż umożliwia pominięcie znacznej części logiki generowania wybranego bloku, a nie tylko pobierania danych.

  4. całe strony

    Dla użytkowników Wordpressa oraz wtyczki WP-Supercache nie jest to nic nowego. Na czym ogólnie to polega? Nie są cache’owane dane, bloki, czy elementy zewnętrzne. Cache’owane są całe strony, co owocuje znacznym spadkiem obciążenia. Dla serwisów, które nie są intensywnie wykorzystywane jest to raczej przerost formy nad treścią, ale dla stron, które przechodzą przez Wykop-efekt, jest to świetne rozwiązanie. W przypadku odpowiedniego wykorzystania mod_rewrite, o czym już pisałem, wysłaniem dokumentu do przeglądarki zajmuje się wyłącznie serwer, już z pominięciem interpretera.

  5. wyniki wyszukiwania

    Można by nad tym punktem polemizować. Dlaczego? Ano, ciężko przecież jest przewidzieć, ilu użytkowników wykorzysta te same frazy. Z drugiej strony, niektórzy kilkukrotnie wywołują te same wyszukiwania, o przekazywaniu linków do wyników nie wspomnę. Z tego, co się orientuję, to niektóre skrypty forów korzystają z tej metody. Samo cache’owanie można również połączyć z analizą statystyczną występowania słów kluczowych w wyszukiwaniach. Wówczas mamy podstawę, wg której możemy dobierać czas ważności cache.

  6. renderowane obrazy

    Rzecz raczej oczywista, ale niestety - spotkałem się już z wieloma skryptami, w których obrazy stworzone przez GD były wyświetlane bezpośrednio, jako strumień zwracany przez funkcje imageXXX. Może jest to wygodne - obrazki są zawsze z najświeższą zawartością, ale jest druga strona medalu: przeglądarka nie będzie miała możliwości pobrania obrazu do pamięci podręcznej. Tym samym: wzrasta zużycie transferu (wielkość otrzymanego obrazka*liczba żądań). Generowanie obrazów należy do jednych z najbardziej zasobożernych czynności - później padają pytania, dlaczego hosting zablokował stronę… Do tego dochodzi ograniczona możliwość optymalizacji - nie robiłem szczegółowych badań na ten temat, ale strumieniując bezpośrednio do przeglądarki, nie ma możliwości skorzystania z optymalizatorów w sposób “jednorazowy”. Owszem, można posłać strumień danych przez stdin/stdout, ale trzeba pofatygować się z wysyłaniem i odbieraniem tego strumienia; moim zdaniem - zbędna logika.

  7. wyeksportowane dane

    Tytuł trochę enigmatyczny, ale już spieszę z wyjaśnieniami. Mianowicie, nieraz istnieje potrzeba eksportowania danych z naszego serwisu do jakichś zewnętrznych formatów, np. XML, czy JSON. Dlaczego wykonywać eksport za każdym razem, jeśli potrzebujemy tych samych danych? ;)

W jakim formacie

Do wyboru mamy kilka różnych formatów różniącymi się czasem dostępu oraz interoperacyjnością.

  • zserializowana tablica

    Ten format jest chyba jednym z lepszych (łatwość użycia w ramach interpretera oraz wydajność parsowania) w celu zapisania nieco bardziej skomplikowanych struktur danych niż ciąg znaków, etc.

  • wyeksportowany kod PHP

    var_export i to do pliku. Pewnie ktoś mnie teraz określi mianem szaleńca. ;) Dla niektórych będzie to zaskoczeniem - w pewnych warunkach taki kod będzie szybciej przetwarzany niż zserializowana zmienna. Właśnie, w pewnych warunkach, czyli wymagana jest obecność jakiegoś akceleratora zainstalowanego razem z interpreterem. Jest to sposób, o którym się rzadko mówi, jednak jest on bardzo efektywny ze względu na fakt, iż akcelerator prekompiluje kod źródłowy i w postaci binarnej przechowuje go w swoim cache. Swoją drogą, jest to metoda wspomniana już na samym wstępie do Zend Server Data Caching. Uwaga - jeśli interpreter nie posiada aktywnego akceleratora, taki format cache okaże się wolniejszy w parsowaniu od zserializowanych danych.

  • XML/JSON/WDDX

    Można powiedzieć, że to raczej format-ciekawostka, który byłby przydatny w dość niewielu sytuacjach. Ze względu na konieczność parsowania oraz narzut konstrukcji, nie jest on dobrym rozwiązaniem, chyba że zachodzi potrzeba współdzielenia cache między różnymi API (np. PHP-Python). Nam się to raczej nie przyda. ;)

  • surowe dane

    Jeśli buforujemy ciąg znaków, czy strumień danych, nie ma sensu go w cokolwiek opakowywac. Wspominam o nim tak dla formalności.

Gdzie?

Jeśli chodzi o miejsce przechowywania, to mamy kilka opcji. Niestety, niektóre z nich są dostępne tylko na nielicznych serwerach, a inne wręcz wymagąją dedykowanych rozwiązań (czytaj: dostępu do konfiguracji systemu serwera).

  • plik

    Jako miejsce przechowywania cache jest on niemal zawsze wykorzystywany, nawet jeśli dane środowisko udostępnia inne metody. Niepodważalną zaletą jest na pewno trwałość - dopóki plik nie zostanie wykasowany przez kontrolera cache, dopóty można z niego korzystać (pomijam sytuację, w której dochodzi do błędu systemu plików). Taki zbiór możemy również udostępnić między inne serwery pracujące w farmie, co dodatkowo podnosi skalowalnosc rozwiązania.

  • SQL

    Tu można by było trochę polemizować, gdyż w zasadzie chcemy pozbyć się ewentualnego narzutu związanego z bazą danych oraz zestawianiem z nią połączeń. Jednak w większości przypadków będziemy mieli do czynienia z sytuacją, w której połączenie będzie już aktywne. Wówczas przyda się silnik MEMORY w przypadku MySQL, który pomimo bardzo ograniczonych możliwości (za wiele w takiej tabeli nie zapiszemy ze względu na restrykcje w przypadku długości pól), umożliwi zapisanie jakichś niewielkich danych, typu statystyki, etc. Pozostaje jeszcze jedna opcja, jaką jest SQLite. Ta szybka w odczycie baza może być nieraz bardzo dobrym rozwiązaniem, jeśli chodzi o spójność w systemie nazewnictwa cache, pilnowania “świeżości”. Natomiast główną wadą będzie ograniczona skalowalność.

  • SHM

    Jest to akronim od SHared Memory, odpowiednie rozszerzenie wkompilowane w PHP udostępnia funkcje pozwalające operowanie na wewnętrznym obszarze pamięci wspoldzielonym między instancje interpretera. Co przemawia przeciwko shmop? Wymagane jest przekompilowanie PHP z opcją –enable-shmop (której administratorzy chyba nie lubią ;)), działanie interpretera w trybie SAPI (DLL/SO ładowany przez demon serwera) lub FastCGI. Niestety, z dobrodziejstw SHM w przypadku zwykłego CGI nie skorzystamy…

  • hybrydowe bufory udostępniane przez akceleratory (SHM+pliki binarne - Zend Data Cache, eAccelerator, APC, XCache)

    Wspomniałem już o akceleratorach, teraz pora na nieco inne ich użycie. Mianowicie - praktycznie każdy akcelerator udostępnia odpowiednie API dające dostęp do swojej przestrzeni cache. To od programisty wówczas zależy, jakie dane w niej wylądują. W zależności od akceleratora, zostaną zapisane bądź w pamięci współdzielonej lub w katalogu tymczasowym. Jeśli chodzi o zachowanie, to jest tak, jak w przypadku SHM. Wady/zalety są w zasadzie identyczne, ale za zarządzanie/udostępnianie obszaru pamięci między interpreterami odpowiada już akcelerator. On również odpowiada za pilnowanie czasu przechowywania.

  • memcachedOstatnio zdobywa sobie dużą popularność. Jak pewnie niektórzy zdążyli się już domyślić, przechowuje on dane bezpośrednio w pamięci operacyjnej serwera. Jednak - w porównaniu do SHM/akceleratorów - posiada kilka istotnych różnic:
    • memcached jest niezależny od interpretera - można korzystać z tych samych danych w dowolnej aplikacji
    • jest skalowalny, a to za sprawą oparcia budowy o protokół TCP/IP, co pozwoliło na spinanie wielu serwerów w klastry zajmujące się wyłącznie obsługą cache. Co więcej, postawienie kilku serwerów bez dysku będzie znacznie obniżało sumaryczne koszty. Wystarczy kilka RPS + jeden VPS/dedyk i można szaleć. ;)

    Pomimo faktu, że memcached jest nieco wolniejszy od SHM/akceleratorów (z powodu korzystania z TCP/IP oraz uzależnienia od ruchu w sieci wewnętrznej), to ze względu na to, iż można spinać wiele serwerów ze sobą, zdobywa strzechy. Niestety, wymaga stawiania osobnego demona - z jego dobrodziejstw skorzystamy tylko na serwerach dedykowanych.

  • sesje

    Pewnie niektórzy stwierdzą, że to bez sensu (o tym, dlaczego - nieco dalej), alejeśli potrzebujemy zbuforować dane przeznaczone dla tylko jednego użytkownika - jest to rozwiązanie dość wygodne ze względu na to, iż wystarczy tylko odwołać się do odpowiedniego elementu w tablicy sesyjnej. Jeśli ktoś nadal nie widzi celu - do takiego cache wrzucamy np. informacje o zalogowanym użytkowniku.

  • ramdysk

    Perełkę zostawiłem sobie na koniec. ;) Można powiedzieć, że trochę jest to rozwiązanie trochę na siłę, gdyż można skorzystać z SHM/memcached. Jednak posiada kilka zalet, które go stawiają nieco wyżej. Jest to brak narzutu protokołu TCP/IP przy jednoczesnym zachowaniu zalet systemu plików oraz pełna kontrola nad wygasaniem konkretnego cache. Wymaga to jednak zaimplementowania odpowiedniej logiki przechowywania. Trzeba przewidzieć sytuacje, w których dochodzi do zapełnienia ramdysku oraz odpowiedniej analizy, jak często jest dany plik wykorzystywany.

W jaki sposób?

Wiemy już co, wiemy w jakim formacie, wiemy gdzie zapisywać, ale jak rozpoznać zdarzenia, które umożliwią odświeżenie cache danych? Istnieje kilka sposobów, które są wykorzystywane - jedne trochę rzadziej, drugie trochę częściej, każdy z nich ma swoje wady i zalety.

  • odświeżanie co żądanie, na podstawie czasu ważności cache

    Najprostszy sposób, gdyż nie wymaga kontrolowania innych czynników niż wspomniany czas. Najczęściej jest ono w pełni spełniające wszystkie wymagania stawiane buforowi danych. Jednak w przypadku mocno obciążonych serwisów nie jest to zbyt efektywne rozwiązanie. Dlaczego? Załóżmy - pewien element strony wymaga przetworzenia olbrzymiej ilości danych, która zajmuje interpreter na dość długi czas. Jeśli na któreś z żądań przypadnie aktualizacja cache - gość otrzyma wiecznie wczytujaca się stronę. Pół biedy, jeśli wówczas skrypt ignoruje ewentualne przerwanie generowania strony (zdenerwowany uzyszkodnik albo opuści stronę, albo odświeży), ale nie uwzględnienie tej sytuacji może doprowadzić do sytuacji, w której zasobożerne ładowanie danych nie zostanie zoptymalizowane.

  • odświeżanie przez cron/harmonogram zadań

    Jak wyżej, ale uniezależnia aktualizację od ilości/częstotliwości żądań. Pozostałe wady, jak wyżej.

  • odświeżanie w tle

    Sposób łączący zalety obu powyższych. Nie blokuje generowania strony - w przypadku konieczności aktualizacji nie jest kasowany konkretny obiekt, a strona zgłasza pseudo-demonowi żądanie odświeżenia danych. Po wygenerowaniu nowej zawartości bufora, stara jest kasowana i zastępowana bezpośrednio nowymi danymi. Powoduje to, w rzeczywistości, podzielenie logiki strony na kilka części, np. modułu odpowiedzialnego za aktualizację danych do przetwarzania, odświeżacza cache oraz części wyswietlajacej.

Logika odświeżania

Ok, teraz pozostaje jeszcze ustalić, kiedy zbuforowane w cache dane ulegną dezaktualizacji. Znów mamy kilka opcji do wyboru:

  • odświeżanie pod względem czasu

    Uniemożliwia uaktualnianie bufora niezależnie od modyfikacji danych (jeśli dane rzadko się aktualizują, np. raz na tydzień, a bufor jest aktualizowany codziennie, wówczas dochodzi do niepotrzebnego odświeżania i marnowania zasobów). Dane są aktualizowane dopiero wtedy, gdy ulegną one przedawnieniu.

  • inteligentne odświeżanie cache

    Sposób najbardziej pracochłonny ze wszystkich, ale jednocześnie najefektywniejszy. Wymaga sumiennego i uważnego pilnowania identyfikatorów cache, gdyż są one podstawą do aktualizacji. Domyślnie cache taki jest przewidziany do dożywotniego przechowywania, konkretny obiekt jest kasowany dopiero w przypadku aktualizacji danych.

Najłatwiej jest wykorzystać czas ważności danych, ale nasz mechanizm rozwija skrzydła dopiero przy mechanizmie inteligentnym. Np. zawartość jakiegoś działu ulega zmianie co tydzień i chcemy, aby zawartość była aktualizowana natychmiast - sztywna data ważności nam tego nie zapewni.

Co wybrać?

Kwestia wyboru odpowiedniego sterownika cache nie jest, w rzeczywistości, takim problemem. Wystarczy zidentyfikować, pozyskiwanie których danych jest najwęższym gardłem systemu. Pomocne będzie tu wykorzystanie slowlogow bazy danych, które umożliwią zidentyfikowanie najwolniej wykonujących się zapytań. Następnie, analizujemy czas wykonywania poszczególnych skryptów. Jeśli to możliwe, korzystamy z tzw. profilera, który pozwala na analizę każdej wykonywanej instrukcji skryptu z uwzględnieniem czasu przetwarzania oraz używanej pamięci. W sytuacji, gdy serwer, na którym działają nasze skrypty, nie posiada debuggera/profilera, pozostaje prosty benchmark bazujący na funkcji microtime. Oprócz powyższych, powinniśmy również wziąć pod lupę logi dostępowe serwera w celu ustalenia, które podstrony generują największą liczbę odwiedzin. Istnieje wiele programów do analizy dzienników demona serwera, wystarczy nieco poszukać, już sam Webalizer dostępny na praktycznie każdym hostingu na to pozwala. Następnie porównujemy to zestawienie z poprzednimi analizami, zwłaszcza tymi dotyczącymi czasu generowania skryptu i pobierania danych. wtedy będziemy mogli rozważyć buforowanie całych elementów, czy wręcz podstron. Po krótkiej, bądź nieco dłuższej analizie, możemy ustalić, który sterownik wybrać dla konkretnego rozwiązania. Na pewno nie ma sensu trzymać wyników rzadko wykonywanych zapytań w SHM, czy memcached, a strony głównej odświeżanej 500x na sekundę w pliku. Pod uwagę bierzemy kilka czynników:

  • częstotliwość pobierania danych
  • zasobożerność
  • rozmiar buforowanych danych (pamięci operacyjnej jest “trochę”; mniej niż dyskowej)
  • cel, w jakim tworzymy cache (miejmy na uwadze np. możliwość pobrania obiektów do pamięci podręcznej gościa)
  • konieczność współdzielenia między maszynami w farmie (SHM będzie nieco marnotrawiacym przypadkiem)

czego nie robić

Każda skrajność jest niebezpieczna, można wpaść na pewne pułapki, czasem zupełnie nieświadomie.

  • Nie cache’owac danych nie powiązanych z konkretnym użytkownikiem w sesji - spowoduje to jedynie marnotrawstwo miejsca na dysku - wielokrotne zapisanie tych samych danych.
  • Nie kompresowac danych cache, z wyjątkiem sytuacji, w której obiekt sprowadzony do takiej postaci trafia bezpośrednio do odbiorcy - np. skompresowane skrypty, arkusze CSS, itp, w ich przypadku ma to sens. Ale już wyniki zapytania do bazy są lekko strzałem w stopę. Nieraz może być tak, iż zdekompresowanie zawartości bufora zajmie więcej czasu niż ponowne zdobycie surowych danych.
  • Nie buforować do wspólnego pojemnika danych, które mogą być spersonalizowane.
  • Jeśli pobranie informacji jest relatywnie krótkie, nie ma sensu tego zapisywać. Trzeba też z głową wybierać, co zapisać. ;)
  • Nie buforować danych na długi czas, jeśli muszą być “świeże”. Jednakże, jeśli dana podstrona jest odświeżana wiele razy i jednocześnie często aktualizowana (np. strona główna, czy indeks newsów), to cache na okres minuty będzie miał sens. Idealnym rozwiązaniem byłoby tu aktualizowanie na żądanie.
  • Nie zapisywać tokenow/capcha. No jest to ewidentnie bez sensu, skoro zawartość obrazka jest w 99% wykorzystywana tylko jeden raz.

co jeszcze?

  • Nagłówki E-Tag/If-Modified. Dlaczego wspomniałem o zapisywaniu obrazków do cache plikowego? Jest to bezpośrednio powiązane również z tymi meta-wartościami - przeglądarka zgłasza, kiedy pobrała dany plik i serwer na podstawie tych danych bądź odpowiada “not modified” lub wysyła nowe dane. Można to wykorzystać przy aktualizacji tokenów (nie zostanie wykonane ponowne renderowanie), czy też sprawdzenie ważności cache danej podstrony - jeśli cache kluczowych bloków nie zdeaktualizowal się - wysyłana jest informacja, że pobrane wcześniej przez przeglądarkę dane są nadal aktualne.
  • Rozważ wydzielenie osobnego serwera na pliki statyczne, na obiekty bufora przesyłane bezpośrednio do użytkownika, a zapisane w plikach. Niektóre demony HTTP są znacznie efektywniejsze dla serwowania gotowych danych niż te, które komunikują się z interpreterami. Pomijam już fakt wydzielenia osobnej (sub)domeny, która odciąży łącze od wysyłania np. ciasteczek w trakcie połączenia.
  • Wykorzystaj moduł przepisywania adresów (rewrite) w celu odciążenia interpretera i ładowania cache bezpośrednio z plików. O tym już wspomniałem, umożliwi to nawet napisanie prostej, dedykowanej aplikacji napisanej w technologii CGI, która byłaby pomostem pomiędzy np. użytkownikiem a memcached. odpada wówczas problem interpretera i serwis dostaje kopa. ;)
  • Tokenizer dla zapytań SQL - temat już wyjaśniłem. ;)
  • Nie opieraj się na wyłącznie jednym sterowniku cache, jeśli to możliwe - nie ma uniwersalnej recepty na jednoznaczne dobranie odpowiedniego miejsca na cache dla wszystkich serwisów. Wymaga to analizy, o której wspomniałem.

epilog

Mam nadzieję, że tym artykułem choć trochę przyblizylem ideę oraz możliwe sposoby cache’owania danych w ramach serwisu. W następnej części porusze część teoretyczną tak, aby wylądowała na poligonie doświadczalnym. Może nie będzie stawiała oporu. ;)

A Ty jakie masz doświadczenia z cache? Podziel się swoimi spostrzeżeniami w komentarzach. :)

11 komentarzy

dopisz swój :: trackback :: RSS z komentarzami

RSS z komentarzami :: trackback

Skomentuj

Możesz używać znaczników XHTML. Dozwolone są następujące tagi: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <code> <em> <i> <strike> <strong>

Wszystkie komentarze przechodzą przez moderację oraz filtry antyspamowe. Nie zostanie opublikowany komentarz, jeśli:

  • Jego treść obraża kogokolwiek.
  • W treści znajdują się wulgaryzmy i słownictwo ogólnie uznane za nieprzyzwoite.
  • Mam wątpliwości co do autora wpisu (Wszelkie anonimy są kasowane - niezależnie od zawartości - wpisz prawdziwy e-mail. Jeśli usunąłem, Twoim zdaniem, komentarz niesłusznie - daj znać). Zdarza się, iż sprawdzam kim jest komentujący.
  • Zawiera jakąkolwiek formę reklamy.

Szufladka