"Necesitamos más RAM" es el diagnóstico al que llegan las empresas de hosting porque es la solución que te pueden vender. Nueve de cada diez veces, cuando me llaman para investigar una aplicación lenta, el cuello de botella está en las consultas — no en el hardware. Este es uno de esos casos.
El Punto de Partida
El cliente tenía una plataforma SaaS B2B. Los tiempos de respuesta habían ido aumentando durante seis meses y recientemente habían cruzado el umbral donde los usuarios se quejaban. La empresa de hosting había ejecutado un informe de utilización de CPU y recomendó actualizar de 16GB a 32GB de RAM. El cliente me llamó antes de firmar el contrato de actualización.
Empecé donde siempre empiezo: las propias herramientas de diagnóstico de la base de datos. En SQL Server, consulté sys.dm_exec_query_stats para las consultas que más recursos consumían por tiempo total de worker. En PostgreSQL, pg_stat_statements te da el mismo panorama. En 20 minutos, tenía una lista de las cinco consultas responsables de la mayor parte de la carga. Ninguna de ellas necesitaba más RAM.
Corrección 1: Reemplazar Subconsultas Anidadas con CTEs
El peor caso era una consulta de reportes que usaba tres niveles de subconsultas anidadas. El planificador de consultas estaba materializando resultados intermedios varias veces, y el plan de ejecución mostraba que la subconsulta interna se ejecutaba una vez por fila de la consulta externa.
Lo reescribí usando Expresiones de Tabla Común (CTEs). Un CTE te permite nombrar y definir un conjunto de datos una vez, luego referenciarlo múltiples veces en la misma consulta. El planificador de consultas puede materializar el resultado del CTE una vez y reutilizarlo, en lugar de recomputar la subconsulta para cada fila.
Esta única reescritura redujo la consulta de 8.4 segundos a 1.1 segundos — una reducción del 87% para esa consulta sola. La lógica se volvió legible como efecto secundario: las subconsultas anidadas son difíciles de seguir; los CTEs con nombre comunican la intención.
Corrección 2: El Problema N+1 Oculto en el ORM
El segundo problema importante era un patrón de consulta N+1 oculto dentro de Entity Framework. El código se veía así:
var orders = await context.Orders
.Where(o => o.Status == OrderStatus.Pending)
.ToListAsync();
foreach (var order in orders)
{
var customer = await context.Customers
.FindAsync(order.CustomerId); // N consultas aquí
// ... procesar
}
Para 500 pedidos pendientes, esto generaba 501 viajes de ida y vuelta a la base de datos. La solución es usar Include de EF Core para cargar datos relacionados en un solo join, o reestructurar la consulta para proyectar los datos necesarios de antemano:
var orders = await context.Orders
.Where(o => o.Status == OrderStatus.Pending)
.Include(o => o.Customer)
.ToListAsync();
Un viaje de ida y vuelta en lugar de 501. El tiempo de respuesta de este endpoint bajó de 4.2 segundos a 280 milisegundos.
Los problemas N+1 son invisibles en desarrollo donde los conjuntos de datos son pequeños. Aparecen en producción cuando la lista tiene cientos de filas. Si usás un ORM, auditar este patrón debería ser parte de cada revisión de código.
Corrección 3: Índices Compuestos Faltantes
El tercer problema era un par de consultas que cada una filtraba en dos columnas — pero la tabla solo tenía índices de una sola columna en cada una. El planificador de consultas estaba escaneando todas las filas que coincidían con la primera condición, luego filtrando en memoria para la segunda.
La solución: un índice compuesto que cubra ambas columnas en el orden correcto. El orden de las columnas del índice importa — la columna principal debe ser la que se usa en comparaciones de igualdad con más frecuencia. Para predicados de rango (rangos de fechas, rangos numéricos), esas columnas van al final del índice.
Después de agregar dos índices compuestos (uno tardó 45 minutos en construirse en la tabla de producción — planificá las adiciones de índices durante períodos de bajo tráfico), esas consultas pasaron de escaneos completos de tabla que tardaban 3 segundos a búsquedas de índice que tardaban menos de 50 milisegundos.
Corrección 4: Índices de Cobertura para Consultas de Lectura Intensiva
Un índice de cobertura incluye todas las columnas necesarias para una consulta, por lo que el motor de base de datos nunca tiene que volver a la tabla principal para obtener datos adicionales. Para las consultas de lectura que se ejecutan con más frecuencia — las que corren miles de veces por hora — esto elimina una porción significativa de I/O.
Identifiqué las tres consultas de mayor frecuencia y agregué índices de cobertura que incluían sus columnas SELECT. El efecto en esas consultas específicas fue modesto (10-20% de mejora), pero a alta frecuencia de ejecución, las mejoras modestas se componen en una reducción significativa de carga.
El Resultado
Después de cuatro correcciones específicas — sin cambios de esquema, sin reescrituras de aplicación, sin cambios de hardware — el tiempo de consulta promedio en toda la aplicación bajó un 71%. La actualización de hosting que el cliente estaba a punto de comprar fue innecesaria. El costo total del engagement fue menor que el primer mes del nivel de hosting actualizado.
Si tu aplicación se está ralentizando y alguien está recomendando más hardware, vale la pena pasar unas horas mirando la capa de consultas primero. La respuesta suele estar ahí.
Para una visión más amplia de cómo las decisiones de diseño de base de datos afectan el rendimiento desde el inicio, mi artículo sobre cuellos de botella en bases de datos de pymes cubre el lado del diagnóstico. Y si querés que ejecute una auditoría de consultas en tu sistema, contactáme.