web-dev-qa-db-fra.com

Problème d'optimisation avec la fonction définie par l'utilisateur

J'ai un problème à comprendre pourquoi SQL Server décide d'appeler une fonction définie par l'utilisateur pour chaque valeur de la table même si une seule ligne doit être récupérée. Le SQL réel est beaucoup plus complexe, mais j'ai pu réduire le problème à ceci:

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'

Pour cette requête, SQL Server décide d'appeler la fonction GetGroupCode pour chaque valeur unique qui existe dans la table PRODUCT, même si l'estimation et le nombre réel de lignes renvoyées par ORDERLINE est 1 (c'est la clé primaire):

Query Plan

Même plan dans l'explorateur de plans montrant le nombre de lignes:

Plan Explorer Les tables:

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

L'index utilisé pour l'analyse est:

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

La fonction est en fait légèrement plus complexe, mais la même chose se produit avec une fonction multi-instructions factice comme celle-ci:

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

J'ai pu "corriger" les performances en forçant SQL Server à récupérer le premier produit, bien que 1 soit le maximum que l'on puisse trouver:

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'

Ensuite, la forme du plan change également pour être quelque chose que je m'attendais à ce qu'il soit à l'origine:

Query Plan with top

J'ai également pensé que l'index PRODUCT_FACTORY étant plus petit que l'index cluster PRODUCT_PK aurait un effet, mais même en forçant la requête à utiliser PRODUCT_PK, le plan est toujours le même que l'original, avec 6655 appels à la fonction.

Si je laisse complètement ORDERHDR, le plan commence par la boucle imbriquée entre ORDERLINE et PRODUCT en premier, et la fonction n'est appelée qu'une seule fois.

Je voudrais comprendre quelle pourrait être la raison de cela car toutes les opérations sont effectuées à l'aide de clés primaires et comment y remédier si cela se produit dans une requête plus complexe qui ne peut pas être résolue aussi facilement.

Modifier: créer des instructions de table:

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)
)
26
James Z

Il y a trois raisons techniques principales pour lesquelles vous obtenez le plan que vous faites:

  1. Le cadre de calcul des coûts de l'optimiseur a pas de support réel pour les fonctions non en ligne. Il n'essaie pas de regarder à l'intérieur de la définition de la fonction pour voir à quel point elle pourrait être chère, elle attribue simplement un très petit coût fixe et estime que la fonction produira 1 ligne de sortie à chaque appel. Ces deux hypothèses de modélisation sont très souvent totalement dangereuses. La situation s'est très légèrement améliorée en 2014 avec le nouvel estimateur de cardinalité activé puisque la supposition fixe à 1 ligne est remplacée par une supposition fixe à 100 lignes. Cependant, il n'y a toujours pas de prise en charge pour chiffrer le contenu des fonctions non en ligne.
  2. SQL Server réduit initialement les jointures et les applique dans une seule jointure logique n-aire interne. Cela permet à l'optimiseur de justifier ultérieurement la jointure des commandes. L'extension de la jointure n-aire unique dans les ordres de jointure candidats vient plus tard et est largement basée sur l'heuristique. Par exemple, les jointures internes précèdent les jointures externes, les petites tables et les jointures sélectives avant les grandes tables et les jointures moins sélectives, etc.
  3. Lorsque SQL Server effectue une optimisation basée sur les coûts, il divise l'effort en phases facultatives pour minimiser les chances de passer trop de temps à optimiser les requêtes à faible coût. Il y a trois phases principales, la recherche 0, la recherche 1 et la recherche 2. Chaque phase a des conditions d'entrée, et les phases ultérieures permettent plus d'explorations d'optimiseur que les précédentes. Il se trouve que votre requête est éligible pour la phase de recherche la moins performante, la phase 0. Un plan de coûts suffisamment bas y est trouvé pour que les étapes suivantes ne soient pas entrées.

Compte tenu de la petite estimation de cardinalité attribuée à l'application UDF, l'heuristique d'expansion de jointure n-aire la repositionne malheureusement plus tôt dans l'arborescence que vous ne le souhaiteriez.

