...

Optymalizacja puli wątków dla serwerów internetowych: porównanie Apache, NGINX i LiteSpeed

Ten artykuł pokazuje, jak serwer internetowy z pulą wątków Konfiguracja w Apache, NGINX i LiteSpeed Kontrola równoległości, opóźnień i zapotrzebowania na pamięć. Wyjaśniam, które ustawienia mają znaczenie pod obciążeniem, a gdzie wystarczy samoczynna regulacja – z wyraźnymi różnicami w liczbie zapytań na sekundę.

Punkty centralne

  • Architektura: Procesy/wątki (Apache) a zdarzenia (NGINX/LiteSpeed)
  • Samoczynne dostrajanie: Automatyczna regulacja zmniejsza opóźnienia i przerwy
  • Zasoby: Rdzenie procesora i pamięć RAM określają sensowne rozmiary wątków
  • Obciążenie pracą: Obciążenie I/O wymaga większej liczby wątków, obciążenie CPU wymaga mniejszej liczby wątków.
  • Strojenie: Małe, ukierunkowane parametry mają większy wpływ niż wartości ryczałtowe.

Porównanie architektur puli wątków

Zaczynam od Architektura, ponieważ określa granice przestrzeni tuningowej. Apache opiera się na procesach lub wątkach na połączenie, co wymaga większej ilości pamięci RAM i zwiększa opóźnienia w godzinach szczytu [1]. NGINX i LiteSpeed stosują model sterowany zdarzeniami, w którym niewielka liczba pracowników obsługuje wiele połączeń – pozwala to uniknąć zmian kontekstu i zmniejsza obciążenie [1]. W testach NGINX przetworzył 6025,3 żądań/s, Apache osiągnął w tym samym scenariuszu 826,5 żądań/s, a LiteSpeed uplasował się na pierwszym miejscu z wynikiem 69 618,5 żądań/s [1]. Osoby, które chcą zagłębić się w porównanie architektur, znajdą więcej kluczowych danych pod adresem Apache kontra NGINX, które wykorzystuję do wstępnej klasyfikacji.

Ważne jest również to, jak każdy silnik radzi sobie z zadaniami blokującymi. NGINX i LiteSpeed oddzielają pętlę zdarzeń od systemu plików lub upstream I/O za pomocą interfejsów asynchronicznych i ograniczonych wątków pomocniczych. Apache w klasycznym modelu wiąże jeden wątek/proces na połączenie; dzięki MPM event można odciążyć Keep-Alive, jednak zużycie pamięci na połączenie pozostaje wyższe. W praktyce oznacza to, że im więcej jednoczesnych wolnych klientów lub dużych przesyłek, tym bardziej opłacalny jest model zdarzeń.

Jak naprawdę działa samoczynna regulacja

Nowoczesne serwery kontrolują Wątek-Liczba często automatycznie. Kontroler sprawdza obciążenie w krótkich cyklach, porównuje aktualne wartości z wartościami historycznymi i skaluje wielkość puli w górę lub w dół [2]. W przypadku zawieszenia kolejki algorytm skraca swój cykl i dodaje dodatkowe wątki, aż przetwarzanie znów przebiega stabilnie [2]. Pozwala to uniknąć interwencji, zapobiega nadmiernej alokacji i zmniejsza prawdopodobieństwo blokad typu „head-of-line”. Jako punkt odniesienia służy mi udokumentowane zachowanie kontrolera samonastawnego w Open Liberty, które dokładnie opisuje tę mechanikę [2].

Zwracam przy tym uwagę na trzy czynniki: Histereza przeciwko flappingowi (brak natychmiastowej reakcji na każdy impuls), a sztywny limit górny przeciwko przepełnieniu pamięci RAM i minimalny rozmiar, aby koszty rozgrzewania nie były ponoszone przy każdym wybuchu. Sensowne jest również ustalenie oddzielnej wartości docelowej dla aktywny Wątki (coreThreads) a maksymalna liczba wątków (maxThreads). Dzięki temu pula pozostaje aktywna, nie zajmując zasobów w stanie bezczynności [2]. W środowiskach współdzielonych ograniczam tempo ekspansji, aby serwer WWW nie zajmował agresywnie slotów procesora w stosunku do sąsiednich usług [4].

Wskaźniki z benchmarków

