Problema de optimización con la función definida por el usuario

26

Tengo un problema para entender por qué el servidor SQL decide llamar a la función definida por el usuario para cada valor de la tabla, aunque solo se debe buscar una fila. El SQL real es mucho más complejo, pero pude reducir el problema a esto:

select  
    S.GROUPCODE,
    H.ORDERCATEGORY
from    
    ORDERLINE L
    join ORDERHDR H on H.ORDERID = L.ORDERID
    join PRODUCT P  on P.PRODUCT = L.PRODUCT    
    cross apply dbo.GetGroupCode (P.FACTORY) S
where   
    L.ORDERNUMBER = 'XXX/YYY-123456' and
    L.RMPHASE = '0' and
    L.ORDERLINE = '01'

Para esta consulta, SQL Server decide llamar a la función GetGroupCode para cada valor individual que exista en la Tabla de PRODUCT, aunque la estimación y el número real de filas devueltas desde ORDERLINE es 1 (es la clave principal):

Plan de consulta

El mismo plan en el explorador de planes que muestra los recuentos de filas:

Plan explorer Mesas:

ORDERLINE: 1.5M rows, primary key: ORDERNUMBER + ORDERLINE + RMPHASE (clustered)
ORDERHDR:  900k rows, primary key: ORDERID (clustered)
PRODUCT:   6655 rows, primary key: PRODUCT (clustered)

El índice que se utiliza para el escaneo es:

create unique nonclustered index PRODUCT_FACTORY on PRODUCT (PRODUCT, FACTORY)

La función es en realidad un poco más compleja, pero lo mismo sucede con una función ficticia de varias instrucciones como esta:

create function GetGroupCode (@FACTORY varchar(4))
returns @t table(
    TYPE        varchar(8),
    GROUPCODE   varchar(30)
)
as begin
    insert into @t (TYPE, GROUPCODE) values ('XX', 'YY')
    return
end

Pude "arreglar" el rendimiento al forzar al servidor SQL a obtener el primer producto, aunque 1 es el máximo que se puede encontrar:

select  
    S.GROUPCODE,
    H.ORDERCAT
from    
    ORDERLINE L
    join ORDERHDR H
        on H.ORDERID = M.ORDERID
    cross apply (select top 1 P.FACTORY from PRODUCT P where P.PRODUCT = L.PRODUCT) P
    cross apply dbo.GetGroupCode (P.FACTORY) S
where   
    L.ORDERNUMBER = 'XXX/YYY-123456' and
    L.RMPHASE = '0' and
    L.ORDERLINE = '01'

Luego, la forma del plan también cambia para ser algo que esperaba que fuera originalmente:

Plan de consulta con la parte superior

También pensé que el índice PRODUCT_FACTORY era más pequeño que el índice agrupado PRODUCT_PK tendría un efecto, pero incluso al obligar a la consulta a usar PRODUCT_PK, el plan sigue siendo el mismo que el original, con 6655 llamadas a la función.

Si dejo completamente ORDERHDR, entonces el plan comienza con un bucle anidado entre ORDERLINE y PRODUCT primero, y la función se llama solo una vez.

Me gustaría entender cuál podría ser la razón de esto, ya que todas las operaciones se realizan utilizando claves primarias y cómo solucionarlo si ocurre en una consulta más compleja que no se puede resolver tan fácilmente.

Editar: Crear declaraciones de tabla:

CREATE TABLE dbo.ORDERHDR(
    ORDERID varchar(8) NOT NULL,
    ORDERCATEGORY varchar(2) NULL,
    CONSTRAINT ORDERHDR_PK PRIMARY KEY CLUSTERED (ORDERID)
)

CREATE TABLE dbo.ORDERLINE(
    ORDERNUMBER varchar(16) NOT NULL,
    RMPHASE char(1) NOT NULL,
    ORDERLINE char(2) NOT NULL,
    ORDERID varchar(8) NOT NULL,
    PRODUCT varchar(8) NOT NULL,
    CONSTRAINT ORDERLINE_PK PRIMARY KEY CLUSTERED (ORDERNUMBER,ORDERLINE,RMPHASE)
)

CREATE TABLE dbo.PRODUCT(
    PRODUCT varchar(8) NOT NULL,
    FACTORY varchar(4) NULL,
    CONSTRAINT PRODUCT_PK PRIMARY KEY CLUSTERED (PRODUCT)
)
James Z
fuente

Respuestas:

30

