La requête s'interrompt après avoir renvoyé un nombre fixe de lignes

8

J'ai une vue qui s'exécute rapidement (quelques secondes) jusqu'à 41 enregistrements (par exemple, TOP 41) mais prend plusieurs minutes pour 44 enregistrements ou plus, avec des résultats intermédiaires si exécutés avec TOP 42ou TOP 43. Plus précisément, il renverra les 39 premiers enregistrements en quelques secondes, puis s'arrêtera pendant près de trois minutes avant de rendre les enregistrements restants. Ce modèle est le même lors de la requête TOP 44ou TOP 100.

Cette vue dérivait à l'origine d'une vue de base, ajoutant à la base un seul filtre, le dernier dans le code ci-dessous. Il ne semble pas y avoir de différence si j'enchaîne la vue enfant depuis la base ou si j'écris la vue enfant avec le code de la base en ligne. La vue de base renvoie 100 enregistrements en quelques secondes. J'aimerais penser que je peux faire fonctionner la vue enfant aussi rapidement que la base, pas 50 fois plus lentement. Quelqu'un a-t-il vu ce genre de comportement? Des suppositions sur la cause ou la résolution?

Ce comportement a été cohérent au cours des dernières heures car j'ai testé les requêtes impliquées, bien que le nombre de lignes renvoyées avant que les choses ne commencent à ralentir ait légèrement augmenté. Ce n'est pas nouveau; Je le regarde maintenant parce que le temps d'exécution total était acceptable (<2 minutes), mais j'ai vu cette pause dans les fichiers journaux associés pendant au moins des mois.

Blocage

Je n'ai jamais vu la requête bloquée, et le problème existe même lorsqu'il n'y a aucune autre activité sur la base de données (telle que validée par sp_WhoIsActive). La vue de base comprend NOLOCKtout au long de ce que cela vaut.

Requêtes

Voici une version réduite de la vue enfant, avec la vue de base intégrée pour plus de simplicité. Il présente toujours le saut dans le temps d'exécution à environ 40 enregistrements.

SELECT TOP 100 PERCENT
    Map.SalesforceAccountID AS Id,
    CAST(C.CustomerID AS NVARCHAR(255)) AS Name,
    CASE WHEN C.StreetAddress = 'Unknown' THEN '' ELSE C.StreetAddress                 END AS BillingStreet,
    CASE WHEN C.City          = 'Unknown' THEN '' ELSE SUBSTRING(C.City,        1, 40) END AS BillingCity,
                                                       SUBSTRING(C.Region,      1, 20)     AS BillingState,
    CASE WHEN C.PostalCode    = 'Unknown' THEN '' ELSE SUBSTRING(C.PostalCode,  1, 20) END AS BillingPostalCode,
    CASE WHEN C.Country       = 'Unknown' THEN '' ELSE SUBSTRING(C.Country,     1, 40) END AS BillingCountry,
    CASE WHEN C.PhoneNumber   = 'Unknown' THEN '' ELSE C.PhoneNumber                   END AS Phone,
    CASE WHEN C.FaxNumber     = 'Unknown' THEN '' ELSE C.FaxNumber                     END AS Fax,
    TransC.WebsiteAddress AS Website,
    C.AccessKey AS AccessKey__c,
    CASE WHEN dbo.ValidateEMail(C.EMailAddress) = 1 THEN C.EMailAddress END,  -- Removing this UDF does not speed things
    TransC.EmailSubscriber
    -- A couple dozen additional TransC fields
FROM
    WarehouseCustomers AS C WITH (NOLOCK)
    INNER JOIN TransactionalCustomers AS TransC WITH (NOLOCK) ON C.CustomerID = TransC.CustomerID
    LEFT JOIN  Salesforce.AccountsMap AS Map WITH (NOLOCK) ON C.CustomerID = Map.CustomerID
WHERE
        C.DateMadeObsolete IS NULL
    AND C.EmailAddress NOT LIKE '%@volusion.%'
    AND C.AccessKey IN ('C', 'R')
    AND C.CustomerID NOT IN (243566)  -- Exclude specific test records
    AND EXISTS (SELECT * FROM Orders AS O WHERE C.CustomerID = O.CustomerID AND O.OrderDate >= '2010-06-28')  -- Only count customers who've placed a recent order
    AND Map.SalesforceAccountID IS NULL  -- Only count customers not already uploaded to Salesforce
-- Removing the ORDER BY clause does not speed things up
ORDER BY
    C.CustomerID DESC

Ce Id IS NULLfiltre supprime la plupart des enregistrements renvoyés par BaseView; sans TOPclause, ils renvoient respectivement 1 100 enregistrements et 267 Ko.

Statistiques

Lors de l'exécution TOP 40:

SQL Server parse and compile time:    CPU time = 234 ms, elapsed time = 247 ms.
SQL Server Execution Times:   CPU time = 0 ms,  elapsed time = 0 ms.
SQL Server Execution Times:   CPU time = 0 ms,  elapsed time = 0 ms.

