Od „To działa” do „To się skaluje”
sgcWebSockets obsługuje kilka tysięcy połączeń od razu po instalacji bez strojenia. Przepychanie się powyżej 50 000 lub 100 000 jednoczesnych gniazd na pojedynczym pudełku jest również możliwe — ale wymaga wybrania właściwej klasy serwera, dostosowania puli wątków do Twojego obciążenia, wyboru właściwej strategii kompresji i strojenia systemu operacyjnego. Ten wpis omawia każdą dźwignię, w kolejności, w jakiej powinieneś ją pociągać, z liczbami benchmarków z Hetzner AX102 (Ryzen 9 7950X3D, 128 GB RAM, NIC 1 Gbps) działającego na Windows Server 2025 i Ubuntu 24.04.
Słowo o metodologii zanim zaczniemy. Każda liczba poniżej pochodzi z syntetycznego obciążenia echo — ładunki 100-bajtowe, 10 komunikatów/s/klienta, bez logiki biznesowej. Prawdziwe aplikacje wylądują gdzieś między tymi liczbami a rzędem wielkości wolniej, w zależności od tego, co faktycznie robi Twój handler OnMessage. Traktuj je jako sufity, nie obietnice. Chodzi o pokazanie kształtu krzywej skalowania i gdzie są klify, a nie o danie gwarancji dla arbitralnego obciążenia.
Także: stroj najpierw rzecz najbliższą użytkownikowi. Nie ma sensu zgarniać 200 mikrosekund z pętli rozgłaszania, gdy Twój handshake TLS zajmuje 80 milisekund. Profiluj, znajdź wąskie gardło, napraw, powtórz. Lista poniżej jest mniej więcej uporządkowana według wpływu-na-wysiłek, ale każde obciążenie jest inne.
1. Wybierz właściwy model I/O
sgcWebSockets dostarcza dwie rodziny serwerów: oparty na Indy TsgcWebSocketServer (jeden-wątek-na-połączenie) i wspierany przez IOCP/epoll TsgcWebSocketHTTPServer (zdarzeniowy). Powyżej około 5000 jednoczesnych połączeń chcesz tego drugiego. Kropka.
| Serwer | Model I/O | Słodki punkt | Twardy sufit |
| TsgcWebSocketServer (Indy) | Wątek na połączenie | <5000 | ~10 000 (wyczerpanie stosu wątków) |
| TsgcWebSocketHTTPServer Win | IOCP | 10 000–100 000 | ~250 000 (deskryptory plików) |
| TsgcWebSocketHTTPServer Lin | epoll | 10 000–100 000 | ~1 000 000 (ze strojeniem) |
| TsgcWebSocketHTTPServer Mac | kqueue | 10 000–50 000 | ~100 000 |
2. Dostosuj rozmiar puli wątków
Serwer IOCP/epoll używa puli pracowników o stałym rozmiarze. Domyślnie CPUCount. Dla obciążeń typu echo / fan-out trzymaj ją małą (2–4 na rdzeń). Dla obciążeń, które dotykają bazy danych lub wywołują zewnętrzne API wewnątrz OnMessage, podbij ją — w przeciwnym razie jedno wolne żądanie blokuje N peerów.
oServer := TsgcWebSocketHTTPServer.Create(Self); oServer.Port := 443; // Tune the worker pool oServer.ThreadPool.PoolSize := CPUCount * 2; // CPU-bound // oServer.ThreadPool.PoolSize := 128; // DB-bound (e.g. 16 cores) oServer.ThreadPool.QueueSize := 4096; oServer.ThreadPool.MaxConnections := 100000; oServer.Active := True;
Reguła kciuka: PoolSize = CPUCount + (average_blocking_ms / average_cpu_ms) * CPUCount. Jeśli widzisz Application Insights / Twój APM raportujący rosnącą głębokość kolejki, podwój pulę. Jest twardy górny limit ustawiony przez Twój scheduler kernela — dziesiątki tysięcy wątków OS na Windows to nie zabawa — ale zwykle jesteś nigdzie blisko tego dla rozsądnych obciążeń. Powyżej 256 pracowników, rozważ przeniesienie blokującej pracy do dedykowanej puli pracowników.
Najlepszą decyzją architektoniczną jest trzymanie OnMessage nieblokujące i przepychanie każdej rzeczywistej jednostki pracy do dedykowanej kolejki obsługiwanej przez oddzielną pulę wątków. To oddziela wątek I/O (który musi pozostać szybki) od wątku biznesowego (który może być wolny bez konsekwencji). Sprawia to również, że jesteś obserwowalny: głębokość kolejki staje się wiodącym wskaźnikiem „za chwilę się przewrócimy”.
3. Kompresja: per-message-deflate
Kompresja WebSocket (RFC 7692) przycina 60–90% ładunków JSON lub tekstowych. Jest również ciężka dla CPU. Włącz ją globalnie tylko wtedy, gdy Twoje obciążenie jest tekstocentryczne ORAZ Twój CPU ma zapas. Dla binarnych lub już skompresowanych ładunków (JPEG, MP4, logi gzipowane) to czysty narzut.
oServer.Extensions.PerMessage_Deflate.Enabled := True; oServer.Extensions.PerMessage_Deflate.ServerMaxWindow := 15; // default oServer.Extensions.PerMessage_Deflate.MemLevel := 8; oServer.Extensions.PerMessage_Deflate.Threshold := 256; // skip tiny msgs
Ustaw Threshold, tak by komunikaty mniejsze niż narzut nagłówków nie były kompresowane. Pomijanie deflate na 60-bajtowym heartbeat oszczędza więcej CPU, niż kosztuje.
Subtelna pułapka: per-message-deflate używa przesuwającego się okna, które zachowuje stan między komunikatami na tym samym połączeniu. Ten stan to pamięć per-połączenie. Z ServerMaxWindow=15 (domyślnie), każde połączenie trzyma około 32 KB słownika. Pomnóż przez 100 000 połączeń, a masz 3 GB RAM tylko dla stanu kompresji. Obniż ServerMaxWindow do 10 lub 11, jeśli jesteś ograniczony pamięcią — tracisz kilka procent współczynnika kompresji w zamian za około 8x mniej pamięci per-połączenie.
4. Fragmentacja
Duże ramki (>1 MB) trzymają wątek pracownika do czasu, aż pełny komunikat zostanie ponownie złożony. Fragmentuj wychodzące komunikaty, by zachować płynne latencje i uwolnić pracowników dla innych peerów.
oClient.WriteOptions.FragmentEnabled := True; oClient.WriteOptions.FragmentSize := 65536; // 64 KB chunks
Po stronie serwera ustaw ReadOptions.MaxFrameSize na rozsądną górną granicę (używamy 4 MB), by chronić przed złośliwymi peerami, którzy próbują alokować bufory gigabajtowe.
5. Optymalizacja rozgłaszania
Wysyłanie tej samej wiadomości do każdego podłączonego klienta to wąskie gardło #1 dla serwerów czatu / tradingu / pub-sub. Naiwna pętla for each client: client.Send(msg) serializuje i kompresuje ten sam ładunek N razy. Użyj wbudowanego rozgłaszania, które serializuje raz i ponownie używa zakodowanej ramki.
// Slow: N encodes, N compresses for i := 0 to oServer.Connections.Count - 1 do oServer.Connections[i].WriteData(vJSON); // Fast: 1 encode, 1 compress, N writes oServer.Broadcast(vJSON); // Fastest for fan-out >10k: pre-encoded buffer vFrame := oServer.EncodeFrame(vJSON); oServer.BroadcastEncoded(vFrame);
Na 16-rdzeniowym pudełku różnica między naiwną pętlą a BroadcastEncoded dla fan-outu 50 000-klientowego to 12 sekund vs 380 ms. Ta sama zasada dotyczy kanałów — przed-zakoduj ramkę, potem przejdź listę subskrybentów. Jeśli Twoi subskrybenci rozdzielają się na wiele kanałów, koduj raz na kanał i rozgłaszaj wewnątrz. Przedwczesna pesymizacja w tej ścieżce kodu zatopi w przeciwnym razie szybki serwer.
6. Strojenie na poziomie OS
Kernel narzuca twarde limity na długo zanim zrobi to komponent. Stroj te zanim obwinisz bibliotekę.
| Ustawienie | Linux | Windows | Zalecenie |
| Limit deskryptorów plików | ulimit -n |
HKLM — MaxUserPort |
2 × oczekiwane połączenia |
| Backlog TCP | net.core.somaxconn |
TcpMaxConnectResponseRetransmissions |
4096+ |
| Ponowne użycie TIME_WAIT | tcp_tw_reuse=1 |
TcpTimedWaitDelay=30 |
Zmniejsza wyczerpanie portów |
| SO_REUSEPORT | kernel ≥3.9 | N/D | Wieloprocesowy akceptor |
| Zakres portów efemerycznych | net.ipv4.ip_local_port_range |
MaxUserPort |
10000–65535 |
7. Heartbeat i wykrywanie bezczynności
Klienci mobilni cały czas wypadają z sieci. Bez heartbeatów Twój serwer trzyma gniazdo otwarte do czasu, aż uruchomi się timer keepalive TCP (zwykle 2 godziny). Skonfiguruj krótkie heartbeaty i wykrywanie martwych peerów.
oServer.HeartBeat.Enabled := True; oServer.HeartBeat.Interval := 30; // seconds oServer.HeartBeat.Timeout := 90; // close if no pong within this
To wykrywa połączenia półotwarte w ciągu 90 sekund zamiast dwóch godzin, uwalniając tysiące przestarzałych gniazd na zajętym serwerze.
8. Parowanie z load balancerem
Jeśli musisz skalować poza pojedyncze pudełko, sparuj sgcWebSockets z naszym TsgcWebSocketHTTPServer_LoadBalancer lub zewnętrznym LB L7 (HAProxy, nginx, AWS ALB). Dwie zasady:
- Używaj sticky sessions — ramki WebSocket nie są idempotentne i nie mogą być ponownie routowane w trakcie rozmowy.
- Przekazuj oryginalne nagłówki
X-Forwarded-Fori terminacji TLS, by Twoja aplikacja widziała prawdziwe IP klientów.
Benchmarki referencyjne
Liczby z pojedynczego pudełka AX102 (16 rdzeni / 32 wątki, 128 GB), uruchamiającego serwer echo z ładunkami 100-bajtowymi przy 10 komunikatach/s/klienta.
| Jednoczesnych klientów | Przepustowość (komunikat/s) | Latencja p50 | Latencja p99 | Użycie CPU | RSS |
| 10 000 | 100 000 | 0,8 ms | 3,2 ms | 14% | 0,9 GB |
| 50 000 | 500 000 | 1,1 ms | 5,4 ms | 38% | 3,8 GB |
| 100 000 | 1 000 000 | 1,7 ms | 9,8 ms | 71% | 7,2 GB |
| 250 000 | 2 500 000 | 3,4 ms | 22 ms | 96% | 17,8 GB |
9. Terminacja TLS
Handshake TLS są kosztowne dla CPU. Jeśli obsługujesz tysiące nowych połączeń na sekundę, terminacja TLS w procesie Delphi może nasycać rdzenie wykonujące kryptografię zamiast obsługiwać ramki. Dla obciążeń o wysokiej rotacji terminujemy TLS w nginx lub HAProxy przed serwerem sgcWebSockets i uruchamiamy backend w zwykłym HTTP. Frontend dostaje sprzętowe przyspieszenie AES, wznowienie sesji i OCSP stapling za darmo, a proces Delphi może wydać 100% swojego CPU na logikę aplikacji.
Dla obciążeń z długotrwałymi połączeniami (typowy scenariusz czatu / tradingu) wewnątrz-procesowy TLS jest OK, bo handshake jest amortyzowany przez godziny lub dni. Dla wybuchów connect-disconnect-reconnect (klienci mobilni na niestabilnych sieciach), umieść reverse proxy z przodu.
10. Strojenie NIC i sieci
Przy 1 Gbps nie jest prawdopodobne, że nasycisz NIC. Powyżej 10 Gbps musisz myśleć o łączeniu przerwań, skalowaniu po stronie odbiorczej (RSS) i przypinaniu wątków pracowników sgcWebSockets do rdzeni lokalnych NUMA. Na Linuksie ethtool -L i set_irq_affinity.sh to Twoi przyjaciele. Na Windows ustaw RSS Profile na NUMAScaling we właściwościach NIC i zweryfikuj z Get-NetAdapterRss. Warto stroić tylko jeśli Twój monitoring mówi Ci, że kernel spędza prawdziwy czas w softirq lub DPC.
Pętla strojenia kierowana profilem
Strojenie jest iteracyjne. Zacznij od domyślnych, uruchom reprezentatywne obciążenie, spójrz na: CPU na pracownika, tempo GC / alokacji, latencję p99 pod fan-outem i liczniki połączeń na poziomie OS. Zmień jedną rzecz, uruchom ponownie, porównaj. Najczęstsze niespodzianki:
- Kompresja włączona na już skompresowanych ładunkach → skoki CPU dla zerowego zysku pasma.
- Synchroniczne wywołania DB wewnątrz
OnMessage→ pula pracowników nasycona przy <1% CPU. - Brak batchingu rozgłaszania → blokowanie head-of-line podczas otwarcia rynku.
- Domyślna pula wątków na 64-rdzeniowym pudełku → serializacja pracy na 64 pracowników, gdy 256 odblokowałoby 4× przepustowość.
Dalsza lektura
Jeśli jeszcze nie wybrałeś właściwej klasy serwera, zacznij od Która edycja. Potem przejdź do komponentu Load Balancer dla skalowania na wiele pudełek. Nowy w bibliotece? Hub Pierwsze kroki przeprowadzi Cię przez instalację w pięć minut.