Cómo hice un job de Sidekiq 6 veces más rápido con HTTP keep-alive
En 2017 trabajaba en un backend Rails con Sidekiq. Éramos uno de los equipos piloto de la API de Bing Ads. Nuestra sincronización era lenta por un motivo que no esperaba: abrir conexiones.
Un job era una cuenta de cliente. Cada job hacía unas 40 peticiones HTTPS a Microsoft, la mayoría para subir archivos XML grandes.
Una sincronización completa eran 3.000 jobs. O sea, 120.000 peticiones. Varias veces al día.
Bien en pruebas, atascado en producción
Probábamos con lotes de 10 a 20 jobs. Todo funcionaba.
Luego llegó producción. Bum. Los jobs se acumulaban en la cola y una sincronización completa no terminaba nunca.
Mis sospechosos:
- La concurrencia de Sidekiq bloqueando jobs.
- Una fuga de memoria. Pero ninguna alerta de memoria.
- Una petición reintentando sin parar y bloqueando a las demás.
Cronometré la generación del XML: 15 ms. Añadí HTTPLog: la llamada HTTP tardaba 44 ms. Nada lento.
El log que lo delató
No recuerdo por qué, pero activé todas las opciones 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
Y vi esto:
[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
¿Lo ves?
600 ms entre "Connecting" y "Sending". Antes de que un solo byte de la petición saliera de nuestro servidor. Los 44 ms que había medido no los incluían.
Qué pasa en esos 600 ms
Cada petición abría una conexión nueva. Con HTTPS, eso son dos handshakes antes de hacer nada útil:
- TCP: SYN, SYN-ACK. Un viaje de ida y vuelta.
- TLS 1.2, lo que todo el mundo usaba en 2017: certificado, intercambio de claves. Dos viajes de ida y vuelta más.
Después, la petición en sí: otro viaje de ida y vuelta.
Lo he vuelto a medir esta semana, con un servidor a 96 ms:
Abrir la conexión son 3 viajes de ida y vuelta. De un servidor en Europa a uno en la costa oeste de EE. UU., cada viaje tarda de 150 a 200 ms. Tres: de 450 a 600 ms. Y no controlábamos el otro lado. Era Microsoft.
Las cuentas
Teníamos 10 procesos de Sidekiq con 3 threads cada uno. 30 jobs a la vez.
Cada petición: 600 ms para abrir la conexión y unos 100 ms de trabajo real. 700 ms.
- Un job: 40 × 0,7 s = 28 s.
- Sincronización completa: 3.000 jobs / 30 threads = 100 jobs por thread. 100 × 28 s = 47 min.
El 86 % de ese tiempo, nuestros threads estaban esperando handshakes.
Y las pruebas no podían mostrarlo. 10 jobs en 30 threads es una sola ronda: 28 s. Lento, pero nada parecía roto.
La solución: mantener el socket abierto
Me pasé a Excon porque tiene una opción persistent:
connection = Excon.new("https://bulk.api.bingads.microsoft.com", persistent: true)
requests.each do |request|
connection.post(path: request.path, body: request.xml) # el mismo socket cada vez
end
Una conexión por job. 40 peticiones sobre ella. Un handshake en lugar de 40.
- Un job: 0,6 s + 40 × 0,1 s = 4,6 s en lugar de 28 s.
- Sincronización completa: 100 × 4,6 s = 7,7 min en lugar de 47.
6 veces más rápido. De 120.000 handshakes por sincronización a 3.000.
Y también hay que contar la ventaja del slow start en los XML grandes
Esas subidas de XML grandes tenían un segundo problema. Una conexión TCP nueva empieza despacio.
El emisor no conoce tu ancho de banda, así que empieza con unos 14 KB en vuelo y los duplica en cada viaje de ida y vuelta: 14, 28, 57, 114 KB... Una conexión nueva necesita varios viajes para mover un archivo grande. Una conexión caliente ya ha cogido velocidad.
Aquí tienes 1 MB llegando desde Singapur, viaje a viaje:
Y el mismo 1 MB desde distintas distancias:
Reutilizar la conexión también arregló esto. Ni siquiera lo sabía en ese momento.
Un detalle en Linux: la ventana vuelve a encogerse cuando la conexión se queda inactiva. En los servidores de Sidekiq, net.ipv4.tcp_slow_start_after_idle=0 la mantiene caliente.
¿Sigue siendo cierto en 2026?
Escribí un pequeño script en Python y medí 11 APIs de LLM (OpenAI, Anthropic, OpenRouter, Gemini, Mistral, Groq y más), además de algunos servidores lejanos. 20 peticiones seguidas: una conexión nueva cada vez, y luego una sola conexión.
Desde 2017 han cambiado dos cosas. Las APIs están detrás de un CDN a pocos ms de ti, y TLS 1.3 necesita un solo viaje de ida y vuelta en lugar de dos. Así que abrir una conexión con OpenAI o Anthropic cuesta unos 20 ms desde mi escritorio.
Cuando el servidor tarda más de 100 ms en responder, eso es entre el 7 % y el 26 % de cada petición. Cuando es rápido, el handshake es la mayor parte: OpenRouter pasa de 45 ms a 14 ms por petición.
¿Y un servidor lejano que sigue con TLS 1.2? 4,2× más rápido con una sola conexión. La misma historia que en 2017.
Lo que hago ahora
- Un cliente HTTP por thread, reutilizado. En Ruby:
Net::HTTP.startcon un bloque,net-http-persistent, HTTPX, o Excon conpersistent: true. En Python:requests.Sessionohttpx.Client. - Crea el cliente de OpenAI o Anthropic una sola vez. Guarda el pool de conexiones. Un cliente nuevo por petición lo tira a la basura.
- Registra el tiempo de conexión, no solo el de la petición. Ahí se escondían mis 600 ms.
- Prueba con volumen de producción. Con 10 jobs nunca lo verás.