Guide d'optimisation Go + Postgres pour APIs générées par IA : gérer le pool, lire les plans, indexer intelligemment, paginer sans risque, et façonner le JSON rapidement.

Les APIs générées par IA peuvent sembler rapides lors des premiers tests. Vous appelez un endpoint quelques fois, le jeu de données est petit et les requêtes arrivent une par une. Puis le trafic réel arrive : endpoints mixtes, pics de charge, caches plus froids, et plus de lignes que prévu. Le même code peut commencer à paraître lent de façon aléatoire même si rien n'a réellement cassé.
Le lent se manifeste généralement de quelques façons : pics de latence (la plupart des requêtes vont bien, certaines prennent 5x à 50x plus longtemps), timeouts (un faible pourcentage échoue), ou CPU élevé (CPU Postgres pour le travail des requêtes, ou CPU Go pour JSON, goroutines, logs et retries).
Un scénario courant est un endpoint de liste avec un filtre flexible qui renvoie une grande réponse JSON. Dans une base de test, il scanne quelques milliers de lignes et finit rapidement. En production, il scanne quelques millions de lignes, les trie, et n'applique le LIMIT qu'après. L'API « fonctionne », mais la latence p95 explose et quelques requêtes expirent lors des pics.
Pour séparer la lenteur DB de la lenteur applicative, gardez le modèle mental simple.
Si la base est lente, votre handler Go passe la plupart du temps à attendre la requête. Vous pouvez aussi voir beaucoup de requêtes coincées « en vol » tandis que le CPU Go semble normal.
Si l'app est lente, la requête finit vite, mais du temps est perdu après la requête : construction d'objets de réponse volumineux, sérialisation JSON, requêtes supplémentaires par ligne ou trop de travail par requête. Le CPU Go monte, la mémoire monte, et la latence croit avec la taille de la réponse.
La performance « suffisante » avant le lancement n'est pas la perfection. Pour beaucoup d'endpoints CRUD, visez une latence p95 stable (pas seulement la moyenne), un comportement prévisible lors des pics et pas de timeouts à votre pic attendu. L'objectif est simple : pas de requêtes lentes surprises quand les données et le trafic augmentent, et des signaux clairs quand quelque chose dérive.
Avant d'optimiser quoi que ce soit, décidez ce que « bien » signifie pour votre API. Sans baseline, il est facile de passer des heures à changer des réglages sans savoir si vous avez réellement amélioré les choses ou simplement déplacé le goulot.
Trois chiffres racontent généralement l'essentiel :
Le p95 est la métrique du « mauvais jour ». Si le p95 est élevé mais que la moyenne est correcte, un petit sous-ensemble de requêtes fait trop de travail, se bloque sur des locks ou déclenche des plans lents.
Rendez les requêtes lentes visibles tôt. Dans Postgres, activez la journalisation des requêtes lentes avec un seuil bas pour les tests avant lancement (par exemple 100–200 ms), et loggez l'instruction complète pour pouvoir la copier dans un client SQL. Gardez cela temporaire. Logger chaque requête lente en production devient vite bruyant.
Ensuite, testez avec des requêtes qui ressemblent à la réalité, pas seulement une route « hello world ». Un petit ensemble suffit s'il correspond à ce que feront réellement les utilisateurs : un appel de liste avec filtres et tri, une page détail avec quelques jointures, un create/update avec validation, et une requête de type recherche avec correspondances partielles.
Si vous générez des endpoints à partir d'un spec (par exemple avec un outil de génération comme Koder.ai), exécutez le même petit jeu de requêtes de manière répétée avec des entrées constantes. Cela rend les changements comme les index, ajustements de pagination et réécritures de requêtes faciles à mesurer.
Enfin, fixez un objectif que vous pouvez dire à voix haute. Exemple : « La plupart des requêtes restent sous 200 ms p95 à 50 utilisateurs concurrents, et les erreurs restent sous 0,5 %. » Les chiffres exacts dépendent de votre produit, mais un objectif clair évite de bricoler indéfiniment.
Un pool de connexions maintient un nombre limité de connexions ouvertes vers la base et les réutilise. Sans pool, chaque requête peut ouvrir une nouvelle connexion, et Postgres dépense du temps et de la mémoire à gérer des sessions au lieu d'exécuter des requêtes.
Le but est de garder Postgres occupé à faire du travail utile, pas à faire du contexte-switching entre trop de connexions. C'est souvent le premier gain significatif, surtout pour des APIs générées qui peuvent devenir bavardes sans s'en rendre compte.
En Go, on ajuste généralement max open connections, max idle connections et la durée de vie des connexions. Un point de départ sûr pour beaucoup de petites APIs est un petit multiple des cœurs CPU (souvent 5 à 20 connexions au total), avec un nombre similaire en idle, et recycler les connexions périodiquement (par exemple toutes les 30 à 60 minutes).
Si vous exécutez plusieurs instances d'API, rappelez-vous que le pool se multiplie. Un pool de 20 connexions sur 10 instances, ça fait 200 connexions sur Postgres — c'est comme ça que les équipes atteignent inattendument les limites de connexion.
Les problèmes de pool se ressentent différemment d'un SQL lent.
Si le pool est trop petit, les requêtes attendent avant même d'atteindre Postgres. La latence fait des pics, mais le CPU DB et les temps de requête peuvent sembler normaux.
Si le pool est trop grand, Postgres semble surchargé : beaucoup de sessions actives, pression mémoire et latence inégale entre endpoints.
Un moyen rapide de séparer les deux est de minuter vos appels DB en deux parties : temps passé à attendre une connexion vs temps passé à exécuter la requête. Si la plupart du temps est « en attente », le pool est le goulot. Si la plupart est « en requête », concentrez-vous sur le SQL et les index.
Contrôles rapides utiles :
max_connections.pgxpool vs database/sqlSi vous utilisez pgxpool, vous avez un pool orienté Postgres avec des stats claires et de bons paramètres par défaut pour le comportement Postgres. Si vous utilisez database/sql, vous disposez d'une interface standard qui marche sur plusieurs bases, mais il faut être explicite sur les paramètres du pool et sur le comportement du driver.
Règle pratique : si vous êtes complètement sur Postgres et voulez un contrôle direct, pgxpool est souvent plus simple. Si vous dépendez de bibliothèques qui attendent database/sql, restez-y, configurez explicitement le pool et mesurez les temps d'attente.
Exemple : un endpoint qui liste des commandes peut tourner en 20 ms, mais sous 100 utilisateurs concurrents il passe à 2 s. Si les logs montrent 1,9 s d'attente pour une connexion, l'optimisation des requêtes ne servira à rien tant que le pool et le total de connexions Postgres ne sont pas dimensionnés correctement.
Quand un endpoint semble lent, vérifiez ce que Postgres fait réellement. Une lecture rapide de EXPLAIN pointe souvent vers la solution en quelques minutes.
Exécutez ceci sur le SQL exact que votre API envoie :
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, status, created_at
FROM orders
WHERE user_id = $1 AND status = $2
ORDER BY created_at DESC
LIMIT 50;
Quelques lignes comptent le plus. Regardez le noeud supérieur (ce que Postgres a choisi) et les totaux en bas (combien de temps ça a pris). Puis comparez estimé vs réel. De gros écarts signifient souvent que le planner s'est trompé.
Si vous voyez Index Scan ou Index Only Scan, Postgres utilise un index, ce qui est généralement bon. Bitmap Heap Scan peut convenir pour des correspondances de taille moyenne. Seq Scan signifie qu'il a lu toute la table, ce qui n'est acceptable que si la table est petite ou si presque toutes les lignes correspondent.
Signaux d'alerte courants :
ORDER BY)Les plans lents viennent généralement d'un petit ensemble de motifs :
WHERE + ORDER BY (par exemple (user_id, status, created_at))WHERE (par exemple WHERE lower(email) = $1), qui peuvent forcer des scans sauf si vous ajoutez un index d'expression correspondantSi le plan semble étrange et que les estimations sont très fausses, les stats sont souvent périmées. Exécutez ANALYZE (ou laissez autovacuum faire son travail) pour que Postgres connaisse les comptes de lignes et la distribution des valeurs. Cela compte après de gros imports ou lorsque de nouveaux endpoints commencent à écrire beaucoup rapidement.
Les index n'aident que lorsqu'ils correspondent à la façon dont votre API interroge les données. Si vous les créez à partir d'hypothèses, vous obtiendrez des écritures plus lentes, un stockage plus grand et peu ou pas d'accélération.
Une manière pratique de penser : un index est un raccourci pour une question spécifique. Si votre API pose une question différente, Postgres ignore le raccourci.
Si un endpoint filtre par account_id et trie par created_at DESC, un index composite unique sur ces colonnes bat souvent deux index séparés. Il aide Postgres à trouver les bonnes lignes et à les retourner dans le bon ordre avec moins de travail.
Règles empiriques qui tiennent souvent :
Exemple : si votre API a GET /orders?status=paid et affiche toujours les plus récents en premier, un index (status, created_at DESC) est un bon choix. Si la plupart des requêtes filtrent aussi par client, (customer_id, status, created_at) peut être meilleur, mais seulement si c'est ainsi que l'endpoint fonctionne réellement en production.
Si la majorité du trafic cible une tranche étroite de lignes, un index partiel peut être moins cher et plus rapide. Par exemple, si votre app lit surtout des enregistrements actifs, indexer seulement WHERE active = true garde l'index plus petit et plus susceptible de rester en mémoire.
Pour confirmer qu'un index aide, faites des vérifications rapides :
EXPLAIN (ou EXPLAIN ANALYZE en environnement sûr) et cherchez un index scan qui correspond à votre requête.Supprimez les index inutilisés avec prudence. Vérifiez les stats d'utilisation (par exemple, si un index a été scanné). Supprimez-en un à la fois pendant des fenêtres à faible risque et gardez un plan de rollback. Les index inutilisés ne sont pas innocents : ils ralentissent inserts et updates à chaque écriture.
La pagination est souvent l'endroit où une API rapide commence à paraître lente, même quand la base est saine. Traitez la pagination comme un problème de conception de requête, pas comme un détail UI.
LIMIT/OFFSET semble simple, mais les pages plus profondes coûtent souvent plus cher. Postgres doit quand même parcourir (et souvent trier) les lignes que vous sautez. La page 1 peut toucher quelques dizaines de lignes. La page 500 peut forcer la base à scanner et jeter des dizaines de milliers de lignes juste pour renvoyer 20 résultats.
Cela crée aussi des résultats instables quand des lignes sont insérées ou supprimées entre les requêtes. Les utilisateurs peuvent voir des doublons ou manquer des éléments parce que la notion de « ligne 10 000 » change.
La pagination keyset pose une question différente : « Donne-moi les 20 lignes suivantes après la dernière que j'ai vue. » Cela maintient la base sur un petit intervalle cohérent.
Une version simple utilise un id croissant :
SELECT id, created_at, title
FROM posts
WHERE id > $1
ORDER BY id
LIMIT 20;
Votre API renvoie un next_cursor égal au dernier id de la page. La requête suivante utilise cette valeur comme $1.
Pour un tri basé sur le temps, utilisez un ordre stable et cassez les égalités. created_at seul ne suffit pas si deux lignes ont le même timestamp. Utilisez un curseur composé :
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Quelques règles empêchent doublons et omissions :
ORDER BY (généralement id).created_at et id ensemble).Une cause étonnamment fréquente d'une API qui semble lente n'est pas la base, mais la réponse. Les gros JSON prennent plus de temps à construire, plus de temps à être envoyés et plus de temps pour être parsés côté client. Le gain le plus rapide est souvent de renvoyer moins.
Commencez par votre SELECT. Si un endpoint n'a besoin que de id, name et status, sélectionnez ces colonnes et rien d'autre. SELECT * s'alourdit discrètement au fil du temps à mesure que les tables gagnent des textes longs, des blobs JSON et des colonnes d'audit.
Un autre ralentissement fréquent est la construction N+1 : vous récupérez une liste de 50 éléments, puis lancez 50 requêtes supplémentaires pour attacher des données liées. Ça passe en test puis s'effondre en production. Préférez une seule requête qui renvoie ce dont vous avez besoin (jointures contrôlées), ou deux requêtes où la seconde batch par IDs.
Quelques moyens de garder les payloads légers sans casser les clients :
include= (ou un masque fields=) pour que les réponses de liste restent légères et que les détails optent pour les extras.Les deux peuvent être rapides. Choisissez selon ce que vous optimisez.
Les fonctions JSON de Postgres (jsonb_build_object, json_agg) sont utiles quand vous voulez moins de allers-retours et des formes prévisibles depuis une seule requête. Façonner en Go est utile quand vous avez besoin de logique conditionnelle, de réutiliser des structs ou de garder le SQL lisible. Si votre SQL de construction JSON devient difficile à lire, il devient difficile à optimiser.
Bonne règle : laissez Postgres filtrer, trier et agréger. Puis laissez Go gérer la présentation finale.
Si vous générez rapidement des APIs (par exemple avec Koder.ai), ajouter des flags include tôt aide à éviter que les endpoints ne gonflent avec le temps. Cela donne aussi un moyen sûr d'ajouter des champs sans alourdir toutes les réponses.
Vous n'avez pas besoin d'un lab énorme pour repérer la plupart des problèmes de performance. Une passe courte et répétable met au jour les problèmes qui deviennent des incidents une fois que le trafic arrive, surtout si la base de départ est du code généré que vous prévoyez de livrer.
Avant de changer quoi que ce soit, notez une petite baseline :
Commencez petit, changez une chose à la fois et retestez après chaque modification.
Lancez un test de charge de 10 à 15 minutes qui ressemble à l'usage réel. Tapez les mêmes endpoints que vos premiers utilisateurs toucheront (login, pages de liste, recherche, création). Puis triez les routes par latence p95 et temps total passé.
Vérifiez la pression sur les connexions avant d'optimiser le SQL. Un pool trop grand écrase Postgres ; un pool trop petit crée de longues attentes. Cherchez le temps d'attente pour acquérir une connexion et les pics de connexions. Ajustez pool et limites d'idle d'abord, puis relancez la même charge.
EXPLAIN des requêtes lentes en tête et corrigez le plus gros signal d'alerte. Les coupables habituels sont les scans complets sur de grandes tables, les tris sur de grands ensembles, et les jointures qui font exploser le nombre de lignes. Choisissez la requête la plus lente et rendez-la peu intéressante.
Ajoutez ou ajustez un index, puis re-testez. Les index aident quand ils correspondent à votre WHERE et ORDER BY. N'en ajoutez pas cinq à la fois. Si votre endpoint lent est « lister les commandes par user_id triées par created_at », un index composite sur (user_id, created_at) peut faire la différence entre instantané et douloureux.
Allégez les réponses et la pagination, puis retestez encore. Si un endpoint renvoie 50 lignes avec de gros blobs JSON, la DB, le réseau et le client en paient le prix. Renvoyez seulement les champs dont l'UI a besoin et préférez une pagination qui ne ralentit pas avec la croissance des tables.
Tenez un journal simple des modifications : ce qui a changé, pourquoi, et ce qui a bougé sur le p95. Si un changement n'améliore pas votre baseline, revenez en arrière et passez au suivant.
La plupart des problèmes de performance dans des APIs Go sur Postgres sont auto-infligés. La bonne nouvelle, c'est qu'un petit nombre de vérifications permettent d'en attraper la plupart avant l'arrivée du trafic réel.
Un piège classique est de traiter la taille du pool comme un bouton de vitesse. La régler « aussi élevée que possible » rend souvent tout plus lent. Postgres passe plus de temps à gérer des sessions, la mémoire et les locks, et votre app commence à timeout par vagues. Un pool plus petit et stable avec une concurrence prévisible gagne généralement.
Une autre erreur fréquente est « indexer tout ». Des index en plus peuvent aider les lectures, mais ils ralentissent aussi les écritures et peuvent changer les plans de requêtes de façon surprenante. Si votre API insère ou met à jour souvent, chaque index supplémentaire ajoute du travail. Mesurez avant et après, et revérifiez les plans après avoir ajouté un index.
La dette de pagination s'insinue discrètement. La pagination offset a l'air correcte au début, puis le p95 monte avec le temps parce que la base doit parcourir de plus en plus de lignes.
La taille des payloads JSON est une autre taxe cachée. La compression aide la bande passante, mais n'enlève pas le coût de construction, allocation et parsing des objets volumineux. Épurez les champs, évitez les imbrications profondes et renvoyez seulement ce dont l'écran a besoin.
Si vous ne regardez que la moyenne, vous manquerez l'endroit où l'utilisateur souffre réellement. p95 (et parfois p99) est là où la saturation du pool, les attentes de lock et les plans lents apparaissent en premier.
Une auto-vérification rapide pré-lancement :
EXPLAIN après l'ajout d'index ou le changement de filtres.Avant l'arrivée des vrais utilisateurs, vous voulez des preuves que votre API reste prévisible sous stress. L'objectif n'est pas d'avoir des chiffres parfaits, mais de repérer les quelques problèmes qui causent timeouts, pics ou une base incapable d'accepter du nouveau travail.
Exécutez les vérifications dans un staging proche de la production (taille DB similaire, mêmes index, mêmes réglages de pool) : mesurez la latence p95 par endpoint clé sous charge, capturez vos requêtes lentes principales par temps total, surveillez le temps d'attente du pool, EXPLAIN (ANALYZE, BUFFERS) la pire requête pour confirmer qu'elle utilise bien l'index attendu, et contrôlez la taille des payloads sur les routes les plus chargées.
Faites ensuite un run « pire cas » qui mime comment les produits se cassent : demandez une page profonde, appliquez le filtre le plus large, et testez en cold start (redémarrez l'API et touchez la même requête en premier). Si la pagination profonde ralentit à chaque page, passez à la pagination par curseur avant le lancement.
Notez vos paramètres par défaut pour que l'équipe fasse des choix cohérents plus tard : limites et timeouts de pool, règles de pagination (taille max de page, si offset est autorisé, format du curseur), règles de requête (sélectionner seulement les colonnes nécessaires, éviter SELECT *, limiter les filtres coûteux), et règles de logging (seuil requête lente, durée de conservation des échantillons, comment labelliser les endpoints).
Si vous générez et exportez des services Go + Postgres avec Koder.ai, faire une courte passe de planification avant le déploiement aide à garder les filtres, la pagination et les formes de réponse intentionnels. Une fois que vous commencez à ajuster index et formes de requête, les snapshots et le rollback facilitent l'annulation d'un « fix » qui aide un endpoint mais pénalise les autres. Si vous voulez un endroit unique pour itérer ce workflow, Koder.ai sur koder.ai est conçu pour générer et affiner ces services via le chat, puis exporter le source quand vous êtes prêt.
Commencez par séparer le temps d'attente DB du temps de travail applicatif.
Ajoutez des mesures simples autour de « attente de connexion » et « exécution de la requête » pour voir quel côté domine.
Utilisez une petite baseline répétable :
Choisissez un objectif clair comme « p95 sous 200 ms à 50 utilisateurs concurrents, erreurs < 0,5 % ». Changez une seule chose à la fois et re-testez avec le même mix de requêtes.
Activez la journalisation des requêtes lentes avec un seuil bas pour les tests pré-lancement (par exemple 100–200 ms) et loggez l'instruction complète afin de la copier dans un client SQL.
Gardez cela temporaire :
Une fois les pires coupables identifiés, passez à l'échantillonnage ou augmentez le seuil.
Une valeur pratique par défaut est un petit multiple du nombre de cœurs CPU par instance d'API, souvent 5–20 connexions ouvertes max, avec un nombre d'idle similaire, et recycler les connexions toutes les 30–60 minutes.
Deux modes d'échec courants :
N'oubliez pas que les pools se multiplient selon le nombre d'instances (20 connexions × 10 instances = 200 connexions).
Mesurez les appels DB en deux parties :
Si la majeure partie du temps est du wait pool, ajustez la taille du pool, les timeouts et le nombre d'instances. Si la majeure partie est en exécution, concentrez-vous sur EXPLAIN et les index.
Vérifiez aussi que vous fermez toujours les rows rapidement pour retourner les connexions au pool.
Exécutez EXPLAIN (ANALYZE, BUFFERS) sur le SQL exact que votre API envoie et cherchez :
Les index doivent correspondre à ce que l'endpoint fait réellement : filtres + ordre de tri.
Approche par défaut :
WHERE + ORDER BY fréquent.Utilisez un index partiel quand la plupart des requêtes portent sur un sous-ensemble prévisible de lignes.
Pattern d'exemple :
active = trueUn index partiel comme ... WHERE active = true reste plus petit, tient mieux en mémoire et réduit le coût d'écriture comparé à indexer tout le tableau.
Vérifiez avec que Postgres l'utilise réellement pour vos requêtes à fort trafic.
LIMIT/OFFSET ralentit en profondeur de pages parce que Postgres doit quand même parcourir (et souvent trier) les lignes sautées. La page 500 peut être beaucoup plus coûteuse que la page 1.
Préférez la pagination par keyset (curseur) :
Oui, en général pour les endpoints list. La réponse la plus rapide est celle que vous n'envoyez pas.
Gains pratiques :
SELECT *).ORDER BY)Corrigez le plus gros signal d'alerte en premier ; n'essayez pas d'optimiser tout à la fois.
Exemple : si vous filtrez par user_id et affichez du plus récent au plus ancien, un index (user_id, created_at DESC) résout souvent la différence entre p95 stable et des pics.
EXPLAINid).ORDER BY identique entre les requêtes.(created_at, id) ou similaire dans un curseur.Ainsi, le coût de chaque page reste à peu près constant quand la table grossit.
include=fields=Vous réduirez souvent le CPU Go, la pression mémoire et la latence tail simplement en réduisant les payloads.