Wartości rzeczywiste pomagają w Decyzje. W scenariuszach typu burst NGINX wyróżnia się bardzo niską latencją i wysoką stabilnością [3]. W przypadku ekstremalnej równoległości Lighttpd osiąga w testach najwyższą liczbę żądań na sekundę, a OpenLiteSpeed i LiteSpeed plasują się tuż za nim [3]. NGINX radzi sobie z transferem dużych plików z prędkością do 123,26 MB/s, a OpenLiteSpeed plasuje się tuż za nim, co podkreśla wydajność architektury sterowanej zdarzeniami [3]. Wykorzystuję takie wskaźniki, aby ocenić, gdzie dostosowania wątków naprawdę przynoszą korzyści, a gdzie ograniczenia wynikają z architektury.

Serwer Model/Wątki Przykładowa stawka główne przesłanie
Apacz Proces/wątek na połączenie 826,5 żądań/s [1] Elastyczność, ale większe zapotrzebowanie na pamięć RAM
NGINX Wydarzenie + niewielka liczba pracowników 6025,3 żądań/s [1] Niski Opóźnienie, oszczędny
LiteSpeed Zdarzenie + LSAPI 69 618,5 żądań/s [1] Bardzo szybki, dostosowywanie GUI
Lighttpd Zdarzenie + asynchroniczne 28 308 żądań/s (wysoka równoległość) [3] Skalowane w Wskazówki bardzo dobry

Tabela pokazuje względne Zalety, żadnych stałych zobowiązań. Zawsze oceniam je w kontekście własnych obciążeń: krótkie dynamiczne odpowiedzi, wiele małych plików statycznych lub duże strumienie. Odchylenia mogą wynikać z sieci, pamięci masowej, odciążenia TLS lub konfiguracji PHP. Dlatego koreluję wskaźniki, takie jak CPU-Steal, długość kolejki uruchomień i RSS na pracownika z liczbą wątków. Tylko takie podejście pozwala odróżnić rzeczywiste wąskie gardła wątków od ograniczeń I/O lub aplikacji.

Aby uzyskać wiarygodne dane liczbowe, wykorzystuję fazy ramp-up i porównuję opóźnienia p50/p95/p99. A stroma krzywa p99 przy stałych wartościach p50 wskazuje raczej na kolejki niż na czyste nasycenie procesora. Otwarte (sterowane przez RPS) zamiast zamkniętych (sterowanych wyłącznie przez współbieżność) profili obciążenia lepiej pokazują również, gdzie system zaczyna aktywnie odrzucać żądania. W ten sposób mogę zdefiniować punkt, w którym podnoszenie wątków nie przynosi już żadnych korzyści, a bardziej sensowne jest stosowanie przeciwciśnienia lub limitów szybkości.

Praktyka: wymiarowanie elementów i połączeń

Zaczynam od CPU-Rdzenie: worker_processes lub LSWS‑Worker nie mogą przekraczać liczby rdzeni, w przeciwnym razie wzrośnie liczba zmian kontekstu. W przypadku NGINX dostosowuję worker_connections tak, aby suma połączeń i deskryptorów plików pozostała poniżej ulimit‑n. W przypadku Apache unikam zbyt wysokich wartości MaxRequestWorkers, ponieważ RSS na dziecko szybko zużywa pamięć RAM. W LiteSpeed utrzymuję równowagę między pulami procesów PHP i pracownikami HTTP, aby PHP nie stało się wąskim gardłem. Jeśli chcesz zrozumieć różnice w szybkości między silnikami, skorzystaj z porównania. LiteSpeed vs. Apache, którego używam jako tło do tuningu.

Prosta zasada: najpierw obliczam budżet FD (ulimit-n minus rezerwa na logi, upstreamy i pliki), dzielę go przez planowaną liczbę jednoczesnych połączeń na pracownika i sprawdzam, czy suma wystarczy na HTTP + upstream + bufor TLS. Następnie moderuję kolejkę backlogów – wystarczająco dużą, aby obsłużyć nagłe wzrosty, ale wystarczająco małą, aby nie ukrywać przeciążenia. Na koniec ustawiam wartości Keep-Alive tak, aby pasowały do wzorców zapytań: krótkie strony z dużą ilością zasobów korzystają z dłuższych limitów czasu, a ruch API z niewielką liczbą żądań na połączenie raczej z niższymi wartościami.

Precyzyjne dostrojenie LiteSpeed dla dużego obciążenia

