Una API se vuelve lenta y la reunión ya se llena de soluciones antes de ganar una medición. Aparecen más CPU y RAM, caché, cola, microservicios y refactorización. A veces alguien propone hasta cambiar el lenguaje. Raramente la primera sugerencia es abrir el plan de ejecución.
Por eso, empiezo la investigación cerca de los datos. No porque la base sea la culpable por defecto, sino porque mucho pasa por ahí y esa verificación suele dar señal rápida. Si la consulta está saludable, saco la base del frente y sigo el flujo de la petición.
Soluciones rápidas también esconden trabajo caro
Caché y cola resuelven problemas reales. Más máquina también puede ser la decisión correcta. Cuando entran por reflejo, sin embargo, esos recursos pueden solo cambiar el tamaño de la cuenta mientras nadie sabe qué tramo de la petición está reteniendo la respuesta.
Subir CPU reduce la disputa por recurso, la caché saca algunas peticiones del camino y la cola absorbe un pico. Mientras tanto, una consulta que hace un trabajo enorme para devolver casi nada sigue cara en cada ejecución. Lo mismo vale para un loop que dispara una llamada por ítem.
La latencia puede caer por un tiempo, la alerta deja de sonar y el equipo respira. Cuando la carga crece, la operación cara reaparece, ahora acompañada de una infraestructura mayor.
La pregunta que debe venir antes de la discusión de arquitectura: ¿cuánto trabajo está produciendo esta petición para entregar el resultado?
Casi duplicamos la máquina y el dashboard siguió lento
Fue lo que pasó en un dashboard en el que trabajé. Cargaba de forma síncrona, y una única consulta hacía todo a la vez: buscaba los datos, cruzaba varias informaciones y calculaba los valores exhibidos. Como la CPU de la base quedaba muy alta, casi duplicamos CPU y RAM.
La base quedó más grande. El dashboard siguió pasando del minuto para cargar. La misma consulta aún concentraba todo el trabajo pesado en una única ejecución.
Fue cuando abrimos el plan de ejecución. La pantalla devolvía pocos indicadores, pero la consulta atravesaba relaciones, formaba un volumen intermedio grande y gastaba CPU en agregaciones antes de llegar a ellos. La respuesta final era pequeña. El trabajo para producirla, enorme.
Duplicar CPU y RAM había dado más aire a la base, pero la búsqueda seguía obligándola a hacer todo a la vez. El plan mostró que la investigación necesitaba entrar en el camino recorrido por la consulta.
Lo que necesito ver antes de tocar la base
Para que la base se vuelva sospechosa de verdad, quiero el plan de ejecución y las métricas apuntando en esa dirección. Tiempo de la consulta, lecturas y cardinalidad suelen confirmar o eliminar una hipótesis en pocos minutos.
Si esas medidas están saludables, saco la base del frente. Ya tomé API lenta con la consulta respondiendo dentro de lo esperado, mientras el retraso estaba en el procesamiento de la aplicación y en llamadas remotas hechas de forma secuencial. A partir de ahí, seguir buscando un defecto en la base sería solo insistir en la capa equivocada.
Antes de profundizar, paso un radar corto por la consulta y por el código. Una query para cargar la lista seguida de otra para cada registro pide un conteo de consultas. Ese N+1 aparece con frecuencia cuando el ORM deja las relaciones para que el backend las busque una a una.
Un JOIN que multiplica filas antes de la agregación pide la medición del volumen intermedio. Índice ausente pide plan. Un filtro que aplica una función sobre la columna indexada también.
En el código, busco llamadas remotas o consultas con await dentro de for. Cada espera puede parecer pequeña aisladamente y aun así dominar el tiempo total cuando todas ejecutan en fila.
Ninguna de estas señales cierra el diagnóstico. Un N+1 en una ruta con dos ítems puede tener impacto irrelevante, un índice nuevo puede ayudar poco en una columna con baja selectividad y un join voluminoso quizás sea necesario para producir el resultado. Por eso, cuento las consultas, cronometro el loop entero y reviso en el plan cuántas filas y lecturas se produjeron.
Ese radar solo elige la primera medición. La próxima capa de la investigación viene de la cuenta.
Una línea correcta puede desperdiciar el índice
Una de esas líneas suele pasar desapercibida en la revisión. Devuelve los registros de un día entero.
WHERE CAST(campo_data_hora AS date) = @data
El resultado de la pantalla parece correcto. En el plan, la historia puede ser otra. Aplicar CAST a la columna obliga a la consulta a transformar los valores antes de la comparación. Con un índice en campo_data_hora, esto puede impedir una búsqueda directa por el intervalo, aumentar bastante las lecturas e incluso llevar a un scan.
Cuando el pedido es por los registros de un día entero, calculo los bordes fuera de la columna.
WHERE campo_data_hora >= @inicio_do_dia
AND campo_data_hora < @inicio_do_proximo_dia
Si el inicio es el 16 de julio a la medianoche, el límite siguiente es el 17 de julio a la medianoche. Así entran todos los valores del día 16, incluso aquellos con fracciones de segundo al final, sin depender de 23:59:59.999.
El resultado permanece correcto, pero ahora existe un intervalo que el índice puede recorrer. La confirmación viene de la comparación de las lecturas y del operador de acceso en los dos planes. Si la métrica no cambia, la hipótesis no se sostuvo.
Lee el plan por la secuencia del trabajo
Después de comparar las versiones del filtro, leo el plan como una historia del trabajo producido por la consulta. Empiezo por el tiempo total y por las lecturas. Si la pantalla devuelve diez indicadores, pero la ejecución hace cientos de miles de lecturas, hay una cuenta por explicar.
Después comparo la cardinalidad estimada con la real. Cuando el optimizador esperaba pocas filas y recibió muchas, puede haber elegido joins, memoria y agregaciones para un escenario bien menor que el encontrado durante la ejecución.
Enseguida acompaño dónde crecen los datos. Busco el operador en que un JOIN lleva miles de filas a cientos de miles, poco antes de que un filtro o GROUP BY lo reduzca todo de nuevo. La respuesta final pequeña puede esconder un volumen intermedio enorme. Es en ese camino donde agregaciones y ordenaciones empiezan a gastar demasiada CPU.
Tampoco tomo el porcentaje de costo exhibido por el plan como sentencia. Sirve para elegir dónde medir. Marco el operador caro, cuántas filas entran y salen de él y cuánto tiempo consume.
Si el costo aparece después de la multiplicación de las filas y antes de los pocos indicadores necesarios, ya existe una hipótesis concreta para probar.
Romper la consulta donde crece el costo
Lo que resolvió el dashboard fue romper la consulta y aprovechar los índices correctamente. La división no ocurrió de forma arbitraria. El propio plan mostró dónde el costo empezaba a crecer.
Mantuvimos en la base los filtros y la búsqueda indexada de los registros necesarios. Llevar ese recorte a la aplicación haría que el servidor recibiera un conjunto mayor antes de poder descartarlo. También desperdiciaría el camino que los índices ya acortaban.
La composición final de los indicadores fue a la aplicación. Esa etapa venía después de las relaciones y agregaciones que elevaban el volumen intermedio y mantenían alta la CPU de la base. Con los datos ya recortados, el servidor podía montar los valores de la pantalla sin concentrar toda la ejecución en una única query.
La base pasó a reducir el conjunto temprano. La aplicación recibía los registros seleccionados y componía los indicadores. Tras el cambio, el dashboard dejó de depender de aquella consulta pesada y quedó más predecible, porque la división acompañaba el punto en que el costo crecía en el plan.
Esa frontera no vino de una preferencia por query única o por más lógica en la aplicación. Marqué dónde CPU, lecturas y filas intermedias se disparaban después de los filtros selectivos. Entonces verifiqué si el tramo caro podría recibir fuera de la base un conjunto ya reducido.
Antes de mover esa etapa, dos cuentas necesitaban cerrar. La base debía seguir haciendo el recorte que aprovechaba los índices. La aplicación debía componer el resultado sin buscar demasiadas filas, crear llamadas por ítem o transformar la red en el nuevo cuello de botella.
La validación necesitaba mostrar menos CPU y lecturas en la base sin aumentar el payload ni la latencia de la ruta. Sin esa mejora, la división solo transferiría el costo a otra capa y el dashboard seguiría caro.
Cuando el plan apunta fuera de la base
En otra ruta, el plan mostraba una consulta saludable, con lecturas y tiempo dentro de lo esperado. Aun así, la API seguía lenta. Cuando medí el flujo entero, el retraso apareció después de la consulta, en el procesamiento de la aplicación y en llamadas remotas ejecutadas en secuencia.
Un tramo así cambia la cuenta.
for (const item of itens) {
await buscarDetalhe(item.id);
}
Con 50 ítems y 40 ms por llamada, el loop puede añadir cerca de dos segundos a la ruta. Cada await inicia la próxima llamada solo después de la respuesta anterior. Los 40 ms parecen pequeños vistos solos. El tiempo total muestra las cincuenta esperas en fila.
Cuando el profiling apunta ese camino, toco el código y el diseño del flujo. Separo las operaciones que dependen de la respuesta anterior de aquellas que pueden correr juntas. Para las independientes, pruebo concurrencia limitada.
Disparar cincuenta peticiones a la vez también puede solo desplazar la presión hacia la integración. El cambio debe validarse por el tiempo total de la ruta, por la tasa de errores y por la saturación del servicio remoto. Mejorar una medida mientras otra explota no resuelve el problema.
Dos horas para localizar la capa, un día para probar
“Encontramos el cuello de botella” suele volverse “resolvimos la lentitud” demasiado pronto. Después de localizar una consulta mala o un tramo secuencial, aún faltan el cambio, el deploy y la confirmación en producción. Encontrar la capa solo acorta la lista de sospechosos.
En las primeras dos horas, quiero localizar dónde profundizar: base, aplicación, integración o infraestructura. Abro el plan, comparo tiempo y lecturas, cuento consultas y cronometro el tramo ejecutado después de la base.
Si el plan está saludable y cincuenta llamadas de 40 ms suman cerca de dos segundos, dejo de buscar índice y mido el código. Si las lecturas explotan antes de la agregación, el código espera mientras pruebo la consulta.
Ese plazo es una regla de investigación, no una garantía. En el dashboard lento, pasé cerca de tres horas en la terminal mirando el plan, comparando métricas y probando la ruptura de la consulta. El resto del día involucró reunión, acceso, deploy y confirmación en producción.
Por eso, en el primer día quiero al menos una hipótesis probada con medida de antes y después. La prueba puede reescribir el filtro sin CAST, dividir la query o limitar la concurrencia del loop. Después del deploy, comparo la métrica que motivó el cambio: lecturas y CPU para la base; latencia, errores y saturación para la ruta.
En fallos intermitentes, esa ventana crece. Hay que esperar que el síntoma reaparezca con métricas suficientes para comparar.
La confirmación ocurre después del deploy
Después de algunos errores en producción, dejé de buscar un culpable estándar. Base, aplicación e infraestructura entran como hipótesis, cada una acompañada de una medida que puede confirmarla o descartarla.
Cuando el plan muestra lecturas altas y cardinalidad distante de la realidad, trabajo en la consulta. Cuando la consulta responde bien y el profiling concentra tiempo después de ella, sigo hacia el código o hacia la integración.
Caché, cola, índice y máquina mayor siguen disponibles. No son decisiones prohibidas. Solo no deberían entrar antes de saber qué trabajo está reteniendo la respuesta.
Antes de cambiar, defino la medida que debe mejorar y la que no puede empeorar. Después del deploy, vuelvo al plan, a las métricas y al profiling. Si la métrica objetivo no mejora, la hipótesis no se sostuvo. Si la medida de protección empeora, la solución creó otro problema.
Descarto la hipótesis y regreso al punto del flujo en que el tiempo aún crece.
Publicado originalmente en LinkedIn el 16 de julio de 2026.