Go + Postgres prestatieafstemming voor AI-gegenereerde API's: pool instellen, EXPLAIN checken, slim indexeren, veilige paginatie en JSON snel vormgeven.

AI-gegenereerde API's kunnen in vroege tests snel aanvoelen. Je raakt een endpoint een paar keer aan, de dataset is klein en requests komen één voor één binnen. Dan verschijnt echt verkeer: gemixte endpoints, piekbelastingen, koudere caches en meer rijen dan je had verwacht. Dezelfde code kan willekeurig traag gaan aanvoelen, ook al is er niets echt stuk.
Traagheid verschijnt meestal op een paar manieren: latency-spikes (de meeste requests zijn oké, sommige duren 5x tot 50x langer), timeouts (een klein percentage faalt) of CPU die hoog draait (Postgres-CPU door querywerk, of Go-CPU door JSON, goroutines, logging en retries).
Een veel voorkomend scenario is een list-endpoint met een flexibele zoekfilter dat een grote JSON-respons teruggeeft. In een testdatabase scant het een paar duizend rijen en is het snel klaar. In productie scant het een paar miljoen rijen, sorteert ze en past pas daarna een LIMIT toe. De API “werkt” nog, maar p95-latentie explodeert en een paar requests timen out tijdens pieken.
Om database-tragheid van app-tragheid te scheiden, houd het mentale model simpel.
Als de database traag is, brengt je Go-handler het grootste deel van de tijd door met wachten op de query. Je ziet mogelijk ook veel requests vast “in flight” terwijl Go-CPU normaal lijkt.
Als de app traag is, is de query snel klaar, maar gaat er tijd verloren na de query: grote response-objecten bouwen, JSON marshalen, extra queries per rij uitvoeren, of te veel werk per request doen. Go-CPU stijgt, geheugen stijgt en latentie groeit met de responsgrootte.
“Goed genoeg” performance voor de lancering is geen perfectie. Voor veel CRUD-endpoints streef je naar stabiele p95-latentie (niet alleen het gemiddelde), voorspelbaar gedrag bij pieken en geen timeouts bij je verwachte piek. Het doel is duidelijk: geen onverwachte trage requests wanneer data en verkeer groeien, en heldere signalen wanneer iets drift.
Voordat je iets tuneert, bepaal wat “goed” betekent voor je API. Zonder baseline is het makkelijk om uren instellingen te veranderen en nog steeds niet te weten of je verbeterde of gewoon de bottleneck verplaatst hebt.
Drie cijfers vertellen meestal het grootste deel van het verhaal:
p95 is de “slechte dag”-metric. Als p95 hoog is maar het gemiddelde goed, doet een kleine set requests te veel werk, wordt geblokkeerd op locks, of triggert het langzame plannen.
Maak trage queries vroeg zichtbaar. In Postgres schakel je slow query logging in met een lage drempel voor pre-launch tests (bijvoorbeeld 100–200 ms) en log je de volledige statement zodat je die in een SQL-client kunt plakken. Houd dit tijdelijk. Het loggen van elke trage query in productie wordt snel rumoerig.
Test vervolgens met realistische requests, niet alleen een enkele “hello world”-route. Een kleine set is genoeg als die overeenkomt met wat gebruikers daadwerkelijk doen: een list-call met filters en sortering, een detailpagina met een paar joins, een create of update met validatie, en een zoekachtige query met partial matches.
Als je endpoints genereert vanuit een specificatie (bijvoorbeeld met een vibe-coding tool zoals Koder.ai), draai dan steeds dezelfde handvol requests met consistente inputs. Dat maakt veranderingen zoals indexen, paginatie-aanpassingen en query-herschrijvingen makkelijker meetbaar.
Kies tenslotte een doel dat je hardop kunt zeggen. Voorbeeld: “De meeste requests blijven onder 200 ms p95 bij 50 gelijktijdige gebruikers, en fouten blijven onder 0,5%.” De exacte cijfers hangen van je product af, maar een duidelijk doel voorkomt eindeloos gesleutel.
Een connection pool houdt een beperkt aantal open databaseverbindingen en hergebruikt ze. Zonder pool opent elke request mogelijk een nieuwe verbinding, en Postgres verspilt tijd en geheugen aan sessiebeheer in plaats van queries uit te voeren.
Het doel is Postgres bezig te houden met nuttig werk, niet met context-switching tussen te veel verbindingen. Dit is vaak de eerste zinvolle winst, zeker voor AI-gegenereerde API's die ongemerkt chattery endpoints worden.
In Go stem je meestal max open connections, max idle connections en connection lifetime af. Een veilige start voor veel kleine API's is een klein veelvoud van je CPU-cores (vaak 5 tot 20 totale connecties), met een vergelijkbaar aantal idle connections, en verbindingen periodiek recyclen (bijvoorbeeld elke 30–60 minuten).
Als je meerdere API-instances draait, onthoud dan dat de pool vermenigvuldigt. Een pool van 20 connecties over 10 instances is 200 connecties naar Postgres, en zo lopen teams onverwacht tegen connection limits aan.
Pool-problemen voelen anders dan trage SQL.
Als de pool te klein is, wachten requests voordat ze zelfs Postgres bereiken. Latentie spike-t, maar database-CPU en querytijden kunnen er normaal uitzien.
Als de pool te groot is, lijkt Postgres overbelast: veel actieve sessies, geheugenstress en ongelijke latentie tussen endpoints.
Een snelle manier om de twee te scheiden is je DB-calls in twee delen te timen: tijd besteed aan wachten op een verbinding versus tijd besteed aan het uitvoeren van de query. Als de meeste tijd “wachten” is, is de pool de bottleneck. Als de meeste tijd “in query” is, richt je op SQL en indexen.
Handige snelle checks:
max_connections zit.Als je pgxpool gebruikt, krijg je een Postgres-eerst pool met duidelijke statistieken en goede defaults voor Postgres-gedrag. Als je database/sql gebruikt, krijg je een standaardinterface die werkt over databases heen, maar moet je expliciet pool-instellingen en driver-gedrag instellen.
Een praktische regel: als je volledig op Postgres inzet en directe controle wilt, is pgxpool vaak eenvoudiger. Als je afhankelijk bent van libraries die database/sql verwachten, blijf dan daarbij, stel de pool expliciet in en meet waits.
Voorbeeld: een endpoint dat orders listeert doet er misschien 20 ms over, maar bij 100 gelijktijdige gebruikers springt het naar 2 s. Als logs 1.9 s tonen die wordt besteed aan wachten op een verbinding, helpt query-tuning niet totdat pool en totale Postgres-verbindingen juist geschaald zijn.
Wanneer een endpoint traag aanvoelt, kijk wat Postgres daadwerkelijk doet. Een snelle blik op EXPLAIN wijst vaak binnen enkele minuten naar de oplossing.
Draai dit op de exacte SQL die je API verstuurt:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, status, created_at
FROM orders
WHERE user_id = $1 AND status = $2
ORDER BY created_at DESC
LIMIT 50;
Een paar regels zijn het belangrijkst. Kijk naar de top-node (wat Postgres koos) en de totalen onderaan (hoe lang het duurde). Vergelijk daarna geschatte vs daadwerkelijke rijen. Grote verschillen betekenen meestal dat de planner het fout had.
Als je Index Scan of Index Only Scan ziet, gebruikt Postgres een index, wat meestal goed is. Bitmap Heap Scan kan acceptabel zijn voor middelgrote matches. Seq Scan betekent dat de hele tabel is gelezen, wat alleen oké is wanneer de tabel klein is of bijna elke rij matcht.
Veelvoorkomende rode vlaggen:
ORDER BY)Trage plannen komen meestal door een handvol patronen:
WHERE + ORDER BY patroon (bijv. (user_id, status, created_at))WHERE (bijv. WHERE lower(email) = $1), wat scans kan forceren tenzij je een bijpassende expression-index toevoegtAls het plan vreemd uitziet en schattingen veraf zitten, zijn statistieken vaak verouderd. Draai ANALYZE (of laat autovacuum bijbenen) zodat Postgres actuele rij-aantallen en waardeverdelingen leert. Dit is belangrijk na grote imports of wanneer nieuwe endpoints veel schrijven.
Begin met het scheiden van DB-wachtijd en app-werk.
Voeg eenvoudige timing toe rond “wachten op verbinding” en “query-uitvoering” om te zien welke kant de overhand heeft.
Gebruik een klein herhaalbaar baseline:
Kies een duidelijk doel zoals “p95 < 200 ms bij 50 gelijktijdige gebruikers, fouten < 0,5%”. Verander daarna steeds maar één ding en test opnieuw met dezelfde request-mix.
Zet slow query logging aan met een lage drempel in pre-launch testing (bijvoorbeeld 100–200 ms) en log de volledige statement zodat je die in een SQL-client kunt plakken.
Houd het tijdelijk:
Als je de ergste schuldigen hebt gevonden, schakel dan over op sampling of verhoog de drempel.
Een praktisch startpunt is een kleine veelvoud van CPU-cores per API-instance, vaak 5–20 max open connections, met vergelijkbaar aantal idle connections, en recycleer verbindingen elke 30–60 minuten.
Twee veelvoorkomende faalmodi:
Onthoud dat pools vermenigvuldigen over instances (20 verbindingen × 10 instances = 200 verbindingen).
Tijd DB-calls in twee delen:
Als de meeste tijd pool-wacht is, pas dan pool-grootte, timeouts en instance-aantallen aan. Als de meeste tijd query-executie is, richt je op EXPLAIN en indexen.
Controleer ook dat je altijd rows sluit zodat verbindingen snel terugkeren naar de pool.
Draai EXPLAIN (ANALYZE, BUFFERS) op de exacte SQL die je API verstuurt en let op:
Indexen moeten overeenkomen met wat het endpoint daadwerkelijk doet: filters + sortering.
Goed basisprincipe:
WHERE + ORDER BY patroon.Gebruik een partial index wanneer het meeste verkeer een voorspelbare subset van rijen raakt.
Voorbeeldpatroon:
active = trueEen partial index zoals ... WHERE active = true blijft kleiner, past eerder in geheugen en verlaagt write-overhead vergeleken met indexeren van alles.
Bevestig met dat Postgres het daadwerkelijk gebruikt voor je drukke queries.
LIMIT/OFFSET wordt trager op diepe pagina's omdat Postgres nog steeds langs (en vaak sorteert) de rijen moet lopen die je overslaat. Pagina 1 kan een paar dozijn rijen aanraken; pagina 500 kan tienduizenden moeten scannen en weggooien om 20 resultaten terug te geven.
Geef de voorkeur aan keyset (cursor) paginatie:
Meestal wel voor list-endpoints. De snelste response is degene die je niet verzendt.
Praktische wins:
SELECT *).include= of toe zodat clients zware velden opt-in krijgen.ORDER BY)Los de grootste rode vlag eerst op; tune niet alles tegelijk.
Voorbeeld: als je filtert op user_id en sorteert op nieuwste, is een index zoals (user_id, created_at DESC) vaak het verschil tussen stabiele p95 en spikes.
EXPLAINid).ORDER BY identiek over requests.(created_at, id) of iets soortgelijks in een cursor.Dit houdt de kosten per pagina ongeveer constant naarmate tabellen groeien.
fields=Je vermindert vaak Go-CPU, geheugenbelasting en tail-latentie door payloads te verkleinen.