Todos hemos vivido la misma situación.

Un proceso que tardaba 5 minutos empieza a tardar 10. Después 15. Un día alguien abre una incidencia porque un job nocturno no ha terminado a tiempo o porque un servicio empieza a responder más lento de lo habitual.

La reacción inicial suele ser abrir el código y buscar optimizaciones. Quizás un bucle mejor. Quizás una colección distinta. Quizás cambiar un stream por un for.

Sin embargo, tras años trabajando con sistemas backend en producción, mi experiencia es que los mayores problemas de rendimiento rara vez se solucionan con microoptimizaciones.

La mayoría de las veces el cuello de botella está en alguno de estos puntos:

  • CPU Throttling.
  • Consultas N+1.
  • Llamadas remotas innecesarias.
  • Procesos secuenciales que podrían ejecutarse en paralelo.
  • Mala utilización de los recursos disponibles.

En este artículo quiero compartir una metodología práctica para analizar y optimizar aplicaciones backend modernas ejecutándose en Kubernetes.


🔇 El enemigo silencioso: CPU Throttling

Cuando analizamos un servicio solemos empezar por algo parecido a esto:

MétricaValor
CPU Usage400m
CPU Limit1000m
MemoriaCorrecta

A simple vista parece que todo está bajo control. Si el pod consume 400m y tiene límite de 1000m, parece que hay margen suficiente.

Pero esta interpretación puede ser completamente errónea.

La CPU media oculta los picos de ejecución. Un proceso puede mostrar consumo medio de 400m con picos que superan los 1500m. Y precisamente en esos picos es donde Kubernetes empieza a intervenir.

Cuando un contenedor supera el límite configurado, el kernel Linux restringe temporalmente su ejecución. El proceso no falla, no genera excepciones, no aparecen errores en los logs. Simplemente deja de ejecutarse durante pequeños intervalos de tiempo. Desde la perspectiva de la aplicación ocurre algo así:

Trabajar → Esperar → Trabajar → Esperar → Trabajar → Esperar

Aunque la máquina física tenga CPU disponible. A este comportamiento se le conoce como CPU Throttling.

La métrica más importante para detectarlo es CPU Throttling %. Una regla práctica:

  • Menos del 5% → Normal.
  • Entre 5% y 10% → Conviene revisar.
  • Más del 20% → Posible problema.
  • Más del 50% → Muy probablemente está impactando en el rendimiento.

En muchas ocasiones encontramos aplicaciones con CPU aparentemente baja, memoria estable, sin errores... y latencias elevadas. El culpable termina siendo el throttling.

Una vez detectado, aumentar los límites puede ser una solución:

resources:
  limits:
    cpu: "1500m"

Pero aquí aparece una lección importante: eliminar el throttling no implica automáticamente que el proceso vaya a ser más rápido. Todo depende del tipo de carga que estamos ejecutando.


⚖️ CPU-Bound vs I/O-Bound

Antes de optimizar debemos entender dónde se está consumiendo realmente el tiempo.

CPU-Bound

El proceso emplea la mayor parte del tiempo realizando cálculos: compresión, transformaciones complejas, procesamiento de imágenes, cálculos estadísticos, enriquecimiento masivo de datos. La CPU es elevada, hay poca espera externa y, en estos casos, más CPU suele traducirse directamente en menor tiempo de ejecución.

I/O-Bound

El proceso pasa gran parte del tiempo esperando recursos externos: bases de datos, DynamoDB, Kafka, Redis, APIs externas, S3. La CPU es relativamente baja pero las latencias son elevadas porque el proceso está bloqueado esperando respuestas. En estos casos, duplicar la CPU normalmente aporta poco valor.


🔁 El patrón N+1 y las llamadas remotas

Uno de los problemas más frecuentes en sistemas backend es el patrón N+1. Supongamos el siguiente código:

List<Product> products = repository.findAll();
 
for (Product product : products) {
    detailsRepository.findByProductId(product.getId());
}

Si obtenemos 1000 productos: 1 consulta inicial + 1000 consultas adicionales = 1001 consultas.

Lo que podría haberse resuelto con una única consulta optimizada termina generando cientos o miles de viajes a la base de datos. Los síntomas son latencias elevadas, sobrecarga en base de datos, saturación de conexiones e incremento del tiempo total de ejecución.

En muchas ocasiones eliminar un N+1 aporta más rendimiento que duplicar la CPU disponible.

