Klient napisał do nas z prostą obserwacją, która okazała się całkowicie trafna: serwowanie plików z serwera HTTP zużywało dużo pamięci. Jeśli TsgcWebSocketHTTPServer serwował duży plik z DocumentRoot, cały plik był wczytywany do RAM dla każdego połączenia. Stu klientów pobierających ten sam plik o rozmiarze 1 GB oznaczało mniej więcej 100 GB RAM. To wydanie rozwiązuje ten problem. Pliki są teraz strumieniowane z dysku, a zużycie pamięci serwera pozostaje płaskie niezależnie od rozmiaru pliku i liczby podłączonych klientów. Zmiana trafia do sgcWebSockets 2026.6.0.
Problem: jedna pełna kopia na każde połączenie
Obie ścieżki serwowania plików statycznych, DoResponseHTTP dla HTTP/1.x oraz DoHTTP2DocumentRoot dla HTTP/2, budowały odpowiedź w ten sam sposób. Tworzyły TMemoryStream, wywoływały LoadFromFile i przekazywały ten strumień do odpowiedzi. LoadFromFile wczytuje cały plik do pamięci za jednym razem, więc koszt na żądanie wynosił pełny rozmiar pliku, a każde równoległe pobranie utrzymywało własną kopię.
// Old behaviour, simplified
oStream := TMemoryStream.Create;
oStream.LoadFromFile(vDocument); // entire file into RAM
AResponseInfo.ContentStream := oStream;
To sprawia, że szczytowe zużycie pamięci wynosi mniej więcej rozmiar pliku × liczba równoległych połączeń. Garstka klientów pobierających film, instalator, obraz dysku lub kopię zapasową bazy danych wystarczy, aby zepchnąć serwer w swapowanie lub awarię z powodu braku pamięci.
Mnożnik powolnego odczytu
Ten sam klient opisał drugi scenariusz, a jest to realny atak: wiele połączeń, z których każde odczytuje odpowiedź bardzo wolno, rzędu jednego bajta na sekundę. To atak typu slow-read, odmiana Slowloris. Klient, który sączy swoje odczyty, utrzymuje połączenie otwarte przez bardzo długi czas, a w starym kodzie każde z tych połączeń przypinało też pełną kopię pliku w pamięci. Kilka tysięcy powolnych czytelników skromnego pliku mogło przetrzymywać gigabajty jako zakładnika, przesyłając przy tym niemal nic.
Jak to zmierzyliśmy
Zbudowaliśmy niewielkie środowisko testowe: serwer z DocumentRoot i dużym plikiem testowym oraz klienta z dwoma trybami. Tryb „szybki” otwiera N równoległych normalnych pobrań, a tryb „wolny” otwiera M surowych połączeń, które wysyłają poprawne żądanie, a następnie odczytują treść z prędkością jednego bajta na sekundę. Próbkowaliśmy zestaw roboczy serwera dla pliku o rozmiarze 100 MB, przed poprawką i po niej.
| Scenariusz (plik 100 MB) | Przed poprawką | Po poprawce |
|---|---|---|
| 5 równoległych pobrań | ~511 MB | ~11 MB |
| 10 równoległych pobrań | ~1012 MB | ~11 MB |
| 20 równoległych pobrań | ~1.7 GB | ~12 MB |
| 100 połączeń z powolnym odczytem (1 byte/sec) | ~1.67 GB utrzymywane płasko | ~19 MB |
| Integralność pliku | nie dotyczy | SHA-256 bajt w bajt, poprawny Content-Length |
Przed poprawką zużycie pamięci śledziło rozmiar pliku pomnożony przez liczbę połączeń niemal dokładnie. Po poprawce pozostaje na poziomie dziesiątek megabajtów niezależnie od tego, jak duży jest plik i ilu klientów się łączy.
Poprawka: strumieniowanie z dysku
Plik jest teraz serwowany za pomocą tylko do odczytu, współdzielonego TFileStream zamiast TMemoryStream. Nic innego w potoku nie musiało się zmienić, ponieważ strona zapisu już strumieniuje: serwer HTTP/1.x wysyła strumień treści w porcjach 32 KB, a serwer HTTP/2 emituje go w ramkach DATA o rozmiarze mniej więcej 64 KB. Serwer odczytuje więc małą porcję z dysku, wysyła ją i przechodzi dalej, co obniża koszt pamięci na połączenie z całego pliku do kilkudziesięciu kilobajtów.
// New behaviour, simplified
oStream := TFileStream.Create(vDocument, fmOpenRead or fmShareDenyWrite);
oStream.Position := 0;
AResponseInfo.ContentStream := oStream;
Content-Length jest nadal poprawny, ponieważ pochodzi z TFileStream.Size, a strumień jest zwalniany po odpowiedzi dokładnie tak jak wcześniej. Zweryfikowaliśmy, że pobrania są bajt w bajt identyczne z plikiem źródłowym za pomocą porównania SHA-256 oraz że nagłówek raportuje właściwą długość. Nie ma żadnej zmiany API ani niczego do skonfigurowania: ustawienie DocumentRoot działa tak jak zawsze, po prostu nie buforuje już pliku.
oServer := TsgcWebSocketHTTPServer.Create(nil);
oServer.Port := 80;
oServer.DocumentRoot := 'C:\inetpub\files'; // large files now stream from disk
oServer.Active := True;
Edycja .NET dziedziczy poprawkę automatycznie, ponieważ jej serwowanie plików z DocumentRoot działa w tym samym skompilowanym rdzeniu, którego używają komponenty Delphi.
Spowalnianie powolnych czytelników
Strumieniowanie z dysku usuwa amplifikację pamięci, więc powolny czytelnik nie może już przypiąć całego pliku w RAM. Pozostaje samo połączenie, a na to jest limit czasu wysyłki. Options.WriteTimeOut ustawia termin na zapisy do gniazda, więc klient, który przestaje przyjmować dane, jest odłączany zamiast blokować wątek serwera na zawsze. W tym wydaniu ten limit czasu działa również na Linuksie i innych platformach POSIX, nie tylko w Windows. API gniazd POSIX oczekuje timeval, a nie liczby całkowitej milisekund, i jest to teraz obsługiwane poprawnie.
oServer := TsgcWebSocketHTTPServer.Create(nil);
oServer.DocumentRoot := 'C:\inetpub\files';
oServer.Options.WriteTimeOut := 15000; // drop a client that stalls for 15s, Windows and POSIX
oServer.Active := True;
W przypadku serwera publicznego zalecamy połączenie trzech dźwigni: ustaw Options.WriteTimeOut, aby zablokowane zapisy były odrzucane, użyj RateLimit.MaxConnectionsPerIP z zapory, aby ograniczyć, ile połączeń może otworzyć pojedynczy adres, oraz rozważ harmonogram puli wątków lub handlery IOCP i EPOLL, tak aby zalew powolnych połączeń nie mógł wyczerpać po jednym wątku na każde.
Aktualizacja
Poprawka strumieniowania jest typu drop-in. Gdy tylko zaktualizujesz, DocumentRoot serwuje pliki z dysku z płaskim zużyciem pamięci, bez zmiany kodu. Zmiana została zweryfikowana w Delphi od 7 do 13, na ścieżkach HTTP/1.x i HTTP/2, a kompilacja .NET przejmuje ją przez współdzielony rdzeń. Jeśli serwujesz duże pliki statyczne, to znacząca redukcja zużycia pamięci i przydatny krok przeciwko nadużyciom typu slow-read.
Zaktualizuj ze strony pobierania sgcWebSockets lub pobierz przez GetIt albo swoje zarejestrowane konto.
Pytania, opinie lub pomoc przy migracji? Skontaktuj się z nami, otrzymasz odpowiedź od ludzi, którzy napisali ten kod.
