Problema de otimização com função definida pelo usuário


26

Eu tenho um problema para entender por que o SQL Server decide chamar a função definida pelo usuário para cada valor da tabela, mesmo que apenas uma linha deva ser buscada. O SQL atual é muito mais complexo, mas consegui reduzir o problema para isso:

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, o SQL Server decide chamar a função GetGroupCode para cada valor único existente na tabela PRODUCT, mesmo que a estimativa e o número real de linhas retornadas de ORDERLINE sejam 1 (é a chave primária):

Plano de consulta

O mesmo plano no explorador de plano mostrando a contagem de linhas:

Plan explorer Tabelas:

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

O índice que está sendo usado para a verificação é:

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

A função é realmente um pouco mais complexa, mas o mesmo acontece com uma função fictícia de múltiplas instruções 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

Consegui "consertar" o desempenho forçando o SQL Server a buscar o 1 produto principal, embora 1 seja o máximo que pode ser encontrado:

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'

Em seguida, a forma do plano também muda para algo que eu esperava que fosse originalmente:

Plano de consulta com a parte superior

Eu também acho que o índice PRODUCT_FACTORY sendo menor que o índice agrupado PRODUCT_PK teria um efeito, mas mesmo ao forçar a consulta a usar o PRODUCT_PK, o plano ainda é o mesmo que o original, com 6655 chamadas para a função.

Se eu excluir ORDERHDR completamente, o plano começará com um loop aninhado entre ORDERLINE e PRODUCT primeiro, e a função será chamada apenas uma vez.

Gostaria de entender o que poderia ser o motivo disso, já que todas as operações são feitas usando chaves primárias e como corrigi-lo se ocorrer em uma consulta mais complexa que não pode ser resolvida com facilidade.

Editar: Criar instruções da tabela:

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)
)

Respostas:


30

Há três razões técnicas principais para você obter o plano que faz:

  1. A estrutura de custos do otimizador não tem suporte real para funções não embutidas. Ele não faz nenhuma tentativa de olhar dentro da definição da função para ver quão caro pode ser, apenas atribui um custo fixo muito pequeno e estima que a função produzirá 1 linha de saída toda vez que for chamada. Ambas as premissas de modelagem são muitas vezes completamente inseguras. A situação foi levemente melhorada em 2014 com o novo estimador de cardinalidade ativado, pois o palpite fixo de 1 linha é substituído por um palpite fixo de 100 linhas. No entanto, ainda não há suporte para custear o conteúdo de funções não embutidas.
  2. O SQL Server inicialmente recolhe as junções e se aplica a uma única junção lógica n-ária interna. Isso ajuda o otimizador a raciocinar sobre pedidos de ingresso mais tarde. A expansão da junção n-ária única em pedidos de junção de candidatos ocorre mais tarde e é amplamente baseada em heurísticas. Por exemplo, junções internas vêm antes de junções externas, pequenas tabelas e junções seletivas antes de tabelas grandes e junções menos seletivas, e assim por diante.
  3. Quando o SQL Server executa a otimização baseada em custos, divide o esforço em fases opcionais para minimizar as chances de gastar muito tempo otimizando consultas de baixo custo. Existem três fases principais: pesquisa 0, pesquisa 1 e pesquisa 2. Cada fase possui condições de entrada e as fases posteriores permitem mais explorações do otimizador do que as anteriores. Por acaso, sua consulta está qualificada para a fase de pesquisa com menor capacidade, fase 0. Um plano de custo baixo o suficiente é encontrado lá e os estágios posteriores não são inseridos.

Dada a pequena estimativa de cardinalidade atribuída à UDF, as heurísticas de expansão da junção n-ária infelizmente reposicionam-na mais cedo na árvore do que você gostaria.

A consulta também se qualifica para a otimização da pesquisa 0 em virtude de ter pelo menos três junções (incluindo se aplica). O plano físico final que você obtém, com a varredura de aparência estranha, baseia-se nessa ordem de junção deduzida heuristicamente. O custo é baixo o suficiente para que o otimizador considere o plano "bom o suficiente". A estimativa de baixo custo e a cardinalidade da UDF contribuem para esse término inicial.

A pesquisa 0 (também conhecida como fase Transaction Processing) tem como alvo consultas do tipo OLTP de baixa cardinalidade, com planos finais que geralmente apresentam junções de loops aninhados. Mais importante, a pesquisa 0 executa apenas um subconjunto relativamente pequeno das habilidades de exploração do otimizador. Esse subconjunto não inclui puxar e aplicar a árvore de consulta sobre uma junção (regra PullApplyOverJoin). É exatamente isso que é necessário no caso de teste para reposicionar o UDF aplicado acima das junções, para aparecer por último na sequência de operações (por assim dizer).