Más allá del N+1, cada llamada a PostgreSQL, MySQL, DynamoDB, Redis, Kafka o APIs externas tiene un coste. Por eso merece la pena preguntarse:

  • ¿Estoy consultando información repetidamente?
  • ¿Puedo agrupar peticiones?
  • ¿Puedo cachear resultados?
  • ¿Puedo reducir viajes de red?

Muy a menudo el verdadero cuello de botella no está dentro de la JVM sino fuera de ella.


⚡ Paralelización: aprovechando los cores disponibles

Una vez descartados problemas de infraestructura y acceso a datos, llega el momento de analizar la paralelización.

Si un proceso es CPU-Bound y cada elemento puede procesarse de forma independiente, probablemente estamos dejando rendimiento sobre la mesa. Con 100.000 productos procesados secuencialmente solo utilizamos un core, mientras la máquina tiene varios infrautilizados.

Desde Java 8 podemos paralelizar fácilmente con Parallel Streams:

products.parallelStream()
    .map(this::transform)
    .toList();

Internamente Java utiliza un ForkJoinPool que implementa la estrategia Divide and Conquer: divide el problema en tareas más pequeñas que se ejecutan en paralelo y combina los resultados al final.

Cuando la carga es realmente CPU-Bound los resultados pueden ser muy interesantes. Pero hay que tener cuidado con cuándo NO usarlo.

Un error muy habitual es paralelizar operaciones dominadas por I/O:

products.parallelStream()
    .map(product -> apiClient.getDetails(product.getId()))
    .toList();

Esto puede provocar saturación de APIs, timeouts, exceso de conexiones y problemas de rate limiting. El cuello de botella no es la CPU, es la latencia de la dependencia externa.

Hay además un detalle importante en contenedores. ForkJoinPool utiliza como referencia Runtime.getRuntime().availableProcessors(), por lo que los límites de CPU en Kubernetes influyen directamente en el paralelismo efectivo. Tiene poco sentido diseñar algoritmos altamente paralelos si luego limitamos el contenedor de forma agresiva.

Para aplicaciones grandes, en lugar del pool compartido, suele ser recomendable un ForkJoinPool dedicado:

ForkJoinPool pool = new ForkJoinPool(8);
 
List<Result> results = pool.submit(() ->
    products.parallelStream()
        .map(this::transform)
        .toList()
).join();

Esto permite controlar la concurrencia, aislar cargas de trabajo y evitar interferencias entre distintos procesos paralelos.


🗺️ Una metodología práctica para optimizar rendimiento

Cuando analizo un problema de performance suelo seguir siempre el mismo orden:

  1. Revisar CPU Throttling — ¿La infraestructura está limitando artificialmente la aplicación?
  2. Analizar tiempos históricos — ¿El rendimiento ha empeorado progresivamente?
  3. Revisar dependencias externas — ¿Dónde pasa realmente el tiempo? Base de datos, Redis, Kafka, APIs, S3.
  4. Buscar patrones N+1 — ¿Existe algún bucle que dispara llamadas remotas repetitivas?
  5. Identificar si es CPU-Bound o I/O-Bound — ¿Qué recurso limita realmente el sistema?
  6. Evaluar paralelización — ¿Es posible aprovechar múltiples cores?
  7. Medir nuevamente — Toda optimización es una hipótesis hasta que una métrica demuestra la mejora.

📊 Lo que más rendimiento suele aportar en producción

Mi experiencia personal es que el impacto suele seguir este orden:

OptimizaciónImpacto habitual
Eliminar N+1🔴 Muy alto
Reducir llamadas remotas🔴 Muy alto
Eliminar CPU Throttling🟠 Alto
Paralelizar procesos CPU-Bound🟠 Alto
Ajustar configuración JVM🟡 Medio
Microoptimizaciones de código🟢 Bajo
Cambios sintácticos⚪ Casi nulo

Por eso, antes de discutir si un stream es más eficiente que un for, merece la pena hacerse dos preguntas:

¿Estoy haciendo diez consultas cuando podría hacer una?

¿Estoy utilizando un único core cuando tengo cuatro disponibles?

La respuesta a esas preguntas suele aportar más rendimiento que cualquier refactorización menor.


Conclusión

La optimización de rendimiento no consiste en escribir código más rápido. Consiste en identificar correctamente el cuello de botella.

En muchos sistemas modernos los mayores problemas no son algorítmicos. Son operacionales.

CPU throttling. Consultas N+1. Dependencias lentas. Procesos secuenciales ejecutándose sobre máquinas con múltiples cores disponibles.

Por eso, antes de optimizar, mide. Antes de paralelizar, entiende. Y antes de modificar código, asegúrate de que el problema realmente está en el código.

Porque en producción, performar sí es gerundio.