W przypadku LiteSpeed stawiam na LSAPI, ponieważ minimalizuje to zmiany kontekstu. Gdy tylko zauważę, że procesy CHILD są wyczerpane, stopniowo zwiększam LSAPI_CHILDREN z 10 do 40, a w razie potrzeby do 100 – za każdym razem sprawdzając CPU i RAM [6]. GUI ułatwia mi tworzenie słuchaczy, udostępnianie portów, przekierowania i wczytywanie plików .htaccess, co przyspiesza wprowadzanie zmian [1]. Pod stałym obciążeniem testuję efekt małych kroków zamiast dużych skoków, aby wcześnie wykrywać szczyty opóźnień. W środowiskach współdzielonych zmniejszam liczbę coreThreads, gdy inne usługi obciążają procesor, aby Self‑Tuner nie utrzymywał zbyt wielu aktywnych wątków [2][4].

Dodatkowo obserwuję Keep-Alive na słuchacza i wykorzystanie HTTP/2/HTTP/3: multipleksowanie zmniejsza liczbę połączeń, ale zwiększa zapotrzebowanie na pamięć na gniazdo. Dlatego też zachowuję bufory wysyłania na konserwatywnym poziomie i aktywuję kompresję tylko tam, gdzie korzyść netto jest oczywista (wiele odpowiedzi tekstowych, niewielkie ograniczenia procesora). W przypadku dużych plików statycznych polegam na mechanizmach zero-copy i ograniczam liczbę jednoczesnych slotów pobierania, aby pracownicy PHP nie głodowali w przypadku wystąpienia szczytów ruchu.

NGINX: Efektywne wykorzystanie modelu zdarzeń

Dla NGINX ustawiam worker_processes na samochód lub liczbę rdzeni. Dzięki epoll/kqueue, aktywnemu accept_mutex i dostosowanym wartościom backlog utrzymuję równomierny poziom akceptacji połączeń. Dbam o to, aby keepalive_requests i keepalive_timeout były ustawione tak, aby nieaktywne gniazda nie zatykały puli FD. Duże pliki statyczne przesyłam za pomocą sendfile, tcp_nopush i odpowiednich output_buffers. Ograniczanie szybkości i limitów połączeń stosuję tylko wtedy, gdy boty lub bursty pośrednio obciążają pulę wątków, ponieważ każde ograniczenie powoduje dodatkowe zarządzanie stanem.

W scenariuszach proxy jest Upstream‑Keepalive Decydujące znaczenie ma to, że zbyt niska wartość powoduje opóźnienia w nawiązywaniu połączeń, a zbyt wysoka blokuje FD. Wybieram wartości odpowiednie do wydajności zaplecza i wyraźnie rozdzielam limity czasu dla connect/read/send, aby uszkodzone zaplecze nie blokowało pętli zdarzeń. Za pomocą reuseport i opcjonalnej afiniczności procesora rozkładam obciążenie bardziej równomiernie na rdzenie, o ile pozwalają na to ustawienia IRQ/RSS karty sieciowej. W przypadku HTTP/2/3 ostrożnie kalibruję limity nagłówków i kontroli przepływu, aby pojedyncze duże strumienie nie dominowały całego połączenia.

Apache: Prawidłowe ustawienie MPM event

W Apache używam wydarzenie zamiast prefork, aby sesje Keep-Alive nie zajmowały na stałe procesów roboczych. MinSpareThreads i MaxRequestWorkers ustawiam tak, aby kolejka uruchomień na rdzeń pozostawała poniżej 1. Utrzymuję ThreadStackSize na niskim poziomie, aby więcej procesów roboczych zmieściło się w dostępnej pamięci RAM; nie może być jednak zbyt mały, ponieważ w przeciwnym razie istnieje ryzyko przepełnienia stosu w modułach. Dzięki umiarkowanemu limitowi czasu KeepAlive i ograniczonym żądaniom KeepAliveRequests zapobiegam blokowaniu wielu wątków przez niewielką liczbę klientów. Przenoszę PHP do PHP-FPM lub LSAPI, aby serwer WWW pozostał lekki.

Zwracam również uwagę na stosunek ServerLimit, ThreadsPerChild i MaxRequestWorkers: te trzy parametry razem określają, ile wątków może faktycznie powstać. W przypadku HTTP/2 używam MPM event z umiarkowanymi limitami strumieni; zbyt wysokie wartości powodują wzrost zużycia pamięci RAM i kosztów harmonogramu. Moduły z dużymi globalnymi pamięciami podręcznymi ładuję tylko wtedy, gdy są potrzebne, ponieważ korzyści płynące z kopiowania przy zapisie zanikają, gdy procesy działają przez długi czas i zmieniają pamięć.

