MySQL vs PostgreSQL : « timeouts aléatoires » — réseau, DNS et pooling en cause

Cet article vous a aidé ?

« Timeouts aléatoires » : c’est l’expression que l’on utilise quand le canal d’incident bouge plus vite que les graphes. Une requête reste bloquée 30 secondes, une autre finit en 12 ms, et la base de données est accusée parce que c’est la seule dépendance partagée que tout le monde sait épeler.

La plupart du temps, la base de données est innocente. Ou du moins : coupable d’une manière plus intéressante que « Postgres est lent » ou « MySQL a coupé les connexions ». Les timeouts vivent dans les fissures — entre l’application et la BD — là où les files réseau, le cache DNS, les load balancers et les pools de connexions changent discrètement les règles.

Ce que « aléatoire » signifie vraiment

« Aléatoire » signifie typiquement corrélé, mais pas avec ce que vous regardez actuellement. La même requête peut timeoutter pour un utilisateur et pas pour un autre parce que ce n’est pas qu’une question de requête. C’est :

  • Quelle instance d’application vous touchez (cache DNS différent, pool différent, noyau de nœud différent, état de conntrack différent).
  • Quel endpoint DB vous a été résolu (DNS stale, split-horizon, mismatch IPv6/IPv4 lors de la stratégie Happy Eyeballs).
  • Quel chemin a pris le paquet (changement d’ECMP hash, un lien congestionné, une VM « noisy neighbor »).
  • Quelle connexion vous avez réutilisée (le pool vous donne une session TCP à moitié morte ; la BD vous a déjà oublié).
  • Quel verrou vous attendiez (une transaction bloquée sur une ligne est indiscernable d’un blocage réseau à moins d’instrumenter).

Les timeouts peuvent aussi se cumuler. Une app peut avoir un timeout de requête de 2s, le driver un timeout de connexion de 5s, un pooler un server_login_retry de 30s, et un load balancer un idle timeout de 60s. Quand quelqu’un dit « ça timeout après environ 30 secondes », ce n’est pas un indice. C’est une confession : une valeur par défaut a fait ça.

MySQL vs PostgreSQL : à quoi ressemblent typiquement les timeouts

Où les « timeouts aléatoires » tombent généralement dans les systèmes MySQL

Dans les environnements MySQL, les « timeouts aléatoires » sont souvent des effets secondaires de la rotation des connexions et des limites de gestion des threads/connexions :

  • Storms de connexions après des déploiements (ou après un redémarrage de pooler) qui peuvent saturer MySQL avec le coût d’authentification et de création de threads.
  • wait_timeout et interactive_timeout peuvent tuer des connexions inactives que le pool croyait vivantes, produisant des erreurs sporadiques « server has gone away » ou « lost connection ».
  • Reverse lookups DNS (lorsque la résolution de nom intervient pour des grants ou des logs) peuvent ajouter une latence inattendue au moment de la connexion si le DNS est instable.
  • Couches proxy (proxies SQL-aware, load balancers L4) peuvent introduire des idle timeouts qui ressemblent à « MySQL est instable ».

Où les « timeouts aléatoires » tombent généralement dans les systèmes PostgreSQL

Dans Postgres, les timeouts apparaissent souvent comme des attentes—sur des verrous, des slots de connexion ou des ressources serveur :

  • Saturation de max_connections crée un motif très spécifique : certains clients se connectent instantanément, d’autres restent bloqués, d’autres échouent—selon le comportement du pool et le backoff.
  • Verrous et longues transactions font « accrocher » des requêtes derrière un mauvais acteur.
  • statement_timeout et idle_in_transaction_session_timeout peuvent transformer un chemin lent en erreur, ce qui est utile—jusqu’à ce que ce soit mal réglé et que ça devienne du bruit.
  • PgBouncer en transaction/statement mode peut amplifier des bizarreries si votre application compte sur l’état de session.

Les deux bases subissent la même physique : TCP n’est pas magique, DNS est un cache distribué de mensonges, et les poolers sont merveilleux… jusqu’à ce qu’ils ne le soient plus. Les différences tiennent au comportement d’échec par défaut et à ce que l’on a tendance à greffer autour.

Idée paraphrasée de Werner Vogels : « Tout échoue, tout le temps. » Ce n’est pas du pessimisme ; c’est une exigence de conception.

Blague #1 : Les « timeouts aléatoires » ne sont que des pannes déterministes qui n’ont pas encore rencontré vos dashboards.

Faits intéressants et un peu d’histoire (utile)

  1. PostgreSQL descend de POSTGRES (années 1980), conçu pour l’extensibilité et la correction ; cela se voit dans sa rigueur autour des transactions et des verrous.
  2. La popularité précoce de MySQL (fin 1990s/2000s) venait de sa rapidité et sa facilité pour les charges web ; beaucoup de valeurs par défaut opérationnelles et d’hypothèses d’écosystème reflètent encore « beaucoup de requêtes courtes ».
  3. MySQL s’appuyait historiquement sur des moteurs non transactionnels (comme MyISAM) avant qu’InnoDB ne devienne la norme ; le folklore opérationnel sur « MySQL est simple » ignore souvent le comportement d’InnoDB sous contention.
  4. Postgres a introduit MVCC tôt et l’a adopté ; « les requêtes ne bloquent pas les écritures » est majoritairement vrai—jusqu’aux verrous et DDL.
  5. PgBouncer est devenu courant car les connexions Postgres coûtent cher ; une flotte importante faisant TLS + auth par requête peut ressembler à un DDoS payé par erreur.
  6. Les proxies MySQL (et load balancers) sont devenus courants pour l’échelle en lecture ; des composants L4/L7 peuvent injecter des idle timeouts qui miment une instabilité serveur.
  7. Les TTL DNS existent parce que les résolveurs sont des caches ; les clients ne s’accordent pas toujours sur le respect des TTL, et certaines bibliothèques cachent plus longtemps que prévu—surtout dans des processus longue durée.
  8. conntrack Linux (netfilter) peut être un goulot dans les environnements riches en NAT ; ce n’est pas spécifique à la base, mais le trafic DB, suffisamment constant, le révèle.
  9. Les load balancers cloud ont souvent des timeouts inamovibles par défaut ; les bases sont bavardes mais parfois calmes, donc « idle » peut encore être « sain ».

