MĂ©thodes d'optimisation des requĂȘtes LINQ en C # .NET

introduction


Dans cet article, nous avons discutĂ© de certaines techniques d'optimisation des requĂȘtes LINQ .
Voici quelques autres approches d'optimisation de code liĂ©es aux requĂȘtes LINQ .

Il est connu que LINQ (Language-Integrated Query) est un langage simple et pratique pour interroger une source de données.

Et LINQ to SQL est une technologie d'accĂšs aux donnĂ©es dans un SGBD. Il s'agit d'un outil puissant pour travailler avec des donnĂ©es, oĂč les requĂȘtes sont construites via un langage dĂ©claratif, qui sera ensuite converti en requĂȘtes SQL par la plateforme et envoyĂ© au serveur de base de donnĂ©es dĂ©jĂ  pour exĂ©cution. Dans notre cas, par SGBD, nous entendons MS SQL Server .

Cependant, les requĂȘtes LINQ ne sont pas converties en requĂȘtes SQL Ă©crites de maniĂšre optimale qu'un DBA expĂ©rimentĂ© pourrait Ă©crire avec toutes les nuances de l'optimisation des requĂȘtes SQL :

  1. connexions optimales ( JOIN ) et filtrage des résultats ( WHERE )
  2. de nombreuses nuances dans l'utilisation des composés et des conditions de groupe
  3. de nombreuses variantes pour remplacer les conditions IN par EXISTS et NOT IN , <> par EXISTS
  4. mise en cache intermédiaire des résultats via des tables temporaires, CTE, variables de table
  5. en utilisant une clause ( OPTION ) avec des instructions et des conseils de table WITH (...)
  6. l'utilisation de vues indexées, comme moyen de se débarrasser des lectures redondantes des données dans les échantillons

Les principaux goulots d'Ă©tranglement des performances des requĂȘtes SQL rĂ©sultantes lors de la compilation des requĂȘtes LINQ sont les suivants:

  1. consolidation de l'ensemble du mécanisme de sélection des données en une seule demande
  2. duplication de blocs de code identiques, ce qui conduit finalement à de multiples lectures supplémentaires de données
  3. groupes de conditions à plusieurs composants (logiques "et" et "ou") - ET et OU , se combinant dans des conditions difficiles, conduit au fait que l'optimiseur, ayant des index non clusterisés appropriés, par les champs nécessaires, commence finalement à balayer par l'index de cluster ( INDEX SCAN ) par groupe de conditions
  4. imbrication profonde des sous - requĂȘtes rend trĂšs problĂ©matique pour analyser les instructions SQL et d' analyser les plans de requĂȘtes des dĂ©veloppeurs et administrateurs de bases

MĂ©thodes d'optimisation


Passons maintenant directement aux méthodes d'optimisation.

1) Indexation supplémentaire


Il est prĂ©fĂ©rable de considĂ©rer les filtres sur les tables d'Ă©chantillonnage principales, car trĂšs souvent, la requĂȘte entiĂšre est construite autour d'une ou deux tables principales (applications-personnes-opĂ©rations) et avec un ensemble standard de conditions (IsClosed, Canceled, Enabled, Status). Il est important que les Ă©chantillons identifiĂ©s crĂ©ent les index correspondants.

Cette solution est logique lorsque le choix dans ces champs limite considĂ©rablement l'ensemble renvoyĂ© Ă  la requĂȘte.

Par exemple, nous avons 500 000 applications. Cependant, il n'y a que 2 000 entrées actives. Ensuite, un index correctement sélectionné nous sauvera d' INDEX SCAN sur une grande table et nous permettra de sélectionner rapidement les données via un index non clusterisé.

Le manque d'index peut Ă©galement ĂȘtre dĂ©tectĂ© via des invites pour l'analyse des plans de requĂȘte ou la collecte de statistiques pour les vues systĂšmeMS SQL Server :

  1. sys.dm_db_missing_index_groups
  2. sys.dm_db_missing_index_group_stats
  3. sys.dm_db_missing_index_details

Toutes les données de vue contiennent des informations sur les index manquants, à l'exception des index spatiaux.

Cependant, les index et la mise en cache sont souvent des mĂ©thodes pour gĂ©rer les effets des requĂȘtes LINQ et SQL mal Ă©crites .

