Jit bottleneck in postgresql
Strona główna/Blog/2 sekundy checkoutu, których nie było widać w kodzie: Postgres JIT w Medusa.js

2 sekundy checkoutu, których nie było widać w kodzie: Postgres JIT w Medusa.js

Arkadiusz Wawrzyniak

Arkadiusz Wawrzyniak

15 lipca 2026 - 68 wyświetlenia - 10 min czytania

Udostępnij

Case study z produkcji Artovnii - jak domyślne ustawienie Postgresa, o którym nikt nie wiedział, że jest włączone, dokładało 2 sekundy do każdej mutacji koszyka.

TL;DR

Refaktor flow checkoutu w Artovnii, usunięcie manualnego potwierdzania każdego kroku, na rzecz zautomatyzowanego, reaktywnego działania, ujawnił, że był wolny na produkcji (dodanie do koszyka: 2,4–3,7 s, wybór dostawy: 2,5–2,8 s), a lokalnie te same operacje, te same dane poniżej pół sekundy. Winowajcą nie był kod, nie brakujący indeks i nie wolumen danych. Był nim Postgres JIT mechanizm kompilacji zapytań, który przy błędnej estymacji liczby wierszy potrafi kosztować więcej niż zyskuje. W moim przypadku: kompilacja trwała ~2 sekundy, a samo zapytanie wykonywało się w 1,4 milisekundy.

W dodatku nikt tego JIT-a świadomie nie włączył. To domyślne ustawienie Postgresa od wersji 12 i nie ma go gdzie zobaczyć, dopóki nie zapytasz bazy wprost. Poza tym lokalnie działam na Windows z wirtualnym obrazem linux, a produkcja to Railway (Linux, oficjalny obraz z llvmjit)

Objaw

Metryki z produkcji mówiły same za siebie:

  • POST /store/carts/.../line-items (dodanie do koszyka) — 2,4–3,7 s
  • POST /store/carts/.../shipping-methods (wybór dostawy) — 2,5–2,8 s
  • POST /store/carts (utworzenie koszyka, bez promocji) — 255 ms, w tym baza: 11 ms

Ten ostatni wiersz jest kluczowy. Tworzenie koszyka, czyli operacja bez logiki promocyjnej, było szybkie. Wszystko, co dotykało przeliczenia promocji, było wolne. To zawęziło problem, zanim jeszcze uruchomiłem EXPLAIN.

Lokalnie te same żądania z tymi samymi (albo bardzo zbliżonymi) danymi wykonywały się poniżej pół sekundy. Ten sam kod. Ta sama liczba promocji, reguł, wartości. Więc to nie był algorytm ani N+1 w sensie klasycznym, to było coś środowiskowego.

Zanim trafiłem na właściwy trop

Zanim w ogóle sięgnąłem po EXPLAIN, zrobiłem to, co wydawało się najbardziej oczywiste: spróbowałem poprawić odczuwalną płynność checkoutu od strony frontu. Przycisk "Dodaj do koszyka" przestał czekać na pełną odpowiedź backendu i pokazywał potwierdzenie natychmiast, korzystając ze stanu, który i tak trafiał już do globalnego store koszyka. Przejście z sekcji adresu do sekcji dostawy zaczęło się dziać optymistycznie, zamiast czekać na twarde potwierdzenie zapisu. Sekcja dostawy dostała skeleton zamiast pustego ekranu na czas oczekiwania.

Te zmiany były warte zrobienia same w sobie, checkout przestał sprawiać wrażenie zawieszonego, użytkownik widział reakcję na swoje działanie od razu. Ale to były poprawki percepcji, nie rzeczywistego czasu. Krok odpowiedzialny za przeliczenie promocji nadal trwał te same ~2,5 sekundy po stronie serwera, obojętnie jak sprytnie interfejs to maskował. Optymistyczny UI skraca czas, w którym użytkownik czeka bezczynnie, nie skraca czasu, którego serwer faktycznie potrzebuje. To dwa osobne pomiary i łatwo je pomylić, jeśli patrzy się tylko na to, "jak szybko to teraz wygląda".

Dopiero kiedy przestałem próbować ukrywać wolność interfejsem i zacząłem ją mierzyć bezpośrednio na backendzie, problem przestał być tajemnicą.

Jak to namierzyłem

