Po aktualizacji do PHP 8.2 strony losowo przekraczały limit 30 sekund. Winna była blokada pliku sesji, nie PHP 8

Po migracji z PHP 7.4 na PHP 8.2 ta sama strona raz ładowała się normalnie, a raz kończyła się błędem po 30 sekundach. Odtworzyliśmy obie wersje obok siebie i znaleźliśmy przyczynę: około 20 równoległych wywołań AJAX jednego użytkownika czekało po kolei na blokadę pliku sesji. Naprawione jedną linią na skrypt, bez przepisywania aplikacji.

  • 20 równoległych wywołań AJAX: 30 s, po zmianie 1,5 s
  • Oczekiwanie strony głównej: 1,6 s, po zmianie 0 s
  • Jedna linia na skrypt zamiast nowej aplikacji
U góry pod session_start() dwadzieścia czarnych pasków ułożonych w schody, jeden po drugim, aż do znacznika 30 s; na dole pod + session_write_close() te same dwadzieścia pasków na pomarańczowo obok siebie, wszystkie gotowe po 1,5 s

Aplikacja webowa na Windows Server, Apache 2.4 z mod_php, PHP 7.4.9 na 8.2.35

Branża: Portale internetowe · Czas trwania: 4 godziny

Wyzwanie

Strona główna aplikacji jest renderowana w PHP, a po jej załadowaniu przeglądarka uruchamia równolegle około 20 wywołań AJAX. Wszystkie skrypty dołączają wspólny plik startowy, który ładuje konfigurację, autoloader i silnik szablonów, wywołuje session_start() i zapisuje wartość do $_SESSION.

Po aktualizacji PHP zespół klienta zgłosił, że aplikacja „spowolniła nierównomiernie”. Pod uwagę brano ogólnie wolniejsze PHP 8 i obsługę sesji. Doraźnie pomogło dodanie session_write_close() w jednym ze skryptów AJAX, ale nie było jasne, dlaczego działa ani czy PHP 8 zmieniło zachowanie sesji. Pytanie brzmiało: czy da się to naprawić bez przepisywania programu?

Rozwiązanie

Odtworzyliśmy aplikację w dwóch kontenerach Docker, php:7.4-apache i php:8.2-apache, czyli Apache z mod_php jak u klienta, z tymi samymi plikami i prawdziwym silnikiem szablonów przy każdym wywołaniu. Generator obciążenia zachowywał się jak przeglądarka: jedna zalogowana sesja, w każdej rundzie 21 równoległych wywołań z tym samym cookie, strona główna i 20 wywołań AJAX. Do tego test ciągły: sześciu klientów wysyła wywołania AJAX bez przerwy, a co 2 sekundy ładowana jest strona główna.

Obie wersje zachowały się identycznie, to nie była regresja PHP 8. Domyślny handler sesji files zakłada w session_start() wyłączną, blokującą blokadę na pliku sesji i trzyma ją do session_write_close() albo do końca skryptu. Wszystkie wywołania jednego użytkownika wykonują się więc po kolei: dwadzieścia wywołań po 1,5 s to 30 s dla ostatniego z nich, a strona główna kliknięta w tym czasie staje w tej samej kolejce. Na Windows max_execution_time liczy czas rzeczywisty, więc samo czekanie na blokadę zużywa limit i kończy się błędem krytycznym.

Rozwiązaniem jest przełącznik we wspólnym pliku startowym: skrypt, który sesję tylko czyta, zwalnia blokadę zaraz po odczycie. Jedna linia na skrypt, bez przebudowy aplikacji.

Wyniki

20 równoległych wywołań AJAX po 1,5 s, czas ostatniego
Z 30,0 s do 1,5 s
Oczekiwanie strony głównej podczas równoległych wywołań
0,00 s zamiast 1,6 do 1,8 s
Zmiana w kodzie
Jeden przełącznik w pliku startowym, jedna linia na skrypt
Zachowanie blokady sesji
PHP 7.4 i PHP 8.2 tak samo

Jak pracowaliśmy

  1. Odtworzenie w kontrolowanych warunkach

    Dwa kontenery, PHP 7.4 i PHP 8.2 z Apache i mod_php jak u klienta, te same pliki i prawdziwy silnik szablonów przy każdym wywołaniu.

  2. Obciążenie jak z przeglądarki

    Jedna zalogowana sesja, 21 równoległych wywołań z tym samym cookie, pomiar czasu każdego z nich; czas pracy wywołania ustawiany parametrem.

  3. Test ciągły z perspektywy użytkownika

    Sześciu klientów wysyła wywołania AJAX bez przerwy, strona główna co 2 sekundy; mierzymy, jak długo czeka strona główna.

  4. Porównanie źródeł PHP

    Moduł sesji, emulacja flock() dla Windows, implementacja limitu czasu wykonania i notatki UPGRADING wszystkich wydań 8.x.

  5. Weryfikacja hipotez pobocznych

    Opcache, garbage collector sesji, zachowanie session_write_close() i sesja bez blokady.