Comme le montre la dure pratique de la vie pour l'entreprise, il est souvent important de mettre en Ɠuvre les fonctionnalitĂ©s de l'entreprise Ă  une certaine date. Et par consĂ©quent, les requĂȘtes souvent lourdes sont mises en arriĂšre-plan avec la mise en cache.

Cela est en partie justifié, car l'utilisateur n'a pas toujours besoin des derniÚres données et un niveau acceptable de réponse d'interface utilisateur se produit.

Cette approche vous permet de résoudre les besoins de l'entreprise, mais réduit finalement l'efficacité du systÚme d'information, retardant simplement la résolution des problÚmes.

Il convient Ă©galement de se rappeler que dans le processus de recherche nĂ©cessaire pour ajouter de nouveaux index, les propositions d' optimisation MS SQL peuvent ĂȘtre incorrectes, y compris dans les conditions suivantes:

  1. si des index avec un ensemble similaire de champs existent déjà
  2. si les champs de la table ne peuvent pas ĂȘtre indexĂ©s en raison de restrictions d'indexation (plus d'informations Ă  ce sujet sont dĂ©crites ici ).

2) Fusion des attributs en un nouvel attribut


Parfois, certains champs de la mĂȘme table par laquelle un groupe de conditions se produit peuvent ĂȘtre remplacĂ©s par l'introduction d'un nouveau champ.

Cela est particuliÚrement vrai pour les champs d'état, qui, par type, sont généralement au niveau du bit ou de l'entier.

Exemple:

IsClosed = 0 AND Canceled = 0 AND Enabled = 0 est remplacé par Status = 1 .

Vous saisissez ici l'attribut entier Status, qui est fourni en remplissant ces statuts dans le tableau. L'Ă©tape suivante consiste Ă  indexer ce nouvel attribut.

Il s'agit d'une solution fondamentale au problÚme de performances, car nous demandons des données sans calculs inutiles.

3) Matérialisation de la soumission


Malheureusement, les requĂȘtes LINQ ne peuvent pas utiliser directement des tables temporaires, des CTE et des variables de table.

Cependant, il existe un autre moyen d'optimiser ce cas: il s'agit des vues indexées.

Un groupe de conditions (de l'exemple ci-dessus) IsClosed = 0 AND Canceled = 0 AND Enabled = 0 (ou un ensemble d'autres conditions similaires) devient une bonne option pour les utiliser dans une vue indexée, en mettant en cache une petite tranche de données à partir d'un grand ensemble.

Mais il existe un certain nombre de limitations lors de la matérialisation d'une vue:

  1. en utilisant des sous-requĂȘtes, les clauses EXISTS doivent ĂȘtre remplacĂ©es Ă  l'aide de JOIN
  2. Impossible d' utiliser les clauses UNION , UNION ALL , EXCEPTION , INTERSECT
  3. vous ne pouvez pas utiliser les conseils de table et les clauses OPTION
  4. aucune capacité à travailler avec des cycles
  5. il est impossible d'afficher des données dans une vue à partir de différentes tables

Il est important de se rappeler que les avantages rĂ©els de l'utilisation d'une vue indexĂ©e ne peuvent ĂȘtre obtenus qu'en l'indexant.

Mais lors de l'appel d'une vue, ces index ne peuvent pas ĂȘtre utilisĂ©s et pour les utiliser explicitement, vous devez spĂ©cifier WITH (NOEXPAND) .

Puisqu'il est impossible de dĂ©finir des conseils de table dans les requĂȘtes LINQ , vous devez donc faire une autre reprĂ©sentation - un «wrapper» de la forme suivante:

CREATE VIEW _ AS SELECT * FROM MAT_VIEW WITH (NOEXPAND);

4) Utilisation des fonctions de table


Souvent dans les requĂȘtes LINQ, de grands blocs de sous-requĂȘte ou des blocs qui utilisent des reprĂ©sentations avec une structure complexe forment la requĂȘte finale avec une structure d'exĂ©cution trĂšs complexe et non optimale.

