Comment j'ai rendu un job Sidekiq 6x plus rapide avec le keep-alive HTTP

En 2017, je travaillais sur un backend Rails avec Sidekiq. Nous faisions partie des équipes pilotes de l'API Bing Ads. Notre synchro était lente pour une raison que je n'attendais pas : l'ouverture des connexions.

Un job, c'était un compte client. Chaque job faisait environ 40 requêtes HTTPS vers Microsoft, la plupart pour envoyer de gros fichiers XML.

Une synchro complète, c'était 3 000 jobs. Donc 120 000 requêtes. Plusieurs fois par jour.

OK en test, bloqué en production

On testait avec des lots de 10 à 20 jobs. Tout marchait.

Puis la production. Boum. Les jobs s'empilaient dans la file et une synchro complète n'en finissait plus.

Mes suspects :

  • La concurrence de Sidekiq qui bloque des jobs.
  • Une fuite mémoire. Mais aucune alerte mémoire.
  • Une requête qui réessaie en boucle et bloque les autres.

J'ai chronométré la génération du XML : 15 ms. J'ai ajouté HTTPLog : l'appel HTTP prenait 44 ms. Rien de lent.

Le log qui a tout révélé

Je ne sais plus pourquoi, mais j'ai activé toutes les options de HTTPLog :

HttpLog.configure do |config|
  config.log_connect   = true
  config.log_request   = true
  config.log_data      = true
  config.log_status    = true
  config.log_response  = true
  config.log_benchmark = true
end

Et j'ai vu ça :

[2017-10-09-10:34:33.15] [httplog] Connecting: bulk.api.bingads.microsoft.com:443
[2017-10-09-10:34:33.75] [httplog] Sending: POST https://bulk.api.bingads.microsoft.com/...
[2017-10-09-10:34:33.77] [httplog] Status: 200

Vous l'avez vu ?

600 ms entre « Connecting » et « Sending ». Avant qu'un seul octet de la requête ne quitte notre serveur. Les 44 ms mesurées avant ne les comptaient pas.

Ce qui se passe pendant ces 600 ms

Chaque requête ouvrait une nouvelle connexion. En HTTPS, ça veut dire deux handshakes avant de faire quoi que ce soit d'utile :

  • TCP : SYN, SYN-ACK. Un aller-retour.
  • TLS 1.2, ce que tout le monde utilisait en 2017 : certificat, échange de clés. Deux allers-retours de plus.

Ensuite, la requête elle-même : encore un aller-retour.

Je l'ai remesuré cette semaine, sur un serveur à 96 ms :

Moins de temps, c'est mieuxHandshakes TCP + TLSLa requête elle-même
0100200300400msNouvelle connexion (TLS 1.2)votre serveurserveur APISYNSYN-ACKClientHellocertificat, clééchange de clés, FinishedFinishedPOSTréponse403 msConnexion déjà ouvertevotre serveurserveur APIPOSTréponse99 ms304 msavant le POST
Mesuré cette semaine sur un serveur à 96 ms. Le temps descend. Chaque flèche est un trajet sur le réseau.

Ouvrir la connexion, c'est 3 allers-retours. D'un serveur en Europe vers un serveur sur la côte ouest des États-Unis, un aller-retour prend 150 à 200 ms. Trois : 450 à 600 ms. Et on ne contrôlait pas l'autre côté. C'était Microsoft.

Le calcul

On avait 10 processus Sidekiq avec 3 threads chacun. 30 jobs en même temps.

Chaque requête : 600 ms pour ouvrir la connexion, environ 100 ms de vrai travail. 700 ms.

  • Un job : 40 × 0,7 s = 28 s.
  • Synchro complète : 3 000 jobs / 30 threads = 100 jobs par thread. 100 × 28 s = 47 min.
