Señales de que una consulta va a tener un mal rendimiento en SQL Server

Señales de que una consulta va a tener un mal rendimiento en SQL Server: cursores, funciones, joins, filtros y qué medir antes de optimizar.

Abrimos un procedimiento almacenado de varios cientos de líneas y encontramos cursores, funciones en el WHERE, cinco tablas temporales, varios DISTINCT y una colección de joins que parece haber crecido mediante sedimentación geológica. Todavía no hemos ejecutado nada, pero ya sospechamos que la consulta va a tener un mal rendimiento.

Esa sospecha puede estar justificada. 

Voy a dedicar este post a resumir las distintas señales que provocan esa misma reacción entre profesionales de SQL Server: bucles, funciones escalares, conversiones implícitas, joins innecesarios, filtros aplicados demasiado tarde, comodines iniciales o consultas que nadie quiere volver a leer.

Ahora viene la parte importante, ninguno de esos elementos demuestra por sí solo que una consulta sea lenta. Son olores de código. Nos indican dónde debemos mirar, pero el rendimiento depende del volumen, la distribución de los datos, los índices, las estimaciones de cardinalidad, el plan elegido y la concurrencia. Una consulta fea puede ejecutarse en dos milisegundos porque procesa veinte filas. Otra impecablemente formateada puede leer media base de datos para devolver tres registros.

Una señal no es todavía un problema de rendimiento

Cuando revisamos una consulta sin ejecutarla, buscamos construcciones que puedan obligar al motor a realizar más trabajo del necesario. Ese trabajo suele aparecer en alguna de estas formas:

  • Procesar filas repetidamente en lugar de hacerlo como un conjunto.
  • Leer más páginas de las necesarias.
  • Ordenar, agregar o unir grandes volúmenes.
  • Reservar demasiada memoria o derramar datos a TempDB.
  • Compilar un espacio de alternativas excesivamente complejo.
  • Impedir que el optimizador estime correctamente cuántas filas circularán.
  • Mantener bloqueos o consumir workers durante más tiempo.

Por tanto, el problema no está en que una consulta contenga una palabra determinada. El problema aparece cuando esa construcción modifica el plan físico, aumenta el volumen intermedio o reduce las opciones disponibles para el optimizador.

Un CURSOR sobre diez filas no va a derribar el servidor. Un SELECT aparentemente sencillo que se ejecuta quince mil veces por minuto sí puede hacerlo.

Tenemos que valorar el coste por ejecución y multiplicarlo por la frecuencia y la concurrencia. Los datos y la concurrencia de usuarios suelen encargarse de esa multiplicación.

Cursores, bucles y el coste de trabajar fila a fila

Los cursores y los bucles WHILE son probablemente la señal más reconocible. Su mala reputación procede del patrón RBAR (row by agonizing row), obtener una fila, ejecutar una operación, avanzar a la siguiente y repetir.

SQL Server está diseñado para trabajar con conjuntos. Una operación declarativa permite que el optimizador elija el orden de acceso, combine operadores, use paralelismo y reduzca pasos intermedios. Un bucle traslada parte de esa lógica al código procedural y obliga al motor a ejecutar muchas operaciones pequeñas.

El coste puede aparecer en CPU por la repetición, en lecturas acumuladas, en escrituras al log si modificamos datos y en una transacción más larga que conserva bloqueos durante más tiempo. Además, una consulta rápida ejecutada cien mil veces deja de ser rápida cuando medimos el conjunto.

Eso no convierte cualquier cursor en un error. Puede tener sentido cuando cada elemento requiere una operación realmente independiente, cuando interactuamos con recursos externos o cuando el volumen está estrictamente limitado. Lo sospechoso es encontrarlo procesando cientos de miles de filas que podrían actualizarse con una única operación basada en conjuntos.

La pregunta no es “¿hay un cursor?”, sino “¿cuántas veces ejecutamos su cuerpo y qué trabajo repetimos en cada iteración?”.