Principaux avantages de l'utilisation des fonctions de table dans les requĂȘtes LINQ :

  1. La possibilité, comme dans le cas des vues, d'utiliser et de spécifier comme objet, mais vous pouvez passer un ensemble de paramÚtres d'entrée:
    FROM FUNCTION (@ param1, @ param2 ...)
    au final, vous pouvez obtenir un échantillonnage de données flexible
  2. Lorsque vous utilisez une fonction de table, il n'y a pas de restrictions aussi strictes que dans le cas des vues indexées décrites ci-dessus:

    1. :
      LINQ .
      .
      ,
    2. , , :

      • ( )
      • UNION EXISTS

  3. OPTION , OPTION(MAXDOP N), . :

    • OPTION (RECOMPILE)
    • , OPTION (FORCE ORDER)

    OPTION .
  4. :
    ( ), .
    , , WHERE (a, b, c).

    a = 0 and b = 0.

    , c .

    a = 0 and b = 0 , .

    Ici, la fonction de table peut ĂȘtre une meilleure option.

    De plus, la fonction de table est plus prévisible et constante en temps d'exécution.

Exemples


Prenons un exemple d'implémentation utilisant l'exemple de la base de données Questions.

Il existe une requĂȘte SELECT qui combine plusieurs tables et utilise une seule vue (OperativeQuestions), qui vĂ©rifie par email l'affiliation (via EXISTS ) de «Active Queries» ([OperativeQuestions]):

Demande n ° 1
(@p__linq__0 nvarchar(4000))SELECT
1 AS [C1],
[Extent1].[Id] AS [Id],
[Join2].[Object_Id] AS [Object_Id],
[Join2].[ObjectType_Id] AS [ObjectType_Id],
[Join2].[Name] AS [Name],
[Join2].[ExternalId] AS [ExternalId]
FROM [dbo].[Questions] AS [Extent1]
INNER JOIN (SELECT [Extent2].[Object_Id] AS [Object_Id],
[Extent2].[Question_Id] AS [Question_Id], [Extent3].[ExternalId] AS [ExternalId],
[Extent3].[ObjectType_Id] AS [ObjectType_Id], [Extent4].[Name] AS [Name]
FROM [dbo].[ObjectQuestions] AS [Extent2]
INNER JOIN [dbo].[Objects] AS [Extent3] ON [Extent2].[Object_Id] = [Extent3].[Id]
LEFT OUTER JOIN [dbo].[ObjectTypes] AS [Extent4] 
ON [Extent3].[ObjectType_Id] = [Extent4].[Id] ) AS [Join2] 
ON [Extent1].[Id] = [Join2].[Question_Id]
WHERE ([Extent1].[AnswerId] IS NULL) AND (0 = [Extent1].[Exp]) AND ( EXISTS (SELECT
1 AS [C1]
FROM [dbo].[OperativeQuestions] AS [Extent5]
WHERE (([Extent5].[Email] = @p__linq__0) OR (([Extent5].[Email] IS NULL) 
AND (@p__linq__0 IS NULL))) AND ([Extent5].[Id] = [Extent1].[Id])
));


La vue a une structure assez compliquĂ©e: elle a des jointures de sous-requĂȘte et l'utilisation du tri DISTINCT , qui dans le cas gĂ©nĂ©ral est une opĂ©ration plutĂŽt gourmande en ressources.

Une sélection d'environ dix mille enregistrements d'OperativeQuestions.

Le principal problĂšme de cette requĂȘte est que pour les enregistrements d'une requĂȘte externe, une sous-requĂȘte interne est effectuĂ©e sur la vue [OperativeQuestions], ce qui devrait limiter l'Ă©chantillon de sortie (via EXISTS ) Ă  des centaines d'enregistrements pour [Email] = @ p__linq__0 .

Et il peut sembler que la sous-requĂȘte devrait une fois calculer les enregistrements par [Email] = @ p__linq__0, puis ces quelques centaines d'enregistrements devraient ĂȘtre connectĂ©s par des questions Id c, et la requĂȘte sera rapide.

En fait, toutes les tables sont connectées en série: les questions d'ID et l'ID des questions opérationnelles sont vérifiées pour la conformité et le courrier électronique est filtré.

En fait, la demande fonctionne avec les dizaines de milliers d'enregistrements d'OperativeQuestions et vous n'avez besoin que de donnĂ©es d'intĂ©rĂȘt par e-mail.

Texte de la vue OperativeQuestions:

Demande n ° 2
 
