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

26

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

Plan de requête

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

Explorateur de plans 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:

Plan de requête avec le haut

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 une 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)
)
James Z
la source

Réponses:

30

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

  1. Le cadre d'établissement des coûts de l'optimiseur ne prend pas réellement en charge les fonctions non intégrées. 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 se qualifie 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 ultérieures ne soient pas entrées.

Étant donné la petite estimation de cardinalité attribuée à la FDU, 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 vers le haut de 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:

Prévoyez des tables vides avec la recherche 0 désactivée

Il n'y a 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' TOP (1)idée ou l'utilisation de tables temporaires intermédiaires) et éviter les `` boîtes noires '' à faible coût (d'un point de vue QO) comme les fonctions non en ligne.

La réécriture en CROSS APPLYtant que OUTER APPLYpeut également fonctionner, car elle 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, rejeter toutes les NULLlignes étendues qui pourraient être introduites, sans que l'optimiseur ne se replie en un croix s'appliquent). Cependant, vous devez être conscient 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.

Paul White dit GoFundMonica
la source
24

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

entrez la description de l'image ici

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

entrez la description de l'image ici

Je suppose que le coût pour 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

entrez la description de l'image ici

La meilleure solution de contournement 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 l'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

entrez la description de l'image ici

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 UDF 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 de scan du plan même dans SQL Server 2014.

Mikael Eriksson
la source
2
Cela me rappelle en quelque sorte la session d'Adam Machanic sur SQL Bits: sqlbits.com/Sessions/Event14/…
James Z