Playbook do tuningu wydajności Go + Postgres dla AI-generowanych API: pula połączeń, analiza planów zapytań, mądre indeksowanie, bezpieczna paginacja i szybkie kształtowanie JSON.

AI-generowane API mogą wydawać się szybkie w wczesnych testach. Odpalasz endpoint kilka razy, zestaw danych jest mały, a żądania przychodzą pojedynczo. Potem pojawia się prawdziwy ruch: mieszane endpointy, nagłe skoki obciążenia, zimniejsze cache i więcej wierszy niż się spodziewałeś. Ten sam kod może zacząć działać losowo wolno, choć nic nie uległo awarii.
Zwykle „wolno” objawia się kilkoma sposobami: skoki latencji (większość żądań jest w porządku, niektóre trwają 5x–50x dłużej), timeouty (mały odsetek kończy się niepowodzeniem) lub wysokie użycie CPU (Postgres wykonuje zapytania, albo Go zużywa CPU na JSON, goroutiny, logowanie i ponawianie).
Częsty scenariusz to endpoint listy z elastycznym filtrem, który zwraca dużą odpowiedź JSON. W testowej bazie skanuje kilka tysięcy wierszy i kończy szybko. W produkcji skanuje kilka milionów, sortuje je i dopiero potem stosuje LIMIT. API wciąż „działa”, ale p95 latencja rośnie wykładniczo i kilka żądań kończy się timeoutem podczas skoków.
Aby oddzielić powolność bazy od powolności aplikacji, trzymaj prosty model myślowy.
Jeśli baza jest wolna, handler w Go spędza większość czasu czekając na zapytanie. Możesz też zobaczyć wiele żądań „w locie”, podczas gdy CPU Go wygląda normalnie.
Jeśli aplikacja jest wolna, zapytanie kończy się szybko, ale czas jest tracony po zapytaniu: budowanie dużych obiektów odpowiedzi, marshaling JSON, dodatkowe zapytania per wiersz lub za dużo pracy na żądanie. CPU Go rośnie, pamięć rośnie, a latencja rośnie wraz z rozmiarem odpowiedzi.
„Dostatecznie dobrze” przed uruchomieniem to nie perfekcja. Dla wielu CRUD-owych endpointów celem jest stabilna p95 latencja (nie tylko średnia), przewidywalne zachowanie przy skokach i brak timeoutów przy oczekiwanym szczycie. Cel jest prosty: brak niespodziewanych wolnych żądań, gdy dane i ruch rosną, oraz wyraźne sygnały, gdy coś odpływa.
Zanim zaczniesz stroić cokolwiek, zdecyduj, co znaczy „dobrze” dla twojego API. Bez bazy wyjściowej łatwo spędzić godziny na zmianach ustawień i nadal nie wiedzieć, czy poprawiłeś sytuację, czy tylko przesunąłeś wąskie gardło.
Zwykle trzy liczby mówią najwięcej:
p95 to metryka „złego dnia”. Jeśli p95 jest wysoki, ale średnia OK, to mały zestaw żądań robi za dużo pracy, blokuje się na lockach lub uruchamia wolne plany.
Uczyń wolne zapytania widocznymi wcześnie. W Postgresie włącz logowanie wolnych zapytań z niskim progiem do testów przed uruchomieniem (np. 100–200 ms) i loguj pełne zapytanie, żeby móc wkleić je do klienta SQL. Zachowaj to tymczasowo. Logowanie każdego wolnego zapytania w produkcji szybko staje się hałaśliwe.
Następnie testuj z prawdziwie wyglądającymi żądaniami, a nie tylko z jedną trasą „hello world”. Wystarczy mały zestaw, jeśli odzwierciedla rzeczywiste użycie: wywołanie listy z filtrami i sortowaniem, strona szczegółów z kilkoma joinami, tworzenie/aktualizacja z walidacją oraz zapytanie w stylu wyszukiwania z dopasowaniami częściowymi.
Jeśli generujesz endpointy ze specyfikacji (np. narzędziem vibe-coding jak Koder.ai), uruchamiaj ten sam kilka żądań wielokrotnie z powtarzalnymi danymi. To ułatwia mierzenie zmian jak indeksy, poprawki paginacji i przepisywanie zapytań.
Na koniec wybierz cel, który możesz wypowiedzieć na głos. Przykład: „Większość żądań poniżej 200 ms p95 przy 50 równoczesnych użytkownikach, błędy poniżej 0,5%.” Dokładne liczby zależą od produktu, ale jasny cel zapobiega niekończącym się drobnym poprawkom.
Pula połączeń utrzymuje ograniczoną liczbę otwartych połączeń do bazy i je ponownie wykorzystuje. Bez puli każde żądanie może otwierać nowe połączenie, a Postgres marnuje czas i pamięć na zarządzanie sesjami zamiast wykonywać zapytania.
Celem jest utrzymanie Postgresa zajętego użyteczną pracą, a nie przełączaniem kontekstu między zbyt wieloma połączeniami. To często pierwszy znaczący zysk, szczególnie dla AI-generowanych API, które mogą po cichu stać się gadatliwymi endpointami.
W Go zwykle stroisz max open connections, max idle connections i time-to-live połączeń. Bezpieczny punkt startowy dla wielu małych API to mała wielokrotność rdzeni CPU (często 5–20 łącznie), podobna liczba trzymana jako idle oraz okresowa recyklingacja połączeń (np. co 30–60 minut).
Jeśli uruchamiasz wiele instancji API, pamiętaj, że pula się mnoży. Pula 20 połączeń na 10 instancji to 200 połączeń uderzających w Postgresa — stąd zespoły niespodziewanie napotykają limity połączeń.
Problemy z pulą czują się inaczej niż wolne SQL.
Jeśli pula jest za mała, żądania czekają zanim w ogóle trafią do Postgresa. Latencja skacze, ale CPU bazy i czasy zapytań mogą wyglądać dobrze.
Jeśli pula jest za duża, Postgres wygląda na przeciążony: dużo aktywnych sesji, presja pamięci i nierównomierna latencja między endpointami.
Szybki sposób rozdzielenia to zmierzyć wywołania DB w dwóch częściach: czas oczekiwania na połączenie vs czas wykonywania zapytania. Jeśli większość to „oczekiwanie”, to pula jest wąskim gardłem. Jeśli większość to „w zapytaniu”, skup się na SQL i indeksach.
Przydatne szybkie kontrole:
max_connections.Jeśli używasz pgxpool, masz pulę zaprojektowaną pod Postgresa z czytelnymi statystykami i dobrymi domyślnymi zachowaniami. Jeśli używasz database/sql, masz standardowy interfejs działający z wieloma bazami, ale musisz jawnie ustawić parametry puli i zachowanie sterownika.
Praktyczna zasada: jeśli jesteś w pełni na Postgresie i chcesz kontroli, pgxpool jest często prostszy. Jeśli polegasz na bibliotekach oczekujących database/sql, pozostań przy nim, ustaw pulę jawnie i mierz oczekiwania.
Przykład: endpoint listujący zamówienia może działać w 20 ms, ale przy 100 równoczesnych użytkownikach skacze do 2 s. Jeśli logi pokazują 1.9 s oczekiwania na połączenie, tunning zapytań nie pomoże, dopóki pula i łączna liczba połączeń do Postgresa nie będą właściwie dopasowane.
Gdy endpoint wydaje się wolny, sprawdź, co Postgres faktycznie robi. Szybkie odczytanie EXPLAIN często wskazuje poprawkę w minutach.
Uruchom to na dokładnym SQL, które wysyła twoje API:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, status, created_at
FROM orders
WHERE user_id = $1 AND status = $2
ORDER BY created_at DESC
LIMIT 50;
Kilka linii ma największe znaczenie. Spójrz na węzeł najwyższego poziomu (co Postgres wybrał) i sumy na dole (ile to zajęło). Porównaj też estymowane vs rzeczywiste wiersze. Duże rozbieżności zwykle oznaczają, że planner źle zgadł.
Jeśli widzisz Index Scan lub Index Only Scan, Postgres używa indeksu, co zwykle jest dobre. Bitmap Heap Scan może być ok dla średnich dopasowań. Seq Scan oznacza przeszukanie całej tabeli, co jest dopuszczalne tylko, gdy tabela jest mała lub prawie każdy wiersz pasuje.
Typowe czerwone flagi:
ORDER BY)Wolne plany zwykle wynikają z kilku wzorców:
WHERE + ORDER BY (np. (user_id, status, created_at))WHERE (np. WHERE lower(email) = $1), co może wymusić skany, chyba że dodasz indeks wyrażeniowyJeśli plan wygląda dziwnie i estymaty są daleko, statystyki często są przestarzałe. Uruchom ANALYZE (lub pozwól autovacuum nadrobić zaległości), żeby Postgres poznał bieżącą liczbę wierszy i rozkład wartości. To ma znaczenie po dużych importach lub gdy nowe endpointy szybko zapisują dużo danych.
Indeksy pomagają tylko wtedy, gdy pasują do tego, jak API pyta o dane. Jeśli tworzysz je na zgadywankę, dostajesz wolniejsze zapisy, większe zajęcie dysku i niewielkie przyspieszenie.
Praktyczny sposób myślenia: indeks to skrót dla konkretnego pytania. Jeśli API zada inne pytanie, Postgres zignoruje skrót.
Jeśli endpoint filtruje po account_id i sortuje po created_at DESC, pojedynczy indeks złożony często bije dwa osobne indeksy. Pomaga Postgresowi znaleźć właściwe wiersze i zwrócić je w odpowiedniej kolejności przy mniejszej pracy.
Zasady praktyczne, które zwykle się sprawdzają:
Przykład: jeśli twoje API ma GET /orders?status=paid i zawsze pokazuje najnowsze, indeks (status, created_at DESC) dobrze pasuje. Jeśli większość zapytań również filtruje po kliencie, (customer_id, status, created_at) może być lepszy, ale tylko jeśli tak endpoint rzeczywiście działa w produkcji.
Jeśli większość ruchu trafia do wąskiego wycinka wierszy, indeks częściowy może być tańszy i szybszy. Na przykład, jeśli aplikacja głównie czyta rekordy aktywne, indeksując tylko WHERE active = true indeks będzie mniejszy i bardziej prawdopodobne, że zostanie w pamięci.
Aby potwierdzić, że indeks pomaga, wykonaj szybkie kontrole:
EXPLAIN (lub EXPLAIN ANALYZE w bezpiecznym środowisku) i szukaj indeksowego skanu pasującego do zapytania.Usuwaj nieużywane indeksy ostrożnie. Sprawdź statystyki użycia (czy indeks był skanowany). Rzucaj je pojedynczo w oknach niskiego ryzyka i miej plan rollbacku. Nieużywane indeksy nie są nieszkodliwe — spowalniają inserty i aktualizacje przy każdym zapisie.
Paginacja to często miejsce, gdzie szybkie API zaczyna się wydawać wolne, nawet gdy baza jest zdrowa. Traktuj paginację jako problem projektowania zapytania, nie szczegół UI.
LIMIT/OFFSET wygląda prosto, ale głębsze strony zwykle kosztują więcej. Postgres dalej musi przejść (i często posortować) wiersze, które pomijasz. Strona 1 może dotykać kilkudziesięciu wierszy. Strona 500 może zmusić bazę do przeskanowania i odrzucenia dziesiątek tysięcy tylko po to, by zwrócić 20 wyników.
Może też tworzyć niestabilne wyniki, gdy wiersze są wstawiane lub usuwane między żądaniami. Użytkownicy mogą zobaczyć duplikaty lub brakujące elementy, bo znaczenie „wiersz 10 000” zmienia się w czasie.
Paginacja kluczowa zadaje inne pytanie: „Daj mi następne 20 wierszy po ostatnim, który widziałem.” To trzyma DB przy małym, spójnym wycinku.
Prosta wersja używa rosnącego id:
SELECT id, created_at, title
FROM posts
WHERE id > $1
ORDER BY id
LIMIT 20;
Twoje API zwraca next_cursor równe ostatniemu id na stronie. Następne żądanie używa tej wartości jako $1.
Dla sortowania opartego na czasie użyj stabilnego porządku i rozwiązywania remisów. created_at sam w sobie nie wystarcza, jeśli dwa wiersze mają ten sam znacznik czasu. Użyj złożonego kursora:
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Kilka zasad zapobiega duplikatom i brakującym wierszom:
ORDER BY (zwykle id).created_at i id razem).Zadziwiająco częstą przyczyną odczucia wolnego API nie jest baza. To odpowiedź. Duże JSON-y zajmują więcej czasu na budowę, wysyłkę i parsowanie po stronie klienta. Najszybszy zysk to często zwrócenie mniej.
Zacznij od SELECT. Jeśli endpoint potrzebuje tylko id, name i status, poproś o te kolumny i nic więcej. SELECT * cicho staje się cięższe w czasie, gdy tabele zyskują długie teksty, bloby JSON i kolumny audytowe.
Innym częstym spowolnieniem jest N+1 przy budowaniu odpowiedzi: pobierasz listę 50 elementów, a potem uruchamiasz 50 dodatkowych zapytań, by dołączyć powiązane dane. Może przejść w testach, a potem zawalić się przy prawdziwym ruchu. Preferuj jedno zapytanie zwracające to, czego potrzebujesz (ostrożne joiny), lub dwa zapytania, gdzie drugie grupuje po ID.
Kilka sposobów, by utrzymać payloady mniejsze bez łamania klientów:
include= (lub maski fields=), aby odpowiedzi listy były szczupłe, a szczegóły mogły opcjonalnie załadować extras.Oba podejścia mogą być szybkie. Wybierz w zależności od tego, co optymalizujesz.
Funkcje JSON Postgresa (jsonb_build_object, json_agg) są przydatne, gdy chcesz mniej rund podróży i przewidywalne kształty z jednego zapytania. Kształtowanie w Go pomaga, gdy potrzebujesz logiki warunkowej, wielokrotnego wykorzystania struktur lub prostszych SQL-i do utrzymania. Jeśli SQL budujący JSON staje się trudny do czytania, trudniej go potem stroić.
Dobra zasada: pozwól Postgresowi filtrować, sortować i agregować. Potem niech Go zajmie się końcową prezentacją.
Jeśli szybko generujesz API (np. z Koder.ai), wczesne dodanie flag include pomaga uniknąć endpointów, które z czasem puchną. Daje też bezpieczny sposób dodawania pól bez obciążania każdej odpowiedzi.
Nie potrzebujesz ogromnego laboratorium testowego, żeby wykryć większość problemów wydajnościowych. Krótka, powtarzalna procedura ujawnia problemy, które zamieniają się w outage’y, gdy przychodzi prawdziwy ruch, szczególnie gdy punkt startowy to generowany kod, który masz zamiar wypuścić.
Zanim cokolwiek zmienisz, zapisz małą bazę wyjściową:
Zacznij od małych kroków, zmieniaj jedną rzecz naraz i testuj po każdej zmianie.
Uruchom 10–15 minutowy test obciążeniowy wyglądający jak rzeczywiste użycie. Traf te same endpointy, które będą używać pierwsi użytkownicy (login, listy, wyszukiwanie, tworzenie). Posortuj trasy według p95 latencji i całkowitego czasu.
Sprawdź presję na połączenia zanim stuningujesz SQL. Zbyt duża pula przytłoczy Postgresa. Zbyt mała pula tworzy długie oczekiwania. Szukaj rosnącego czasu oczekiwania na zdobycie połączenia i skoków w liczbie połączeń. Dostosuj limity puli i idle najpierw, potem powtórz test.
EXPLAIN najwolniejszych zapytań i napraw największy czerwony alert. Zwykli podejrzani to pełne skany tabel na dużych tabelach, sorty na dużych zestawach wyników i joiny, które eksplodują liczbę wierszy. Wybierz jedno najgorsze zapytanie i spraw, by stało się nudne (czyli przewidywalnie szybkie).
Dodaj lub dostosuj pojedynczy indeks, potem testuj ponownie. Indeksy pomagają, gdy pasują do twojego WHERE i ORDER BY. Nie dodawaj pięciu naraz. Jeśli wolny endpoint to „lista zamówień po user_id sortowana po created_at”, indeks złożony (user_id, created_at) może być różnicą między natychmiast a bolesnym.
Ogranicz odpowiedzi i paginację, potem przetestuj znowu. Jeśli endpoint zwraca 50 wierszy z wielkimi blobami JSON, płacą za to baza, sieć i klient. Zwracaj tylko pola, których UI potrzebuje i preferuj paginację, która nie zwalnia wraz ze wzrostem tabel.
Prowadź prosty changelog: co zmieniono, dlaczego i co ruszyło w p95. Jeśli zmiana nie poprawia bazy, wycofaj ją i idź dalej.
Większość problemów wydajnościowych w API Go + Postgres jest samoszkodliwa. Dobra wiadomość jest taka, że kilka kontroli wykrywa je przed przyjściem realnego ruchu.
Klasyczna pułapka to traktowanie rozmiaru puli jak pokrętła prędkości. Ustawienie jej „tak wysokiej, jak możliwe” często spowalnia wszystko. Postgres spędza więcej czasu na żonglowaniu sesjami, pamięcią i lockami, a twoja aplikacja zaczyna dostawać timeouty falami. Mniejsza, stabilna pula z przewidywalną współbieżnością zwykle wygrywa.
Inny częsty błąd to „zindeksuj wszystko”. Dodatkowe indeksy mogą pomóc odczytom, ale też spowalniają zapisy i mogą zmieniać plany zapytań w zaskakujący sposób. Jeśli API często insertuje lub aktualizuje, każdy dodatkowy indeks dodaje pracy. Mierz przed i po, i ponownie sprawdzaj plany po dodaniu indeksu.
Dług paginacyjny wkrada się cicho. Offsetowa paginacja wygląda dobrze na początku, potem p95 rośnie, bo DB musi przejść coraz więcej wierszy.
Rozmiar payloadu JSON to kolejny ukryty koszt. Kompresja pomaga z pasmem, ale nie usuwa kosztu budowania, alokowania i parsowania dużych obiektów. Przycinaj pola, unikaj głębokiego zagnieżdżenia i zwracaj tylko to, czego ekran potrzebuje.
Jeśli patrzysz tylko na średni czas odpowiedzi, przegapisz, gdzie zaczyna się ból użytkownika. p95 (a czasem p99) to miejsca, gdzie saturacja puli, oczekiwania na locki i wolne plany pokazują się pierwsze.
Szybka pre-launch checklista:
EXPLAIN po dodaniu indeksów lub zmianie filtrów.Przed pojawieniem się rzeczywistych użytkowników chcesz dowodu, że API pozostanie przewidywalne pod obciążeniem. Cel to nie perfekcyjne liczby, a wychwycenie kilku problemów, które powodują timeouty, skoki lub bazę, która przestaje przyjmować pracę.
Uruchom kontrole w stagingu przypominającym produkcję (podobny rozmiar DB, te same indeksy, te same ustawienia puli): zmierz p95 latency dla kluczowych endpointów pod obciążeniem, złap najwolniejsze zapytania według łącznego czasu, obserwuj czas oczekiwania puli, uruchom EXPLAIN (ANALYZE, BUFFERS) najgorszego zapytania, by potwierdzić, że używa oczekiwanego indeksu, i sanity-check rozmiary payloadów na najruchliwszych trasach.
Potem zrób jeden run worst-case, który imituje, jak produkty się psują: zażądaj głęboką stronę, zastosuj najszerszy filtr i potestuj przy zimnym starcie (zrestartuj API i uderz w to samo żądanie jako pierwsze). Jeśli głęboka paginacja pogarsza się z każdą stroną, przełącz na paginację kursorem przed uruchomieniem.
Zapisz domyślne wybory, żeby zespół podejmował spójne decyzje później: limity i timeouty puli, zasady paginacji (maksymalny rozmiar strony, czy offset jest dozwolony, format kursora), zasady zapytań (wybieraj tylko potrzebne kolumny, unikaj SELECT *, ogranicz kosztowne filtry) oraz zasady logowania (próg wolnych zapytań, jak długo trzymać próbki, jak etykietować endpointy).
Jeśli budujesz i eksportujesz usługi Go + Postgres za pomocą Koder.ai, krótka faza planowania przed wdrożeniem pomaga utrzymać filtry, paginację i kształty odpowiedzi świadomie. Gdy zaczniesz stroić indeksy i kształty zapytań, snapshoty i rollback ułatwiają cofnięcie „poprawki”, która pomaga jednemu endpointowi, a szkodzi innym. Jeśli chcesz jedno miejsce do iteracji nad tym workflow, Koder.ai na koder.ai jest zaprojektowane wokół generowania i usprawniania tych serwisów przez czat, a potem eksportu źródeł, gdy jesteś gotowy.
Zacznij od rozdzielenia czasu oczekiwania na DB od czasu pracy aplikacji.
Dodaj proste mierniki rozbijające „oczekiwanie na połączenie” i „wykonanie zapytania”, aby zobaczyć, która strona dominuje.
Użyj małej, powtarzalnej bazy pomiarowej:
Wybierz jasny cel, np. „p95 poniżej 200 ms przy 50 równoczesnych użytkownikach, błędy < 0.5%”. Zmieniaj tylko jedną rzecz naraz i powtarzaj ten sam miks żądań.
Włącz logowanie wolnych zapytań z niskim progiem w testach przed uruchomieniem (na przykład 100–200 ms) i loguj pełne zapytanie, by skopiować je do klienta SQL.
Zachowaj to tymczasowo:
Gdy znajdziesz największych winowajców, przejdź do próbkowania lub podnieś próg.
Praktyczny domyśl to mała wielokrotność rdzeni CPU na instancję API, często 5–20 max open connections, z podobną liczbą połączeń bezczynnych i recyklingiem co 30–60 minut.
Dwa typowe tryby awarii:
Pamiętaj, że pule się mnożą przez instancje (20 połączeń × 10 instancji = 200 połączeń).
Podziel czas wywołań DB na dwie części:
Jeśli większość czasu to oczekiwanie na pulę, dostosuj rozmiar puli, timeouty i liczbę instancji. Jeśli większość to wykonanie zapytania, skup się na EXPLAIN i indeksach.
Potwierdź też, że zawsze zamykasz rows, żeby połączenia wracały do puli.
Uruchom EXPLAIN (ANALYZE, BUFFERS) na dokładnym SQL, które wysyła API i zwróć uwagę na:
Indeksy powinny pasować do tego, co faktycznie robi endpoint: filtry + porządek sortowania.
Dobre domyślne podejście:
WHERE + ORDER BY.Użyj indeksu częściowego, gdy większość ruchu trafia w przewidywalny podzbiór wierszy.
Przykład:
active = trueIndeks częściowy jak ... WHERE active = true pozostaje mniejszy, częściej mieści się w pamięci i zmniejsza narzut zapisu w porównaniu z indeksowaniem wszystkiego.
Potwierdź przez , że Postgres faktycznie z niego korzysta.
LIMIT/OFFSET staje się wolniejsze na głębokich stronach, bo Postgres dalej musi przeskoczyć (i często posortować) pomijane wiersze. Strona 1 dotyka kilkudziesięciu wierszy, strona 500 może zmusić bazę do przejrzenia i odrzucenia dziesiątek tysięcy, by zwrócić 20 wyników.
Preferuj paginację kluczową (keyset/cursor):
Zazwyczaj tak dla endpointów listujących. Najszybsza odpowiedź to ta, której nie wysyłasz.
Praktyczne zwycięstwa:
SELECT *).include= lub , by klienta opcjonalnie załadował ciężkie pola.ORDER BY)Najpierw napraw największy czerwony alert; nie tunuj wszystkiego naraz.
Przykład: jeśli filtrujesz po user_id i sortujesz po newest, indeks (user_id, created_at DESC) często rozwiązuje problem p95.
EXPLAINid).ORDER BY identyczne między żądaniami.(created_at, id) lub podobne w kursorze.To utrzymuje koszt każdej strony mniej więcej stały wraz ze wzrostem tabeli.
fields=Często zmniejszając payload redukujesz CPU Go, presję pamięci i tail latency.