Playbook de diagnostic rapide

Voici l’ordre que j’utilise quand quelqu’un dit « timeouts DB » et qu’il n’y a pas le temps de débattre architecture.

Première étape : classifier le timeout

  • Connect timeout (impossible d’établir TCP/TLS/auth). Suspects : DNS, routage, firewall, santé du load balancer, max connections, SYN backlog.
  • Read timeout (connecté, puis bloqué). Suspects : verrous, longues transactions, CPU/IO serveur, pertes de paquets/retransmits, pool fournissant une socket morte.
  • Write timeout (ressemblera souvent à un read timeout mais au commit). Suspects : fsync/IO stalls, pression de réplication, synchronous commit, latence de stockage, réseau vers le stockage.
  • Application timeout (votre propre deadline déclenchée). Suspects : tout ce qui précède plus « vous avez réglé 500ms et espéré ».

Deuxième étape : trouver l’étendue

  • Un seul nœud d’app ? Vérifiez le cache DNS, le noyau, conntrack, l’état local du pool, le noisy neighbor.
  • Une seule AZ/subnet ? Vérifiez changements de routage, security groups, mismatch MTU, perte de paquets, brownouts.
  • Tous les clients ? Regardez la saturation DB, les stalls de stockage, ou un proxy/load balancer partagé.
  • Seulement les nouvelles connexions ? Vérifiez auth/DNS, handshake TLS, exhaustion du pool, max connections, SYN backlog.

Troisième étape : décidez si vous attendez ou si c’est perdu

  • En attente : le serveur montre des sessions coincées sur des verrous/IO/CPU ; le réseau montre peu de perte mais une latence élevée ; les requêtes apparaissent « actives » mais bloquées.
  • Perte : resets TCP, broken pipes, « server closed the connection », pics de retransmits/timeouts, pression sur la table NAT.

Quatrième étape : réduire la surface du problème

  • Essayez un client, une connexion, direct vers l’IP DB (bypass DNS + pooler) pour séparer les couches.
  • Testez depuis un autre nœud/subnet pour séparer local vs systémique.
  • Réduisez temporairement la concurrency pour stopper les storms de connexions auto-infligées pendant l’enquête.

Tâches pratiques : commandes, sorties et décisions

Ce sont des tâches réelles à lancer pendant un incident. Chacune inclut ce que signifie la sortie et la décision suivante. Choisissez celles qui correspondent à votre environnement (bare metal, VM, Kubernetes).

1) Confirmer la réponse DNS, le TTL, et s’il oscille

cr0x@server:~$ dig +noall +answer +ttlid db.internal.example A
db.internal.example. 5 IN A 10.20.30.41

Ce que cela signifie : TTL=5 secondes. C’est agressif. Si l’endpoint change ou si le résolveur est lent, vous le remarquerez.

Décision : Si le TTL est très bas, vérifiez la santé des résolveurs et le comportement de cache des clients. Envisagez d’augmenter le TTL pour des services stables ou d’utiliser un VIP/proxy stable.

2) Vérifier si votre nœud utilise un chemin de résolveur défaillant

cr0x@server:~$ resolvectl status | sed -n '1,80p'
Global
       Protocols: -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
resolv.conf mode: stub
Current DNS Server: 10.0.0.2
       DNS Servers: 10.0.0.2 10.0.0.3

Ce que cela signifie : Cet hôte utilise systemd-resolved avec deux serveurs DNS. Si 10.0.0.2 est malade, vous verrez des délais de lookup intermittents.

Décision : Si vous observez des pics de latence de lookup, testez chaque résolveur directement ; envisagez de retirer un résolveur malsain ou de corriger le routage/sécurité le concernant.

3) Mesurer la latence DNS directement (ne pas deviner)

cr0x@server:~$ for i in {1..5}; do time dig +tries=1 +timeout=1 @10.0.0.2 db.internal.example A >/dev/null; done
real    0m0.012s
real    0m0.980s
real    0m0.011s
real    0m1.003s
real    0m0.010s

Ce que cela signifie : Moitié des requêtes frôlent le timeout de 1s. Ce n’est pas « correct ». C’est de la roulette.

Décision : Traitez le DNS comme partie du chemin critique. Corrigez la charge du résolveur, la perte de paquets, ou la récursion upstream. En attendant, augmentez le cache DNS côté client seulement si vous comprenez les implications de basculement.

4) Confirmer la route et le MTU vers la DB

cr0x@server:~$ ip route get 10.20.30.41
10.20.30.41 via 10.20.0.1 dev eth0 src 10.20.10.55 uid 1000
    cache

Ce que cela signifie : Vous avez un chemin routé via 10.20.0.1. Si cela change pendant les incidents, c’est un indice.

Décision : Si le routage est instable, faites intervenir l’équipe réseau rapidement. Des routes instables produisent des timeouts « aléatoires » qu’aucun réglage DB ne corrigera.

5) Test TCP rapide (en contournant les drivers)

cr0x@server:~$ nc -vz -w 2 10.20.30.41 5432
Connection to 10.20.30.41 5432 port [tcp/postgresql] succeeded!