Mam w projekcie panel z instrumentacją per-krok (timeStep), który rozbija czas każdego żądania na etapy przetwarzania. Dla najbardziej dotkniętego endpointu wyglądało to tak:

Bash
POST /store/carts/.../shipping-methods    total 2.70s
  promotion_refresh   2.57s (95%)
  final_graph          50ms (2%)
  add_method           36ms (1%)
  resolve_cart         29ms (1%)
  loyalty                8ms (0%)

I kolejne dwa żądania, ten sam wzorzec:

Bash
total 2.79s → promotion_refresh 2.65s (95%)
total 2.53s → promotion_refresh 2.38s (94%)

Jeden krok, promotion_refresh, konsekwentnie zjadał 94–95% całego czasu żądania. Reszta procesu, rozwiązanie koszyka, dodanie metody dostawy, lojalność, budowa finalnego grafu, to razem kilkadziesiąt milisekund. Zero podejrzeń w tamtą stronę.

Krok niżej: telemetria zapytań SQL dla tego samego żądania pokazała db.query.count=75, db.query.total_ms≈2409, db.query.max_ms≈2068. Czyli spośród 75 zapytań wykonanych przy przeliczaniu promocji, jedno pojedyncze zapytanie odpowiadało niemal za cały czas.

Slow-query log wskazał je precyzyjnie, zapytanie ładujące promocje wraz z całym drzewem relacji: metodę aplikacji, reguły docelowe, reguły "kup X", wartości reguł, kampanię, budżet kampanii. Typowy, szeroki SELECT z kaskadą LEFT JOIN, jaki generuje MikroORM pod spodem Medusy przy ładowaniu grafu obiektów.

Wtedy sięgnąłem po EXPLAIN (ANALYZE, BUFFERS) bezpośrednio na produkcji i tu zaczyna się właściwa historia.

Winowajca: Postgres JIT

Plan zapytania pokazał na końcu sekcję, której nie widuje się w typowym debugowaniu:

Bash
JIT:
  Functions: 107
  Timing: Generation 8.7 ms, Inlining 23.1 ms,
          Optimization 1212.2 ms, Emission 810.3 ms,
          Total 2054.3 ms
Execution Time: 2056.2 ms

Z 2056 ms całkowitego czasu wykonania, 2054 ms to była sama kompilacja JIT. Rzeczywiste skanowanie tabel i joiny, widoczne w poszczególnych węzłach planu, zajmowały poniżej 2 ms. Innymi słowy: Postgres poświęcił 99,9% czasu na kompilowanie kodu do wykonania zapytania, które i tak było błyskawiczne.

Dla porównania, to samo zapytanie z wyłączonym JIT-em:

sql
Settings: jit = 'off'
Execution Time: 1.453 ms

Ten sam plan zapytania. Te same węzły, te same koszty, te same bufory. Jedyna różnica: 2056 ms kontra 1,4 ms.

Dlaczego JIT się w ogóle uruchomił

Postgres nie kompiluje każdego zapytania, to by było bez sensu przy prostych operacjach. Odpala JIT dopiero, gdy szacowany koszt planu przekroczy próg (jit_above_cost, domyślnie 100 000). Jeśli koszt jest jeszcze wyższy, dokłada droższe warianty: optymalizację LLVM i inlining funkcji (progi jit_optimize_above_cost i jit_inline_above_cost, domyślnie po 500 000).

Problem w tym zapytaniu: planer estymował wynik pośredniego joina na 1 151 348 wierszy. Realnie wracało 103. Błąd rzędu jedenastu tysięcy razy. Estymowany koszt całego planu wyszedł na ponad 2,4 miliona, grubo ponad wszystkie trzy progi naraz, więc Postgres odpalił JIT na pełnej mocy: kompilację, optymalizację i inlining 107 funkcji, dla zapytania które realnie zwraca sto kilka wierszy w niecałą milisekundę.

Skąd tak absurdalnie zawyżona estymacja? Zapytanie ma kaskadę około dziesięciu LEFT JOIN, w tym kilka relacji jeden-do-wielu przechodzących przez tabele pomostowe, plus warunek WHERE z alternatywą po dwóch różnych kolumnach (code IN (...) OR id IN (...)). Model kosztu Postgresa zakłada niezależność statystyczną między kolejnymi joinami w łańcuchu, mnoży selektywności przez siebie zamiast liczyć ich faktyczną korelację. Im dłuższy łańcuch joinów, tym bardziej ten błąd narasta wykładniczo. To nie jest kwestia nieaktualnych statystyk, to fundamentalne ograniczenie planera przy szerokich, wielotabelowych zapytaniach, jakie regularnie generują ORM-y ładujące złożone grafy obiektów.