Plus court, c'est mieuxOuverture des connexionsVrai travail
file d'attente3 000 jobsprocessus 1processus 1, thread 1 : un jobjobprocessus 1, thread 2 : un jobjobprocessus 1, thread 3 : un jobjobprocessus 2processus 2, thread 1 : un jobjobprocessus 2, thread 2 : un jobjobprocessus 2, thread 3 : un jobjobprocessus 3processus 3, thread 1 : un jobjobprocessus 3, thread 2 : un jobjobprocessus 3, thread 3 : un jobjobprocessus 4processus 4, thread 1 : un jobjobprocessus 4, thread 2 : un jobjobprocessus 4, thread 3 : un jobjobprocessus 5processus 5, thread 1 : un jobjobprocessus 5, thread 2 : un jobjobprocessus 5, thread 3 : un jobjobprocessus 6processus 6, thread 1 : un jobjobprocessus 6, thread 2 : un jobjobprocessus 6, thread 3 : un jobjobprocessus 7processus 7, thread 1 : un jobjobprocessus 7, thread 2 : un jobjobprocessus 7, thread 3 : un jobjobprocessus 8processus 8, thread 1 : un jobjobprocessus 8, thread 2 : un jobjobprocessus 8, thread 3 : un jobjobprocessus 9processus 9, thread 1 : un jobjobprocessus 9, thread 2 : un jobjobprocessus 9, thread 3 : un jobjobprocessus 10processus 10, thread 1 : un jobjobprocessus 10, thread 2 : un jobjobprocessus 10, thread 3 : un jobjob30 jobs en même temps. Chaque thread enchaîne 100 jobs.Synchro complète, 3 000 jobs sur 30 threads0 min10 min20 min30 min40 min50 minConnexion par requêteOuverture des connexions : 40 minVrai travail : 6,7 min46,7 minConnexion par jobOuverture des connexions : 1 minVrai travail : 6,7 min7,7 min

86 % de ce temps, nos threads attendaient des handshakes.

Et les tests ne pouvaient pas le montrer. 10 jobs sur 30 threads, c'est un seul tour : 28 s. Lent, mais rien ne semblait cassé.

La solution : garder la socket ouverte

Je suis passé à Excon parce qu'il a une option persistent :

connection = Excon.new("https://bulk.api.bingads.microsoft.com", persistent: true)

requests.each do |request|
  connection.post(path: request.path, body: request.xml) # même socket à chaque fois
end

Une connexion par job. 40 requêtes dessus. Un handshake au lieu de 40.

