Liczba wyświetleń to pierwsza liczba, której ufa odwiedzający. Mówi im ona, czy film jest wart trzydziestu sekund, czy trzydziestu minut ich czasu. W TopVideoHub ta liczba zmienia się błyskawicznie. Popularny klip może zdobyć 40 000 wyświetleń w dziesięć minut. Jeśli licznik na stronie zamarznie, użytkownik ma wrażenie, że w pokoju nikogo nie ma. Ludzie opuszczają stronę.
Dostarczenie tej liczby do przeglądarki brzmi trywialnie. Tak nie jest. Pierwszym rozwiązaniem, po które sięga większość zespołów, jest polling (odpytywanie). Jest łatwy do wdrożenia i działa bez zarzutu w środowisku stagingowym. Ale staging kłamie.
Kiedy polling staje się atakiem DDoS na samego siebie
Zespół TopVideoHub zbudował prosty poller w JavaScript. Pobierał on najnowszą liczbę wyświetleń co pięć sekund. W środowisku testowym z trzema otwartymi przeglądarkami wyglądało to świetnie. Na produkcji doprowadziło to do załamania platformy.
Osiem tysięcy jednoczesnych widzów odświeżających stronę co pięć sekund generowało 1600 żądań na sekundę. Każde żądanie uderzało bezpośrednio w bazę danych. Opóźnienie replikacji gwałtownie wzrosło. Replikaty do odczytu stękały pod ciężarem. Warstwy cache były omijane. Zespół nie serwował wideo. Serwował obciążenie, które sam wygenerował.
Polling jest niewinny, dopóki nie przestanie nim być. Dla pulpitów nawigacyjnych o niskim natężeniu ruchu lub paneli administracyjnych jest w porządku. Dla strony z wiralowym filmem to tykająca bomba. Zespół potrzebował stałego kanału przesyłu danych z serwera do przeglądarki, ale nie potrzebował złożoności protokołu full-duplex.
Dlaczego SSE pasuje do jednokierunkowych połączeń
Server-Sent Events (SSE) zostało stworzone właśnie do tego typu problemów: serwer posiada dane, a przeglądarka musi tylko ich słuchać.
W przeciwieństwie do WebSocketów, SSE opiera się na zwykłym protokole HTTP. Ma to większe znaczenie, niż się wydaje. Nie potrzebujesz nowych reguł proxy, nagłówków upgrade ani akrobacji z load-balancerem. Jeśli Twój serwer obsługuje HTTP/1.1 lub HTTP/2, SSE będzie działać. Debugowanie jest bezbolesne, ponieważ strumień to po prostu tekst. Możesz skierować curl na endpoint i obserwować przewijające się liczby w czasie rzeczywistym, co jest lepsze niż zgadywanie, dlaczego binarny ramka gniazda (socket frame) poszła nie tak.
Przeglądarka zajmuje się żmudną robotą za darmo. Jeśli połączenie zostanie przerwane, SSE automatycznie nawiąże je ponownie, używając nagłówka Last-Event-ID, dzięki czemu serwer będzie wiedział, od którego miejsca wznowić przesyłanie. Powierzchnia API w JavaScript jest minimalna: tworzysz EventSource, podpinasz handler onmessage i gotowe.
Cache to prawdziwa architektura
Największym błędem architektonicznym w licznikach na żywo jest traktowanie każdego połączenia przeglądarki jako powodu do zapytania do bazy danych. Jeśli 8 000 osób ogląda ten sam film, uruchamianie 8 000 zapytań co dwie sekundy to szaleństwo. Twoja baza danych nie przetrwa wiralowego ruchu.
TopVideoHub rozwiązało to za pomocą APCu, pamięci podręcznej opcode i użytkownika w PHP. Przepływ jest prosty. Proces działający w tle — lub lekki endpoint wywoływany przez timer — zapisuje aktualną liczbę wyświetleń w APCu raz na dwie sekundy. Endpoint SSE, który może być otwarty przez tysiące przeglądarek, odczytuje dane wyłącznie z APCu.
Wynik: baza danych otrzymuje jedno zapytanie co dwie sekundy, niezależnie od liczby widzów. Cache staje się amortyzatorem. APCu nie jest egzotyczne. Jest dostarczane wraz z PHP, działa w pamięci współdzielonej i odczytuje dane szybciej niż jakikolwiek cykl sieciowy. Dla pojedynczej liczby, która zmienia się często, ale nie natychmiastowo, jest to odpowiednie narzędzie.
Jeśli nie używasz APCu, sprawdzą się również Redis lub Memcached. Zasada pozostaje ta sama: oddziel ścieżkę gorącego odczytu od bazy danych.
Przekonywanie PHP, LiteSpeed i Cloudflare do przesyłania strumieniowego
PHP chce skończyć pracę i iść do domu. Serwery WWW chcą buforować wyjście i dostarczyć uporządkowaną odpowiedź. SSE wymaga czegoś przeciwnego: połączenia, które pozostaje otwarte, wypłukując bajty w miarę ich napływania. Bez odpowiedniej uwagi Twój „strumień” dotrze jako pojedynczy blok danych trzydzieści sekund później, co niweczy cały cel.
Oto jak TopVideoHub zadbało o drożność połączenia.
Wyłącz buforowanie wyjścia. Na początku skryptu SSE wyłącz każdą warstwę buforowania, którą PHP mogło aktywować. Wywołaj ob_end_flush(), jeśli bufor jest aktywny, i wyłącz niejawne wypłukiwanie za pomocą ob_implicit_flush(true) po wysłaniu nagłówków.
Powiedz proxy, żeby odpuściło. Wyślij nagłówek X-Accel-Buffering: no. Nginx go słucha. LiteSpeed go słucha. Sygnalizuje on, że odpowiedź nie powinna być buforowana ani kompresowana w jedną, możliwą do zbuforowania całość.
Ustaw krótki czas życia. Każde połączenie SSE zajmuje proces (worker) PHP. TopVideoHub ogranicza czas trwania strumieni do 55 sekund. Gdy licznik dobije do limitu, serwer wysyła końcowy komentarz, zamyka strumień, a przeglądarka automatycznie nawiązuje połączenie ponownie. To ponowne połączenie trafia do świeżego procesu, co zapobiega zajmowaniu zasobów przez pojedynczy proces na zawsze.
Ping, aby utrzymać połączenie. Wysyłaj linię komentarza — coś w stylu : ping — co dwadzieścia sekund. Komentarze w SSE są ignorowane przez handler wiadomości przeglądarki, ale utrzymują połączenie TCP w stanie aktywnym. Load balancery i sieci CDN często zrywają ciche połączenia po trzydziestu lub sześćdziesięciu sekundach. Tani znak nowej linii uchroni Cię przed tym cięciem.
Szanuj kartę użytkownika. Gdy odwiedzający zminimalizuje lub ukryje kartę, przerwij działanie. Nasłuchuj zdarzenia visibilitychange w przeglądarce i wywołaj eventSource.close(). Serwer również powinien wykryć rozłączenie klienta i zakończyć pętlę. PHP może sprawdzać connection_aborted() wewnątrz pętli. Nie pozwól, aby „duchy” połączeń marnowały workery dla osób, które wyszły dziesięć minut temu.
Twardy sufit: PHP Workers
SSE w PHP uczciwie pokazuje swoje ograniczenia. Każde otwarte połączenie SSE zużywa jednego PHP workera. Jeśli Twój pool ma sto workerów, masz sto strumieni. Kropka. Nie ma żadnego obejścia asynchronicznego, dopóki działasz w modelu procesów Apache lub PHP-FPM. Możesz dostosować pm.max_children, ale to pamięć i CPU wyznaczają rzeczywistą granicę.
To ograniczenie uderza szybko, jeśli używasz workerów również do zwykłego ładowania stron, wywołań API i generowania zasobów. Monitoruj uważnie nasycenie workerów. Jeśli Twój endpoint SSE zacznie kolejkować, ponieważ wszystkie workery są zablokowane w dwudziestominutowych strumieniach, cała Twoja witryna zwolni.
Gdy liczby przestają się zgadzać, zmień technologię. Go jest zazwyczaj kolejnym krokiem, choć Rust, Node.js lub Erlang mogą pełnić tę samą rolę. Kluczem są goroutines w Go. Goroutine kosztuje zaledwie kilka kilobajtów. Możesz utrzymać dziesiątki tysięcy strumieni na skromnym sprzęcie bez większego wysiłku. Logika rdzenia pozostaje identyczna — odczyt z cache, zapis do socketu — ale środowisko uruchomieniowe zmienia się z ciężkich procesów na lekkie wątki.
Nie zaczynaj jednak od tego. PHP zaprowadzi Cię zaskakująco daleko. Najpierw zweryfikuj produkt. Gdy strona z metrykami pokaże wyczerpanie workerów zamiast przeciążenia bazy danych, oznacza to, że wyrósłeś ponad ten stos technologiczny. To dobry problem.
Podsumowanie
Liczniki na żywo nie polegają na samej technologii. Chodzi o ochronę Twojej bazy danych przed Twoimi własnymi użytkownikami. Zacznij od SSE, ponieważ jest to prostsze, niż się wydaje. Agresywnie buforuj dane (cache) między strumieniem a bazą danych, aby liczba połączeń nie przełożyła się na liczbę zapytań. Pilnuj limitów workerów jak sokół. I zacznij od prostych rozwiązań. PHP wystarczy, dopóki nie przestanie wystarczać, a do tego czasu będziesz dokładnie wiedzieć, dlaczego musisz przepisać kod.
Dla Twojego następnego projektu:
- Używaj SSE, gdy dane płyną w jedną stronę, z serwera do przeglądarki.
- Umieść warstwę cache przed bazą danych. Jedno zapytanie co kilka sekund jest lepsze niż tysiące.
- Ogranicz czas trwania połączeń SSE do mniej niż minuty i pozwól przeglądarce na ponowne połączenie.
- Zamykaj strumienie po ukryciu karty. Nie marnuj aktywnych workerów na nieaktywne połączenia.
- Monitoruj zużycie workerów PHP. Gdy osiągniesz limit, przenieś warstwę strumieniowania do Go.
