Playbook per l'ottimizzazione delle prestazioni Go + Postgres per API generate da AI: gestisci il pool di connessioni, leggi i piani di query, indicizza con criterio, usa paginazione sicura e ottimizza JSON.

Le API generate per AI possono sembrare veloci nei primi test. Colpisci un endpoint poche volte, il dataset è piccolo e le richieste arrivano una alla volta. Poi arriva il traffico reale: endpoint misti, carichi a raffica, cache più fredde e molte più righe del previsto. Lo stesso codice può iniziare a sembrare casualmente lento anche se nulla si è rotto.
La lentezza di solito si vede in pochi modi: picchi di latenza (la maggior parte delle richieste va bene, ma alcune impiegano 5x–50x più tempo), timeout (una piccola percentuale fallisce) o CPU alta (Postgres per lavoro di query, o Go per JSON, goroutine, logging e retry).
Uno scenario comune è un endpoint di lista con un filtro di ricerca flessibile che restituisce una grande risposta JSON. In un DB di test scansiona poche migliaia di righe e finisce in fretta. In produzione scansiona milioni di righe, le ordina e solo dopo applica un LIMIT. L'API continua a "funzionare", ma la latenza p95 esplode e alcune richieste timeout durante i picchi.
Per separare la lentezza del database da quella dell'app, mantieni il modello mentale semplice.
Se il database è lento, il tuo handler Go passa la maggior parte del tempo in attesa della query. Potresti anche vedere molte richieste "in volo" mentre la CPU di Go sembra normale.
Se l'app è lenta, la query termina in fretta, ma si perde tempo dopo la query: costruire grandi oggetti di risposta, marshaling JSON, eseguire query extra per riga o fare troppo lavoro per richiesta. CPU e memoria di Go salgono e la latenza cresce con la dimensione della risposta.
"Abbastanza buono" prima del lancio non è perfezione. Per molti endpoint CRUD, punta a una p95 stabile (non solo alla media), comportamento prevedibile sotto raffiche e nessun timeout al picco previsto. L'obiettivo è semplice: niente richieste lente a sorpresa quando dati e traffico crescono, e segnali chiari quando qualcosa degrada.
Prima di ottimizzare, decidi cosa significa "bene" per la tua API. Senza baseline, è facile passare ore a cambiare impostazioni e non sapere se hai migliorato o solo spostato il collo di bottiglia.
Tre numeri di solito raccontano la maggior parte della storia:
p95 è la metrica del "giorno no". Se p95 è alto ma la media va bene, un piccolo sottoinsieme di richieste sta facendo troppo lavoro, è bloccato da lock o innesca piani lenti.
Rendi visibili le query lente presto. In Postgres, abilita il logging delle query lente con una soglia bassa per i test pre-lancio (ad esempio 100–200 ms) e registra l'intera istruzione così da poterla copiare in un client SQL. Mantieni questa pratica temporanea: registrare ogni query lenta in produzione diventa rumoroso rapidamente.
Poi testa con richieste realistiche, non solo una singola rotta "hello world". Un piccolo set è sufficiente se rispecchia cosa faranno gli utenti: una chiamata di lista con filtri e ordinamento, una pagina dettaglio con un paio di join, una create o update con validazione e una query in stile search con match parziale.
Se generi endpoint da uno spec (ad esempio con uno strumento di generazione come Koder.ai), esegui lo stesso insieme di richieste ripetutamente con input costanti. Questo rende più facili da misurare cambi come indici, modifiche di paginazione e riscritture di query.
Infine, scegli un obiettivo che puoi dire ad alta voce. Esempio: "La maggior parte delle richieste resta sotto 200 ms p95 con 50 utenti concorrenti e gli errori sotto lo 0,5%." I numeri esatti dipendono dal prodotto, ma un obiettivo chiaro evita tentativi infiniti.
Un connection pool mantiene un numero limitato di connessioni aperte al DB e le riusa. Senza pool, ogni richiesta può aprire una nuova connessione e Postgres spreca tempo e memoria a gestire sessioni invece di eseguire query.
L'obiettivo è tenere Postgres occupato a fare lavoro utile, non a fare context-switching tra troppe connessioni. Questo è spesso il primo miglioramento significativo, specialmente per API generate che possono diventare silenziosamente chatter.
In Go si regolano solitamente max open connections, max idle connections e la durata delle connessioni. Un punto di partenza sicuro per molte piccole API è un piccolo multiplo dei core CPU (spesso 5–20 connessioni totali), con un numero simile tenuto idle, e riciclo delle connessioni periodico (ad esempio ogni 30–60 minuti).
Se esegui più istanze dell'API, ricorda che il pool si moltiplica. Un pool di 20 connessioni su 10 istanze sono 200 connessioni che colpiscono Postgres, ed è così che i team si scontrano inaspettatamente con i limiti di connessione.
I problemi di pool si sentono diversi dalla SQL lenta.
Se il pool è troppo piccolo, le richieste aspettano prima ancora di raggiungere Postgres. Si vedono picchi di latenza, ma CPU del DB e tempi di query possono sembrare normali.
Se il pool è troppo grande, Postgres sembra sovraccarico: molte sessioni attive, pressione sulla memoria e latenza disomogenea tra gli endpoint.
Un modo veloce per separare i due è cronometrare le chiamate al DB in due parti: tempo speso ad aspettare una connessione vs tempo speso ad eseguire la query. Se la maggior parte del tempo è "in attesa", il pool è il collo di bottiglia. Se la maggior parte è "in query", concentra gli sforzi su SQL e indici.
Controlli rapidi utili:
max_connections.Se usi pgxpool, ottieni un pool pensato per Postgres con statistiche chiare e buone impostazioni di default. Se usi database/sql, ottieni un'interfaccia standard che funziona su più DB, ma devi essere esplicito sulle impostazioni del pool e sul comportamento del driver.
Una regola pratica: se sei tutto-in su Postgres e vuoi controllo diretto, pgxpool è spesso più semplice. Se dipendi da librerie che si aspettano database/sql, resta con esso, imposta il pool esplicitamente e misura le attese.
Esempio: un endpoint che lista ordini potrebbe impiegare 20 ms, ma sotto 100 utenti concorrenti salta a 2 s. Se i log mostrano 1.9 s in attesa di una connessione, l'ottimizzazione delle query non aiuterà finché pool e connessioni totali su Postgres non sono dimensionati correttamente.
Quando un endpoint sembra lento, controlla cosa fa effettivamente Postgres. Una lettura rapida di EXPLAIN spesso indica la soluzione in pochi minuti.
Esegui questo sulla SQL esatta che invia la tua 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;
Alcune righe contano più di altre. Guarda il nodo in cima (quello scelto da Postgres) e i totali in fondo (quanto ha impiegato). Poi confronta righe stimate vs reali. Grossi scarti di solito significano che il planner ha sbagliato.
Se vedi Index Scan o Index Only Scan, Postgres sta usando un indice, che di solito è positivo. Bitmap Heap Scan può andare bene per match di dimensioni medie. Seq Scan significa che ha letto l'intera tabella, accettabile solo quando la tabella è piccola o quasi tutte le righe corrispondono.
Segnali d'allarme comuni:
ORDER BY)I piani lenti vengono quasi sempre da pochi pattern:
WHERE + ORDER BY (es. (user_id, status, created_at))WHERE (es. WHERE lower(email) = $1), che costringono spesso a scansioni a meno che non aggiungi un indice sull'espressione corrispondenteSe il piano sembra strano e le stime sono molto sbagliate, spesso le statistiche sono obsolete. Esegui ANALYZE (o lascia che autovacuum faccia il suo lavoro) così Postgres apprende i conteggi e la distribuzione dei valori correnti. Questo è importante dopo grandi importazioni o quando nuovi endpoint scrivono molte righe rapidamente.
Gli indici aiutano solo quando corrispondono a come interroghi i dati. Se li crei a intuito, ottieni scritture più lente, storage maggiore e poco o nessun miglioramento.
Un modo pratico per pensarci: un indice è una scorciatoia per una domanda specifica. Se la tua API fa una domanda diversa, Postgres ignora la scorciatoia.
Se un endpoint filtra per account_id e ordina per created_at DESC, un singolo indice composto spesso batte due indici separati. Aiuta Postgres a trovare le righe giuste e restituirle già ordinate con meno lavoro.
Regole empiriche che tengono spesso:
Esempio: se la tua API ha GET /orders?status=paid e mostra sempre i più recenti, un indice come (status, created_at DESC) è una buona scelta. Se la maggior parte delle query filtra anche per customer, (customer_id, status, created_at) può essere migliore, ma solo se è così che l'endpoint viene effettivamente usato in produzione.
Se la maggior parte del traffico colpisce una fetta ristretta di righe, un indice parziale può essere più economico e veloce. Per esempio, se l'app legge per lo più record attivi, indicizzare solo WHERE active = true mantiene l'indice più piccolo e più probabile che resti in memoria.
Per confermare che un indice aiuta, fai controlli rapidi:
EXPLAIN (o EXPLAIN ANALYZE in un ambiente sicuro) e cerca uno index scan che corrisponda alla tua query.Rimuovi indici inutilizzati con attenzione. Controlla le statistiche d'uso (ad esempio se un indice è stato scansionato). Elimina uno alla volta in finestre a basso rischio e tieni un piano di rollback. Gli indici inutilizzati non sono innocui: rallentano insert e update a ogni scrittura.
La paginazione è spesso dove una API veloce inizia a sembrare lenta, anche quando il DB è sano. Tratta la paginazione come un problema di progettazione della query, non come un dettaglio UI.
LIMIT/OFFSET sembra semplice, ma le pagine più profonde costano di più. Postgres deve comunque scorrere le righe da saltare (e spesso ordinarle). La pagina 1 può toccare poche dozzine di righe. La pagina 500 può costringere il DB a scansionare e scartare decine di migliaia di righe solo per restituire 20 risultati.
Può anche creare risultati instabili quando righe vengono inserite o cancellate tra richieste. Gli utenti possono vedere duplicati o perdere elementi perché il significato di "riga 10.000" cambia con le modifiche alla tabella.
La paginazione keyset pone una domanda diversa: "Dammi le prossime 20 righe dopo l'ultima che ho visto." Così il database lavora su una fetta piccola e consistente.
Una versione semplice usa un id incrementale:
SELECT id, created_at, title
FROM posts
WHERE id > $1
ORDER BY id
LIMIT 20;
La tua API restituisce un next_cursor pari all'ultimo id nella pagina. La richiesta successiva usa quel valore come $1.
Per ordinamenti basati sul tempo, usa un ordine stabile e risolvi i pareggi. created_at da solo non è sufficiente se due righe condividono lo stesso timestamp. Usa un cursore composto:
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Alcune regole per evitare duplicati e elementi mancanti:
ORDER BY (di solito id).created_at e id insieme).Un motivo sorprendentemente comune per cui un'API sembra lenta non è il database ma la risposta. Grandi JSON richiedono più tempo a costruirsi, più tempo a essere inviati e più tempo a essere parsati dai client. Il guadagno più rapido è spesso restituire meno.
Inizia dal tuo SELECT. Se un endpoint ha bisogno solo di id, name e status, chiedi solo quelle colonne. SELECT * si appesantisce silenziosamente con il tempo man mano che le tabelle guadagnano testo lungo, blob JSON e colonne di audit.
Un altro rallentamento frequente è la costruzione della risposta N+1: recuperi una lista di 50 elementi e poi esegui 50 query in più per attaccare dati correlati. Può passare i test e poi crollare sotto traffico reale. Preferisci una singola query che restituisca ciò che ti serve (join attenti) o due query dove la seconda batcha per ID.
Alcuni modi per mantenere i payload piccoli senza rompere i client:
include= (o una maschera fields=) così le liste restano snelle e le risposte di dettaglio optano per i campi extra.Entrambi possono essere veloci. Scegli in base a cosa stai ottimizzando.
Le funzioni JSON di Postgres (jsonb_build_object, json_agg) sono utili quando vuoi meno round-trip e forme prevedibili da una singola query. Modellare in Go è utile quando ti serve logica condizionale, riuso di struct o mantenere SQL più leggibile. Se il SQL per costruire JSON diventa difficile da leggere, diventa difficile anche da ottimizzare.
Una buona regola: lascia a Postgres filtrare, ordinare e aggregare. Poi lascia a Go la presentazione finale.
Se stai generando API rapidamente (ad esempio con Koder.ai), aggiungere flag include presto aiuta a evitare endpoint che si gonfiano col tempo. Inoltre ti dà un modo sicuro per aggiungere campi senza appesantire ogni risposta.
Non ti serve un enorme laboratorio di test per intercettare la maggior parte dei problemi di performance. Una breve passata ripetibile fa emergere i problemi che si trasformano in outage quando arriva il traffico, specialmente se il punto di partenza è codice generato che intendi spedire.
Prima di cambiare qualcosa, annota un piccolo baseline:
Inizia in piccolo, cambia una cosa alla volta e ritesta dopo ogni modifica.
Esegui un test di carico di 10–15 minuti che somigli al traffico reale. Colpisci gli stessi endpoint che avranno i primi utenti (login, liste, ricerca, create). Poi ordina le route per p95 e tempo totale.
Controlla la pressione sulle connessioni prima di ottimizzare la SQL. Un pool troppo grande sovraccarica Postgres. Un pool troppo piccolo crea attese lunghe. Cerca tempo di attesa per ottenere una connessione e conteggi di connessioni che schizzano durante le raffiche. Regola i limiti del pool e gli idle prima, poi ri-esegui lo stesso test.
EXPLAIN sulle query più lente e risolvi il problema più evidente. I colpevoli usuali sono scansioni su tabelle grandi, sort su insiemi di risultato grandi e join che esplodono i conteggi di righe. Scegli la query peggiore e rendila banale.
Aggiungi o adatta un indice, poi ri-testa. Gli indici aiutano quando corrispondono a WHERE e ORDER BY. Non aggiungere cinque indici in una volta. Se l'endpoint lento è "lista ordini per user_id ordinati per created_at", un indice composto su (user_id, created_at) può fare la differenza tra istantaneo e doloroso.
Snellisci risposte e paginazione, poi ri-testa di nuovo. Se un endpoint restituisce 50 righe con grandi blob JSON, DB, rete e client ne pagano il conto. Restituisci solo i campi necessari all'interfaccia e preferisci paginazione che non rallenti con la crescita delle tabelle.
Tieni un semplice changelog: cosa è cambiato, perché e cosa è migliorato in p95. Se una modifica non migliora la baseline, fai revert e vai avanti.
La maggior parte dei problemi di performance in API Go su Postgres è auto-inflitta. La buona notizia è che pochi controlli intercettano molti problemi prima che arrivi il traffico reale.
Una trappola classica è trattare la dimensione del pool come una manopola di velocità. Impostarlo "più grande possibile" spesso peggiora tutto. Postgres passa più tempo a gestire sessioni, memoria e lock, e la tua app inizia a fare timeout a ondate. Un pool più piccolo e stabile con concorrenza prevedibile spesso vince.
Un altro errore comune è "indicizzare tutto". Indici extra possono aiutare le letture, ma rallentano anche le scritture e possono cambiare i piani di query in modi sorprendenti. Se la tua API inserisce o aggiorna frequentemente, ogni indice in più aggiunge lavoro. Misura prima e dopo e ricontrolla i piani dopo aver aggiunto un indice.
Il debito di paginazione si insinua silenziosamente. La paginazione offset sembra ok all'inizio, poi la p95 aumenta con il tempo perché il DB deve scorrere sempre più righe.
La dimensione dei payload JSON è un'altra tassa nascosta. La compressione aiuta la banda, ma non rimuove il costo di costruire, allocare e parsare grandi oggetti. Riduci campi, evita annidamenti profondi e restituisci solo ciò che serve alla schermata.
Se guardi solo la latenza media, ti perdi dove il vero dolore utente inizia. p95 (e talvolta p99) è dove saturazione del pool, attese di lock e piani lenti si mostrano per primi.
Un rapido self-check pre-lancio:
EXPLAIN dopo aver aggiunto indici o cambiato filtri.Prima che arrivino utenti reali, vuoi prove che la tua API resti prevedibile sotto stress. L'obiettivo non è la perfezione, ma intercettare i problemi che causano timeout, picchi o un database che smette di accettare lavoro.
Esegui i controlli in uno staging che somigli a produzione (dimensione DB simile, stessi indici, stesse impostazioni di pool): misura p95 per endpoint chiave sotto carico, cattura le query più lente per tempo totale, osserva il tempo di wait del pool, esegui EXPLAIN (ANALYZE, BUFFERS) sulla query peggiore per confermare che usa l'indice atteso e verifica le dimensioni dei payload sulle route più usate.
Poi fai una prova worst-case che imiti come i prodotti si rompono: richiedi una pagina profonda, applica il filtro più ampio e prova da cold start (riavvia l'API e colpisci la stessa richiesta per prima). Se la paginazione profonda rallenta ad ogni pagina, passa a cursor-based prima del lancio.
Annota i tuoi default così il team prenda decisioni coerenti in futuro: limiti e timeout del pool, regole di paginazione (max page size, se offset è permesso, formato del cursore), regole di query (seleziona solo le colonne necessarie, evita SELECT *, limita filtri costosi) e regole di logging (soglia query lente, durata di conservazione dei campioni, come etichettare gli endpoint).
Se generi ed esporti servizi Go + Postgres con Koder.ai, fare una breve pianificazione prima del deploy aiuta a mantenere filtri, paginazione e forme di risposta intenzionali. Una volta che inizi a sintonizzare indici e forme di query, snapshot e rollback rendono più facile annullare un "fix" che aiuta un endpoint ma danneggia altri. Se vuoi un posto unico per iterare su quel workflow, Koder.ai su koder.ai è progettato per generare e rifinire quei servizi tramite chat, poi esportare il sorgente quando sei pronto.
Inizia separando il tempo di attesa DB dal tempo di lavoro dell'app.
Aggiungi misurazioni semplici attorno a “attesa connessione” e “esecuzione query” per vedere quale lato domina.
Usa un piccolo baseline ripetibile:
Scegli un obiettivo chiaro come “p95 sotto 200 ms con 50 utenti concorrenti, errori sotto 0.5%”. Cambia una cosa alla volta e ri-testa lo stesso mix di richieste.
Attiva il logging delle query lente con una soglia bassa durante i test pre-lancio (ad esempio 100–200 ms) e registra l'intera istruzione così da poterla copiare in un client SQL.
Mantienilo temporaneo:
Dopo aver trovato i peggiori, passa al campionamento o aumenta la soglia.
Un default pratico è un piccolo multiplo dei core CPU per istanza API, spesso 5–20 connessioni aperte massime, con un numero simile di connessioni inattive e riciclo delle connessioni ogni 30–60 minuti.
Due modalità di errore comuni:
Ricorda che i pool si moltiplicano tra le istanze (20 connessioni × 10 istanze = 200 connessioni).
Temporizza le chiamate DB in due parti:
Se la maggior parte del tempo è wait del pool, ridimensiona pool, timeout e numero di istanze. Se la maggior parte è esecuzione query, concentrati su EXPLAIN e indici.
Conferma inoltre che chiudi sempre le rows prontamente così le connessioni tornano al pool.
Esegui EXPLAIN (ANALYZE, BUFFERS) sulla SQL esatta che invia la tua API e cerca:
Gli indici devono corrispondere a quello che l'endpoint effettivamente fa: filtri + ordine.
Approccio pratico:
WHERE + ORDER BY comune.Usa un indice parziale quando la maggior parte del traffico colpisce una porzione prevedibile di righe.
Esempio:
active = trueUn indice parziale come ... WHERE active = true rimane più piccolo, ha più probabilità di stare in memoria e riduce l'overhead sulle scritture rispetto a indicizzare tutto.
Conferma con che Postgres lo usa per le tue query ad alto traffico.
LIMIT/OFFSET peggiora sulle pagine profonde perché Postgres deve comunque scorrere (e spesso ordinare) le righe saltate. La pagina 1 può toccare poche decine di righe; la pagina 500 può costare molto di più.
Preferisci la paginazione keyset (a cursore):
Di solito sì per gli endpoint di lista. La risposta più veloce è quella che non invii.
Guadagni pratici:
SELECT *).include= o così i client optano per i campi pesanti.ORDER BYFissa prima il problema più evidente; non ottimizzare tutto insieme.
Esempio: se filtri per user_id e ordini per i più recenti, un indice come (user_id, created_at DESC) spesso risolve i picchi di p95.
EXPLAINid).ORDER BY identico tra le richieste.(created_at, id) o simili nel cursore.Così il costo di ogni pagina resta approssimativamente costante con la crescita della tabella.
fields=Spesso ridurrai CPU di Go, pressione della memoria e latenza di coda semplicemente riducendo i payload.