Há também um problema em que o otimizador pode decidir entre a junção de loops aninhados ingênuos (predicado de junção na própria junção) e uma junção indexada correlacionada (aplicar) em que o predicado correlacionado é aplicado no lado interno da junção usando uma busca de índice. O último é geralmente o formato do plano desejado, mas o otimizador é capaz de explorar os dois. Com estimativas incorretas de custo e cardinalidade, ele pode escolher a junção NL não aplicada, como nos planos enviados (explicando a varredura).

Portanto, há vários motivos de interação envolvendo vários recursos gerais do otimizador que normalmente funcionam bem para encontrar bons planos em um curto período de tempo sem o uso de recursos excessivos. Evitar qualquer um dos motivos é suficiente para produzir a forma do plano 'esperado' para a consulta de amostra, mesmo com tabelas vazias:

Planejar tabelas vazias com a pesquisa 0 desativada

Não há maneira suportada de evitar a seleção do plano de pesquisa 0, o encerramento antecipado do otimizador ou melhorar o custo dos UDFs (além dos aprimoramentos limitados no modelo do SQL Server 2014 CE para isso). Isso deixa coisas como guias de plano, reescritas manuais de consultas (incluindo a TOP (1)ideia ou o uso de tabelas temporárias intermediárias) e evitar 'caixas-pretas' com custo baixo (do ponto de vista do QO) como funções não-inline.

Reescrever CROSS APPLYcomo OUTER APPLYtambém pode funcionar, pois atualmente evita parte do trabalho inicial de colapso de junções, mas você deve ter cuidado para preservar a semântica da consulta original (por exemplo, rejeitar qualquer NULLlinha estendida que possa ser introduzida, sem que o otimizador volte a aplicação cruzada). Você precisa estar ciente, no entanto, de que esse comportamento não é garantido para permanecer estável; portanto, lembre-se de testar novamente esses comportamentos observados toda vez que você corrigir ou atualizar o SQL Server.

No geral, a solução certa para você depende de vários fatores que não podemos julgar por você. No entanto, encorajo você a considerar soluções que garantem que sempre funcionem no futuro e que funcionam com (e não contra) o otimizador sempre que possível.


24

Parece que esta é uma decisão baseada no custo do otimizador, mas uma decisão bastante ruim.

Se você adicionar 50000 linhas ao PRODUCT, o otimizador achará que a verificação é muito trabalhosa e fornecerá um plano com três buscas e uma chamada para o UDF.

O plano que recebo para 6655 linhas no PRODUCT

insira a descrição da imagem aqui

Com 50000 linhas no PRODUCT, recebo esse plano.

insira a descrição da imagem aqui

Eu acho que o custo para ligar para a UDF é subestimado.

Uma solução alternativa que funciona bem nesse caso é alterar a consulta para usar a aplicação externa no UDF. Eu obtenho o bom plano, não importa quantas linhas existam na tabela 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

insira a descrição da imagem aqui

A melhor solução alternativa no seu caso é provavelmente obter os valores necessários em uma tabela temporária e, em seguida, consultar a tabela temporária com uma cruz aplicada ao UDF. Dessa forma, você tem certeza de que o UDF não será executado mais do que o necessário.

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

Em vez de persistir até a tabela temporária, você pode usar top()em uma tabela derivada para forçar o SQL Server a avaliar o resultado das associações antes que o UDF seja chamado. Basta usar um número realmente alto no topo, fazendo com que o SQL Server conte suas linhas para essa parte da consulta antes que ela possa continuar e use o 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

insira a descrição da imagem aqui

Gostaria de entender o que poderia ser o motivo disso, já que todas as operações são feitas usando chaves primárias e como corrigi-lo se ocorrer em uma consulta mais complexa que não pode ser resolvida com facilidade.

Eu realmente não posso responder isso, mas pensei que deveria compartilhar o que sei de qualquer maneira. Não sei por que uma varredura da tabela PRODUCT é considerada. Pode haver casos em que essa é a melhor coisa a fazer e há coisas a respeito de como os otimizadores tratam UDFs que eu não conheço.

Uma observação extra foi que sua consulta obtém um bom plano no SQL Server 2014 com o novo estimador de cardinalidade. Isso ocorre porque o número estimado de linhas para cada chamada ao UDF é 100 em vez de 1, como no SQL Server 2012 e antes. Mas ainda assim tomará a mesma decisão baseada em custo entre a versão de varredura e a versão de busca do plano. Com menos de 500 (497 no meu caso) linhas no PRODUCT, você obtém a versão de varredura do plano, mesmo no SQL Server 2014.


2
De alguma forma me faz lembrar da sessão de Adam Machanic no SQL Bits: sqlbits.com/Sessions/Event14/...
James Z
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.