RAM i wątki: dokładne obliczenia pamięci

Liczę RSS na pracownika/dziecko razy planowaną maksymalną liczbę i dodaję bufor jądra oraz pamięci podręczne. Jeśli nie pozostaje żaden bufor, nigdy nie zmniejszam liczby wątków ani nie zwiększam pamięci wymiany, ponieważ wymiana powoduje gwałtowny wzrost opóźnień. W przypadku PHP-FPM lub LSAPI dodatkowo obliczam średnią wartość PHP-RSS, aby suma serwera WWW i SAPI pozostała stabilna. Uwzględniam koszty terminacji TLS, ponieważ uzgodnienia certyfikatów i duże bufory wychodzące zwiększają zużycie. Dopiero gdy budżet pamięci RAM jest zrównoważony, dalej zwiększam liczbę wątków.

W przypadku HTTP/2/3 uwzględniam dodatkowe stany nagłówków/kontroli przepływu dla każdego połączenia. GZIP/Brotli buforują jednocześnie skompresowane i nieskompresowane dane, co może oznaczać kilkaset KB dodatkowej przestrzeni na każde żądanie. Planuję również rezerwy na logi i pliki tymczasowe. W przypadku Apache mniejsze wartości ThreadStackSize zwiększają gęstość, natomiast w przypadku NGINX i LiteSpeed decydujące znaczenie ma liczba równoległych gniazd i rozmiar buforów wysyłania/odbioru. Zsumowanie wszystkich komponentów przed dostrojeniem pozwala uniknąć przykrych niespodzianek w przyszłości.

Kiedy interweniuję ręcznie

Polegam na Samoczynne dostrajanie, dopóki wskaźniki nie pokażą czegoś przeciwnego. Jeśli dzielę maszynę w ramach hostingu współdzielonego, ograniczam coreThreads lub MaxThreads, aby inne procesy miały wystarczającą ilość czasu procesora [2][4]. Jeśli istnieje sztywny limit wątków na proces, ustawiam maxThreads konserwatywnie, aby uniknąć błędów systemu operacyjnego [2]. Jeśli pojawiają się wzorce podobne do zakleszczenia, zwiększam tylko krótkoterminowo rozmiar puli, obserwuję kolejki, a następnie ponownie zmniejszam rozmiar. Jeśli chcesz porównać typowe wzorce z wartościami pomiarowymi, wskazówki znajdziesz w Porównanie prędkości serwera WWW, który chętnie wykorzystuję jako test wiarygodności.

Jako sygnały interwencji wykorzystuję przede wszystkim: utrzymujące się szczyty p99 pomimo niskiego obciążenia procesora, rosnące kolejki gniazd, silnie rosnące TIME_WAITLiczby lub nagły wzrost otwartych FD. W takich przypadkach najpierw ograniczam założenia (limity połączeń/szybkości), odłączam backendy z limitami czasu, a dopiero potem ostrożnie zwiększam liczbę wątków. W ten sposób unikam przeniesienia przeciążenia tylko do wewnątrz i pogorszenia opóźnień dla wszystkich.

Typowe błędy i szybkie kontrole

Często oglądam wysoki Limity czasu Keep-Alive, które wiążą wątki, mimo że nie przepływają żadne dane. Również powszechne: MaxRequestWorkers znacznie przekraczające budżet pamięci RAM i ulimit-n zbyt niskie dla docelowej równoległości. W NGINX wielu nie docenia wykorzystania FD przez połączenia upstream; każdy backend liczy się podwójnie. W LiteSpeed pule PHP rosną szybciej niż pracownicy HTTP, co powoduje, że żądania są przyjmowane, ale obsługiwane zbyt późno. Dzięki krótkim testom obciążenia, porównaniu Heap/RSS i spojrzeniu na kolejkę uruchomień znajduję te wzorce w ciągu kilku minut.

Również często: zbyt mały syn-backlog, przez co połączenia odbijają się jeszcze przed serwerem WWW; logi dostępu bez bufora, które zapisują się synchronicznie na wolnej pamięci; logi debugowania/śledzenia, które przypadkowo pozostają aktywne i obciążają procesor. Po przejściu na HTTP/2/3 zbyt duże limity strumieni i bufory nagłówków zwiększają zużycie pamięci na połączenie – jest to szczególnie widoczne, gdy wielu klientów przesyła niewielką ilość danych. Dlatego sprawdzam rozkład krótkich i długich odpowiedzi i odpowiednio dostosowuję limity.