Ce que cela signifie : Le handshake TCP a réussi. Cela ne prouve pas l’authent ou la réussite d’une requête, mais ça exclut « port bloqué » à ce moment.

Décision : Si TCP échoue de manière intermittente, regardez les règles de sécurité, le SYN backlog, les firewalls stateful, l’exhaustion conntrack/NAT, et la santé des checks du LB.

6) Inspecter les retransmits et la santé TCP côté client

cr0x@server:~$ ss -ti dst 10.20.30.41 | sed -n '1,40p'
ESTAB 0 0 10.20.10.55:49822 10.20.30.41:5432
	 cubic wscale:7,7 rto:204 rtt:2.3/0.7 ato:40 mss:1448 pmtu:1500 rcvmss:1448 advmss:1448 cwnd:10 bytes_sent:21984 bytes_retrans:2896 segs_out:322 segs_in:310 send 50.4Mbps lastsnd:12 lastrcv:12 lastack:12 pacing_rate 100Mbps unacked:2 retrans:1/7

Ce que cela signifie : Il y a des retransmits (bytes_retrans et compteurs retrans). Un peu est normal ; des pics corrèlent fortement avec des stalls « aléatoires ».

Décision : Si les retransmits augmentent pendant un incident, arrêtez de débattre des plans de requête et commencez à regarder la perte de paquets, la congestion, MTU/PMTU, ou des NICs défaillantes.

7) Vérifier la pression conntrack (environnements NAT-intensifs)

cr0x@server:~$ sudo sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
net.netfilter.nf_conntrack_count = 248901
net.netfilter.nf_conntrack_max = 262144

Ce que cela signifie : Vous êtes proche de la limite de la table conntrack. Quand elle se remplit, vous obtenez des drops de paquets qui ressemblent à des timeouts intermittents.

Décision : Augmentez les limites conntrack (en tenant compte de la mémoire), réduisez le churn de connexions, évitez le NAT inutile, et envisagez du pooling en périphérie.

8) Vérifier le comportement d’idle timeout du load balancer / proxy avec une pause contrôlée

cr0x@server:~$ (echo "ping"; sleep 75; echo "ping") | nc 10.20.30.50 3306
ping

Ce que cela signifie : Si le second « ping » ne reçoit jamais de réponse ou si la connexion tombe après ~60s, un middlebox applique un idle timeout.

Décision : Alignez les keepalive : keepalives TCP du noyau, keepalives du driver, et idle timeouts des LB/proxy. Ou retirez le LB du chemin DB si ce n’est pas l’outil adapté.

9) PostgreSQL : voir si les clients sont bloqués sur des verrous

cr0x@server:~$ psql -h 10.20.30.41 -U ops -d appdb -c "select pid, wait_event_type, wait_event, state, now()-query_start as age, left(query,80) from pg_stat_activity where state <> 'idle' order by age desc limit 10;"
 pid  | wait_event_type |  wait_event   | state  |   age   |                                      left
------+-----------------+---------------+--------+---------+--------------------------------------------------------------------------------
 8123 | Lock            | transactionid | active | 00:00:31| update orders set status='paid' where id=$1
 7991 | Client          | ClientRead    | active | 00:00:09| select * from orders where id=$1

Ce que cela signifie : Une requête attend un verrou depuis 31 secondes. Ce n’est pas un timeout réseau. C’est une transaction bloquante.

Décision : Trouvez le bloqueur (tâche suivante), puis décidez de l’abattre, d’ajuster la portée des transactions dans l’application, ou d’ajouter des index/réécritures pour réduire la durée des verrous.

10) PostgreSQL : trouver la requête bloqueuse

cr0x@server:~$ psql -h 10.20.30.41 -U ops -d appdb -c "select blocked.pid as blocked_pid, blocker.pid as blocker_pid, now()-blocker.query_start as blocker_age, left(blocker.query,80) as blocker_query from pg_locks blocked_locks join pg_stat_activity blocked on blocked.pid=blocked_locks.pid join pg_locks blocker_locks on blocker_locks.locktype=blocked_locks.locktype and blocker_locks.database is not distinct from blocked_locks.database and blocker_locks.relation is not distinct from blocked_locks.relation and blocker_locks.page is not distinct from blocked_locks.page and blocker_locks.tuple is not distinct from blocked_locks.tuple and blocker_locks.virtualxid is not distinct from blocked_locks.virtualxid and blocker_locks.transactionid is not distinct from blocked_locks.transactionid and blocker_locks.classid is not distinct from blocked_locks.classid and blocker_locks.objid is not distinct from blocked_locks.objid and blocker_locks.objsubid is not distinct from blocked_locks.objsubid and blocker_locks.pid != blocked_locks.pid join pg_stat_activity blocker on blocker.pid=blocker_locks.pid where not blocked_locks.granted and blocker_locks.granted;"
 blocked_pid | blocker_pid | blocker_age |                         blocker_query
-------------+-------------+-------------+---------------------------------------------------------------
        8123 |        7701 | 00:02:14    | begin; select * from orders where customer_id=$1 for update;

Ce que cela signifie : Le PID 7701 détient des verrous depuis plus de 2 minutes. Vos « timeouts aléatoires » sont votre application qui attend poliment.

Décision : Si c’est une transaction hors de contrôle, terminez-la. Si c’est normal, changez le code : gardez les transactions courtes, évitez FOR UPDATE sauf nécessité, et indexez le prédicat.

11) PostgreSQL : détecter l’épuisement des slots de connexion

cr0x@server:~$ psql -h 10.20.30.41 -U ops -d appdb -c "select count(*) as total, sum(case when state='active' then 1 else 0 end) as active from pg_stat_activity;"
 total | active