Hay tres razones técnicas principales para obtener el plan que hace:

  1. El marco de costos del optimizador no tiene soporte real para funciones no en línea. No hace ningún intento de mirar dentro de la definición de la función para ver cuán costoso puede ser, solo asigna un costo fijo muy pequeño y estima que la función producirá 1 fila de salida cada vez que se llame. Ambos supuestos de modelado son muy a menudo completamente inseguros. La situación ha mejorado ligeramente en 2014 con el nuevo estimador de cardinalidad habilitado ya que la suposición fija de 1 fila se reemplaza por una suposición fija de 100 filas. Sin embargo, todavía no hay soporte para calcular el costo del contenido de funciones no en línea.
  2. SQL Server inicialmente contrae las uniones y se aplica en una sola unión lógica interna n-aria. Esto ayuda al optimizador a razonar sobre las órdenes de unión más adelante. La expansión de la combinación n-aria única en órdenes de unión candidatas viene más tarde y se basa en gran medida en la heurística. Por ejemplo, las combinaciones internas vienen antes que las combinaciones externas, las tablas pequeñas y las combinaciones selectivas antes de las tablas grandes y las combinaciones menos selectivas, y así sucesivamente.
  3. Cuando SQL Server realiza una optimización basada en el costo, divide el esfuerzo en fases opcionales para minimizar las posibilidades de pasar demasiado tiempo optimizando consultas de bajo costo. Hay tres fases principales, búsqueda 0, búsqueda 1 y búsqueda 2. Cada fase tiene condiciones de entrada, y las fases posteriores permiten más exploraciones optimizadoras que las anteriores. Su consulta califica para la fase de búsqueda menos capacitada, fase 0. Allí se encuentra un plan de costo lo suficientemente bajo que no se ingresan etapas posteriores.

Dada la pequeña estimación de cardinalidad asignada a la UDF, la heurística de expansión de unión n-aria desafortunadamente la reposiciona más temprano en el árbol de lo que desearía.

La consulta también califica para la optimización de búsqueda 0 en virtud de tener al menos tres uniones (incluyendo aplica). El plan físico final que obtienes, con el escaneo de aspecto extraño, se basa en ese orden de unión deducido heurísticamente. Su costo es lo suficientemente bajo como para que el optimizador considere el plan "suficientemente bueno". La estimación de bajo costo y la cardinalidad para el UDF contribuyen a este final temprano.

La búsqueda 0 (también conocida como la fase de procesamiento de transacciones) se dirige a consultas de tipo OLTP de baja cardinalidad, con planes finales que generalmente presentan uniones de bucles anidados. Más importante aún, la búsqueda 0 ejecuta solo un subconjunto relativamente pequeño de las habilidades de exploración del optimizador. Este subconjunto no incluye extraer y aplicar el árbol de consulta sobre una combinación (regla PullApplyOverJoin). Esto es exactamente lo que se requiere en el caso de prueba para reposicionar la aplicación UDF por encima de las uniones, para aparecer en último lugar en la secuencia de operaciones (por así decir).

También hay un problema en el que el optimizador puede decidir entre una unión de bucles anidados ingenua (predicado de unión en la propia unión) y una unión indexada correlacionada (aplicar) donde el predicado correlacionado se aplica en el lado interno de la unión usando una búsqueda de índice. Esta última suele ser la forma de plano deseada, pero el optimizador es capaz de explorar ambas. Con estimaciones incorrectas de costos y cardinalidad, puede elegir la unión NL no aplicada, como en los planes enviados (explicando el escaneo).

Por lo tanto, existen múltiples razones de interacción que involucran varias características generales del optimizador que normalmente funcionan bien para encontrar buenos planes en un corto período de tiempo sin usar recursos excesivos. Evitar cualquiera de los motivos es suficiente para producir la forma de plan 'esperada' para la consulta de muestra, incluso con tablas vacías:

Planifique en tablas vacías con la búsqueda 0 deshabilitada

No hay una forma compatible de evitar la selección del plan de búsqueda 0, la terminación temprana del optimizador o mejorar el costo de las UDF (aparte de las mejoras limitadas en el modelo CE de SQL Server 2014 para esto). Esto deja cosas como guías de plan, reescrituras de consultas manuales (incluida la TOP (1)idea o el uso de tablas temporales intermedias) y evitar 'recuadros negros' de bajo costo (desde un punto de vista de QO) como funciones no en línea.

Reescribiendo CROSS APPLYcomo OUTER APPLYpuede también trabajo, ya que actualmente impide que algunos de los primeros trabajos join-colapso, pero hay que tener cuidado para preservar la semántica consulta original (por ejemplo, rechazando cualquier NULLfilas -extended que podrían introducirse, sin colapsar el optimizador de nuevo a una aplicación cruzada). Sin embargo, debe tener en cuenta que no se garantiza que este comportamiento permanezca estable, por lo que deberá recordar volver a probar dichos comportamientos observados cada vez que aplique un parche o actualice SQL Server.

