Por qué tu servicio empeora con el tiempo
El servicio arranca bien y empeora a lo largo de los días. Tres causas explican casi todos los casos, y las tres tienen señales distintas.
El patrón es conocido: el servicio arranca bien después del deploy. El segundo día va un poco más lento. El cuarto, alguien lo reinicia. Y el ciclo vuelve a empezar.
"Reiniciar lo arregla" es un diagnóstico, no una solución, y apunta a tres causas posibles.
Causa 1: fuga
Una fuga es retener un recurso que ya no necesitas. Hay tres tipos, y todos tienen el mismo síntoma temporal.
Fuga de memoria. Mantienes referencias a objetos que deberían haber muerto. En un lenguaje con
recolector de basura, eso no es un free olvidado: es una referencia viva.
El campeón absoluto: caché sin límite. Un Map que solo crece, poblado en cada petición, sin
política de expulsión. Parece una caché; es una fuga con nombre bonito.
Segundo lugar: listeners y callbacks registrados y nunca eliminados. Tercero: ThreadLocal en un pool de hilos, donde el hilo vuelve al pool cargando el valor.
Fuga de hilos. Creas hilos o executors y nunca los cierras. El número de hilos crece monotónicamente. Síntoma final: fallo al crear un hilo, o lentitud por cambio de contexto excesivo.
Fuga de descriptores. Archivo, socket o conexión abierto y no cerrado, normalmente en un camino de error que no pasa por el bloque de cierre. Síntoma final: "too many open files".
Cómo distinguirlas: sigue tres números a lo largo de un día: memoria residente, número de hilos, número de descriptores. El que crece y nunca baja es tu fuga.
ps -o rss= -p <pid> # memoria residente
ls /proc/<pid>/task | wc -l # hilos
ls /proc/<pid>/fd | wc -l # descriptoresCausa 2: contención
La contención son hilos peleándose por el mismo recurso protegido.
La señal es característica y fácil de reconocer: añades CPU y el rendimiento no sube, o baja.
Eso pasa porque el trabajo útil está serializado en un punto. Más hilos significa más gente en la cola del mismo lock, más cambios de contexto, y más tiempo gastado coordinando que trabajando.
La Ley Universal de Escalabilidad lo formaliza: además de la parte secuencial de la Ley de Amdahl, existe un coste de coordinación que crece con el cuadrado del número de participantes. En algún punto, añadir capacidad empeora el rendimiento.
Cómo medirla: no mires solo el tiempo de CPU. Mira el tiempo gastado esperando locks. La mayoría de los profilers modernos tiene un modo para eso; en Java, es el profiler de contención de monitores.
Y el false sharing, que es contención invisible: dos variables independientes caen en la misma línea de caché de 64 bytes. Hilos en núcleos distintos tocan variables distintas, pero el procesador invalida la línea entera cada vez.
Pagas sincronización sin haber escrito ningún lock. La corrección es padding: rellenar con espacio para separar las variables en líneas distintas. Es un problema raro en código de aplicación y común en estructuras de datos de alto rendimiento.
Causa 3: recolector de basura
El recolector generacional parte de una observación empírica: la mayoría de los objetos muere joven.
Entonces divide la memoria en generación nueva y vieja. Recolecta la nueva con frecuencia, y es barato, porque la mayor parte ya murió y solo hay que copiar lo que sobrevive. El objeto que sobrevive a varias recolecciones se promueve a la generación vieja, recolectada rara vez.
Lo que duele es la pausa. Los recolectores modernos (G1, ZGC, Shenandoah) hacen casi todo el trabajo en paralelo con la aplicación, y las pausas bajaron al orden del milisegundo.
Pero siguen existiendo, y siguen apareciendo en tu p99 como picos periódicos sin causa aparente en el gráfico de tráfico.
Qué hacer, en orden de eficacia
1. Asigna menos. La mayor parte del problema de GC es basura innecesaria creada en un bucle caliente. Concatenación de strings dentro del bucle, objetos intermedios en un stream, boxing de primitivos. Un profiler de asignación lo muestra en minutos.
2. Dimensiona el heap con holgura. Un heap apretado recolecta todo el tiempo. Un heap demasiado grande aumenta la duración de cada recolección completa. Existe un punto óptimo, y se encuentra midiendo.
3. Elige el recolector por el objetivo. Rendimiento o latencia: los recolectores están optimizados para uno o para el otro, y el que viene por defecto puede no ser el que quieres.
4. Monitoriza el tiempo total en pausa, no el número de recolecciones. Cien recolecciones de un milisegundo es mejor que dos de medio segundo.
- Fugacrece con el tiempoDegradación a lo largo de horas o días. Reiniciar lo arregla. Memoria, hilos o descriptores creciendo y nunca bajando.
- Contencióncrece con la cargaEmpeora en el pico, mejora en el valle. Añadir CPU no ayuda, y a veces empeora.
- Recolector de basurapicos periódicosSaltos en el p99 sin correlación con el tráfico. El p50 sigue óptimo. Aparece en el log de GC.
Cómo distinguir las tres
Tienen firmas distintas, y eso es lo que hace rápido el diagnóstico:
Fuga: degradación monotónica a lo largo de horas o días. Reiniciar lo arregla. Memoria, hilos o descriptores creciendo sin bajar.
Contención: degradación correlacionada con la concurrencia, no con el tiempo. Empeora en el pico, mejora en el valle. Añadir CPU no ayuda.
GC: picos periódicos en el p99, sin correlación con el tráfico. El p50 queda óptimo. Aparece en los logs de GC.
Si no sabes cuál es, empieza por los tres contadores de la fuga, que es lo más barato de descartar.
El caso más común de todos
En mi experiencia, el campeón es la caché sin límite.
Alguien añade un Map estático para evitar una consulta repetida. Funciona maravillosamente. Nadie le
pone límite de tamaño ni expiración, porque "son pocas claves".
Seis meses después, las claves incluyen el identificador de la petición, y el mapa tiene diez millones de entradas.
La corrección es trivial: usar una estructura con límite y política de expulsión, tipo Caffeine en Java o un LRU simple. La dificultad es encontrarla, y la forma de encontrarla es un dump de heap comparando dos momentos.
El hábito que lo previene
Una práctica barata que evita la mayor parte de esto: prueba de resistencia.
Ejecuta la carga normal del sistema durante algunas horas (no la de pico, la normal) y sigue memoria, hilos y descriptores.
Si alguno de los tres crece monotónicamente a lo largo de cuatro horas, tienes una fuga, y acabas de descubrirla antes de producción.
Es el tipo de prueba que casi nadie ejecuta y que se paga sola la primera vez que pilla algo.
Lee esto después
- IngenieríaPaso 17Las vulnerabilidades que aparecen de verdadLa mayoría de las filtraciones que salen en las noticias no usa técnicas sofisticadas. Usa uno de estos seis fallos, y los seis tienen corrección conocida y barata.Leer artículo
- IngenieríaPaso 16Diagnosticar la red en diez minutosUn guion de seis comandos que convierte "debe de ser la red" en "es la capa X, en el tramo Y".Leer artículo
- IngenieríaPaso 14Event loop o hilo por petición: cómo elegirLa elección entre los dos modelos no es de gusto ni de moda. La determina el perfil de tu carga, y hay una cuenta que la decide.Leer artículo