-------+--------
  498  |   112

Ce que cela signifie : 498 sessions présentes. Si max_connections est 500, vous vivez sur la corde.

Décision : Placez PgBouncer en amont (avec précaution), réduisez les tailles de pool applicatives, et réservez des connexions pour la maintenance. « Augmenter max_connections » est souvent un plan mémoire déguisé en optimisme.

12) MySQL : vérifier si vous atteignez des limites de connexion ou une pression sur les threads

cr0x@server:~$ mysql -h 10.20.30.42 -u ops -p -e "SHOW GLOBAL STATUS LIKE 'Threads_connected'; SHOW GLOBAL STATUS LIKE 'Threads_running'; SHOW VARIABLES LIKE 'max_connections';"
+-------------------+-------+
| Variable_name     | Value |
+-------------------+-------+
| Threads_connected | 942   |
+-------------------+-------+
+-----------------+-------+
| Variable_name   | Value |
+-----------------+-------+
| Threads_running | 87    |
+-----------------+-------+
+-----------------+-------+
| Variable_name   | Value |
+-----------------+-------+
| max_connections | 1000  |
+-----------------+-------+

Ce que cela signifie : Vous êtes proche du maximum de connexions. Même si le CPU est correct, le serveur peut thrash sur la gestion des connexions et le context switching.

Décision : Réduisez le nombre de connexions via du pooling, définissez des limites par service, et empêchez chaque microservice de penser qu’il mérite 200 connexions « au cas où ».

13) MySQL : identifier les causes racines de « server has gone away » (timeouts vs taille de paquet)

cr0x@server:~$ mysql -h 10.20.30.42 -u ops -p -e "SHOW VARIABLES LIKE 'wait_timeout'; SHOW VARIABLES LIKE 'interactive_timeout'; SHOW VARIABLES LIKE 'max_allowed_packet';"
+--------------------+-------+
| Variable_name      | Value |
+--------------------+-------+
| wait_timeout       | 60    |
+--------------------+-------+
+--------------------+-------+
| Variable_name      | Value |
+--------------------+-------+
| interactive_timeout| 60    |
+--------------------+-------+
+--------------------+----------+
| Variable_name      | Value    |
+--------------------+----------+
| max_allowed_packet | 67108864 |
+--------------------+----------+

Ce que cela signifie : Les connexions inactives meurent après 60 secondes. C’est bien pour des clients éphémères, catastrophique pour des pools qui gardent des connexions plus longtemps.

Décision : Soit augmentez les timeouts et activez keepalives ; soit réduisez la lifetime idle du pool pour que le pool jette les connexions avant le serveur.

14) Valider les timers TCP keepalive du noyau (côté client)

cr0x@server:~$ sysctl net.ipv4.tcp_keepalive_time net.ipv4.tcp_keepalive_intvl net.ipv4.tcp_keepalive_probes
net.ipv4.tcp_keepalive_time = 7200
net.ipv4.tcp_keepalive_intvl = 75
net.ipv4.tcp_keepalive_probes = 9

Ce que cela signifie : Le premier keepalive est envoyé après 2 heures. Si votre load balancer tue les sessions inactives après 60 secondes, keepalive ne vous aidera pas.

Décision : Ajustez les keepalives (prudemment) pour des clients DB derrière des LB/NAT, ou cessez de placer les bases derrière des dispositifs qui terminent les connexions inactives.

15) Valider la saturation des pools applicatifs au niveau OS

cr0x@server:~$ ss -s
Total: 1632 (kernel 0)
TCP:   902 (estab 611, closed 221, orphaned 0, synrecv 0, timewait 221/0), ports 0

Transport Total     IP        IPv6
RAW	  0         0         0
UDP	  12        10        2
TCP	  681       643       38
INET	  693       653       40
FRAG	  0         0         0

Ce que cela signifie : 611 connexions TCP établies. Si votre app « devrait » en avoir 50, vous avez une fuite de pool ou un storm de connexions.

Décision : Limitez le pool, ajoutez du backpressure, et corrigez la fuite. Monter la BD pour compenser un pool buggy est la voie rapide vers des budgets explosés.

16) Vue serveur rapide : êtes-vous CPU ou IO bound ?

cr0x@server:~$ iostat -x 1 3
avg-cpu:  %user   %nice %system %iowait  %steal   %idle
          18.21    0.00    6.10   24.77    0.00   50.92

Device            r/s     rkB/s   rrqm/s  %rrqm r_await rareq-sz     w/s     wkB/s   w_await aqu-sz  %util
nvme0n1         120.0   40960.0     0.0   0.00    8.10   341.3   220.0   53248.0   21.40   4.90   96.80

Ce que cela signifie : Le disque est à ~97% d’utilisation ; write await à 21 ms. C’est la latence de commit qui attend le stockage, pas un « bug driver ».

Décision : Si l’IO est saturé, regardez le checkpointing, le comportement fsync, les réglages de réplication, la limitation du stockage, et les noisy neighbors.

DNS : le saboteur silencieux

Le DNS est rarement la cause racine des timeouts DB. Il en est généralement l’amplificateur : un petit souci DNS transforme une logique de reconnect routinière en panne synchronisée.

Comment le DNS cause des timeouts « aléatoires »

  • Lookups lents lors de la création de connexion : chaque reconnexion attend le DNS, votre pool de connexions devient une suite de tests de performance DNS.
  • Réponses obsolètes : certains clients continuent d’utiliser une vieille IP après un failover ; la BD est OK à la nouvelle IP, et vous vous acharnez sur l’ancienne.
  • Cache négatif : un NXDOMAIN ou SERVFAIL transitoire est mis en cache, et maintenant vous ne pouvez plus résoudre la DB pendant un moment.
  • Mismatches split-horizon : DNS interne/externe donnent des réponses différentes ; la moitié de vos pods résolvent vers des adresses injoignables.
  • Quirks Happy Eyeballs : des réponses AAAA IPv6 entraînent des tentatives sur un chemin v6 cassé avant que le v4 ne fonctionne.