Las funciones escalares pueden esconder mucho más que una fórmula

Una función escalar dentro de un SELECT, un JOIN o un filtro parece una forma limpia de reutilizar lógica. El código principal queda más corto, pero el coste no desaparece. Solo lo hemos guardado en otro sitio.

Tradicionalmente, SQL Server ejecutaba las funciones escalares T-SQL sentencia a sentencia, sin optimización conjunta con la consulta que las invocaba y sin paralelismo intraconsulta. SQL Server 2019 introdujo el scalar UDF inlining, que puede transformar funciones compatibles en expresiones relacionales para que el optimizador las integre en el plan. Sin embargo, la función tiene que cumplir los requisitos de inlining. no debemos asumir que el motor siempre aplicará la transformación.

Por eso, encontrar una UDF escalar sigue siendo una señal válida. Tenemos que comprobar si se ha incluido físicamente en el plan o si continúa ejecutándose de forma independiente.

También debemos revisar cuántas filas reciben la función. Una operación insignificante aplicada a cinco millones de filas deja de ser insignificante. SQL Server no ofrece descuentos por volumen en CPU.

Las funciones y conversiones en los filtros dificultan localizar las filas

Veamos una construcción habitual:

La intención lógica resulta clara, queremos los pedidos de un día. Físicamente, sin embargo, hemos aplicado una función sobre la columna. El motor puede verse obligado a calcular el resultado para muchas filas antes de comprobar cuáles cumplen el filtro.

Podemos expresar la misma condición como un intervalo:

Esta forma permite que un índice adecuado sobre FechaPedido pueda utilizar sus valores ordenados para localizar el rango. Importante, no garantiza un Index Seek, el optimizador podría preferir un Scan si el intervalo devuelve buena parte de la tabla, si el índice no cubre la consulta o si el coste de los lookups supera al de leerla secuencialmente.

Ese matiz lo cambia todo. El objetivo no consiste en conseguir un icono concreto (el de index seek) en el plan, sino en reducir el trabajo global.

Las conversiones implícitas plantean un problema parecido. Cuando comparamos datos de tipos distintos, SQL Server convierte el tipo de menor precedencia al de mayor precedencia. Dependiendo de qué lado reciba la conversión, el motor puede perder capacidad para aprovechar el orden del índice y también puede empeorar la estimación de cardinalidad.

Conviene revisar especialmente parámetros nvarchar comparados con columnas varchar, números almacenados como texto y joins entre columnas que representan el mismo dato con tipos diferentes. La consulta nunca va a ser óptima si el modelo subyacente no es correcto. Dicho de otro modo, no podemos arreglar con una consulta un mal diseño de la base de datos.

DISTINCT puede estar ocultando un problema de cardinalidad

DISTINCT no es malo. Si necesitamos valores distintos, expresa correctamente el resultado que queremos.

La señal de alerta aparece cuando encontramos DISTINCT después de varios joins y no queda claro por qué existen duplicados. En muchos casos, la consulta ha multiplicado filas debido a una relación uno a varios, un join incompleto o una tabla añadida únicamente para comprobar que existe un registro relacionado. Después se utiliza DISTINCT para eliminar el resultado de esa multiplicación.

Lógicamente obtenemos las filas esperadas. Físicamente, SQL Server puede haber leído, unido y transportado un volumen mucho mayor para descartarlo al final.

Eliminar duplicados suele requerir un Sort o una agregación. Esos operadores necesitan un memory grant calculado a partir de las filas estimadas. Si la reserva resulta insuficiente, el operador puede realizar un spill y escribir datos intermedios en TempDB. SQL Server también utiliza TempDB para resultados intermedios de determinadas operaciones GROUP BY, ORDER BY y UNION.

Con poca carga, ese coste puede pasar desapercibido. Con varias consultas concurrentes solicitando memoria y escribiendo en TempDB, el problema deja de pertenecer a una consulta concreta y empieza a afectar a todo el servidor.