(40 row(s) affected)
Table 'CustomersHistory'. Scan count 2, logical reads 39112, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Orders'. Scan count 1, logical reads 752, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'AccountsMap'. Scan count 1, logical reads 458, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

SQL Server Execution Times:   CPU time = 2199 ms,  elapsed time = 7644 ms.

Lors de l'exécution TOP 45:

(45 row(s) affected)
Table 'CustomersHistory'. Scan count 2, logical reads 98268, physical reads 1, read-ahead reads 3, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Orders'. Scan count 1, logical reads 1788, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'AccountsMap'. Scan count 1, logical reads 2152, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

SQL Server Execution Times: CPU time = 41980 ms,  elapsed time = 177231 ms.

Je suis surpris de voir le nombre de lectures bondir d'environ 3 fois pour cette modeste différence de sortie réelle.

En comparant les plans d'exécution, ils sont identiques, à l'exception du nombre de lignes renvoyées. Comme avec les statistiques ci-dessus, le nombre réel de lignes pour les premières étapes est considérablement plus élevé dans la TOP 45requête, pas seulement 12,5% plus élevé.

En résumé, il analyse un index de couverture à partir des commandes, en recherchant les enregistrements correspondants auprès de WarehouseCustomers; joindre cette boucle à TransactionalCustomers (requête distante, plan exact inconnu); et la fusionner avec une analyse de table de AccountsMap. La requête à distance représente 94% du coût estimé.

Notes diverses

Plus tôt, lorsque j'ai exécuté le contenu étendu de la vue en tant que requête autonome, cela s'est déroulé assez rapidement: 13 secondes pour 100 enregistrements. Je teste maintenant une version réduite de la requête, sans sous-requêtes, et cette requête beaucoup plus simple prend trois minutes pour demander de renvoyer plus de 40 lignes, même lorsqu'elle est exécutée en tant que requête autonome.

La vue enfant comprend un nombre important de lectures (~ 1 M par sp_WhoIsActive), mais sur cette machine (huit cœurs, 32 Go de RAM, 95% de boîte SQL dédiée), ce n'est normalement pas un problème.

J'ai supprimé et recréé les deux vues plusieurs fois, sans aucun changement.

Les données ne comprennent aucun champ TEXT ou BLOB. Un domaine implique un UDF; le retirer n'empêche pas la pause.

Les temps sont similaires, que ce soit sur le serveur lui-même ou sur mon poste de travail à 1 400 miles de distance, donc le délai semble être inhérent à la requête elle-même plutôt que d'envoyer les résultats au client.

Notes Re: la solution

Le correctif a fini par être simple: remplacer le LEFT JOINto Map par une NOT EXISTSclause. Cela ne provoque qu'une seule petite différence dans le plan de requête, la jointure à la table TransactionCustomers (une requête distante) après la jointure à la table Map au lieu d'avant. Cela peut signifier qu'il ne demande que les enregistrements nécessaires au serveur distant, ce qui réduirait le volume transmis ~ 100 fois.

D'ordinaire, je suis le premier à applaudir NOT EXISTS; il est souvent plus rapide qu'une LEFT JOIN...WHERE ID IS NULLconstruction et légèrement plus compact. Dans ce cas, c'est gênant car la requête problématique est construite sur une vue existante, et bien que le champ nécessaire à l'anti-jointure soit exposé par la vue de base, il est d'abord converti de l'entier en texte. Donc, pour des performances décentes, je dois supprimer le modèle à deux couches et avoir à la place deux vues presque identiques, la seconde comprenant la NOT EXISTSclause.

Merci à tous pour votre aide dans la résolution de ce problème! Cela peut être trop spécifique à ma situation pour aider quelqu'un d'autre, mais j'espère que non. Si rien d'autre, c'est un exemple d' NOT EXISTSêtre plus que légèrement plus rapide que LEFT JOIN...WHERE ID IS NULL. Mais la vraie leçon est probablement de s'assurer que les requêtes distantes sont jointes aussi efficacement que possible; le plan de requête prétend qu'il représente 2% du coût, mais il n'est pas toujours estimé avec précision.

Jon de tous les métiers
la source
Les commentaires ne sont pas pour une discussion approfondie; cette conversation a été déplacée vers le chat .
Paul White 9

Réponses:

4

Quelques choses à essayer:

  1. Vérifiez vos index

    • Tous les JOINchamps clés sont-ils indexés? Si vous utilisez beaucoup cette vue, j'irais jusqu'à ajouter un index filtré pour les critères dans la vue. Par exemple...

    • CREATE INDEX ix_CustomerId ON WarehouseCustomers(CustomerId, EmailAddress) WHERE DateMadeObsolete IS NULL AND AccessKey IN ('C', 'R') AND CustomerID NOT IN (243566)

  2. Mettre à jour les statistiques

    • Il pourrait y avoir des problèmes avec les statistiques obsolètes. Si vous pouvez le balancer, je le ferais FULLSCAN. S'il y a un grand nombre de lignes, il est possible que les données aient considérablement changé sans déclencher un recalcul automatique.
  3. Nettoyer la requête

    • Faites Map JOINun NOT EXISTS- Vous n'avez pas besoin de données de cette table, car vous ne voulez que des enregistrements non correspondants

    • Retirez le ORDER BY. Je sais que les commentaires disent que cela n'a pas d'importance, mais je trouve cela très difficile à croire. Cela peut ne pas avoir d'importance pour vos jeux de résultats plus petits car les pages de données sont déjà mises en cache.