MySQL vs Postgres : surprises liées au DNS

Les deux sont impactés au moment de la connexion car le client résout l’hôte avant d’établir le TCP. Les différences surprenantes viennent souvent de ce qui les entoure :

  • Déploiements MySQL incluent souvent des proxies SQL-aware ou des load balancers L4 pour le split read/write. Cela ajoute un hostname, une résolution et une couche de cache supplémentaire.
  • Déploiements Postgres incluent souvent PgBouncer. PgBouncer résout lui-même les hostnames en amont et a son propre comportement de reconnexion et de retry.

Opérationnellement : ne laissez pas chaque instance d’app faire sa gymnastique DNS pendant un incident. Placez un endpoint stable devant la BD (VIP, proxy ou endpoint managé) et testez le mode d’échec intentionnellement.

Réalités réseau : retransmissions, MTU, et « ce n’est pas perdu, c’est mis en file »

Les réseaux ne tombent pas habituellement en coupure totale. Ils échouent en devenant suffisamment peu fiables pour que vos timeouts deviennent statistiquement intéressants.

Retransmits : la taxe cachée

Une seule retransmission peut coûter des dizaines ou centaines de millisecondes selon le RTO et le contrôle de congestion. Quelques pourcents de perte de paquets peuvent transformer un protocole de base de données bavard en machine à timeouts, surtout avec TLS au-dessus.

MTU et PMTUD : le classique « ça marche jusqu’à ce que ça casse »

Un mismatch MTU peut se manifester par :

  • Les petites requêtes fonctionnent, les grosses réponses stagnent.
  • La connexion réussit, le premier gros résultat bloque.
  • Certaines routes fonctionnent (même rack), d’autres échouent (cross-AZ).

Si les ICMP « fragmentation needed » sont bloqués quelque part, Path MTU Discovery casse et vous obtenez un comportement de blackhole. Cela ressemble à un timeout de base car la BD est la première à envoyer régulièrement de gros paquets.

Files et bufferbloat

Toute latence n’est pas perte. Si vous avez des liens congestionnés, vos paquets peuvent attendre dans une file. TCP finira par les livrer, mais votre application aura déjà timeouté. Voilà pourquoi « pas de perte de paquets » ne veut pas dire « le réseau est sain ».

Blague #2 : Le réseau est comme l’imprimante du bureau : il fonctionne parfaitement jusqu’à ce que quelqu’un regarde.

Coupables du pooling : PgBouncer, proxys et valeurs par défaut « aidantes »

Le pooling existe parce que créer une connexion BD coûte cher—CPU, mémoire, handshakes TLS, authent, bookkeeping serveur. Mais le pooling est aussi l’endroit où la réalité est abstraite en quelque chose que vos développeurs comprennent mal.

Les trois types de pools (et pourquoi s’en préoccuper)

  • Pools côté client (dans l’app) : simples, mais peuvent multiplier les connexions par nombre d’instances. Parfaits pour créer des storms de connexions.
  • Poolers externes (PgBouncer, ProxySQL) : peuvent plafonner les connexions serveur et absorber le churn client. Ajoutent aussi un saut et une surface de configuration de timeouts supplémentaire.
  • Proxys managés (cloud DB proxies) : pratiques, mais leur comportement lors du failover et leurs timeouts idle par défaut sont souvent la source des « aléatoires ».

Modes d’échec de pooler qui ressemblent à des timeouts DB

  • Saturation du pool : les requêtes font la queue en attendant une connexion ; l’app timeoutte ; la BD est inoccupée.
  • Connexions mortes réutilisées : le pool donne une socket qu’un middlebox a tuée ; la première requête bloque ou échoue.
  • Storms de retry : le pool retry agressivement ; il ajoute de la charge quand la BD est déjà mal en point.
  • Hypothèses sur l’état de session : le transaction pooling casse les apps qui comptent sur des variables de session, des tables temporaires, des advisory locks, des prepared statements, ou SET LOCAL (selon DB et mode).

MySQL vs Postgres : pièges du pooling

Postgres est particulièrement sensible au nombre de connexions car chaque connexion correspond à un processus backend (architecture classique). C’est pourquoi PgBouncer est si courant. Mais les modes de PgBouncer comptent :

  • Session pooling : le plus sûr ; moins de surprises ; moins efficace pour réduire les connexions serveur sous une concurrence rafaleuse.
  • Transaction pooling : efficace ; casse tout ce qui nécessite de l’affinité de session sauf si vous concevez avec discipline.
  • Statement pooling : arme tranchante ; rarement utile pour des apps générales.

MySQL gère typiquement de nombreuses connexions différemment (thread-per-connection sauf thread pool selon la distribution/édition). Le compte de connexions reste un problème, mais la signature d’échec ressemble souvent à une surcharge CPU et du scheduler churn plutôt qu’à un « plus de slots ».

Modes d’échec spécifiques à MySQL

Mismatch de timeout idle (wait_timeout vs lifetime du pool)

Un des échecs « aléatoires » les plus courants : le serveur termine des connexions inactives, le pool les garde, et votre prochaine requête découvre le cadavre.

Que faire : Faites en sorte que la max lifetime et l’idle timeout du pool soient plus courts que wait_timeout, ou augmentez wait_timeout et activez TCP keepalive. Choisissez l’une des deux approches ; ne les laissez pas diverger.

Lenteur d’authent ou de reverse lookup DNS

