SQL Performance Lab
Tres anti-patrones de SQL Server diagnosticados y corregidos en un ERP farmacéutico real — con benchmarks en caché frío y planes de ejecución que respaldan cada cifra.
Q1 — −59% elapsed · −99.3% reads en SalesOrders (2,856 → 19)
Q2 — −99.2% reads (4,958 → 38) con columna calculada como leading key
Q3 — −99.8% server time · operador Sort eliminado por completo
Resumen ejecutivo
Contexto de negocio
ERP de distribuidor farmacéutico con 169K órdenes, 900K líneas, 182K cuentas por cobrar y 506K registros de inventario. Tres consultas usadas en reportes de ventas, cartera y cierre mensual de inventario mostraban tiempos de respuesta inaceptables. El análisis de planes de ejecución reveló anti-patrones clásicos que impedían el uso de índices.
Mi rol
Identifiqué los anti-patrones en los planes de ejecución, diseñé los índices correctivos con justificación de leading key en cada caso, y medí el impacto en caché frío (DBCC DROPCLEANBUFFERS + FREEPROCCACHE) para aislar el efecto real del servidor.
Stakeholders
Sistemas y fuentes
Preguntas de negocio que responde
- ¿Por qué la consulta de ventas del mes tarda 5 segundos con índice existente?
- ¿Cómo reducir los 4,960 reads de cartera vencida cuando el 99.7% de filas pasan el filtro de fecha?
- ¿Qué hace que el cierre de inventario tarde 7 segundos si la tabla tiene solo 506K filas?
- ¿Cuándo un índice adicional mejora las lecturas pero empeora el tiempo real?
Tecnologías
El problema
Tres consultas usadas a diario en reportes de ventas, cartera e inventario eran lentas — no porque el servidor estuviera subdimensionado, sino porque cada una tenía un anti-patrón que impedía a SQL Server usar cualquier índice.
Anti-patrón 1 — Función en columna indexada (Q1)
-- ANTES: YEAR() envuelve OrderDate → SQL Server no puede usar el índice → scan completo en 169K filas
WHERE YEAR(OrderDate) = 2024 AND MONTH(OrderDate) = 1
-- DESPUÉS: rango explícito → Index Seek en IX_SalesOrders_Date_Covering
WHERE OrderDate >= '2024-01-01' AND OrderDate < '2024-02-01'
Fix: covering index con OrderDate como leading key. Lecturas lógicas en SalesOrders: 2,856 → 19 (−99.3%). Elapsed: 5,056 ms → 2,053 ms (−59%).
Anti-patrón 2 — CONVERT no-sargable + correlated subquery (Q2)
-- ANTES: CONVERT envuelve DueDate → no puede usar ningún índice
WHERE CONVERT(varchar(10), DueDate, 23) < CONVERT(varchar(10), GETDATE(), 23)
-- + correlated subquery ejecuta un lookup por cada fila devuelta
Causa raíz: el 99.7% de las filas pasan el filtro de fecha — un índice por fecha es casi inútil. El predicado selectivo es BalanceDue > 0 (solo 1,872 de 182,070 filas). Fix: columna calculada persistida BalanceDue AS (OriginalAmount + Charges - Payments) como leading key del índice — SQL Server hace seek directo a las 1,872 filas con saldo. Lecturas: 4,958 → 38 (−99.2%).
Anti-patrón 3 — SELECT * + filtro sin índice + Sort implícito (Q3)
-- ANTES: lee las 14 columnas por página, sin índice en IsClosed ni PeriodId
SELECT * FROM InventoryMonthlyBalance
WHERE IsClosed = 1 AND PeriodId >= '202401' AND PeriodId <= '202412'
ORDER BY PeriodId, WarehouseId, ProductId
Fix: lista explícita de 11 columnas + covering index con (PeriodId, IsClosed, WarehouseId, ProductId) como keys. Páginas más angostas (−50% reads), e índice pre-ordenado en el mismo orden del ORDER BY → operador Sort eliminado completamente. Tiempo servidor: 7,024 ms → 16 ms (−99.8%).
Insight clave — no todo índice mejora toda consulta
Agregar un covering index en SalesOrderLines(OrderId, IsCancelled) para Q1 redujo las lecturas lógicas un 88% (60,418 → 7,450) pero aumentó el elapsed de 2,053 ms a 4,119 ms. El optimizador cambió de Hash Match en paralelo (9 hilos) a Nested Loop (2,256 seeks secuenciales). Para un scan mensual que devuelve 12K filas, el plan paralelo gana en tiempo real aunque lea más páginas. Por eso se mide antes de enviar cualquier índice a producción.
¿Qué te pareció este proyecto?
Si tienes preguntas sobre cómo lo hice o quieres charlar sobre datos, escríbeme.