Zmierzone w obu wersjach PHP

Wariant PHP 7.4 PHP 8.2
20 wywołań AJAX po 1,5 s, bez zwalniania blokady 30,0 s 30,0 s
To samo z session_write_close() zaraz po odczycie sesji 1,5 s 1,5 s
To samo z session_start() i opcją read_and_close 1,5 s 1,5 s
To samo z handlerem sesji bez blokady 1,5 s 1,5 s
Test ciągły: oczekiwanie strony głównej, blokada do końca skryptu 1,6 do 1,8 s 1,5 do 1,6 s
Test ciągły: oczekiwanie strony głównej, blokada zwolniona wcześnie 0,00 s 0,00 s

Wyzwania, które pokonaliśmy

Na Linuxie tego błędu nie da się odtworzyć

Na Linuxie max_execution_time liczy czas procesora, a czekanie na blokadę go nie zużywa. Na Windows mierzony jest czas rzeczywisty, co potwierdza dokumentacja PHP. Dlatego w testach na Linuxie mierzyliśmy czas zegarowy wywołań, a wartości powyżej 30 s traktowaliśmy jako przekroczenie limitu na produkcji.

PHP 8 nie zmieniło blokowania sesji, ale PHP 7.4.22 zmieniło flock() na Windows

Poprawka buga #81223 (otwiera się w nowym oknie) z lipca 2021 r. sprawiła, że na Windows flock() blokuje cały plik zamiast pierwszego bajtu. Klient przechodził z PHP 7.4.9, więc dla niego była to realna różnica, choć niezwiązana z PHP 8. Na Windows blokada jest obowiązkowa, więc od tej wersji każdy inny proces czytający pliki sesji, na przykład antywirus albo indeksator, koliduje z całym plikiem. Dlatego katalog sesji wyłączamy ze skanowania.

Nierównomierność wynika z kolejności budzenia

Czekający na blokadę nie są obsługiwani w kolejności zgłoszeń. Przy identycznym obciążeniu strona główna czekała raz 0 s, a raz 4,5 s. Stąd wrażenie, że „raz działa, raz nie”.

Garbage collector sesji działa pod blokadą

Uruchamia się losowo w session_start(), po zdobyciu blokady, i skanuje cały katalog sesji. Przy 100 000 plików trwało to 0,3 s na Linuxie, a na NTFS z antywirusem znacznie dłużej. Bez php.ini wbudowane prawdopodobieństwo to 1 na 100 wywołań, a nie 1 na 1000 jak w php.ini-production; przy 20 wywołaniach na stronę dotyczy to odczuwalnej części odsłon.

session_write_close() zmienia zasady zapisu, po cichu

Przypisanie do $_SESSION po zamknięciu nie zgłasza błędu, ale niczego nie zapisuje. Ponowny session_start() znów czeka na blokadę i zastępuje $_SESSION zawartością pliku, więc wartości ustawione w międzyczasie przepadają. Po wysłaniu nagłówków ponowny start kończy się ostrzeżeniem. Każde z tych zachowań potwierdziliśmy na obu wersjach.

Sesja bez blokady nie jest rozwiązaniem

Handler bez blokady jest możliwy, ale kosztuje utratę zapisów: w teście wolniejsze wywołanie nadpisało swoją nieaktualną kopią sesji dane zapisane równolegle przez szybsze. W aplikacji, która przy każdym wywołaniu zapisuje coś do sesji, oznaczałoby to losowe wylogowania i znikające ustawienia. Opcache też tylko przyspiesza: wywołanie trwało 0,5 zamiast 1,9 ms, a blokada dalej 30 s.

Rozwiązanie, przełącznik w pliku startowym

Zamiast dopisywać session_write_close() do każdego skryptu AJAX osobno, we wspólnym pliku startowym pojawił się jeden przełącznik. Skrypt, który sesję tylko czyta, deklaruje to przed dołączeniem pliku startowego:

define('SESSION_READONLY', true);

Plik startowy robi to, co dotychczas, a następnie zwalnia blokadę natychmiast po odczycie:

$bSessionReadOnly = defined('SESSION_READONLY') && SESSION_READONLY;

session_start();
$_SESSION['znacznik'] = 0;

if ($bSessionReadOnly) {
    session_write_close();
}

$_SESSION pozostaje czytelne do końca skryptu, a blokada trwa ułamek milisekundy. Gdy taki skrypt rzadko musi jednak coś zapisać, najpierw liczy wynik, potem na krótko otwiera sesję, zapisuje i zamyka ją, zanim cokolwiek wyśle.

Do tego doszły ustawienia bez zmian w kodzie: opcache z file_cache jako zapasem na Windows, osobny katalog sesji wyłączony ze skanowania antywirusowego, z kontrolą liczby plików i jawnie ustawionym session.gc_divisor. Klient otrzymał narzędzie w PHP oparte na curl_multi, które na serwerze Windows mierzy pojedyncze wywołanie i 21 równoległych, przed zmianą i po niej: jeśli równoległe trwają około 20 razy dłużej niż jedno, wykonują się po kolei.

Co z tego wynika

  • „Po aktualizacji” nie musi oznaczać regresji. Tutaj aktualizacja uwidoczniła słabość, która istniała od lat.
  • Dwie wersje obok siebie, przy identycznym obciążeniu, rozstrzygają spór o przyczynę szybciej niż jakakolwiek dyskusja.
  • W razie wątpliwości warto przeczytać źródła. Kilkadziesiąt linii w mod_files.c i flock_compat.c zamknęło temat „PHP 8 czeka na sesję”.
  • Różnice platform mają znaczenie. Ten sam kod daje błąd krytyczny na Windows i działa na Linuxie wyłącznie przez definicję limitu czasu.
  • Najlepsza poprawka to ta, która zmienia najmniej. Jeden przełącznik tam, gdzie zespół zagląda przy każdym problemie z sesją, zamiast przepisywania aplikacji albo wyłączania blokad kosztem spójności danych.

Źródła

Powiązane sytuacje

  • Dla PHP 8.2 poprawki bezpieczeństwa kończą się 31.12.2026, dla PHP 8.1 i starszych już się skończyły (stan: 2026-10). Przenosimy rozbudowywane od lat aplikacje PHP, także z szablonami Smarty i frontendem w jQuery, na PHP 8.4 lub 8.5. Testujemy każdy etap.

  • Państwa system działa, ale nikt nie zna go dokładnie. Spisujemy aplikację, bazę danych, serwer i pocztę, oceniamy ryzyka, na piśmie rekomendujemy dalszą drogę, a na życzenie przejmujemy utrzymanie systemu.

Inne projekty

  • Przeglądarka, ramka z podpisem Apache i trzy tory do PHP, PHP i CFML; tor CFML i jego ramka są pomarańczowe

    Produkcyjny ColdFusion 2025 za Apache: pięć nieoczywistych pułapek

    Przygotowaliśmy produkcyjną instalację Adobe ColdFusion 2025 na Debianie 12, za serwerem Apache 2.4, który obsługuje już kilka innych serwisów. Sama instalacja trwa 34 sekundy. Konektor i łatwe do przeoczenia szczegóły konfiguracji wymagały więcej uwagi: błąd 503 przez IPv6, mapowania sięgające do każdego hosta wirtualnego i profil, który po cichu dokłada debugger.

  • Cztery koperty przechodzą przez bramki SPF, DKIM i DMARC, odhaczone na pomarańczowo, w drodze do skrzynki odbiorczej

    Dostarczalność poczty dla czterech domen firmowych

    Serwer pocztowy czterech domen firmowych niczego nie podpisywał, nie publikował polityki DMARC i był odrzucany przez Microsoft. Nasi inżynierowie sprawdzili go, przetestowali zmianę na jednej domenie i wdrożyli ją partiami. Efekt: wszystkie trzy mechanizmy przechodzą sprawdzenie w Gmailu, blokada Microsoftu została zdjęta, a użytkownicy niczego nie odczuli.

W 20 minut ustalimy, jaki pierwszy krok pasuje do Państwa systemu

Państwo opisują cel i obecny stan, my mówimy otwarcie, czy i jak możemy pomóc. Odpowiedź w ciągu jednego dnia roboczego, bez zobowiązań.

Telefon (niemiecki, polski, angielski) +48 501 250 220
E-mail E-mail

Dostępni: poniedziałek–piątek, 9:00–17:00 (czas środkowoeuropejski); wsparcie i awarie całą dobę (24/7)