Cuando veamos DISTINCT, debemos comprobar cuántas filas llegan al operador y cuántas sobreviven. Si entran diez millones y salen mil, no tenemos un problema de duplicados. Tenemos un problema anterior disfrazado de limpieza.

Muchos joins amplían el volumen y complican la optimización

Una consulta con muchos joins no tiene por qué ser lenta. El modelo de datos puede exigirlos y el optimizador sabe evaluar diferentes órdenes y algoritmos de unión.

La señal aparece cuando los joins producen grandes volúmenes intermedios, cuando faltan predicados, cuando las columnas relacionadas tienen tipos distintos o cuando la consulta mezcla numerosas relaciones opcionales sin controlar su cardinalidad.

Un CROSS JOIN merece una revisión inmediata porque genera el producto cartesiano de ambos conjuntos. También debemos desconfiar de cadenas de LEFT JOIN seguidas de filtros sobre la tabla derecha que terminan convirtiendo lógicamente la operación en un INNER JOIN. Puede funcionar, pero suele indicar que la intención del código no está bien expresada.

La longitud y complejidad también afectan a la fase de compilación. El optimizador no puede explorar indefinidamente todas las combinaciones posibles. En consultas complejas puede finalizar la búsqueda antes de evaluar alternativas mejores y dejar constancia de un StatementOptmEarlyAbortReason como TimeOut. Un timeout del optimizador no significa que la compilación haya durado el timeout configurado por la aplicación, significa que alcanzó su presupuesto interno de exploración.

Dividir una consulta mediante tablas temporales puede reducir esa complejidad y proporcionar estadísticas sobre resultados intermedios. Pero también introduce escrituras, lecturas, mantenimiento de objetos temporales y posibles problemas de TempDB.

Una cosa es separar fases con una razón técnica. Otra es construir un procedimiento como si cada bloque anterior hubiera dejado una nota diciendo “que lo arregle el siguiente”.

Filtrar al final obliga a transportar datos que no necesitábamos

Otra señal clara aparece cuando la consulta obtiene grandes conjuntos, los almacena o los une y solo aplica los filtros selectivos en la última fase.

Cuanto antes podamos eliminar filas sin cambiar el resultado lógico, menos trabajo tendrán que realizar los operadores posteriores. Reduciremos lecturas, CPU, memoria, tráfico entre hilos y, en arquitecturas distribuidas, movimiento de datos por la red.

Eso no significa que debamos reescribir obsesivamente el orden textual de cada condición. SQL Server puede empujar predicados y reorganizar operaciones durante la optimización. Lo importante es no impedirlo mediante funciones, dependencias complejas, conversiones o materializaciones innecesarias.

Debemos observar el plan real y comparar Actual Number of Rows con las filas leídas y con las que llegan a cada operador. Un filtro que devuelve cien filas después de procesar diez millones merece más atención que un Scan sobre una tabla de quinientas filas.

Los hints suelen revelar una solución que ha envejecido

Encontrar hints como FORCESEEK, INDEX, LOOP JOIN, HASH JOIN o MAXDOP no demuestra que el autor se equivocara. Puede existir una razón válida y medible.

La señal consiste en que estamos limitando deliberadamente las decisiones del optimizador. Esa elección se tomó para un volumen, una distribución de datos, unos índices y una versión concreta. Cuando cualquiera de esos elementos cambia, el hint permanece.

Un hint puede solucionar hoy una mala estimación y convertirse mañana en el motivo por el que el motor no puede elegir un plan mejor. Por eso necesita contexto, comentarios y una prueba de regresión. “Funcionaba más rápido cuando lo pusimos” no es documentación técnica.

Antes de eliminarlo, debemos comparar planes y cargas representativas. Antes de mantenerlo, tenemos que poder explicar qué alternativa evita y por qué sigue siendo perjudicial.