En general, la solución adecuada para usted depende de una variedad de factores que no podemos juzgar por usted. Sin embargo, le animo a que considere soluciones que garanticen que siempre funcionarán en el futuro, y que funcionen con (en lugar de en contra) el optimizador siempre que sea posible.

Paul White dice GoFundMonica
fuente
24

Parece que esta es una decisión basada en los costos del optimizador, pero una decisión bastante mala.

Si agrega 50000 filas a PRODUCT, el optimizador piensa que el escaneo es demasiado trabajo y le brinda un plan con tres búsquedas y una llamada al UDF.

El plan que obtengo para 6655 filas en PRODUCT

ingrese la descripción de la imagen aquí

Con 50000 filas en PRODUCT, obtengo este plan en su lugar.

ingrese la descripción de la imagen aquí

Supongo que el costo de llamar al UDF está muy subestimado.

Una solución alternativa que funciona bien en este caso es cambiar la consulta para usar la aplicación externa contra el UDF. Obtengo el buen plan sin importar cuántas filas haya en la tabla PRODUCT.

select  
    S.GROUPCODE,
    H.ORDERCATEGORY
from    
    ORDERLINE L
    join ORDERHDR H on H.ORDERID = L.ORDERID
    join PRODUCT P  on P.PRODUCT = L.PRODUCT    
    outer apply dbo.GetGroupCode (P.FACTORY) S
where   
    L.ORDERNUMBER = 'XXX/YYY-123456' and
    L.RMPHASE = '0' and
    L.ORDERLINE = '01' and
    S.GROUPCODE is not null

ingrese la descripción de la imagen aquí

La mejor solución en su caso es probablemente obtener los valores que necesita en una tabla temporal y luego consultar la tabla temporal con una cruz para aplicar a la UDF. De esa manera, está seguro de que el UDF no se ejecutará más de lo necesario.

select  
    P.FACTORY,
    H.ORDERCATEGORY
into #T
from    
    ORDERLINE L
    join ORDERHDR H on H.ORDERID = L.ORDERID
    join PRODUCT P  on P.PRODUCT = L.PRODUCT
where   
    L.ORDERNUMBER = 'XXX/YYY-123456' and
    L.RMPHASE = '0' and
    L.ORDERLINE = '01'

select  
    S.GROUPCODE,
    T.ORDERCATEGORY
from #T as T
  cross apply dbo.GetGroupCode (T.FACTORY) S

drop table #T

En lugar de persistir en la tabla temporal, puede usarla top()en una tabla derivada para obligar a SQL Server a evaluar el resultado de las uniones antes de que se llame al UDF. Simplemente use un número realmente alto en la parte superior, lo que hace que SQL Server tenga que contar sus filas para esa parte de la consulta antes de que pueda continuar y usar el UDF.

select S.GROUPCODE,
       T.ORDERCATEGORY
from (
     select top(2147483647)
         P.FACTORY,
         H.ORDERCATEGORY
     from    
         ORDERLINE L
         join ORDERHDR H on H.ORDERID = L.ORDERID
         join PRODUCT P  on P.PRODUCT = L.PRODUCT    
     where   
         L.ORDERNUMBER = 'XXX/YYY-123456' and
         L.RMPHASE = '0' and
         L.ORDERLINE = '01'
     ) as T
  cross apply dbo.GetGroupCode (T.FACTORY) S

ingrese la descripción de la imagen aquí

Me gustaría entender cuál podría ser la razón de esto, ya que todas las operaciones se realizan utilizando claves primarias y cómo solucionarlo si ocurre en una consulta más compleja que no se puede resolver tan fácilmente.

Realmente no puedo responder eso, pero pensé que debería compartir lo que sé de todos modos. No sé por qué se considera un escaneo de la tabla PRODUCT. Puede haber casos en los que eso sea lo mejor que se puede hacer y hay cosas sobre cómo los optimizadores tratan los UDF que no conozco.

Una observación adicional fue que su consulta obtiene un buen plan en SQL Server 2014 con el nuevo estimador de cardinalidad. Esto se debe a que el número estimado de filas para cada llamada a la UDF es 100 en lugar de 1, como en SQL Server 2012 y anteriores. Pero seguirá tomando la misma decisión basada en el costo entre la versión de escaneo y la versión de búsqueda del plan. Con menos de 500 (497 en mi caso) filas en PRODUCT, obtienes la versión de escaneo del plan incluso en SQL Server 2014.

Mikael Eriksson
fuente
2
De alguna manera me recuerda la sesión de Adam Machanic en SQL Bits: sqlbits.com/Sessions/Event14/…
James Z