Votre base de données croule sous les connexions, pas sous les requêtes
Avant de commander de nouveaux serveurs ou de migrer vers une architecture distribuée coûteuse, il existe souvent une optimisation bien plus rentable qui traîne sous le nez de la plupart des équipes PHP : le transaction pooling. Dans les applications Laravel sous forte charge, le vrai goulot d'étranglement n'est que rarement la lenteur des requêtes SQL. C'est la gestion des connexions elle-même.
Le problème réel : la gestion des connexions Postgres sous charge
Postgres est un serveur de processus. Chaque connexion cliente instancie un processus dédié côté serveur. À grande échelle, cette architecture a un coût réel :
- Mémoire : chaque backend Postgres consomme entre 5 et 10 Mo de RAM dès l'ouverture.
- Planification : le système d'exploitation doit ordonnancer des centaines de processus, même si la majorité sont en attente.
- Temps d'établissement : ouvrir une connexion TCP + TLS + authentification Postgres prend entre 10 et 50 ms selon la configuration réseau.
Dans un contexte Laravel typique, la situation empire rapidement :
- Les workers Laravel Horizon se multiplient sous charge (10, 20, 50 workers simultanés).
- Les jobs schedulés arrivent en rafales à heure fixe.
- Les exports CSV et dashboards admin génèrent des pics imprévisibles.
- Chaque requête HTTP PHP-FPM ouvre et ferme sa propre connexion.
Résultat : vous atteignez la limite max_connections de Postgres bien avant d'avoir saturé le CPU ou l'I/O disque. L'erreur classique — FATAL: sorry, too many clients already — est souvent le premier signal d'alarme.
PgBouncer et le transaction pooling : le principe
PgBouncer est un proxy léger qui s'intercale entre votre application et Postgres. Il maintient un pool réduit de connexions ouvertes côté Postgres, et les prête aux clients applicatifs à la demande.
Il existe trois modes de pooling :
| Mode | Durée de l'assignation | Compatibilité Laravel |
|---|---|---|
| Session pooling | Toute la durée de la session client | Maximale, sans contrainte |
| Transaction pooling | Le temps d'une transaction | Bonne, avec quelques précautions |
| Statement pooling | Le temps d'une requête | Très restrictif, déconseillé |
Le transaction pooling est le plus efficace : une connexion Postgres n'est allouée qu'entre le BEGIN et le COMMIT/ROLLBACK. Entre deux transactions, le slot est disponible pour un autre client. En pratique, 10 connexions Postgres peuvent servir 100 clients applicatifs simultanés, car ces derniers passent la majorité de leur temps à exécuter du code PHP, pas du SQL.
Mise en place basique avec PgBouncer
Installation sur un serveur Debian/Ubuntu :
apt install pgbouncer
Configuration minimale dans /etc/pgbouncer/pgbouncer.ini :
[databases]
myapp = host=127.0.0.1 port=5432 dbname=myapp
[pgbouncer]
listen_port = 6432
listen_addr = 127.0.0.1
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 500
default_pool_size = 20
Dans votre .env Laravel, pointez sur PgBouncer plutôt que sur Postgres directement :
DB_HOST=127.0.0.1
DB_PORT=6432
Les pièges du transaction pooling avec Laravel
Le transaction pooling impose une contrainte fondamentale : l'application doit se comporter comme un client SQL sans état entre les transactions. La connexion Postgres peut changer d'une transaction à l'autre, donc toute donnée stockée au niveau de la session Postgres disparaît.
Concrètement, ces patterns Laravel deviennent problématiques :
1. Les prepared statements persistants
Par défaut, PDO utilise des prepared statements émulés avec Laravel/Postgres. En transaction pooling, les vrais prepared statements côté serveur ne survivent pas au changement de connexion. Désactivez-les dans votre configuration :
// config/database.php
'pgsql' => [
// ...
'options' => [
PDO::ATTR_EMULATE_PREPARES => true,
],
],
2. Les variables de session Postgres
Tout SET Postgres (timezone, search_path, paramètres de locale) n'est valide que pour la connexion en cours. Si votre code de tenancy multi-tenant fait un SET app.tenant_id = 123 en dehors d'une transaction, cette valeur sera perdue à la prochaine requête.
Solution : encapsuler systématiquement la logique de session dans des transactions explicites, ou migrer vers des Row-Level Security policies qui n'ont pas cet inconvénient.
3. Les advisory locks
pg_advisory_lock() est lié à la session Postgres. En transaction pooling, ces locks deviennent non fiables car la session change. Préférez pg_advisory_xact_lock() qui est lié à la transaction, ou utilisez le système de locks Laravel (cache-based).
4. Les curseurs et les tables temporaires
Même problème : tables TEMP et curseurs SQL sont des objets de session. En transaction pooling, ils sont inaccessibles entre deux transactions. Refactorisez ces patterns vers des requêtes standard ou des CTE.
Traiter la migration comme un audit de code
L'article original de Saqueib Ansari sur dev.to insiste sur un point clé que beaucoup d'équipes sous-estiment : passer au transaction pooling, c'est d'abord faire un audit de code, pas juste changer une config.
PgBouncer est particulièrement efficace pour exposer les hypothèses cachées de session qui se sont accumulées dans le code au fil du temps :
- La logique de boot du tenancy dans un
AppServiceProvider. - Des helpers SQL bruts dans des commandes Artisan legacy.
- Des packages tiers qui utilisent des variables de session sans le documenter.
- Des jobs d'import qui mélangent logique applicative et manipulation directe de connexion.
Une stratégie pragmatique : déployez d'abord en session pooling (aucun impact comportemental), collectez des métriques de connexion réelles avec pgbouncer stats, puis basculez progressivement en transaction pooling en testant environnement par environnement.
Conclusion : optimiser avant de dépenser
Face à des problèmes de scalabilité base de données, le réflexe habituel est d'augmenter les ressources : plus gros serveur, réplicas en lecture, voire sharding. Ces solutions ont leur place, mais elles coûtent cher et prennent du temps.
Le transaction pooling via PgBouncer est souvent la première optimisation à tester, précisément parce qu'il s'attaque à la vraie source du problème : le gaspillage de slots de connexion par des clients PHP à courte durée de vie. Avec 20 connexions Postgres bien gérées, vous pouvez absorber des centaines de workers Laravel sans saturer votre base de données.
La condition sine qua non reste la rigueur dans le code : pas de session-state implicite, pas d'advisory locks de session, prepared statements désactivés. Ce n'est pas une contrainte punitive — c'est simplement écrire du code SQL propre et explicite. Votre futur vous remerciera, et votre facture cloud aussi.