JNK
la source
Point intéressant: l'indice filtré. La requête ne l'utilise pas automatiquement, mais je vais tester le forcer avec un indice. J'ai mis à jour les statistiques et je peux tester cela et vos autres recommandations plus tard dans la journée; Je dois laisser un backlog s'accumuler après EOWD afin de pouvoir tester un ensemble de données décent.
Jon of All Trades
J'ai essayé différentes combinaisons de ces réglages, et la clé semble être l'anti-jointure avec Map. Comme LEFT JOIN...WHERE Id IS NULL, je reçois cette pause; en tant que NOT EXISTSclause, le temps d'exécution est en secondes. Je suis surpris, mais je ne peux pas contester les résultats!
Jon of All Trades
2

Amélioration 1 Supprimez la sous-requête pour les commandes et convertissez-la en jointure

FROM
WarehouseCustomers AS C WITH (NOLOCK)
INNER JOIN TransactionalCustomers AS TransC WITH (NOLOCK) 
                                                        ON C.CustomerID = TransC.CustomerID
LEFT JOIN  Salesforce.AccountsMap AS Map WITH (NOLOCK) 
                                                        ON C.CustomerID = Map.CustomerID
INNER Join Orders AS O 
                                                        ON C.CustomerID = O.CustomerID

 WHERE
    C.DateMadeObsolete IS NULL
    AND C.EmailAddress NOT LIKE '%@volusion.%'
    AND C.AccessKey IN ('C', 'R')
    AND C.CustomerID NOT IN (243566)
    AND O.OrderDate >= '2010-06-28'
    AND Map.SalesforceAccountID IS NULL

Amélioration 2 - Conserver les enregistrements filtrés TransactionalCustomers dans une table temporaire locale

Select 
    CAST(C.CustomerID AS NVARCHAR(255)) AS Name,
    CASE WHEN C.StreetAddress = 'Unknown' THEN '' ELSE C.StreetAddress                 END AS BillingStreet,
    CASE WHEN C.City          = 'Unknown' THEN '' ELSE SUBSTRING(C.City,        1, 40) END AS BillingCity,
                                                       SUBSTRING(C.Region,      1, 20)     AS BillingState,
    CASE WHEN C.PostalCode    = 'Unknown' THEN '' ELSE SUBSTRING(C.PostalCode,  1, 20) END AS BillingPostalCode,
    CASE WHEN C.Country       = 'Unknown' THEN '' ELSE SUBSTRING(C.Country,     1, 40) END AS BillingCountry,
    CASE WHEN C.PhoneNumber   = 'Unknown' THEN '' ELSE C.PhoneNumber                   END AS Phone,
    CASE WHEN C.FaxNumber     = 'Unknown' THEN '' ELSE C.FaxNumber                     END AS Fax,
    C.AccessKey AS AccessKey__c
Into #Temp
From  WarehouseCustomers C
Where C.DateMadeObsolete IS NULL
        AND C.EmailAddress NOT LIKE '%@volusion.%'
        AND C.AccessKey IN ('C', 'R')
        AND C.CustomerID NOT IN (243566)

Requête finale

FROM
#Temp AS C WITH (NOLOCK)
INNER JOIN TransactionalCustomers AS TransC WITH (NOLOCK) 
                                                            ON C.CustomerID = TransC.CustomerID
LEFT JOIN Salesforce.AccountsMap AS Map WITH (NOLOCK) 
                                                            ON C.CustomerID = Map.CustomerID
INNER Join Orders AS O 
                                                            ON C.CustomerID = O.CustomerID

WHERE
C.DateMadeObsolete IS NULL
AND C.EmailAddress NOT LIKE '%@volusion.%'
AND C.AccessKey IN ('C', 'R')
AND C.CustomerID NOT IN (243566)
AND O.OrderDate >= '2010-06-28'
AND Map.SalesforceAccountID IS NULL

Point 3 - Je suppose que vous avez des index sur CustomerID, EmailAddress, OrderDate

Pankaj Garg
la source
1
Re: "Amélioration" 1 - EXISTSest normalement plus rapide que JOINdans ce cas, et élimine les dupes potentiels. Je ne pense pas que ce serait une amélioration du tout.
JNK
1
le problème est double, cependant - cela changera potentiellement les résultats, et à moins que les deux tables aient un index clusterisé unique sur les champs utilisés dans la jointure, il sera moins efficace qu'un EXISTS. Les paragraphes ne sont pas toujours mauvais.
JNK
@PankajGarg: Merci pour les suggestions, malheureusement il y a généralement plusieurs commandes par client, donc EXISTSc'est obligatoire. De plus, dans une vue, je ne peux pas mettre en cache les données client réutilisées, même si j'ai joué avec l'idée d'un TVF factice sans paramètres.
Jon of All Trades