Plus court, c'est mieuxOuverture de la connexion (TCP + TLS) : 600 msRequête : 100 ms
0 s5 s10 s15 s20 s25 s30 sAvant : une nouvelle connexion pour chaque requêteRequête 1 : 600 ms pour ouvrir la connexionRequête 1 : 100 ms de vrai travailRequête 2 : 600 ms pour ouvrir la connexionRequête 2 : 100 ms de vrai travailRequête 3 : 600 ms pour ouvrir la connexionRequête 3 : 100 ms de vrai travailRequête 4 : 600 ms pour ouvrir la connexionRequête 4 : 100 ms de vrai travailRequête 5 : 600 ms pour ouvrir la connexionRequête 5 : 100 ms de vrai travailRequête 6 : 600 ms pour ouvrir la connexionRequête 6 : 100 ms de vrai travailRequête 7 : 600 ms pour ouvrir la connexionRequête 7 : 100 ms de vrai travailRequête 8 : 600 ms pour ouvrir la connexionRequête 8 : 100 ms de vrai travailRequête 9 : 600 ms pour ouvrir la connexionRequête 9 : 100 ms de vrai travailRequête 10 : 600 ms pour ouvrir la connexionRequête 10 : 100 ms de vrai travailRequête 11 : 600 ms pour ouvrir la connexionRequête 11 : 100 ms de vrai travailRequête 12 : 600 ms pour ouvrir la connexionRequête 12 : 100 ms de vrai travailRequête 13 : 600 ms pour ouvrir la connexionRequête 13 : 100 ms de vrai travailRequête 14 : 600 ms pour ouvrir la connexionRequête 14 : 100 ms de vrai travailRequête 15 : 600 ms pour ouvrir la connexionRequête 15 : 100 ms de vrai travailRequête 16 : 600 ms pour ouvrir la connexionRequête 16 : 100 ms de vrai travailRequête 17 : 600 ms pour ouvrir la connexionRequête 17 : 100 ms de vrai travailRequête 18 : 600 ms pour ouvrir la connexionRequête 18 : 100 ms de vrai travailRequête 19 : 600 ms pour ouvrir la connexionRequête 19 : 100 ms de vrai travailRequête 20 : 600 ms pour ouvrir la connexionRequête 20 : 100 ms de vrai travailRequête 21 : 600 ms pour ouvrir la connexionRequête 21 : 100 ms de vrai travailRequête 22 : 600 ms pour ouvrir la connexionRequête 22 : 100 ms de vrai travailRequête 23 : 600 ms pour ouvrir la connexionRequête 23 : 100 ms de vrai travailRequête 24 : 600 ms pour ouvrir la connexionRequête 24 : 100 ms de vrai travailRequête 25 : 600 ms pour ouvrir la connexionRequête 25 : 100 ms de vrai travailRequête 26 : 600 ms pour ouvrir la connexionRequête 26 : 100 ms de vrai travailRequête 27 : 600 ms pour ouvrir la connexionRequête 27 : 100 ms de vrai travailRequête 28 : 600 ms pour ouvrir la connexionRequête 28 : 100 ms de vrai travailRequête 29 : 600 ms pour ouvrir la connexionRequête 29 : 100 ms de vrai travailRequête 30 : 600 ms pour ouvrir la connexionRequête 30 : 100 ms de vrai travailRequête 31 : 600 ms pour ouvrir la connexionRequête 31 : 100 ms de vrai travailRequête 32 : 600 ms pour ouvrir la connexionRequête 32 : 100 ms de vrai travailRequête 33 : 600 ms pour ouvrir la connexionRequête 33 : 100 ms de vrai travailRequête 34 : 600 ms pour ouvrir la connexionRequête 34 : 100 ms de vrai travailRequête 35 : 600 ms pour ouvrir la connexionRequête 35 : 100 ms de vrai travailRequête 36 : 600 ms pour ouvrir la connexionRequête 36 : 100 ms de vrai travailRequête 37 : 600 ms pour ouvrir la connexionRequête 37 : 100 ms de vrai travailRequête 38 : 600 ms pour ouvrir la connexionRequête 38 : 100 ms de vrai travailRequête 39 : 600 ms pour ouvrir la connexionRequête 39 : 100 ms de vrai travailRequête 40 : 600 ms pour ouvrir la connexionRequête 40 : 100 ms de vrai travail28 sAprès : une connexion par job600 ms pour ouvrir la connexion, une seule fois40 requêtes × 100 ms de vrai travail4,6 s
Un job, 40 requêtes, à l'échelle. Survolez un bloc pour voir sa durée.
  • Un job : 0,6 s + 40 × 0,1 s = 4,6 s au lieu de 28 s.
  • Synchro complète : 100 × 4,6 s = 7,7 min au lieu de 47.

6x plus rapide. De 120 000 handshakes par synchro à 3 000.

Et il faut aussi compter le gain du slow start sur les gros XML

Ces gros envois de XML avaient un deuxième problème. Une nouvelle connexion TCP démarre lentement.

L'émetteur ne connaît pas votre bande passante, alors il démarre avec environ 14 Ko en vol et double à chaque aller-retour : 14, 28, 57, 114 Ko... Une connexion neuve a besoin de plusieurs allers-retours pour transférer un gros fichier. Une connexion chaude a déjà pris de la vitesse.

Voici 1 Mo qui arrive de Singapour, aller-retour par aller-retour :

Plus de Ko par aller-retour, c'est mieuxConnexion neuve : 5 allers-retoursConnexion chaude : 1 aller-retour
Ko02505007501 000Aller-retour 1, connexion neuve : 82 Ko82Aller-retour 1, connexion chaude : 1 000 Ko1 000aller-retour 1Aller-retour 2, connexion neuve : 82 Ko82aller-retour 2Aller-retour 3, connexion neuve : 147 Ko147aller-retour 3Aller-retour 4, connexion neuve : 262 Ko262aller-retour 4Aller-retour 5, connexion neuve : 427 Ko427aller-retour 5
1 Mo depuis Singapour, 290 ms par aller-retour, mesuré le 30 septembre 2026. Ko reçus à chaque aller-retour après le premier octet. Chaque aller-retour en plus coûte encore 290 ms.

Et le même 1 Mo depuis différentes distances :

