~/blog / timeout-mysql-une-ligne
⚙️ craft & architecture · post-mortem de production

Le timeout MySQL à une ligne qui m'a pris trois jours à diagnostiquer

Luc Del Beato 11 juin 2026 11 min de lecture

Des HTTP 500 aléatoires à la création de factures. Seulement sous charge. Impossibles à reproduire en local. Pas de stack trace qui se ressemble d'une fois sur l'autre. Trois jours de lecture de logs de prod pour un fix qui tient en une ligne. Voici le post-mortem, parce que le fix était trivial, mais le diagnostic, c'était tout le métier.

TL;DR

Le symptôme : un 500 qui ne se ressemble jamais

Un CRM B2B pour des commerciaux qui closent des deals et émettent des factures. Un matin, le support remonte des erreurs « la facture ne s'est pas créée ». Pas systématiquement. Pas chez tout le monde. Et, le détail qui rend fou, impossible à reproduire en cliquant moi-même. Je crée dix factures à la suite : zéro erreur. Je rejoue le scénario exact d'un ticket : ça passe.

Côté serveur, des HTTP 500 par à-coups. Mais pas de pattern dans les stack traces : tantôt l'erreur pointait sur l'écriture en base, tantôt sur une opération réseau, tantôt sur rien d'identifiable. Un bug qui n'a pas de signature claire, c'est le pire des deux mondes : on ne sait même pas quoi chercher dans le code.

Un bug qui ne se reproduit qu'en prod et seulement sous charge n'est presque jamais « dans le code » au sens classique. Il est dans ce que la charge ajoute : la concurrence, les timeouts, la contention de ressources.

L'enquête : corréler plutôt que deviner

Première règle quand on ne peut pas reproduire : arrêter d'essayer de reproduire, et aller lire ce que la prod, elle, a vu. J'ai passé deux jours dans les logs applicatifs et les breadcrumbs Sentry du flux de facturation. Pas pour trouver « l'erreur », je l'avais déjà, c'était un 500, mais pour trouver ce qui était différent les fois où ça plantait.

La piste est venue d'une corrélation temporelle. En alignant l'horodatage des 500 avec les logs métier, un motif est apparu : les erreurs tombaient quand deux agents facturaient le même deal quasi simultanément. Un cas rare en démo, fréquent en vrai : deux personnes d'une même équipe qui cliquent à quelques secondes d'intervalle sur le même dossier en fin de journée.

Et le second indice, décisif : les 500 ne tombaient jamais tout de suite. Ils arrivaient environ 50 secondes après le début de la requête concurrente. Un chiffre rond, reproductible. Quand un délai d'erreur est ce stable, ce n'est pas du hasard réseau, c'est un timeout configuré qui se déclenche.

🔍
Le réflexe qui débloque : un délai d'échec constant (ici ~50s) est une signature. Les pannes aléatoires varient ; les timeouts, eux, frappent toujours à la même seconde. Dès qu'un nombre rond revient, cherchez quelle valeur de configuration vaut exactement ça.

La cause racine : une transaction trop longue contre un timeout trop court

La création d'une facture, ce n'est pas une simple insertion. C'est une transaction multi-étapes : générer le PDF, l'uploader sur Firebase Storage, puis écrire la ligne en MySQL. Le tout dans une seule transaction pour garantir la cohérence : pas de facture en base sans PDF accessible.

Or la génération du PDF et surtout l'upload vers Firebase prennent du temps. En conditions normales, quelques secondes. Mais sous charge, avec un Firebase qui ralentit et un PDF lourd, ça pouvait grimper à 70 à 120 secondes. Pendant tout ce temps, la transaction tient un verrou de ligne sur le deal facturé.