Dla niewtajemniczonych: czym właściwie jest ten JIT

Jeśli nie siedzisz w bazach danych na co dzień, warto rozjaśnić, o co tu w ogóle chodzi, bo na pierwszy rzut oka brzmi to jak coś, co powinno tylko przyspieszać.

Normalnie Postgres wykonuje zapytanie, interpretując jego plan krok po kroku, trochę jak ktoś czytający przepis na głos i wykonujący każdą instrukcję osobno, za każdym razem od nowa. JIT (Just-In-Time compilation) to próba przyspieszenia tego: zamiast interpretować powtarzalne fragmenty (np. warunki filtrowania, odczyt wartości z wiersza), Postgres kompiluje je do natywnego kodu maszynowego, korzystając z LLVM, tego samego frameworka kompilatorowego co Clang czy Rust.

To ma sens przy ciężkich, analitycznych zapytaniach: agregacjach na milionach wierszy, złożonych obliczeniach powtarzanych setki tysięcy razy. Tam koszt kompilacji (rzędu dziesiątek-setek milisekund) rozkłada się na ogromną liczbę wykonań i realnie się opłaca.

Problem: sam koszt kompilacji trzeba ponieść z góry, niezależnie od tego, ile faktycznie zapytanie robi. Jeśli Postgres źle oszacuje, że zapytanie będzie "ciężkie" tak jak w moim przypadku, gdzie spodziewał się miliona wierszy zamiast stu, kompiluje kod tak, jakby to była duża analityczna operacja. A potem to zapytanie i tak wykonuje się w mikrosekundach, bo w rzeczywistości nigdy nie było ciężkie. Zapłaciłem za budowę autostrady, żeby przejechać nią raz, dwadzieścia metrów.

Czemu tego nie widać "tak po prostu" w ustawieniach

Tu pojawia się pytanie, które nasuwa się samo: skoro to takie problematyczne, to po co ktoś to włączył?

Odpowiedź jest mniej satysfakcjonująca niż "ktoś popełnił błąd konfiguracyjny": nikt tego nie włączył. Postgres nie ma czegoś w rodzaju zakładki ustawień z przełącznikami. Cała konfiguracja żyje w płaskim pliku tekstowym (postgresql.conf) z kilkuset parametrami, bez interfejsu, bez opisów w UI, bez niczego, co powiadomiłoby administratora "hej, ta funkcja jest aktywna i może wpłynąć na twoje zapytania".

Do tego dochodzi dodatkowa pułapka przy czytaniu tego pliku. Zakomentowana linia w postgresql.conf:

Bash
#jit = on    # allow JIT compilation

nie oznacza "wyłączone". Oznacza: nikt tego nie nadpisał, więc obowiązuje wbudowana wartość domyślna, a tą wartością jest on. To akurat zmyliło nawet narzędzie diagnostyczne AI, którego użyłem po drodze do sprawdzenia configu na hostingu, odczytało #jit = on jako dowód, że JIT jest domyślnie wyłączony, bo linia jest zakomentowana. Jeśli nawet automatyczny agent wpadł w tę pułapkę interpretacji, tym bardziej nie jest to coś oczywistego dla człowieka przeglądającego plik pobieżnie.

Platformy hostingowe typu Railway świadomie nie owijają Postgresa w warstwę UI z ustawieniami, dostajesz "vanilla" obraz, zero konfiguracji na wierzchu, więc dziedziczysz każdy domyślny parametr projektu Postgres bez zmian, łącznie z tymi, które zmieniły się między wersjami. A JIT to dokładnie taki przypadek: w Postgresie 11 domyślnie było off (funkcja była nowa), od Postgresa 12 domyślnie jest on. Ja byłem na Postgresie 16. Dostałem fabryczne ustawienie z czterech kolejnych wersji major, bez żadnej ludzkiej decyzji po drodze.

Jedyny sposób, żeby się o tym dowiedzieć, to zapytać bazę wprost:

sql
SELECT name, setting, source FROM pg_settings WHERE name = 'jit';