Si MySQL est configuré pour effectuer une résolution de nom (ou si l’environnement fait des reverse lookups pour logging/control d’accès), une oscillation DNS devient un stall au moment de la connexion. Ce n’est pas que MySQL « ne peut pas gérer » ; c’est que votre chemin d’auth est désormais un système distribué.

Trop de connexions : « ça marche jusqu’au déploiement »

MySQL peut sembler stable à 500 connexions puis s’effondrer à 900 — pas parce que les requêtes ont changé, mais à cause de la surcharge opérationnelle. Threads, mémoire par connexion, contention de mutex, et churn de cache se manifestent tous comme des timeouts côté app.

Opinion tranchée : Si vous avez des centaines d’instances applicatives autorisées à ouvrir des dizaines de connexions, vous n’avez pas un problème de base de données. Vous avez un problème de coordination.

Modes d’échec spécifiques à PostgreSQL

Verrous et longues transactions

Postgres est excellent en concurrence—jusqu’à ce que vous gardiez des verrous plus longtemps que prévu. Le coupable habituel est une transaction qui commence tôt, exécute de la « logique métier », puis met à jour des lignes à la fin. Sous charge, ces verrous s’accumulent et tout attend « aléatoirement ».

Que faire : Gardez les transactions courtes, réduisez la portée des verrous, indexez précisément les lignes touchées, et utilisez des timeouts qui échouent assez vite pour protéger le système.

Épuisement des slots de connexion et le mythe de « simplement augmenter max_connections »

Chaque connexion Postgres consomme de la mémoire et de l’overhead. Augmenter max_connections sans repenser work_mem, le parallélisme et la RAM totale est la manière de transformer un incident de timeout en incident OOM.

Que faire : Placez PgBouncer en mode session ou transaction, puis redimensionnez correctement les pools applicatifs. Réservez de la marge pour les connexions d’administration.

Réglages de timeout absents ou transformés en armes

Postgres vous donne de bons boutons : statement_timeout, lock_timeout, idle_in_transaction_session_timeout. Le piège est de les régler globalement sur des valeurs qui correspondent à une seule attente.

Que faire : Définissez des timeouts par rôle ou par service. Différents workloads méritent différents modes d’échec.

Trois mini-histoires d’entreprise

Mini-histoire 1 : l’incident causé par une mauvaise hypothèse

L’entreprise avait un service de checkout chargé et un primaire Postgres avec un hot standby. Lors d’un test de failover planifié, ils ont basculé le rôle primaire. Le nom DNS de l’endpoint était censé suivre le primaire, TTL bas, tout « moderne ».

Après le failover, un tiers des requêtes a commencé à timeoutter. Pas des erreurs—des timeouts. Les graphes DB semblaient normaux. CPU normal. Disque OK. La réplication rattrapait. Tout le monde regardait Postgres, parce que c’est ce que l’on fait quand on a peur.

La mauvaise hypothèse était simple : « Nos apps Java respectent le TTL DNS. » Elles ne le faisaient pas—pas de manière cohérente. Certaines instances gardaient l’IP résolue plus longtemps à cause du cache JVM combiné au comportement de la librairie client. Ces instances essayaient toujours l’ancienne IP primaire, qui refusait maintenant les écritures ou était injoignable pour des règles de sécurité.

La correction n’a pas été héroïque. Ils ont placé l’endpoint DB derrière un proxy stable qui ne changeait pas d’IP au failover, et ils ont configuré explicitement le caching DNS de la JVM pour respecter le TTL dans cet environnement. Ils ont aussi ajouté un runbook : lors d’un failover, mesurer la résolution DNS et l’IP de destination réelle depuis un échantillon de pods. Finis les réseaux basés sur la foi.

Mini-histoire 2 : l’optimisation qui s’est retournée contre eux

Une équipe utilisant MySQL pour une API multi-tenant voulait réduire l’overhead de connexion. Ils ont drastiquement augmenté la taille des pools « pour réutiliser les connexions » et réduit les timeouts « pour échouer rapidement ». Résultat en staging : moins de connexions par seconde, latence médiane plus basse.

En production, un pic hebdomadaire de trafic est arrivé. Le service a scale-out rapidement. Chaque nouvelle instance démarrait avec un grand pool, et elles ont toutes essayé de le chauffer en même temps. MySQL acceptait les connexions jusqu’à ce que non. Les threads ont explosé. Le CPU a augmenté non pas à cause des requêtes, mais à cause de la gestion des connexions et du context switching. Puis le vrai festival : les retries.

La politique de retry client, combinée à un timeout de connexion court, a créé un storm de retries. Des connexions qui auraient réussi en 300 ms échouaient à 100 ms et étaient immédiatement retentées. L’« optimisation » a rendu le système moins patient justement quand il devait l’être.

Ils ont annulé l’augmentation des pools et remplacé cela par des budgets de connexions globaux stricts par service, plus une stratégie de slow-start au démarrage. Ils ont aussi rendu les retries exponentiels avec jitter et ajouté un circuit breaker autour de la connectivité DB. La latence médiane a légèrement augmenté. Les incidents ont chuté dramatiquement. Les systèmes de production préfèrent ennuyeux à ingénieux.

Mini-histoire 3 : la pratique ennuyeuse mais correcte qui a sauvé la mise

Une autre organisation faisait tourner Postgres avec PgBouncer en session mode. Rien de fancy. Leur équipe SRE avait une habitude en apparence terne : chaque trimestre, ils lançaient des drills de panne contrôlés—tuer un nœud app, redémarrer PgBouncer, simuler de la lenteur DNS, introduire une petite perte de paquets dans un environnement de test qui reflétait la topologie prod.

