Blog
Dlaczego aplikacje Symfony stają się wolne po latach rozwoju
Aplikacje Symfony zwykle zwalniają przez lata małych decyzji: zapytań, listenerów, serializacji, integracji synchronicznych, dużych encji i cache bez jasnego unieważniania.
Gdzie znika czas w dojrzałej aplikacji Symfony
Lista postów zyskuje nazwiska autorów, potem tagi i przetłumaczone tytuły. Do zapisu dochodzi dziennik audytowy oraz aktualizacja indeksu wyszukiwarki. Każdy dodatek ma sens, ale razem zmieniają koszt operacji, od której zaczynaliśmy.
Nagromadzona praca jest jednym z wyjaśnień spowolnienia, nie gotową diagnozą. Przyczyną może być również zły plan zapytania, wzrost ruchu, limit zasobów, zmiana wdrożenia albo regresja frameworka. Punktem wyjścia jest całe żądanie: co odczytuje, na co czeka, jakie obiekty tworzy i co wysyła.
Przykłady są hipotetyczne. Nie stanowią benchmarków GiSoft ani opisów incydentów u klientów. Fragmenty PHP zakładają wersję 8.2 lub nowszą ze względu na klasy readonly; biblioteki i narzędzia testowe mogą mieć wyższe wymagania. Jeśli nie pokazano ich wprost, encje projektu, mapowania, importy, konfiguracja usług i funkcje pomocnicze testów są pominięte.
Mierz operację widzianą przez użytkownika
Obok czasu odpowiedzi mierz pracę bazy, hydratację, normalizację lub renderowanie, wywołania zewnętrzne, cache i logowanie. Uwzględnij routing, zabezpieczenia, sesję i inicjalizację kontenera, zamiast z góry pomijać narzut frameworka. Na granicy infrastruktury rozróżnij przetwarzanie w aplikacji, oczekiwanie na wolny proces PHP i przesłanie odpowiedzi do klienta.
Symfony Profiler pomaga zbadać pojedyncze żądanie w chronionym środowisku deweloperskim. Ślady APM i monitoring bazy pokazują zachowanie produkcyjne bez publicznego udostępniania profilera diagnostycznego. Szczegółowe kolektory mają własny koszt; wersje aplikacji porównuj przy tej samej instrumentacji i ustawieniach zbliżonych do produkcji.
Poniższy wymyślony profil pokazuje, jakich danych szukać:
GET /api/{locale}/posts 20 zwróconych postów
Czas odpowiedzi 480 ms
Baza danych 63 zapytania / 120 ms
Hydratacja 90 ms
Serializacja 180 ms
Wywołania zewnętrzne 0
Szczytowe użycie pamięci 82 MB
Treść odpowiedzi 1,8 MBNie sumuj tych czasów bez ustalenia, czy mierzone zakresy są rozłączne. W czasie serializacji mogą mieścić się zapytania wywołane leniwym ładowaniem, a równoległe żądania HTTP mogą nakładać się w czasie. Kolektory różnie uwzględniają pobieranie wierszy w czasie bazy. Przykład kieruje uwagę na odczyt danych i budowanie odpowiedzi, ale rzeczywiste wąskie gardło musi pokazać profil. Skrócenie startu frameworka o pięć milisekund nie wyjaśni pozostałego kosztu.
Odtwarzaj rozkład danych, wielkości relacji i uprawnienia, nie tylko liczbę rekordów. Przykładowe zbiory 50, 5000 i 100 000 rekordów mogą ujawnić różne plany zapytań. Zwiększaj bazę, pozostawiając na stronie 50 elementów. Cztery zapytania na każdej skali nadal nie mówią, ile wierszy baza przegląda, jak sortuje ani ile kosztują hydratacja i transfer.
Z kolei 52, 502 i 5002 zapytania dla 50, 500 i 5000 zwracanych elementów ilustrują możliwy wzorzec N + 2. To inny eksperyment: rośnie także wynik. Osobno sprawdzaj pusty i rozgrzany cache, realistyczną współbieżność oraz powtarzalny stan EntityManagera.
Ogranicz odczyt, zanim wybierzesz strategię ładowania
Ta lista administracyjna celowo nie ma limitu:
$posts = $postRepository->findBy(
[],
['createdAt' => 'DESC', 'id' => 'DESC'],
);
$rows = [];
foreach ($posts as $post) {
$rows[] = [
'title' => $post->translationFor($locale)->getTitle(),
'author' => $post->getAuthor()->getDisplayName(),
'tags' => $post->getTags()->map(
static fn (Tag $tag): string => $tag->getName(),
)->toArray(),
];
}Fragment zakłada, że każdy post ma autora, a translationFor() zwraca tłumaczenie w żądanym języku lub zgodnie z ustaloną regułą zastępczą. Braki wymagają jawnej obsługi. Tag i pokazane metody encji należą do przykładowej aplikacji.
Odczyt nazwy autora, wybór tłumaczenia i iteracja tagów mogą załadować relacje. Dla niezaładowanych powiązań uproszczony model to jedno zapytanie o posty i do jednego dodatkowego zapytania na post dla każdej z tych trzech ścieżek. Nie jest to gwarantowane 1 + 3N: wspólni autorzy w Identity Map, wcześniej pobrane relacje, mapowania, cache i implementacja tłumaczeń zmieniają wynik. Sam odczyt relacji w pętli nie jest błędem, jeżeli potrzebne dane już odpowiednio załadowano.
DTO listy pokazuje oczekiwany wynik:
final readonly class PostListItem
{
/**
* @param list<string> $tags
*/
public function __construct(
public int $id,
public string $title,
public string $slug,
public string $authorName,
public array $tags,
public \DateTimeImmutable $publishedAt,
) {
}
}Tytuły i nazwy autorów są tutaj wymagane, a opublikowany post ma datę typu DateTimeImmutable. Implementacja musi spełniać te założenia. Może zwracać projekcję wartości skalarnych albo mapować świadomie załadowane encje. Mapper odczytujący leniwie każdą relację odtworzy pierwotny problem.
Kontrakt odczytu pozostaje wąski:
interface PostReadRepositoryInterface
{
/**
* @return list<PostListItem>
*/
public function findPublishedPage(
string $locale,
int $page,
int $limit,
): array;
}Sygnatura sama nie wymusza paginacji, autoryzacji ani wyboru języka zastępczego. Implementacja musi zweryfikować parametry strony, uwzględnić ograniczenia dostępu i zwrócić ograniczony zbiór. PHPStan może sprawdzać list<PostListItem> i kod korzystający z tego wyniku, nie koszt SQL.
DTO pomaga, gdy odczyt potrzebuje kilku wartości, ale nie jest obowiązkowe ani automatycznie szybsze. Encje zarządzane nadal mają zastosowanie w operacjach zmieniających stan biznesowy. Szczegółowe warianty ładowania opisuje artykuł GiSoft „N+1 to problem projektu dostępu do danych”; tutaj interesuje nas ich udział w całkowitym koszcie żądania.
Pobierz relacje potrzebne temu odczytowi
Ten fragment repozytorium pobiera autora razem z postem:
/**
* @return list<Post>
*/
public function findPublishedWithAuthor(
int $limit,
): array {
if ($limit < 1 || $limit > 100) {
throw new \InvalidArgumentException(
'Limit musi mieścić się w zakresie od 1 do 100.',
);
}
return $this->createQueryBuilder('post')
->addSelect('author')
->innerJoin('post.author', 'author')
->andWhere('post.published = :published')
->setParameter('published', true)
->orderBy('post.publishedAt', 'DESC')
->addOrderBy('post.id', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}addSelect('author') włącza do hydratacji obiektowej encję ze złączenia, tworząc fetch join. Złączenie wewnętrzne pomija posty bez autora; autor opcjonalny wymaga odpowiedniego złączenia i obsługi wartości pustej. Przykład zakłada relację do jednego autora i brak innych złączeń mnożących wynik. Identyfikator jako drugie kryterium sortowania ustala jednoznaczną kolejność przy niezmienionych danych.
Relacja to-one zwykle nie mnoży wierszy encji głównej. Kilka niezależnych kolekcji może to zrobić: post z czterema tłumaczeniami, ośmioma tagami, dwudziestoma komentarzami i trzema załącznikami może dać 4 × 8 × 20 × 3 = 1920 wierszy SQL, jeśli złączenie obejmie wszystkie kombinacje. Filtry i warunki złączeń zmieniają tę liczbę. Nie są to 1920 różne posty, ale transfer wierszy i składanie unikalnych obiektów nadal kosztują. DISTINCT nie usuwa kombinacji z różnymi wartościami rekordów podrzędnych.
Przy fetch joinach kolekcji zwykły limit może ograniczać wiersze SQL zamiast pełnych encji głównych. Potrzebny jest odpowiedni paginator Doctrine albo świadomie zaprojektowany odczyt dwuetapowy; ustawień paginacji nie należy przenosić bez sprawdzenia między różnymi zapytaniami.
Wybierz identyfikatory strony, potem ich szczegóły
Poniższe metody są elementami projektu, nie API Doctrine:
$postIds = $this->posts->findPublishedIds(
locale: $locale,
page: $page,
limit: $limit,
);
$items = $postIds === []
? []
: $this->posts->findListItemsByIds(
ids: $postIds,
locale: $locale,
);Pierwszy etap musi wybrać unikalne identyfikatory przy spójnych filtrach, uprawnieniach i stabilnym sortowaniu. Drugi musi zachować te ograniczenia oraz kolejność: samo IN (:ids) nie odtwarza kolejności wejściowej. Potrzebne relacje powinien ładować partiami lub przez projekcje, a nie osobnym zapytaniem dla każdego identyfikatora. Obsługa pustej strony pozwala pominąć zbędny odczyt szczegółów.
Dwa etapy mogą oznaczać więcej niż dwa zapytania, zwłaszcza przy liczeniu wszystkich wyników lub pobieraniu kilku kolekcji. Ograniczają mnożenie wierszy, ale dodają komunikację z bazą. Jeśli znaczenie mają zmiany pomiędzy odczytami, trzeba określić wymaganą spójność migawki lub transakcji.
Paginacja, serializacja i Twig korzystają z jednego budżetu
Nieograniczony odczyt może przestać pasować do ekranu, dla którego powstał:
$repository->findAll();Hipotetyczny wzrost tabeli z 200 do 400 000 rekordów pokazuje ryzyko. Liczba zwracanych elementów, nie tylko wielkość tabeli, wpływa na alokację obiektów i renderowanie. Paginacja należy do serwerowego kontraktu odczytu.
Ten DTO parametrów żądania używa przykładowego limitu projektu wynoszącego 100:
final readonly class PageRequest
{
public function __construct(
public int $page = 1,
public int $limit = 25,
) {
if ($page < 1) {
throw new \InvalidArgumentException(
'Numer strony musi być dodatni.',
);
}
if ($limit < 1 || $limit > 100) {
throw new \InvalidArgumentException(
'Limit musi mieścić się w zakresie od 1 do 100.',
);
}
}
}Parametry HTTP trzeba sparsować i zweryfikować przed utworzeniem obiektu. Typy właściwości PHP nie zastępują pełnej walidacji surowych parametrów adresu. Limit stosujemy w SQL, z jednoznacznym sortowaniem i uwzględnieniem kosztu dużych przesunięć oraz liczenia wyników. Paginacja oparta na kluczu może pomóc przy kolejnym przeglądaniu stron, ale nie zapewnia wprost skoku do dowolnej strony.
Symfony Serializer może odczytywać gettery encji i przechodzić po relacjach zależnie od normalizerów, grup i kontekstu. Może to dodawać zapytania, powiększać odpowiedź lub ujawniać niezamierzone pola. Nie jest to jednak skutek każdego kodowania encji: zwykłe json_encode() nie przechodzi automatycznie po wszystkich prywatnych relacjach. Grupy wybierają pola; nie planują ładowania i nie zastępują autoryzacji.
W odpowiedzi publicznej może pomóc jawna reprezentacja:
final readonly class PublicPostDto
{
/**
* @param list<string> $tags
*/
public function __construct(
public string $title,
public string $slug,
public string $excerpt,
public array $tags,
) {
}
}Należy wypełnić tylko pola dostępne dla odbiorcy i ograniczyć koszt mapowania. DTO zbudowane z nadmiernie dużego grafu nie odzyska czasu poświęconego na jego załadowanie.
Twig może ujawnić ten sam problem ścieżki odczytu:
{% for page in pages %}
<h2>{{ page.translationFor(app.request.locale).title }}</h2>
<span>
{{ page.parent.translationFor(app.request.locale).title }}
</span>
{% for feature in page.features %}
{{ feature.translationFor(app.request.locale).name }}
{% endfor %}
{% endfor %}Fragment zakłada istnienie rodzica oraz wymaganych tłumaczeń; rzeczywisty widok musi obsłużyć ich brak zgodnie z regułami prezentacji. Akcesory mogą inicjalizować niezaładowane relacje, ale nie każdy odczyt właściwości wykonuje SQL. Wystarczające mogą być świadomie pobrane encje; model widoku PageAdminRow jest inną możliwością. Pomiar zapytań całej odpowiedzi powinien obejmować renderowanie lub normalizację, nie tylko repozytorium.
Znajdź pracę synchroniczną poza kontrolerem
Standardowy EventDispatcher Symfony wywołuje listenery synchronicznie. Listener może zlecić pracę kolejce, lecz sama nazwa zdarzenia nie mówi nic o asynchroniczności ani trwałym dostarczeniu. Zapis audytu, tłumaczenia, unieważnianie cache, indeksowanie i webhooki mogą więc wydłużać żądanie bez wyraźnego śladu w kontrolerze.
Zdarzenia cyklu życia Doctrine mają własne zasady. prePersist, postUpdate i postLoad nie uruchamiają się wszystkie przy każdym flush. Przykładowo postUpdate działa dla odpowiednich aktualizacji ORM wewnątrz flush(), przed zatwierdzeniem transakcji; nie jest powiadomieniem po zatwierdzeniu. Także postFlush nie dowodzi, że zatwierdzono nadrzędną transakcję. Przed zmianą zachowania hooków należy sprawdzić używaną wersję ORM. Ponowne wywołanie flush() z listenera uruchomionego przez flush nie jest bezpieczną, ogólną techniką zapisu.
Hooki związane z persystencją warto utrzymywać małe. Kosztowną koordynację działań lepiej ujawnić w usłudze lub handlerze, które da się profilować i testować. To decyzja o odpowiedzialności, nie powód do dodawania warstw do każdej operacji.
Łatwo przeoczyć także koszt śledzenia przesyłek:
$rows = [];
foreach ($orders as $order) {
$tracking = $shippingClient->getTracking(
$order->getTrackingNumber(),
);
$rows[] = $this->mapper->map($order, $tracking);
}getTracking() i mapper to ilustracyjne kontrakty projektu, nie zweryfikowane API SDK przewoźnika. Przy blokujących wywołaniach 50 zamówień i hipotetycznych 100 ms na żądanie daje około pięciu sekund przed uwzględnieniem reszty pracy. Klient leniwy lub współbieżny wymaga innego pomiaru.
Zależnie od dostawcy i wymagań aktualności można rozważyć udokumentowany odczyt zbiorczy, okresowo synchronizowaną lokalną migawkę, cache, pobieranie szczegółów na żądanie albo ograniczoną współbieżność. Nie każdy dostawca obsługuje operacje zbiorcze. Potrzebne są limity równoległości i czasu, respektowanie limitów API oraz określenie, co zobaczy użytkownik przy niedostępnych danych.
Pobieranie usług z kontenera może utrudniać wskazanie źródła pracy:
$service = $container->get('some_service');Wstrzykiwanie przez konstruktor ułatwia przegląd zależności:
final class ProductImportService
{
public function __construct(
private readonly ProductRepositoryInterface $products,
private readonly ProductMapperInterface $mapper,
private readonly ImportMetricsInterface $metrics,
) {
}
}To fragment deklaracji z interfejsami projektu. Wstrzykiwanie samo w sobie nie jest szybsze od pobierania usług z kontenera Symfony, a pobranie usługi współdzielonej nie musi tworzyć jej ponownie. Korzyścią jest tu widoczność odczytu danych, mapowania i pomiarów importu.
Przenieś pracę poza żądanie, ale jej nie zgub
Kolejka zmienia moment i miejsce ponoszenia kosztu. Nie usuwa obciążenia bazy, opłat dostawcy ani pracy procesora. Indeksowanie, raporty, powiadomienia i konwersja mediów mogą działać w tle, jeśli produkt dopuszcza opóźnione zakończenie. Wymagane uprawnienia i warunki zwrócenia sukcesu nadal muszą być sprawdzane w odpowiednim miejscu procesu.
Transactional outbox jest jedną z możliwości, gdy zmiana biznesowa i zamiar dalszego działania mają przetrwać razem:
Żądanie HTTP: walidacja danych i uprawnień
Lokalna transakcja
Zapis stanu biznesowego
Zapis wpisu outbox
COMMIT
Można zwrócić odpowiedź
Publikator może wysłać zatwierdzone wpisy
-> transport -> proces roboczy
ograniczone ponowienia / obsługa duplikatówOba zapisy muszą należeć do tej samej odpowiedniej transakcji bazy. Po zatwierdzeniu odpowiedź HTTP i publikacja nie mają narzuconej kolejności względem siebie. Publikator i odbiorca mogą powtarzać pracę, więc outbox nie oznacza wykonania dokładnie raz. Obok czasu HTTP trzeba obserwować opóźnienie publikacji, wiek kolejki, błędy i przepustowość procesów roboczych.
Przy niezależnym transporcie wysłanie przed zatwierdzeniem może udostępnić komunikat wcześniej niż jego dane. Zapis transportu uczestniczący w tej samej transakcji zachowuje się inaczej. Odroczenie wysyłki przechowywanej tylko w pamięci rozwiązuje problem kolejności, ale pozostawia możliwość awarii przed publikacją. Artykuł GiSoft „Zamówienie obsłużone. Dlaczego komunikat wrócił?” rozwija ponowienia, współbieżność i uzgadnianie skutków zewnętrznych; tutaj nie powtarzamy tego poradnika.
Logowanie i sesje też mogą zatrzymywać żądania
Rozbudowany kontekst logów zwiększa koszt zapisu i przetwarzania:
$this->logger->info('Zaimportowano produkt', [
'product' => $product,
'request' => $request->request->all(),
'response' => $providerResponse,
]);To, czy przekazanie encji uruchomi normalizację lub leniwe ładowanie, zależy od formatterów, procesorów i zachowania obiektu. Nie dzieje się to automatycznie. Niezależnie od tego pełne dane żądania i odpowiedzi dostawcy są złym domyślnym wyborem ze względu na rozmiar i poufność.
Ograniczony wpis jest łatwiejszy do wykorzystania:
$this->logger->info('Import produktu zakończony', [
'productId' => $product->getId(),
'provider' => $providerName,
'durationMs' => $durationMs,
'status' => 'completed',
]);Czas, etykietę dostawcy i identyfikatory trzeba świadomie zebrać. Wartości powinny mieć ograniczony rozmiar; dane uwierzytelniające, tokeny, wrażliwe pola płatności i zbędne dane osobowe należy pominąć. Próbkowanie i poziomy logowania mają zachować informacje przydatne przy awarii, nie wyłączyć całą obserwowalność.
Blokada sesji jest osobnym problemem. Jeśli używany handler blokuje sesję, równoległe żądania współdzielące ją mogą czekać na zakończenie wolnej operacji. Inne handlery lub ustawienia mogą działać inaczej. Warto unikać zbędnego uruchamiania sesji. Jeżeli mechanizm to obsługuje, można ją zapisać i zamknąć przed długą niezależną pracą, ale dopiero po sprawdzeniu późniejszych zapisów, uwierzytelniania i CSRF. Globalna zmiana zabezpieczeń na bezstanowe nie jest poprawką do wdrożenia w ciemno.
Cache wymaga reguł aktualności, nie diagramu przełączania backendów
To szablony kluczy, nie gotowe wartości ani kompletny model kontroli dostępu:
settings.public.{tenant}.{locale}
menu.{tenant}.{category}.{locale}.{audience}
page.{tenant}.{pageId}.{locale}.{audience}
post.list.{tenant}.{locale}.{page}.{limit}.{variant}Parametry w nawiasach należy zastąpić poprawnymi wartościami dla danego backendu. audience musi uwzględniać wszystkie istotne reguły widoczności; sama rola może nie obejmować uprawnień indywidualnych. variant oznacza kanoniczny zapis lub skrót filtrów, sortowania i kontekstu widoczności. Inne dane zmieniające wynik również muszą znaleźć odzwierciedlenie w kluczu albo wykluczać współdzielenie wpisu. Aplikacja jednej organizacji z rzeczywiście publicznymi danymi potrzebuje mniej wymiarów.
Po udanej transakcji należy unieważnić powiązane strony, tłumaczenia i menu, przewidując ścieżkę naprawczą na wypadek błędu unieważnienia. TTL ogranicza czas życia wpisu i powinien wynikać z dopuszczalnej nieaktualności; nie czyni pominiętego unieważnienia nieszkodliwym. Gdy aktualność jest krytyczna, trzeba obsłużyć wyścig między odbudową a unieważnieniem, na przykład wersjonowaniem kluczy. Szerokie czyszczenie może wywołać falę kosztownych odbudów; pomocne bywa współdzielenie jednej odbudowy przez oczekujące żądania lub obsługiwany mechanizm ochrony przed takim przeciążeniem.
Jawne dane cache są często łatwiejsze do kontrolowania niż dowolny graf obiektów ORM:
final readonly class PublicSettingsDto
{
public function __construct(
public string $companyName,
public string $slogan,
public string $phone,
) {
}
}Przechowanie encji PHP nie jest niemożliwe, ale odtworzenie jej z cache aplikacji nie przywraca pierwotnego zarządzania przez EntityManager. Trzeba uwzględnić proxy, niepełne relacje i zgodność między wdrożeniami. Cache wyników lub cache drugiego poziomu Doctrine ma inne zasady niż przechowywanie dowolnych encji w cache aplikacji.
Redis, Memcached i system plików to alternatywy, nie automatyczny łańcuch odporności. Wspólny interfejs nie definiuje zachowania przy awarii. ChainAdapter Symfony jest konfigurowanym cache wielopoziomowym, a nie uniwersalną gwarancją przełączania po awarii backendu. Zastępczy odczyt musi uwzględniać TTL, unieważnianie na różnych węzłach, stare wartości, uprawnienia i pojemność. Zależnie od danych lepszy może być ograniczony odczyt ze źródła, jawnie dopuszczona starsza wartość albo kontrolowany błąd.
Obok trafień mierz czas operacji cache i odbudowy, rozmiar danych, błędy backendu, unieważnienia oraz aktualność. Hipotetyczne 95% trafień może współistnieć z bardzo wolnymi odczytami bez trafienia lub nieaktualnymi odpowiedziami.
Formularze, tłumaczenia i media sprawdzaj osobno
Duże listy wyboru EntityType, zagnieżdżone kolekcje, listenery formularzy i walidacja mogą pobierać znacznie więcej danych, niż sugeruje widok formularza. Pomagają filtrowanie po stronie serwera, późniejsze ładowanie zależnych opcji lub autouzupełnianie; nie każdy formularz potrzebuje nowego API. Przesłane identyfikatory nadal wymagają sprawdzenia istnienia i uprawnień.
Powtarzany wybór tłumaczeń też może wykonywać ukrytą pracę:
$page->translationFor($locale);
$feature->translationFor($locale);
$category->translationFor($locale);Są to metody projektu, nie uniwersalne API tłumaczeń Symfony. Mogą przeglądać załadowaną kolekcję, inicjalizować ją albo wybierać język zastępczy. Zapytanie odczytowe może od razu wybrać żądany język. Ilustracyjny SQL zakłada własny schemat z jednym tłumaczeniem na (page_id, locale), liczbową flagą publikacji i parametrami paginacji wiązanymi jako liczby całkowite:
SELECT
page.id,
translation.title,
translation.slug
FROM pages__page page
INNER JOIN pages__page_translation translation
ON translation.page_id = page.id
AND translation.locale = :locale
WHERE page.published = 1
ORDER BY page.sequence ASC, page.id ASC
LIMIT :limit OFFSET :offset;Złączenie wewnętrzne pomija strony bez danego języka. Zachowanie tych stron lub zastosowanie języka zastępczego wymaga innego wariantu, na przykład odpowiednich lewych złączeń albo rozstrzygnięcia w usłudze odczytu. Tam, gdzie potrzeba, należy dodać filtry organizacji i uprawnień. Indeksy dobiera się do planu i obciążenia, nie do nazw tabel z przykładu.
Dla mediów trzeba rozróżnić wolne przetwarzanie PHP i powolne pobieranie przez przeglądarkę. Zbyt duże oryginały, wielokrotne sprawdzanie plików i konwersja bez cache wymagają różnych poprawek. Weryfikuj pliki wejściowe, ogranicz dozwolone rozmiary wersji pochodnych i wykorzystuj już wygenerowane pliki. Można je tworzyć w kontrolowanym procesie przesyłania lub w tle, jeśli to uzasadnione; tania miniatura na żądanie z cache też może być właściwa. Nagłówki cache HTTP i formaty dopasowane do urządzenia mogą poprawić dostarczanie bez przebudowy backendu.
Zamień ustalenia w budżety i testy regresji
O priorytecie decydują częstotliwość, opóźnienie, presja na zasoby i znaczenie biznesowe. Hipotetyczny endpoint obsługujący 20% ruchu z P95 równym 1,8 s, 74 zapytaniami, szczytem pamięci 140 MB i odpowiedzią 4,2 MB zasługiwałby na analizę. To ilustracja, nie wyniki zaobserwowane razem we wdrożeniu GiSoft. Rzadko używany raport nadal może być ważniejszy, jeśli blokuje krytyczny proces.
Poniższy YAML wyraża możliwe budżety projektu. Symfony nie wdraża ich automatycznie:
performance_budget:
public_page:
max_application_queries: 8
max_response_bytes: 300000
target_p95_ms: 400
max_records_per_page: 50
admin_listing:
max_application_queries: 12
max_records_per_page: 100
target_p95_ms: 800Limity wynikają z wymagań i zmierzonego punktu odniesienia, w tym wielkości danych i warunków obciążenia. Liczbę zapytań i rozmiar treści można sprawdzać w CI; cele percentylowe wymagają reprezentatywnego okresu obserwacji i ruchu. Ani cztery zapytania, ani 300 000 bajtów nie są progiem uniwersalnym.
Poniższe testy Pest używają funkcji projektu createPublishedPosts(), postQuery() i startApplicationQueryCollection(). Przygotowanie musi zapisać dane, ustalić lub wyczyścić stan EntityManagera i przygotować cache przed rozpoczęciem pomiaru. Usługę pobieramy przed uruchomieniem kolektora, a zbieranie zatrzymujemy lub resetujemy między odizolowanymi testami. Kolektor musi obejmować SQL właściwego połączenia, także podczas mapowania danych, lecz pomijać tworzenie danych testowych i start frameworka.
Najpierw strona obejmuje wszystkie 50 przygotowanych postów:
it(
'zachowuje budżet zapytań dla 50 postów',
function (): void {
$this->createPublishedPosts(count: 50);
// Dane zapisano; stan ORM i cache jest kontrolowany.
$query = $this->postQuery();
$collector = $this->startApplicationQueryCollection();
$result = $query->findPublishedPage(
locale: 'en',
page: 1,
limit: 50,
);
expect($result)->toHaveCount(50)
->and($collector->queryCount())->toBeLessThanOrEqual(4);
},
);Następnie baza zawiera 500 postów, ale strona nadal ma 50:
it(
'zachowuje budżet strony przy 500 postach',
function (): void {
$this->createPublishedPosts(count: 500);
// Dane zapisano; stan ORM i cache jest kontrolowany.
$query = $this->postQuery();
$collector = $this->startApplicationQueryCollection();
$result = $query->findPublishedPage(
locale: 'en',
page: 1,
limit: 50,
);
expect($result)->toHaveCount(50)
->and($collector->queryCount())->toBeLessThanOrEqual(4);
},
);Oba testy sprawdzają ten sam górny limit i wielkość wyniku. Nie dowodzą równej liczby zapytań, stałego czasu ani wydajności współbieżnej. Dane powinny zawierać autorów, tłumaczenia i tagi uruchamiające badane ścieżki. Osobne benchmarki mogą zmieniać wielkość strony, relacji i równoległego obciążenia. Asercje mikrosekundowe nie nadają się do zwykłego CI.
Test rozmiaru odpowiedzi chroni inną granicę:
it(
'ogranicza rozmiar odpowiedzi z postami',
function (): void {
$client = static::createClient();
// Przygotuj reprezentatywne dane i wymagane uprawnienia.
$client->request(
'GET',
'/api/en/posts?page=1&limit=25',
);
self::assertResponseIsSuccessful();
$content = $client->getResponse()->getContent();
expect($content)->toBeString();
expect(strlen($content))->toBeLessThan(300_000);
},
);Przykład zakłada Pest skonfigurowany z bazą WebTestCase Symfony, reprezentatywne opublikowane dane, właściwy dostęp i pokazaną trasę. Treść jest ciągiem znaków, nie odpowiedzią strumieniową. strlen() liczy bajty odpowiedzi testowej, niekoniecznie skompresowane bajty przesłane siecią. Osobno trzeba sprawdzić niepoprawne strony, zbyt duże limity, pusty wynik i stabilne sortowanie; ten fragment ich nie obejmuje.
Analiza statyczna, przegląd kodu i dane z produkcji
Pokazany wcześniej kontrakt list<PostListItem> daje PHPStan informacje o elementach i odbiorcach wyniku. Analiza może wykrywać niezgodne typy, niebezpieczne użycie wartości pustych i niespójne struktury, zależnie od konfiguracji i rozszerzeń. Nie mierzy N+1 i nie zakazuje automatycznie zależności od repozytoriów ani serializacji encji. Zastąpienie niepewnego typu przez mixed usuwa przydatną informację.
Testy architektury mogą zapisać konkretne, uzgodnione ograniczenia:
arch('kontrolery API nie używają EntityManagera')
->expect('App\Api\Controller')
->not->toUse('Doctrine\ORM\EntityManagerInterface');
arch('rdzeń nie zależy od zewnętrznego SDK')
->expect('App\Core')
->not->toUse('Vendor\ExternalSdk');Przykłady używają API testów architektury Pest; Vendor\ExternalSdk jest nazwą zastępczą. Trzeba sprawdzić wersje narzędzi, przestrzenie nazw i to, czy reguły obejmują właściwe klasy. Wykrywają wybrane zależności, nie każde pośrednie zapytanie SQL, wartość zwracaną czy problem wydajności podczas działania.
Ten sam przegląd dotyczy zmian pisanych przez człowieka i wspieranych przez AI. Sprawdź faktyczne zachowanie repozytorium, skalę danych, dostęp do relacji, paginację, wywołania zewnętrzne i unieważnianie cache. Potrzebne są wyniki weryfikacji, nie wymyślona prognoza liczby zapytań. Ani czytelna składnia, ani DTO nie dowodzą przewidywalnego kosztu; kod wygenerowany przez AI nie jest też z definicji wolny.
Na produkcji śledź ruch i P50/P95/P99 według wzorców tras, czas bazy i sygnatury zapytań, opóźnienia wywołań zewnętrznych, rozmiar odpowiedzi, pamięć i błędy. Hydratację wydzielaj tylko tam, gdzie pozwala na to instrumentacja. Dla pracy w tle uwzględnij oczekiwanie w kolejce i czas przetwarzania.
Etykiety powinny mieć ograniczoną liczbę wartości, jak typ operacji i kontrolowane identyfikatory wydań. Identyfikatory organizacji, surowe adresy, identyfikatory operacji i dane użytkownika mogą tworzyć metryki o wysokiej kardynalności. Potrzebne szczegóły lepiej umieszczać w próbkowanych śladach z kontrolą dostępu niż dodawać każdą wartość jako etykietę metryki.
Zapis porównania może wyglądać tak; to schemat koncepcyjny, nie propozycja migracji:
performance_snapshot
id, operation, application_version, captured_at
dataset_record_count, record_count, page_size
query_count, database_duration_ms
hydration_duration_ms, external_duration_ms
serialization_duration_ms, total_duration_ms
peak_memory_bytes, response_bytes
cache_state, measurement_scoperecord_count oznacza tutaj zwrócone elementy, odrębnie od wielkości całej bazy testowej. Te same dane może przechowywać APM. Porównanie wymaga zgodnego sprzętu, obciążenia, instrumentacji i stanu cache; nie sumujemy zakresów zawierających się w sobie ani nie traktujemy pojedynczej próbki jak percentyla.
Praktyczna analiza kończy się ograniczoną zmianą, testem regresji, porównywalnym profilem przed i po oraz obserwacją wdrożenia. Globalne wymuszanie EAGER, cache wszędzie, statyczne funkcje pomocnicze czy przepisanie systemu nie zastępują znalezienia kosztu. Celem jest przewidywalna praca aplikacji i proces rozwoju, który zauważa regresje, zanim staną się codziennością.
Dokumentacja techniczna
- Symfony Profiler — https://symfony.com/doc/7.4/profiler.html
- Paginacja Doctrine — https://www.doctrine-project.org/projects/doctrine-orm/en/3.7/tutorials/pagination.html
- Zdarzenia cyklu życia Doctrine — https://www.doctrine-project.org/projects/doctrine-orm/en/3.7/reference/events.html
- Cache Symfony i łączenie jego poziomów — https://symfony.com/doc/current/cache.html
- Sesje Symfony — https://symfony.com/doc/7.4/session.html
- Testy architektury Pest — https://pestphp.com/docs/arch-testing
- Kontrakty PHPDoc w PHPStan — https://phpstan.org/writing-php-code/phpdoc-types