HTTP/2 i HTTP/3: co oznaczają dla pul wątków

Multipleksowanie znacznie zmniejsza liczbę połączeń TCP na klienta. Jest to korzystne dla FD i kosztów akceptacji, ale przenosi presję na stany połączeń. Dlatego dla HTTP/2 ustawiam ostrożne limity dla jednoczesnych strumieni i kalibruję kontrolę przepływu, aby pojedyncze duże pliki do pobrania nie dominowały w połączeniu. W przypadku HTTP/3 nie ma blokad typu „head-of-line” związanych z TCP, ale wzrasta zużycie procesora na pakiet. Nagradzam to wystarczającą wydajnością pracowników i małymi rozmiarami buforów, aby opóźnienia pozostały niskie. We wszystkich przypadkach obowiązuje zasada: lepiej mieć mniej połączeń, które są dobrze wykorzystywane z rozsądnymi wartościami Keep-Alive, niż zbyt długie sesje bezczynności, które zajmują wątki i pamięć.

Czynniki platformy: jądro, kontener i NUMA

W zakresie wirtualizacji zwracam uwagę na CPU-Steal i limity cgroups: jeśli hiperwizor kradnie rdzenie lub kontener posiada tylko częściowe rdzenie, worker_processes=auto może być zbyt optymistyczne. W razie potrzeby przypisuję procesy robocze do rzeczywistych rdzeni i dostosowuję liczbę do skutecznie dostępny budżet. Na hostach NUMA serwery WWW korzystają z lokalnego przypisania pamięci; unikam niepotrzebnego dostępu między węzłami, grupując procesy robocze według gniazd. Transparent Huge Pages często pozostawiam wyłączone dla obciążeń krytycznych pod względem opóźnień, aby uniknąć szczytów błędów stron.

Na poziomie systemu operacyjnego kontroluję limity deskryptorów plików, zaległości połączeń i zakres portów dla połączeń wychodzących. Zwiększam tylko to, czego faktycznie potrzebuję, testuję zachowanie podczas rolloveru i ściśle przestrzegam limitów bezpieczeństwa. Po stronie sieciowej upewniam się, że dystrybucja RSS/IRQ i ustawienia MTU są dostosowane do profilu ruchu – w przeciwnym razie tuning serwera WWW nie przyniesie efektów, ponieważ pakiety będą docierać zbyt wolno lub utkną w kolejce NIC.

Mierzyć zamiast zgadywać: praktyczny przewodnik po testach

Przeprowadzam testy obciążenia w trzech etapach: rozgrzewka (pamięci podręczne, JIT, sesje TLS), plateau (stabilny RPS/współbieżność) i burst (krótkie szczyty). Oddzielne profile dla plików statycznych, wywołań API i stron dynamicznych pomagają w izolowanym określeniu, gdzie występują ograniczenia w zakresie wątków, operacji wejścia/wyjścia lub backendów. Równolegle zapisuję wartości FD, kolejki uruchomień, zmiany kontekstu, RSS na proces oraz opóźnienia p50/p95/p99. Jako cel wybieram punkty operacyjne przy obciążeniu 70–85 % – wystarczający bufor dla rzeczywistych wahań, bez ciągłej pracy w obszarze nasycenia.

Krótki przewodnik po podejmowaniu decyzji

Wybieram NGINX, gdy liczy się małe opóźnienie, oszczędne zasoby i elastyczne możliwości dostosowywania .conf. Stawiam na LiteSpeed, gdy dominuje obciążenie PHP, GUI ma ułatwiać obsługę, a LSAPI zmniejsza wąskie gardła. Sięgam po Apache, gdy potrzebuję modułów i .htaccess oraz mam pod kontrolą konfigurację MPM-event. W wielu przypadkach mechanizmy samoczynnej regulacji są wystarczające; muszę interweniować tylko wtedy, gdy wskaźniki wskazują na zawieszanie się, twarde limity lub obciążenie pamięci RAM [2]. Dzięki realistycznym budżetom rdzenia i pamięci RAM, małym krokom i obserwacji krzywych opóźnień, regulacja wątków niezawodnie prowadzi mnie do celu.

Artykuły bieżące