À passer avant la mise en production, puis à re-passer le jour où la charge double. Chaque point est vérifiable en moins de cinq minutes.

Pool de connexions#

  • max_connections du pool entre 10 et 20 par instance, pas plus. Le calcul qui compte : instances × pool ≤ max_connections Postgres − 10 (les 10 réservées aux migrations, aux sauvegardes et à ta session psql de secours).
  • acquire_timeout réglé à 3 secondes. Sans lui, une saturation du pool se transforme en requêtes qui pendent indéfiniment au lieu d’échouer vite.
  • idle_timeout à 10 minutes, pour que les connexions mortes côté réseau soient renouvelées.
  • min_connections à 1 ou 2, pas plus : garder 20 connexions chaudes pour rien coûte de la mémoire à Postgres.
  • La profondeur de file du pool est exposée en métrique. C’est le premier signal d’une saturation, bien avant la latence.

Schéma et index#

  • Chaque requête de liste a son index composite, dans l’ordre exact WHERE puis ORDER BY. Un WHERE user_id = $1 ORDER BY created_at DESC veut un index (user_id, created_at DESC), pas deux index séparés.
  • EXPLAIN (ANALYZE, BUFFERS) passé sur les cinq requêtes les plus fréquentes, avec un volume de données réaliste. Sur 100 lignes, tout est rapide.
  • Aucun Seq Scan sur une table de plus de 10 000 lignes dans un chemin utilisateur.
  • Les clés étrangères sont indexées côté enfant. Postgres ne le fait pas automatiquement, et un DELETE sur le parent scanne alors toute la table.
  • CREATE INDEX CONCURRENTLY en production : la version sans CONCURRENTLY pose un verrou qui bloque les écritures pendant toute la construction.
  • Les colonnes de dates sont en timestamptz, jamais en timestamp. Le jour du changement d’heure arrive toujours.
  • Les identifiants exposés publiquement sont des UUID v4 ou v7, pas des serial qui révèlent ton volume d’affaires.

Requêtes#

  • Aucun N+1 : une liste de 50 éléments ne fait pas 51 requêtes. Un JOIN ou un WHERE id = ANY($1) règle le problème.
  • Pagination par curseur sur les listes qui grandissent. OFFSET 10000 fait lire 10 000 lignes à Postgres pour les jeter.
  • Les listes ont une limite maximale côté serveur. Un client ne décide pas de la charge du serveur.
  • statement_timeout positionné (5 à 10 secondes) au niveau du rôle applicatif. Une requête pathologique ne doit pas occuper une connexion pendant dix minutes.

Transactions#

  • Les transactions ne contiennent aucun appel réseau externe. Une transaction ouverte pendant un appel HTTP de 2 secondes, c’est 2 secondes de verrous.
  • Les opérations multi-tables qui doivent être cohérentes sont bien dans une transaction. La rotation d’un refresh token en est l’exemple type : révoquer l’ancien et insérer le nouveau, ou rien.
  • Les verrous sont pris toujours dans le même ordre, sinon interblocage garanti sous charge.
  • Les jobs de fond qui tournent sur plusieurs instances utilisent pg_advisory_lock pour ne pas se marcher dessus.

Migrations#

  • Les migrations sont versionnées et jouées au démarrage, jamais à la main.
  • Chaque migration est rétrocompatible avec la version précédente du code. Pendant un déploiement progressif, les deux versions tournent en même temps.
  • Supprimer une colonne se fait en deux déploiements : d’abord le code arrête de la lire, ensuite seulement la colonne part.
  • Une migration sur une grosse table a été chronométrée sur une copie de la production. Un ALTER TABLE qui réécrit 50 millions de lignes bloque tout.

Exploitation#

  • Les sauvegardes existent et une restauration a été testée. Une sauvegarde jamais restaurée n’est pas une sauvegarde.
  • pg_stat_statements est activé. Sans lui, tu débugues une lenteur à l’aveugle.
  • Alerte sur l’âge de la plus vieille transaction ouverte : c’est ce qui empêche l’autovacuum de faire son travail.
  • La chaîne de connexion est en sslmode=require au minimum, et le mot de passe vient d’un secret, pas du dépôt.

Le template Axum applique la plupart de ces points, notamment le pool borné, l’index composite et la rotation de token en transaction.