Kolumna source = 'default' mówi jasno: to wbudowana wartość, nikt jej nigdzie nie nadpisał.
Czy normalnie ktoś tam zagląda? Większość web developerów nigdy nie miała powodu, żeby myśleć o bazie inaczej niż jako o czarnej skrzynce za connection stringiem.
Tutaj wyjątkową koniecznością okazał się bardzo rozbudowany LEFT JOIN medusy.

Fix: jak wyłączyć JIT

Wyłączenie JIT-a na poziomie bazy:

sql
ALTER DATABASE "nazwa_bazy" SET jit = off;

To nie jest zmiana pliku konfiguracyjnego, trafia do katalogu systemowego Postgresa i nie wymaga restartu samego serwera bazy. Działa jednak tylko dla nowych połączeń. Tu jest pierwsza pułapka wdrożeniowa: jeśli aplikacja trzyma pulę już otwartych połączeń (a każdy ORM to robi), te połączenia mają w sobie stare ustawienie do końca swojego życia w puli. Zmiana w bazie bez restartu backendu nic realnie nie zmienia, trzeba zredeployować albo zrestartować aplikację, żeby wymusić nowe połączenia.

Druga pułapka: ALTER DATABASE ... SET to ręczna zmiana na konkretnej instancji bazy, niezapisana nigdzie w kodzie ani migracjach. Jeśli baza kiedykolwiek zostanie odtworzona od zera, nowe środowisko, disaster recovery, migracja do innego hostingu, to ustawienie znika bez śladu. Bezpieczniejsza w dłuższej perspektywie jest wersja, która podróżuje razem z konfiguracją aplikacji, jako parametr startowy w connection stringu:

Bash
postgresql://user:pass@host:port/db?options=-c%20jit%3Doff

(czyli zakodowane w URL -c jit=off, przekazywane do libpq przy każdym połączeniu, niezależnie od tego, czy baza pamięta cokolwiek).

Jest jeszcze wariant pośredni, dla kogoś kto nie chce całkowicie rezygnować z JIT na wypadek prawdziwie ciężkich zapytań analitycznych w przyszłości: zamiast wyłączać, drastycznie podnieść próg aktywacji (jit_above_cost), tak żeby błędnie oszacowane zapytania transakcyjne nigdy go nie przekraczały, a faktycznie ciężkie zapytania owszem.

Efekt

Twardych liczb przed/po z ruchu produkcyjnego nie mam, po wdrożeniu poprawki czasy tych żądań spadły poniżej progu, przy którym mój panel w ogóle rejestruje żądanie jako "wolne". To akurat samo w sobie jest dość wymowne: endpoint, który wcześniej systematycznie trafiał na listę najwolniejszych żądań z czasem rzędu 2,5–3,7 sekundy, po fixie po prostu przestał się tam pojawiać.

To, co mam twardo zmierzone, to sam EXPLAIN na identycznym zapytaniu: 2056 ms z JIT-em, 1,4 ms bez. Reszta różnicy między lokalnym a produkcyjnym środowiskiem to już nie JIT, tylko realny round-trip sieciowy między aplikacją a bazą przy 75 zapytaniach składających się na przeliczenie promocji, osobny temat do optymalizacji.

Wnioski

Jeśli używasz ORM-a, który generuje szerokie zapytania z wieloma JOIN przy ładowaniu grafu obiektów (MikroORM, TypeORM, Prisma z zagnieżdżonym include), masz młodą bazę z niewielką liczbą wierszy w kluczowych tabelach i jesteś na Postgresie 12 lub nowszym, warto to sprawdzić, zanim spędzisz czas na szukaniu winy w indeksach czy N+1:

sql
EXPLAIN (ANALYZE, BUFFERS) <twoje zapytanie>;

Jeśli w wyniku pojawia się sekcja JIT: z czasem porównywalnym albo większym niż realny czas wykonania zapytania widoczny w węzłach planu, to nie jest twoje zapytanie, które jest wolne. To kompilacja, która nie powinna była się w ogóle odpalić.

Ten problem był czysto infrastrukturalny/konfiguracyjny, nie wymagał zmiany ani jednej linijki logiki aplikacji. Czasem najdroższy bug w checkoucie nie jest w kodzie checkoutu.

FAQ

Najczęściej zadawane pytania