Plus court, c'est mieuxConnexion neuveConnexion chaude
05001 0001 5002 0002 500à 4 msà 4 ms: connexion neuve : 57 ms57 msà 4 ms: connexion chaude : 43 ms43 msà 49 ms (Allemagne)à 49 ms (Allemagne): connexion neuve : 329 ms329 msà 49 ms (Allemagne): connexion chaude : 147 ms147 ms · 2,2× plus rapideà 106 ms (côte est US)à 106 ms (côte est US): connexion neuve : 734 ms734 msà 106 ms (côte est US): connexion chaude : 114 ms114 ms · 6,5× plus rapideà 290 ms (Singapour)à 290 ms (Singapour): connexion neuve : 2 027 ms2 027 msà 290 ms (Singapour): connexion chaude : 297 ms297 ms · 6,8× plus rapide
Téléchargement de 1 Mo après le handshake, mesuré le 30 septembre 2026. Même bande passante. La connexion neuve a besoin de plus d'allers-retours pour prendre de la vitesse.

Réutiliser la connexion a aussi réglé ça. Je ne le savais même pas à l'époque.

Un piège sous Linux : la fenêtre rétrécit à nouveau quand la connexion reste inactive. Sur les serveurs Sidekiq, net.ipv4.tcp_slow_start_after_idle=0 la garde chaude.

Est-ce toujours vrai en 2026 ?

J'ai écrit un petit script Python et mesuré 11 API de LLM (OpenAI, Anthropic, OpenRouter, Gemini, Mistral, Groq et d'autres), plus quelques serveurs lointains. 20 requêtes à la suite : une nouvelle connexion à chaque fois, puis une seule connexion.

Plus court, c'est mieuxNouvelle connexion à chaque requêteUne seule connexion pour les 20
0 s2,5 s5 s7,5 s10 s12,5 sServeur à 107 ms, TLS 1.2Serveur à 107 ms, TLS 1.2: nouvelle connexion à chaque fois : 10,8 s10,8 sServeur à 107 ms, TLS 1.2: une seule connexion : 2,6 s2,6 s · 4,2× plus rapideServeur à 162 ms, TLS 1.3Serveur à 162 ms, TLS 1.3: nouvelle connexion à chaque fois : 12,4 s12,4 sServeur à 162 ms, TLS 1.3: une seule connexion : 5,8 s5,8 s · 2,1× plus rapideOpenRouterOpenRouter: nouvelle connexion à chaque fois : 0,9 s0,9 sOpenRouter: une seule connexion : 0,3 s0,3 s · 3,2× plus rapideFireworksFireworks: nouvelle connexion à chaque fois : 1,8 s1,8 sFireworks: une seule connexion : 0,6 s0,6 s · 3,1× plus rapideAnthropicAnthropic: nouvelle connexion à chaque fois : 2,9 s2,9 sAnthropic: une seule connexion : 2,5 s2,5 s · 1,1× plus rapideOpenAIOpenAI: nouvelle connexion à chaque fois : 4,0 s4,0 sOpenAI: une seule connexion : 3,7 s3,7 s · 1,1× plus rapide
20 requêtes à la suite, mesurées le 30 septembre 2026 depuis mon Mac, en HTTPS. Les appels aux API sont sans clé : c'est du temps réseau, pas du temps de modèle.

Deux choses ont changé depuis 2017. Les API sont derrière un CDN à quelques ms de vous, et TLS 1.3 n'a besoin que d'un aller-retour au lieu de deux. Ouvrir une connexion vers OpenAI ou Anthropic coûte donc environ 20 ms depuis mon bureau.

Quand le serveur met plus de 100 ms à répondre, ça représente 7 % à 26 % de chaque requête. Quand il est rapide, le handshake en est la plus grosse partie : OpenRouter passe de 45 ms à 14 ms par requête.

Et un serveur lointain, encore en TLS 1.2 ? 4,2× plus rapide avec une seule connexion. Même histoire qu'en 2017.

Ce que je fais maintenant

  • Un client HTTP par thread, réutilisé. En Ruby : Net::HTTP.start avec un bloc, net-http-persistent, HTTPX, ou Excon avec persistent: true. En Python : requests.Session ou httpx.Client.
  • Créez le client OpenAI ou Anthropic une seule fois. Il contient le pool de connexions. Un nouveau client par requête le jette à la poubelle.
  • Loggez le temps de connexion, pas seulement celui de la requête. C'est là que mes 600 ms se cachaient.
  • Testez avec le volume de production. 10 jobs ne vous montreront jamais ça.