La señal más importante puede no aparecer en el código

Podemos revisar una consulta perfectamente razonable y no encontrar nada sospechoso. Aun así, puede rendir mal por estadísticas desactualizadas, distribución desigual, un plan reutilizado para parámetros poco representativos, bloqueos o presión de recursos.

El optimizador utiliza estadísticas para estimar cuántas filas producirá cada operación. Esas estimaciones condicionan el orden de joins, los algoritmos elegidos, el acceso a índices y la memoria reservada.

Además, SQL Server almacena y reutiliza planes de ejecución. Durante la compilación puede utilizar los valores de los parámetros para generar un plan, y ese plan puede no encajar con ejecuciones posteriores que devuelvan volúmenes muy diferentes.

Por tanto, no basta con leer el código. Tenemos que comprobar:

  • Plan real y diferencias entre filas estimadas y reales.
  • Lecturas lógicas, CPU y duración.
  • Filas leídas frente a filas devueltas.
  • Memory grant solicitado, utilizado y posibles spills.
  • Esperas, bloqueos y consumo bajo concurrencia.
  • Frecuencia de ejecución y coste acumulado.
  • Planes diferentes para distintos parámetros o periodos.

Query Store resulta especialmente útil para estudiar el comportamiento histórico, comparar planes y detectar cuándo una consulta pasó a utilizar una alternativa peor. También permite forzar un plan conocido, aunque el plan forcing debe tratarse como una medida controlada y no como sustituto del diagnóstico.

Conclusión

Las señales de que una consulta va a tener un mal rendimiento nos ayudan a decidir dónde empezar: procesamiento fila a fila, funciones escalares, conversiones implícitas, DISTINCT correctivos, joins que multiplican filas, filtros tardíos, complejidad excesiva o hints sin contexto.

Pero ninguna palabra reservada sustituye a una medición.

Tenemos que relacionar cada señal con su consecuencia física: más filas procesadas, más lecturas, peor estimación, mayor memory grant, spills en TempDB, menor paralelismo, compilaciones complejas o bloqueos más largos. Después debemos validar ese efecto con el plan real, Query Store y una carga representativa.

El error no consiste en utilizar un cursor, una tabla temporal o un DISTINCT. El error consiste en utilizarlos sin saber qué trabajo obligamos a realizar al motor y confiar en que producción será comprensiva.

Si tenéis alguna duda o sugerencia, podéis dejarla en Twitter, por mail o dejarnos un mensaje en los comentarios. Y recuerda que también tenemos un grupo de LinkedIn y un canal de YouTube a los que te puede unir. ¡Hasta la próxima!

Logo SoyDBA

Únete a la newsletter de SoyDBA

Regístrate gratis para no perderte ninguna novedad. Te avisaré de noticias y eventos importantes

¡No hacemos spam! Lee nuestra política de privacidad para obtener más información.

Publicado por Roberto Carrancio

Mi nombre es Roberto Carrancio y soy un DBA de SQL server con más de 10 años de experiencia en el sector. Soy el creador del blog soydba.es donde intento publicar varios artículos a la semana (de lunes a viernes que los fines de semana me gusta estar con mi gente y disfrutar de mi moto) Espero que disfrutes leyendo este blog tanto como yo disfruto escribiendo y que te sea de utilidad. Si tienes alguna sugerencia, pregunta o comentario, puedes dejarlo al final de cada entrada o enviarme un correo electrónico. Estaré encantado de leerte y responderte. ¡Gracias por tu visita! Mi principal interés es compartir mi conocimiento sobre bases de datos con todo el que quiera aprenderlo. Me parece un mundo tan apasionante como desconocido. Fuera de lo profesional me encanta la cocina, la moto y disfrutar de tomar una cervecita con amigos.

1 comentario

Laborconsulting Abogados

Interesante artículo. Gracias!

Deja una respuesta