Un après-midi, une dégradation réseau cloud a touché leur région. La perte de paquets était faible mais non nulle ; la latence a bondi de façon intermittente. Les services ont commencé à signaler des « timeouts base de données ». L’on-call a suivi le playbook du drill : d’abord vérifier les retransmits depuis un nœud client, ensuite la profondeur de queue de PgBouncer, puis les waits de verrous Postgres. En dix minutes ils savaient que c’était une dégradation réseau plus une amplification de retry, pas une régression DB.

Parce qu’ils s’étaient entraînés, ils avaient une mitigation sûre prête : réduire la concurrence en diminuant dynamiquement les tailles de pool applicatif, étendre légèrement certains timeouts clients pour éviter les storms de retries, et désactiver temporairement un job background produisant de gros jeux de résultats. Postgres est resté sain. L’incident est devenu un incident mineur plutôt qu’un marathon de pagers.

Ce n’était pas glamour. C’était l’équivalent opérationnel du passage du fil dentaire. Inintéressant, jusqu’à ce que ça vous économise des milliers en soins dentaires.

Erreurs courantes (symptôme → cause → correction)

1) Les timeouts s’alignent exactement autour de 60 secondes

Symptôme : échecs autour de ~60s, indépendamment de la complexité de la requête.

Cause : idle timeout du load balancer/proxy, ou timeout côté serveur tuant des connexions poolées.

Correction : alignez idle timeouts et keepalives ; définissez la max lifetime du pool plus courte que le idle timeout DB ; évitez les LBs dans le chemin DB sauf si vous connaissez leur comportement.

2) Seules les nouvelles connexions timeouttent ; les existantes fonctionnent

Symptôme : le trafic établi est OK, mais les reconnects échouent pendant l’incident.

Cause : lenteur DNS, goulot TLS handshake, pression du SYN backlog, ou goulot d’auth.

Correction : mesurez la latence DNS ; vérifiez SYN backlog et drops de firewall ; limitez le taux de création de connexions ; utilisez des poolers/proxys pour réduire le churn de handshake.

3) Les requêtes « accrochent » aléatoirement, puis reprennent

Symptôme : une requête pause pendant des secondes, puis se termine ; CPU et IO semblent normaux.

Cause : waits de verrous (Postgres surtout), ou retransmits/queueing réseau.

Correction : vérifiez les events d’attente de verrous ; trouvez les bloqueurs ; réduisez la portée des transactions ; vérifiez les retransmits côté client et la latence réseau.

4) Un seul nœud applicatif est maudit

Symptôme : timeouts uniquement depuis un hôte/pod/nœud spécifique.

Cause : issues de cache DNS local, exhaustion conntrack, erreurs NIC, pool mal dimensionné, ou mauvaise entrée de table de routage.

Correction : comparez le comportement des résolveurs et les retransmits entre nœuds ; drénez et remplacez le nœud ; corrigez la dérive de configuration ; établissez des règles de quarantaine de nœuds basées SLO.

5) « Server has gone away » / « broken pipe » après des périodes d’inactivité

Symptôme : première requête après une période idle échoue ; le retry réussit.

Cause : idle timeout serveur, idle timeout LB, timeout NAT tuant l’état.

Correction : réduisez la lifetime idle du pool ; activez keepalive ; ajustez les timeouts DB ; évitez des middleboxes stateful entre app et DB.

6) Augmentation de max_connections, et les timeouts empirent

Symptôme : moins d’erreurs « too many connections », mais plus de latence/timeouts.

Cause : contention de ressources due à trop de backends/threads concurrents ; pression mémoire ; overhead de context switching.

Correction : ajoutez du pooling ; implémentez des budgets de connexions ; réduisez la concurrence ; scale verticalement seulement après avoir contrôlé le comportement des connexions.

7) Pics de timeouts après déploiements ou redémarrages

Symptôme : fenêtres d’incidents courtes juste après un rollout.

Cause : storms d’échauffement de connexions, retries synchronisés, cold starts du cache, stampedes DNS.

Correction : slow-start pour la création de connexions ; ajoutez du jitter ; étalez les rollouts ; préchauffez avec précaution ; calez les retries.

8) Les reads timeouttent seulement pour des réponses « lourdes »

Symptôme : petites requêtes ok, gros jeux de résultats bloquent.

Cause : blackholes MTU/PMTUD, bufferbloat, ou proxy mal gérant de gros payloads.

Correction : validez le MTU de bout en bout ; autorisez les ICMP nécessaires ; testez avec des tailles de paquets contrôlées ; évitez les proxys inutiles dans le chemin.

Listes de contrôle / plan étape par étape

Checklist A : Quand les timeouts commencent (premières 10 minutes)

  1. Classifiez le timeout : connect vs read vs deadline applicative. Récupérez un échantillon d’erreurs avec timestamps.
  2. Vérifiez la latence DNS depuis au moins deux nœuds clients (tâche 3). Si c’est instable, arrêtez et corrigez le DNS avant toute manœuvre compliquée.
  3. Vérifiez les retransmits depuis un nœud client (tâche 6). La perte explique souvent l’« aléatoire ».
  4. Vérifiez la saturation du pool : métriques app ou connexions OS (tâche 15). Si la mise en queue est au niveau du pool, le tuning DB n’aidera pas.
  5. Vérifiez les attentes DB :
    • Postgres : events d’attente dans pg_stat_activity (tâche 9).
    • MySQL : threads connected/running (tâche 12) et métriques slow query / lock si disponibles.
  6. Appliquez une limitation sûre : réduisez la concurrence et les taux de retry ; stoppez le job background le plus bruyant. Stabilisez d’abord, optimisez ensuite.