La requête se qualifie également pour l'optimisation de la recherche 0 en raison d'avoir au moins trois jointures (y compris s'applique). Le plan physique final que vous obtenez, avec l'analyse étrange, est basé sur cet ordre de jointure déduit heuristiquement. Son coût est suffisamment bas pour que l'optimiseur considère le plan comme "assez bon". L'estimation de faible coût et la cardinalité de l'UDF contribuent à cette finition précoce.

La recherche 0 (également appelée phase de traitement des transactions) cible les requêtes de type OLTP à faible cardinalité, avec des plans finaux qui comportent généralement des jointures de boucles imbriquées. Plus important encore, la recherche 0 n'exécute qu'un sous-ensemble relativement petit des capacités d'exploration de l'optimiseur. Ce sous-ensemble n'inclut pas l'extraction d'une application dans l'arborescence de requête sur une jointure (règle PullApplyOverJoin). C'est exactement ce qui est nécessaire dans le cas de test pour repositionner l'application UDF au-dessus des jointures, pour apparaître en dernier dans la séquence des opérations (pour ainsi dire).

Il existe également un problème où l'optimiseur peut décider entre la jointure de boucles imbriquées naïve (prédicat de jointure sur la jointure elle-même) et une jointure indexée corrélée (appliquer) où le prédicat corrélé est appliqué sur le côté intérieur de la jointure à l'aide d'une recherche d'index. Ce dernier a généralement la forme de plan souhaitée, mais l'optimiseur est capable d'explorer les deux. Avec des estimations de coût et de cardinalité incorrectes, il peut choisir la jointure NL non applicable, comme dans les plans soumis (expliquant l'analyse).

Il existe donc plusieurs raisons d'interaction impliquant plusieurs fonctionnalités d'optimisation générales qui fonctionnent normalement bien pour trouver de bons plans dans un court laps de temps sans utiliser de ressources excessives. Il suffit d'éviter l'une des raisons pour produire la forme de plan "attendue" pour l'exemple de requête, même avec des tables vides:

Plan on empty tables with search 0 disabled

Il n'existe aucun moyen pris en charge pour éviter la sélection du plan de recherche 0, l'arrêt anticipé de l'optimiseur ou pour améliorer le coût des FDU (à part les améliorations limitées du modèle SQL Server 2014 CE pour cela). Cela laisse des choses comme les guides de plan, les réécritures de requêtes manuelles (y compris l'idée TOP (1) ou l'utilisation de tables temporaires intermédiaires) et évitant les `` boîtes noires '' peu coûteuses (du point de vue QO) comme les fonctions non en ligne.

Réécriture CROSS APPLY comme OUTER APPLY peut également fonctionner, car il empêche actuellement certains des premiers travaux de regroupement des jointures, mais vous devez faire attention à conserver la sémantique de la requête d'origine (par exemple, en rejetant toutes les lignes étendues NULL- qui pourraient être introduites, sans l'optimiseur se repliant en croix). Cependant, vous devez savoir que ce comportement n'est pas garanti pour rester stable, vous devez donc vous rappeler de tester à nouveau tous les comportements observés à chaque fois que vous corrigez ou mettez à niveau SQL Server.

Dans l'ensemble, la bonne solution pour vous dépend d'une variété de facteurs que nous ne pouvons pas juger pour vous. Cependant, je vous encourage à envisager des solutions qui sont garanties de toujours fonctionner à l'avenir et qui fonctionnent avec (plutôt que contre) l'optimiseur dans la mesure du possible.

30
Paul White 9

Il semble que ce soit une décision basée sur les coûts de l'optimiseur mais plutôt mauvaise.

Si vous ajoutez 50000 lignes à PRODUCT, l'optimiseur pense que le scan est trop de travail et vous donne un plan avec trois recherches et un appel à l'UDF.

Le plan que j'obtiens pour 6655 lignes dans PRODUCT

enter image description here

Avec 50000 lignes dans PRODUCT, j'obtiens ce plan à la place.

enter image description here

Je suppose que le coût d'appeler l'UDF est largement sous-estimé.

Une solution de contournement qui fonctionne correctement dans ce cas consiste à modifier la requête pour utiliser une application externe contre l'UDF. J'obtiens le bon plan, peu importe le nombre de lignes dans la table PRODUIT.

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

enter image description here

La meilleure solution dans votre cas est probablement d'obtenir les valeurs dont vous avez besoin dans une table temporaire, puis d'interroger la table temporaire avec une croix à appliquer à l'UDF. De cette façon, vous êtes sûr que l'UDF ne sera pas exécuté plus que nécessaire.

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

Au lieu de persister dans la table temporaire, vous pouvez utiliser top() dans une table dérivée pour forcer SQL Server à évaluer le résultat des jointures avant l'appel de l'UDF. Utilisez simplement un nombre très élevé en haut, ce qui oblige SQL Server à compter vos lignes pour cette partie de la requête avant de pouvoir continuer et utiliser l'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

enter image description here

Je voudrais comprendre quelle pourrait être la raison de cela car toutes les opérations sont effectuées à l'aide de clés primaires et comment y remédier si cela se produit dans une requête plus complexe qui ne peut pas être résolue aussi facilement.

Je ne peux vraiment pas répondre à cela, mais j'ai pensé que je devrais partager ce que je sais de toute façon. Je ne sais pas pourquoi une analyse de la table PRODUCT est envisagée. Il peut y avoir des cas où c'est la meilleure chose à faire et il y a des choses concernant la façon dont les optimiseurs traitent les FDU que je ne connais pas.

Une observation supplémentaire était que votre requête obtient un bon plan dans SQL Server 2014 avec le nouvel estimateur de cardinalité. En effet, le nombre estimé de lignes pour chaque appel à l'UDF est de 100 au lieu de 1, comme c'est le cas dans SQL Server 2012 et avant. Mais il prendra toujours la même décision basée sur les coûts entre la version de scan et la version de recherche du plan. Avec moins de 500 lignes (497 dans mon cas) dans PRODUCT, vous obtenez la version d'analyse du plan même dans SQL Server 2014.

24
Mikael Eriksson