Maintenant, le piège. MySQL/InnoDB a une valeur par défaut pour innodb_lock_wait_timeout : 50 secondes. C'est le temps qu'une transaction accepte d'attendre un verrou détenu par une autre avant d'abandonner. Faites le calcul : si une première transaction tient son verrou pendant 90s, et qu'une seconde arrive pour facturer le même deal, la seconde attend… et au bout de 50s, MySQL la tue avec Lock wait timeout exceeded; try restarting transaction. Côté API, ça remonte en 500.

  t=0s    Agent A  ── BEGIN ──▶ lock(deal#42) ─▶ [ génère PDF + upload Firebase ... lent ... ]
                                  │
  t=8s    Agent B  ── BEGIN ──▶ WAIT lock(deal#42)  ⏳
                                  │ (attend que A libère)
  t=58s            Agent B  ✗ Lock wait timeout exceeded (50s)  ──▶ HTTP 500
                                  │
  t=95s   Agent A  ── COMMIT ─▶ (trop tard, B a déjà échoué)

Tout collait. « Seulement sous charge » → il faut deux requêtes concurrentes sur le même deal. « Impossible à reproduire seul » → un seul utilisateur ne crée jamais la collision. « Pas de stack trace stable » → selon le timing, l'erreur surgissait à des endroits différents du flux. Et le délai rond de ~50s était la signature du timeout InnoDB par défaut.

Le fix : une ligne, et de la lumière pour la prochaine fois

Le correctif immédiat tient en une instruction, posée à l'initialisation de chaque connexion du pool. On laisse aux transactions légitimement longues le temps de finir avant que la concurrente n'abandonne :

// init du pool de connexions, exécuté à chaque nouvelle connexion
pool.on('connection', (conn) => {
  // défaut MySQL = 50s, plus court que notre pire transaction de facture (~120s)
  conn.query('SET SESSION innodb_lock_wait_timeout = 120');
});

// + rendre le problème VISIBLE la prochaine fois
Sentry.addBreadcrumb({ category: 'invoice', message: 'tx:start lock(deal)', level: 'info' });
// ... génération PDF + upload Firebase ...
Sentry.addBreadcrumb({ category: 'invoice', message: 'tx:commit', level: 'info' });

Les breadcrumbs Sentry autour du verrou ne corrigent rien, mais ils transforment le prochain incident : au lieu de trois jours à corréler des horodatages à la main, on verra noir sur blanc « tx:start » sans « tx:commit » correspondant, avec la durée. Le diagnostic passe de jours à minutes. C'est un investissement, pas du décor.

⚠️
Pourquoi c'est le bon fix, et ses limites : relever le timeout est correct parce que ces transactions sont légitimement longues ; on ne masque pas un deadlock, on cesse d'abandonner trop tôt. Mais ce n'est pas la fin de l'histoire : ça achète du temps, ça ne réduit pas la fenêtre de verrou. Sous une charge encore plus forte, 120s sera trop court à son tour.

Le fix profond : sortir le lent hors du verrou

La vraie correction structurelle ne touche pas au timeout, elle touche à la durée du verrou. Le PDF et l'upload Firebase n'ont aucune raison de se faire pendant que la ligne est verrouillée. On inverse l'ordre : faire tout le travail lent hors transaction (générer le PDF, l'uploader, récupérer l'URL), puis ouvrir une transaction courte qui ne fait qu'une écriture finale. Le verrou passe de 90 secondes à quelques millisecondes.

Et il y a un corollaire indispensable : si le travail lent se fait avant l'écriture, il faut rendre la création de facture idempotente. Sinon un agent qui réessaie après un échec risque de créer un doublon, ou de ré-uploader un PDF orphelin. Une clé d'idempotence sur le couple (deal, agent) rend le retry sûr, c'est exactement le genre de garantie que je détaille dans mon article sur l'idempotence des agents IA. Sous charge, « réessayer sans danger » n'est pas un luxe, c'est la condition pour que le système se rattrape tout seul.

💡
L'ordre des priorités en incident : d'abord le fix d'une ligne pour stopper l'hémorragie en prod (déployable en minutes), ensuite le refactor de la transaction pour traiter la cause, et enfin l'idempotence pour que les futurs aléas se résorbent sans intervention. Trois niveaux : panser, soigner, immuniser.

Ce que je retiens

Ce post-mortem est la version technique d'une histoire que je raconte aussi côté état d'esprit : la même chasse au bug fantôme, vue comme une discipline mentale, est dans mon article sur le debugging façon hardware vintage. Ici on a démonté le moteur ligne par ligne ; là-bas on parle de la patience et de la méthode qui rendent ce démontage possible. Lecture complémentaire, pas redite.

// contact

Un bug de prod impossible à reproduire ?

C'est mon terrain de jeu. Les 500 aléatoires sous charge, les timeouts qui ne se déclenchent qu'à 3h du matin, les transactions qui se marchent dessus, j'adore les démonter. Parlons-en.

Me contacter
L
Luc Del Beato

Senior Lead Engineer, ~20 ans de web. Do-er passionné de résolution de problèmes, de belle architecture et d'automatisation ; les agents IA, c'est ma direction. Mon parcours →