CREATE VIEW [dbo].[OperativeQuestions]
AS
SELECT DISTINCT Q.Id, USR.email AS Email
FROM            [dbo].Questions AS Q INNER JOIN
                         [dbo].ProcessUserAccesses AS BPU ON BPU.ProcessId = CQ.Process_Id 
OUTER APPLY
                     (SELECT   1 AS HasNoObjects
                      WHERE   NOT EXISTS
                                    (SELECT   1
                                     FROM     [dbo].ObjectUserAccesses AS BOU
                                     WHERE   BOU.ProcessUserAccessId = BPU.[Id] AND BOU.[To] IS NULL)
) AS BO INNER JOIN
                         [dbo].Users AS USR ON USR.Id = BPU.UserId
WHERE        CQ.[Exp] = 0 AND CQ.AnswerId IS NULL AND BPU.[To] IS NULL 
AND (BO.HasNoObjects = 1 OR
              EXISTS (SELECT   1
                           FROM   [dbo].ObjectUserAccesses AS BOU INNER JOIN
                                      [dbo].ObjectQuestions AS QBO 
                                                  ON QBO.[Object_Id] =BOU.ObjectId
                               WHERE  BOU.ProcessUserAccessId = BPU.Id 
                               AND BOU.[To] IS NULL AND QBO.Question_Id = CQ.Id));


Représentation cartographique originale dans DbContext (EF Core 2)
public class QuestionsDbContext : DbContext
{
    //...
    public DbQuery<OperativeQuestion> OperativeQuestions { get; set; }
    //...
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Query<OperativeQuestion>().ToView("OperativeQuestions");
    }
}


RequĂȘte LINQ d'origine
var businessObjectsData = await context
    .OperativeQuestions
    .Where(x => x.Email == Email)
    .Include(x => x.Question)
    .Select(x => x.Question)
    .SelectMany(x => x.ObjectQuestions,
                (x, bo) => new
                {
                    Id = x.Id,
                    ObjectId = bo.Object.Id,
                    ObjectTypeId = bo.Object.ObjectType.Id,
                    ObjectTypeName = bo.Object.ObjectType.Name,
                    ObjectExternalId = bo.Object.ExternalId
                })
    .ToListAsync();


Dans ce cas particulier, une solution Ă  ce problĂšme est envisagĂ©e sans changements d'infrastructure, sans introduire un tableau sĂ©parĂ© avec des rĂ©sultats prĂȘts Ă  l'emploi («Active Queries»), pour lesquels un mĂ©canisme serait nĂ©cessaire pour remplir ses donnĂ©es et les tenir Ă  jour.

Bien qu'il s'agisse d'une bonne solution, il existe une autre option pour optimiser cette tĂąche.

L'objectif principal est de mettre en cache les entrées par [Email] = @ p__linq__0 à partir de la vue OperativeQuestions.

Nous entrons la fonction de table [dbo]. [OperativeQuestionsUserMail] dans la base de données.

En envoyant un e-mail comme paramÚtre d'entrée, nous récupérons la table des valeurs:

Demande n ° 3

CREATE FUNCTION [dbo].[OperativeQuestionsUserMail]
(
    @Email  nvarchar(4000)
)
RETURNS
@tbl TABLE
(
    [Id]           uniqueidentifier,
    [Email]      nvarchar(4000)
)
AS
BEGIN
        INSERT INTO @tbl ([Id], [Email])
        SELECT Id, @Email
        FROM [OperativeQuestions]  AS [x] WHERE [x].[Email] = @Email;
     
    RETURN;
END


Cela renvoie une table de valeurs avec une structure de données prédéfinie.

Pour que les requĂȘtes vers OperativeQuestionsUserMail soient optimales, pour avoir des plans de requĂȘte optimaux, une structure stricte est requise, et non RETURNS TABLE AS RETURN ...

Dans ce cas, la demande 1 souhaitée est convertie en demande 4:

Demande n ° 4
(@p__linq__0 nvarchar(4000))SELECT
1 AS [C1],
[Extent1].[Id] AS [Id],
[Join2].[Object_Id] AS [Object_Id],
[Join2].[ObjectType_Id] AS [ObjectType_Id],
[Join2].[Name] AS [Name],
[Join2].[ExternalId] AS [ExternalId]
FROM (
    SELECT Id, Email FROM [dbo].[OperativeQuestionsUserMail] (@p__linq__0)
) AS [Extent0]
INNER JOIN [dbo].[Questions] AS [Extent1] ON([Extent0].Id=[Extent1].Id)
INNER JOIN (SELECT [Extent2].[Object_Id] AS [Object_Id], [Extent2].[Question_Id] AS [Question_Id], [Extent3].[ExternalId] AS [ExternalId], [Extent3].[ObjectType_Id] AS [ObjectType_Id], [Extent4].[Name] AS [Name]
FROM [dbo].[ObjectQuestions] AS [Extent2]
INNER JOIN [dbo].[Objects] AS [Extent3] ON [Extent2].[Object_Id] = [Extent3].[Id]
LEFT OUTER JOIN [dbo].[ObjectTypes] AS [Extent4] 
ON [Extent3].[ObjectType_Id] = [Extent4].[Id] ) AS [Join2] 
ON [Extent1].[Id] = [Join2].[Question_Id]
WHERE ([Extent1].[AnswerId] IS NULL) AND (0 = [Extent1].[Exp]);


Mappage des vues et des fonctions dans DbContext (EF Core 2)
public class QuestionsDbContext : DbContext
{
    //...
    public DbQuery<OperativeQuestion> OperativeQuestions { get; set; }
    //...
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Query<OperativeQuestion>().ToView("OperativeQuestions");
    }
}
 
public static class FromSqlQueries
{
    public static IQueryable<OperativeQuestion> GetByUserEmail(this DbQuery<OperativeQuestion> source, string Email)
        => source.FromSql($"SELECT Id, Email FROM [dbo].[OperativeQuestionsUserMail] ({Email})");
}


RequĂȘte LINQ finale
var businessObjectsData = await context
    .OperativeQuestions
    .GetByUserEmail(Email)
    .Include(x => x.Question)
    .Select(x => x.Question)
    .SelectMany(x => x.ObjectQuestions,
                (x, bo) => new
                {
                    Id = x.Id,
                    ObjectId = bo.Object.Id,
                    ObjectTypeId = bo.Object.ObjectType.Id,
                    ObjectTypeName = bo.Object.ObjectType.Name,
                    ObjectExternalId = bo.Object.ExternalId
                })
    .ToListAsync();


L'ordre du temps d'exécution est passé de 200 à 800 ms à 2 à 20 ms, etc., c'est-à-dire dix fois plus vite.

Si nous prenons plus de moyenne, alors au lieu de 350 ms, nous avons obtenu 8 ms.

Des avantages Ă©vidents, nous obtenons Ă©galement:

  1. réduction générale de la charge de lecture,
  2. réduction significative de la probabilité de blocage
  3. réduction du temps de blocage moyen à des valeurs acceptables

Conclusion


L'optimisation et le rĂ©glage fin des appels Ă  la base de donnĂ©es MS SQL via LINQ est un problĂšme qui peut ĂȘtre rĂ©solu.

Dans ce travail, le soin et la cohérence sont trÚs importants.

Au début du processus:

  1. il est nĂ©cessaire de vĂ©rifier les donnĂ©es avec lesquelles la requĂȘte fonctionne (valeurs, types de donnĂ©es sĂ©lectionnĂ©s)
  2. indexer correctement ces données
  3. vérifier l'exactitude des conditions de connexion entre les tables

À la prochaine itĂ©ration, l'optimisation rĂ©vĂšle:

  1. la base de la demande et le filtre principal de la demande sont déterminés
  2. rĂ©pĂ©tition de blocs de requĂȘte similaires et de conditions croisĂ©es
  3. dans le SSMS ou une autre interface graphique pour SQL Server , la requĂȘte SQL elle-mĂȘme est optimisĂ©e (allocation d'un magasin de donnĂ©es intermĂ©diaire, construction de la requĂȘte rĂ©sultante Ă  l'aide de ce magasin (il peut y en avoir plusieurs))
  4. Ă  la derniĂšre Ă©tape, en prenant comme base la requĂȘte SQL rĂ©sultante , la structure de requĂȘte LINQ est reconstruite

Par consĂ©quent, la requĂȘte LINQ rĂ©sultante devrait devenir de structure identique Ă  la requĂȘte SQL optimale identifiĂ©e Ă  partir du paragraphe 3.

Remerciements


Un grand merci à mes collÚgues jobgemws et alex_ozrde Fortis pour avoir aidé avec cet article.

All Articles