JIT (Just-In-Time compilation) to funkcja Postgresa wprowadzona w wersji 11, która wykorzystuje LLVM do kompilowania fragmentów planu wykonania zapytania do natywnego kodu maszynowego w czasie rzeczywistym. Aktywuje się, gdy planer zapytań szacuje, że koszt wykonania zapytania przekroczy próg jit_above_cost (domyślnie: 100 000). Założenie jest takie, że dla bardzo kosztownych zapytań jednorazowy koszt kompilacji jest rekompensowany szybszym wykonaniem. Problem pojawia się, gdy statystyki tabel są nieaktualne lub niedokładne — planer może wtedy uruchomić JIT dla zapytań, które w rzeczywistości są szybkie, generując zbędny narzut.

Przyczyną był błąd w szacowaniu przez planer. Postgres oszacował, że zapytanie checkoutowe zwróci 1 151 348 wierszy, co podbijało szacowany koszt powyżej progu jit_above_cost i uruchamiało kompilację JIT. W rzeczywistości zapytanie zwróciło jedynie 103 wiersze. Sama kompilacja LLVM zajęła 2 054 ms — dla zapytania, które po skompilowaniu wykonało się w zaledwie 1,4 ms. Łączny czas wykonania przekroczył 2 sekundy wyłącznie z powodu narzutu kompilacji. Planer został wprowadzony w błąd przez nieaktualne statystyki tabel i potraktował lekkie zapytanie z wieloma JOINami jak masywne obciążenie analityczne.

Możesz odpytać widok systemowy pg_settings, aby sprawdzić bieżącą konfigurację JIT: SELECT name, setting, source FROM pg_settings WHERE name LIKE 'jit%'; Kluczowe kolumny to: setting (on/off lub wartość progu) oraz source. Jeśli source pokazuje 'default', oznacza to, że wartość nie została jawnie skonfigurowana i Postgres używa wbudowanego domyślnego ustawienia — co oznacza, że JIT jest domyślnie włączony w Postgresie 12+. Możesz też uruchomić EXPLAIN (ANALYZE, BUFFERS) na wolnym zapytaniu i poszukać sekcji 'JIT' w wynikach — pokaże ona czas kompilacji oraz liczbę skompilowanych funkcji.

Istnieje kilka sposobów wyłączenia JIT w zależności od konfiguracji. Najbardziej trwałe podejście to wykonanie polecenia ALTER DATABASE nazwa_bazy SET jit = off; — wyłącza JIT dla wszystkich nowych połączeń z tą bazą bez konieczności restartu serwera. Można też przekazać to jako parametr connection stringa (np. options=-c jit=off) lub ustawić w postgresql.conf dla zmiany obejmującej cały serwer. Ważne: istniejące połączenia w puli połączeń nie pobiorą zmiany natychmiast. Należy zrestartować serwer aplikacji lub pulę połączeń (np. zrestartować proces Medusa.js), aby wszystkie połączenia w puli reconnektowały się z nowym ustawieniem.

Problem może dotyczyć każdej aplikacji działającej na Postgresie 12+, która używa ORM-a lub query buildera generującego szerokie zapytania JOIN na wielu tabelach. Nie jest to specyfika Medusa.js — może wystąpić z MikroORM, TypeORM, Prismą (szczególnie przy głęboko zagnieżdżonych include/select) lub dowolnym zapytaniem powodującym przeszacowanie liczby wierszy przez planer. Wyzwalaczem jest kombinacja: (1) zapytania z wieloma JOINami zawyżającego szacowany koszt, (2) nieaktualnych lub niedokładnych statystyk tabel oraz (3) JIT włączonego przy domyślnym progu. Checkouty e-commerce są szczególnie narażone, ponieważ typowo łączą zamówienia, pozycje, produkty, warianty, adresy i metody dostawy w jednym zapytaniu.

Przed naprawą endpoint checkoutowy konsekwentnie zajmował ponad 2 056 ms — niemal w całości z powodu narzutu kompilacji LLVM (2 054 ms), a nie faktycznego wykonania zapytania. Po wyłączeniu JIT poleceniem ALTER DATABASE SET jit = off i restarcie puli połączeń, to samo zapytanie wykonywało się w około 1,4 ms. To poprawa o około 1 468 razy w czasie wykonania zapytania. Endpoint checkoutowy całkowicie zniknął z listy monitorowania wolnych żądań. Naprawa nie wymagała żadnych zmian w kodzie aplikacji, schemacie ani indeksach — jedna zmiana konfiguracji bazy danych rozwiązała cały problem z wydajnością.