Checklist B : Prévenir la répétition (jour suivant)

  1. Standardisez les timeouts entre app, driver, pooler et dispositifs réseau. Documentez les valeurs choisies et pourquoi.
  2. Définissez des budgets de connexion par service. Faites-les respecter dans la config, pas dans la mémoire tribale.
  3. Implémentez backoff avec jitter pour les retries ; ajoutez des circuit breakers sur les échecs de connexion.
  4. Introduisez un endpoint DB stable (proxy/VIP/endpoint managé) pour que les changements DNS ne déclenchent pas le chaos client-side.
  5. Ajoutez des règles d’hygiène des verrous/transactions (surtout pour Postgres) : portée des transactions, timeouts de statement par rôle, alertes sur transactions longues.
  6. Exécutez des drills de panne : simulez lenteur DNS et perte de paquets modeste ; vérifiez que votre système se dégrade de façon prévisible.

Checklist C : Choisir MySQL vs Postgres pour la prévisibilité opérationnelle

  • Si votre organisation ne peut pas faire respecter une discipline sur les connexions, prévoir un pooler/proxy dès le jour 1, quel que soit le choix de la base.
  • Si votre workload est sensible aux verrous et que la logique métier tend à maintenir des transactions ouvertes, Postgres fera remonter la vérité—douloureuse mais réparable.
  • Si votre workload génère beaucoup de churn de connexions (patterns serverless, flottes bursty), l’architecture importe plus que le moteur. Mettez un layer de pooling stable en amont et mesurez DNS/réseau comme une dépendance de première classe.

FAQ

1) Pourquoi les timeouts semblent-ils aléatoires alors que la cause est déterministe ?

Parce que la distribution cache les motifs. Différents clients prennent des routes différentes, utilisent des réponses DNS mises en cache différentes, rencontrent des états de pool différents, et se contentent sur des verrous différents. « Aléatoire » est souvent « sharded ».

2) Comment savoir si le timeout vient du pool plutôt que de la base ?

Regardez le temps de mise en queue dans les métriques du pool, ou inférez-le : si le CPU/IO DB est bas mais que les latences applicatives grimpent et que les comptes de connexions sont élevés, le pool est probablement saturé ou en churn. Les comptes ss côté OS aident aussi.

3) Dois-je mettre un load balancer devant MySQL ou Postgres ?

Seulement si vous savez exactement ce qu’il fait avec des connexions TCP longue durée et les idle timeouts, et que vous avez testé le comportement de failover. Les LB sont super pour HTTP ; les bases ne sont pas HTTP.

4) Un TTL DNS = 5 secondes est-il bon pour le failover ?

Ça peut l’être, mais seulement si vos clients respectent réellement le TTL et que vos résolveurs sont rapides et fiables sous charge. Un TTL bas sans client correct transforme le failover en loterie.

5) Pour Postgres, PgBouncer est-il toujours la solution ?

Souvent oui—mais le mode compte. Le session pooling est le moins surprenant. Le transaction pooling est puissant mais demande une discipline applicative autour de l’état de session et des prepared statements.

6) Pour MySQL, « server has gone away » est-ce toujours un problème réseau ?

Non. Cela peut être des timeouts idle (wait_timeout), des limites de taille de paquet (max_allowed_packet), des redémarrages serveur, ou des middleboxes qui ferment les sessions inactives. Commencez par corréler les erreurs avec des périodes d’inactivité et la réutilisation des connexions.

7) Pourquoi les retries aggravent-ils la situation ?

Les retries convertissent la latence en charge. Si le goulot est partagé (DB, DNS, proxy, conntrack), les retries synchronisent les clients et amplifient la panne. Utilisez backoff exponentiel avec jitter et un budget de retries.

8) Quel est le gain le plus rapide pour réduire les timeouts « aléatoires » ?

Contrôler le comportement des connexions. Limitez les pools, stoppez les storms de connexions, et alignez les timeouts entre couches. Ensuite, mesurez la latence DNS et les retransmits TCP pour arrêter de déboguer des fantômes.

9) Les waits de verrous Postgres sont-ils un problème DB ou applicatif ?

Généralement le comportement applicatif exprimé via la DB. Postgres est juste honnête sur l’attente. Corrigez la portée des transactions, les index et les patterns d’accès ; puis envisagez des niveaux d’isolation et des timeouts.

10) Comment éviter le ping-pong de blâme entre app, DB et réseau ?

Collectez trois artefacts tôt : échantillons de latence DNS, preuves de retransmits TCP, et snapshots d’attente/verrou DB. Avec cela, la discussion devient un plan.

Étapes suivantes (pratiques)

Si vous voulez moins de « timeouts aléatoires », arrêtez de les traiter comme des traits de caractère de la base. Traitez-les comme des interfaces qui échouent sous pression : DNS, TCP, pooling et contrôle de concurrence.

  1. Adoptez le playbook de diagnostic rapide et rendez-le réflexe.
  2. Choisissez une stratégie d’endpoint DB stable et testez le failover avec de vrais clients, pas seulement un changement DNS en théorie.
  3. Définissez des budgets de connexion explicites et des caps de pool par service. Faites-les appliquer.
  4. Alignez les timeouts entre app, driver, pooler, keepalive noyau et tout middlebox. Écrivez-le.
  5. Instrumentez les attentes : waits de verrous Postgres, pression thread MySQL, et retransmits réseau.

Faites cela, et la prochaine fois que quelqu’un dira « timeouts aléatoires », vous pourrez répondre avec le luxe inconfortable : « Super. Quelle couche ? »

← Précédent
Segfault en production : pourquoi un crash peut ruiner un trimestre
Suivant →
Analyse des vulnérabilités Docker : quoi croire et qu’est‑ce qui n’est que du bruit

Laisser un commentaire